diff --git a/Gemfile b/Gemfile index 1d7e16841b6a3..63f70313426a4 100644 --- a/Gemfile +++ b/Gemfile @@ -3,6 +3,8 @@ source 'https://rubygems.org' # spec.add_runtime_dependency '', [] gemspec name: 'metasploit-framework' +gem 'metasploit_data_models', git: 'https://github.com/cdelafuente-r7/metasploit_data_models.git', branch: 'MS-9930_resource_layered_services' + # separate from test as simplecov is not run on travis-ci group :coverage do # code coverage for tests diff --git a/Gemfile.lock b/Gemfile.lock index 03d6e65f32044..8aace321f7b0e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,3 +1,22 @@ +GIT + remote: https://github.com/cdelafuente-r7/metasploit_data_models.git + revision: 40b566f81584a365fea3f4e961e5c2519042d426 + branch: MS-9930_resource_layered_services + specs: + metasploit_data_models (6.0.11) + activerecord (~> 7.0) + activesupport (~> 7.0) + arel-helpers + bigdecimal + drb + metasploit-concern + metasploit-model (>= 3.1) + mutex_m + pg + railties (~> 7.0) + recog + webrick + PATH remote: . specs: @@ -348,16 +367,6 @@ GEM mutex_m railties (~> 7.0) metasploit-payloads (2.0.221) - metasploit_data_models (6.0.9) - activerecord (~> 7.0) - activesupport (~> 7.0) - arel-helpers - metasploit-concern - metasploit-model (>= 3.1) - pg - railties (~> 7.0) - recog - webrick metasploit_payloads-mettle (1.0.45) method_source (1.1.0) mime-types (3.6.0) @@ -669,6 +678,7 @@ DEPENDENCIES license_finder (= 5.11.1) memory_profiler metasploit-framework! + metasploit_data_models! octokit pry-byebug rake diff --git a/db/schema.rb b/db/schema.rb index c79cd937be15d..1d74b6c434eca 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2025_02_04_172657) do +ActiveRecord::Schema[7.2].define(version: 2025_07_21_114306) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -521,6 +521,16 @@ t.string "netmask" end + create_table "service_links", force: :cascade do |t| + t.bigint "parent_id", null: false + t.bigint "child_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["child_id"], name: "index_service_links_on_child_id" + t.index ["parent_id", "child_id"], name: "index_service_links_on_parent_id_and_child_id", unique: true + t.index ["parent_id"], name: "index_service_links_on_parent_id" + end + create_table "services", id: :serial, force: :cascade do |t| t.integer "host_id" t.datetime "created_at", precision: nil @@ -530,7 +540,8 @@ t.string "name" t.datetime "updated_at", precision: nil t.text "info" - t.index ["host_id", "port", "proto"], name: "index_services_on_host_id_and_port_and_proto", unique: true + t.jsonb "resource", default: {}, null: false + t.index ["host_id", "port", "proto", "name", "resource"], name: "index_services_on_5_columns", unique: true t.index ["name"], name: "index_services_on_name" t.index ["port"], name: "index_services_on_port" t.index ["proto"], name: "index_services_on_proto" @@ -686,6 +697,7 @@ t.integer "vuln_attempt_count", default: 0 t.integer "origin_id" t.string "origin_type" + t.jsonb "resource", default: {}, null: false t.index ["name"], name: "index_vulns_on_name" t.index ["origin_id"], name: "index_vulns_on_origin_id" end @@ -803,4 +815,7 @@ t.boolean "limit_to_network", default: false, null: false t.boolean "import_fingerprint", default: false end + + add_foreign_key "service_links", "services", column: "child_id" + add_foreign_key "service_links", "services", column: "parent_id" end diff --git a/lib/msf/core/db_manager/service.rb b/lib/msf/core/db_manager/service.rb index aa60377237db0..2c67220f3387e 100644 --- a/lib/msf/core/db_manager/service.rb +++ b/lib/msf/core/db_manager/service.rb @@ -43,11 +43,15 @@ def find_or_create_service(opts) # +:workspace+:: the workspace for the service # # opts may contain - # +:name+:: the application layer protocol (e.g. ssh, mssql, smb) - # +:sname+:: an alias for the above - # +:info+:: Detailed information about the service such as name and version information - # +:state+:: The current listening state of the service (one of: open, closed, filtered, unknown) - # + # +:name+:: the application layer protocol (e.g. ssh, mssql, smb) + # +:sname+:: an alias for the above + # +:info+:: detailed information about the service such as name and version information + # +:state+:: the current listening state of the service (one of: open, closed, filtered, unknown) + # +:resource+:: the resource this service is associated with, such as a a DN for an an LDAP object + # base URI for a web application, pipe name for DCERPC service, etc. + # +:parents+:: a single service Hash or an Array of service Hash representing the parent services this + # service is associated with, such as a HTTP service for a web application. + #` # @return [Mdm::Service,nil] def report_service(opts) return if !active @@ -69,6 +73,7 @@ def report_service(opts) if opts[:sname] opts[:name] = opts.delete(:sname) end + opts[:name] = opts[:name].to_s.downcase if opts[:name] if addr.kind_of? ::Mdm::Host host = addr @@ -84,7 +89,14 @@ def report_service(opts) proto = opts[:proto] || Msf::DBManager::DEFAULT_SERVICE_PROTO - service = host.services.where(port: opts[:port].to_i, proto: proto).first_or_initialize + sopts = { + port: opts[:port].to_i, + proto: proto + } + sopts[:name] = opts[:name] if opts[:name] + sopts[:resource] = opts[:resource] if opts[:resource] + service = host.services.where(sopts).first_or_initialize + ostate = service.state opts.each { |k,v| if (service.attribute_names.include?(k.to_s)) @@ -93,8 +105,15 @@ def report_service(opts) dlog("Unknown attribute for Service: #{k}") end } + service.state ||= Msf::ServiceState::Open service.info ||= "" + parents = process_service_chain(host, opts.delete(:parents)) if opts[:parents] + if parents + parents.each do |parent| + service.parents << parent if parent && !service.parents.include?(parent) + end + end begin framework.events.on_db_service(service) if service.new_record? @@ -163,4 +182,44 @@ def update_service(opts) return service } end + + def process_service_chain(host, services) + return if services.nil? || host.nil? + return unless services.is_a?(Hash) || services.is_a?(::Array) + return unless host.is_a?(Mdm::Host) + + services = [services] unless services.is_a?(Array) + services.map do |service| + return unless service.is_a?(Hash) + return if service[:port].nil? || service[:proto].nil? + + parents =nil + if service[:parents]&.any? + parents = process_service_chain(host, service[:parents]) + end + + service_info = { + port: service[:port].to_i, + proto: service[:proto].to_s.downcase, + } + service_info[:name] = service[:name].downcase if service[:name] + service_info[:resource] = service[:resource] if service[:resource] + service_obj = host.services.find_or_create_by(service_info) + if service_obj.id.nil? + elog("Failed to create service #{service_info.inspect} for host #{host.name} (#{host.address})") + return + end + service_obj.state ||= Msf::ServiceState::Open + service_obj.info ||= '' + + if parents + parents.each do |parent| + service_obj.parents << parent if parent && !service_obj.parents.include?(parent) + end + end + + service_obj + end + + end end diff --git a/lib/msf/core/db_manager/session.rb b/lib/msf/core/db_manager/session.rb index 25d73a6004a25..4b5fd470fbaac 100644 --- a/lib/msf/core/db_manager/session.rb +++ b/lib/msf/core/db_manager/session.rb @@ -250,10 +250,20 @@ def infer_vuln_from_session(session, wspace) workspace: wspace, } - port = session.exploit_datastore["RPORT"] - service = (port ? host.services.find_by_port(port.to_i) : nil) - - vuln_info[:service] = service if service + if session.exploit.respond_to?(:service_details) && session.exploit.service_details + service_details = session.exploit.service_details + service_name = service_details[:service_name] + port = service_details[:port] + if port.nil? + port = session.respond_to?(:target_port) && session.target_port ? session.target_port : session.exploit_datastore["RPORT"] + end + proto = service_details[:protocol] + vuln_info[:service] = host.services.find_or_create_by(name: service_name, port: port.to_i, proto: proto, state: 'open') + end + unless vuln_info[:service] + port = session.respond_to?(:target_port) && session.target_port ? session.target_port : session.exploit_datastore["RPORT"] + vuln_info[:service] = host.services.find_by_port(port.to_i) if port + end vuln = report_vuln(vuln_info) @@ -261,7 +271,6 @@ def infer_vuln_from_session(session, wspace) host: host, module: mod_fullname, refs: refs, - service: service, session_id: s.id, timestamp: Time.now.utc, username: session.username, diff --git a/lib/msf/core/db_manager/vuln.rb b/lib/msf/core/db_manager/vuln.rb index ecf3454e0e76d..436c7d1834eee 100644 --- a/lib/msf/core/db_manager/vuln.rb +++ b/lib/msf/core/db_manager/vuln.rb @@ -44,11 +44,15 @@ def find_vuln_by_details(details_map, host, service=nil) other_vulns.empty? ? nil : other_vulns.first end - def find_vuln_by_refs(refs, host, service = nil, cve_only = true) + def find_vuln_by_refs(refs, host, service = nil, cve_only = true, resource = nil) ref_ids = cve_only ? refs.find_all { |ref| ref.name.starts_with? 'CVE-'} : refs relation = host.vulns.joins(:refs) if !service.try(:id).nil? - return relation.where(service_id: service.try(:id), refs: { id: ref_ids}).first + if resource + return relation.where(service_id: service.try(:id), refs: { id: ref_ids}, resource: resource).first + else + return relation.where(service_id: service.try(:id), refs: { id: ref_ids}).first + end end return relation.where(refs: { id: ref_ids}).first end @@ -80,12 +84,20 @@ def has_vuln?(name) # opts MUST contain # +:host+:: the host where this vulnerability resides # +:name+:: the friendly name for this vulnerability (title) + # +:workspace+:: the workspace to report this vulnerability in # # opts can contain # +:info+:: a human readable description of the vuln, free-form text # +:refs+:: an array of Ref objects or string names of references # +:details+:: a hash with :key pointed to a find criteria hash and the rest containing VulnDetail fields # +:sname+:: the name of the service this vulnerability relates to, used to associate it or create it. + # +:exploited_at+:: a timestamp indicating when this vulnerability was exploited, if applicable + # +:ref_ids+:: an array of reference IDs to associate with this vulnerability + # +:service+:: a Mdm::Service object or a Hash with service attributes to associate this vulnerability with + # +:port+:: the port number of the service this vulnerability relates to, if applicable + # +:proto+:: the transport layer protocol of the service this vulnerability relates to, if applicable + # +:details_match+:: a Mdm:VulnDetail with details related to this vulnerability + # +:resource+:: a resource hash to associate with this vulnerability, such as a URI or pipe name # def report_vuln(opts) return if not active @@ -141,7 +153,16 @@ def report_vuln(opts) vuln = nil # Identify the associated service - service = opts.delete(:service) + service_opt = opts.delete(:service) + case service_opt + when Mdm::Service + service = service_opt + when Hash + service = report_service(service_opt.merge(workspace: wspace, host: host)) + else + dlog("Skipping service since it is not a Hash or Mdm::Service: #{service.class}") + service = nil + end # Treat port zero as no service if service or opts[:port].to_i > 0 @@ -160,9 +181,17 @@ def report_vuln(opts) sname = opts[:proto] end - services = host.services.where(port: opts[:port].to_i, proto: proto) - services = services.where(name: sname) if sname.present? - service = services.first_or_create + # If sname and proto are not provided, this will assign the first service + # registered in the database for this host with the given port and proto. + # This is likely to be the TCP service. + sopts = { + workspace: wspace, + host: host, + port: opts[:port].to_i, + proto: proto + } + sopts[:name] = sname if sname.present? + service = report_service(sopts) end # Try to find an existing vulnerability with the same service & references @@ -172,8 +201,12 @@ def report_vuln(opts) # prevent dupes of the same vuln found by both local patch and # service detection. if rids and rids.length > 0 - vuln = find_vuln_by_refs(rids, host, service) - vuln.service = service if vuln + if opts[:resource] + vuln = find_vuln_by_refs(rids, host, service, nil, opts[:resource]) + else + vuln = find_vuln_by_refs(rids, host, service) + end + vuln.service = service if vuln && !vuln.service_id? end else # Try to find an existing vulnerability with the same host & references @@ -194,9 +227,17 @@ def report_vuln(opts) # No matches, so create a new vuln record unless vuln if service - vuln = service.vulns.find_by_name(name) + if opts[:resource] + vuln = service.vulns.find_by(name: name, resource: opts[:resource]) + else + vuln = service.vulns.find_by_name(name) + end else - vuln = host.vulns.find_by_name(name) + if opts[:resource] + vuln = host.vulns.find_by(name: name, resource: opts[:resource]) + else + vuln = host.vulns.find_by_name(name) + end end unless vuln @@ -208,6 +249,7 @@ def report_vuln(opts) } vinf[:service_id] = service.id if service + vinf[:resource] = opts[:resource] if opts[:resource] vuln = Mdm::Vuln.create(vinf) begin diff --git a/lib/msf/core/exploit.rb b/lib/msf/core/exploit.rb index 314f511cb8abf..352ce3a70cfd8 100644 --- a/lib/msf/core/exploit.rb +++ b/lib/msf/core/exploit.rb @@ -49,7 +49,7 @@ module CompatDefaults # https://docs.metasploit.com/docs/development/developing-modules/guides/how-to-write-a-check-method.html # ## - class CheckCode < Struct.new(:code, :message, :reason, :details) + class CheckCode < Struct.new(:code, :message, :reason, :details, :vuln) # Do customization here because we need class constants and special # optional values and the block mode of Struct.new does not support that. # @@ -77,8 +77,8 @@ def Appears(reason = nil, details: {}) self.new('appears', reason, details: details) end - def Vulnerable(reason = nil, details: {}) - self.new('vulnerable', reason, details: details) + def Vulnerable(reason = nil, details: {}, vuln: {}) + self.new('vulnerable', reason, details: details, vuln: vuln) end def Unsupported(reason = nil, details: {}) @@ -100,7 +100,7 @@ def ===(other) other.is_a?(self.class) && self.code == other.code end - def initialize(code, reason, details: {}) + def initialize(code, reason, details: {}, vuln: {}) msg = case code when 'unknown'; 'Cannot reliably check exploitability.' when 'safe'; 'The target is not exploitable.' @@ -111,7 +111,7 @@ def initialize(code, reason, details: {}) else '' end - super(code, "#{msg} #{reason}".strip, reason, details) + super(code, "#{msg} #{reason}".strip, reason, details, vuln) end # diff --git a/lib/msf/core/web_services/servlet/service_servlet.rb b/lib/msf/core/web_services/servlet/service_servlet.rb index ce81b55795294..4321d651b7e90 100644 --- a/lib/msf/core/web_services/servlet/service_servlet.rb +++ b/lib/msf/core/web_services/servlet/service_servlet.rb @@ -26,7 +26,7 @@ def self.get_services begin sanitized_params = sanitize_params(params, env['rack.request.query_hash']) data = get_db.services(sanitized_params) - includes = [:host] + includes = [:host, :parents] data = data.first if is_single_object?(data, sanitized_params) set_json_data_response(response: data, includes: includes) rescue => e @@ -39,7 +39,7 @@ def self.report_service lambda { warden.authenticate! job = lambda { |opts| get_db.report_service(opts) } - includes = [:host] + includes = [:host, :parents] exec_report_job(request, includes, &job) } end diff --git a/lib/msf/ui/console/command_dispatcher/db.rb b/lib/msf/ui/console/command_dispatcher/db.rb index bcb3a227230c8..107f4ec2e52cd 100644 --- a/lib/msf/ui/console/command_dispatcher/db.rb +++ b/lib/msf/ui/console/command_dispatcher/db.rb @@ -846,6 +846,7 @@ def cmd_services(*args) output_file = nil set_rhosts = false col_search = ['port', 'proto', 'name', 'state', 'info'] + extra_columns = ['resource', 'parents'] names = nil order_by = nil @@ -962,7 +963,7 @@ def cmd_services(*args) end tbl = Rex::Text::Table.new({ 'Header' => "Services", - 'Columns' => ['host'] + col_names, + 'Columns' => extra_columns.empty? ? (['host'] + col_names) : (['host'] + col_names + extra_columns), 'SortIndex' => order_by }) @@ -976,6 +977,7 @@ def cmd_services(*args) opts[:workspace] = framework.db.workspace opts[:hosts] = {address: host_search} if !host_search.nil? opts[:port] = ports if ports + opts[:name] = names if names framework.db.services(opts).each do |service| unless service.state == 'open' @@ -993,6 +995,10 @@ def cmd_services(*args) end columns = [host.address] + col_names.map { |n| service[n].to_s || "" } + unless extra_columns.empty? + columns << service.resource.to_json + columns << service.parents.map { |parent| "#{parent.name} (#{parent.port}/#{parent.proto})"}.join(', ') + end tbl << columns if set_rhosts addr = (host.scope.to_s != "" ? host.address + '%' + host.scope : host.address ) @@ -1066,7 +1072,7 @@ def cmd_vulns_help def cmd_vulns(*args) return unless active? - default_columns = ['Timestamp', 'Host', 'Name', 'References'] + default_columns = ['Timestamp', 'Host', 'Service', 'Resource', 'Name', 'References'] host_ranges = [] port_ranges = [] svcs = [] @@ -1167,6 +1173,8 @@ def cmd_vulns(*args) row = [] row << vuln.created_at row << vuln.host.address + row << (vuln.service.present? ? "#{vuln.service.name} (#{vuln.service.port}/#{vuln.service.proto})" : 'None') + row << vuln.resource.to_s row << vuln.name row << reflist.join(',') if show_info @@ -2362,6 +2370,15 @@ def print_msgs(status_msg, error_msg) def _format_vulns_and_vuln_attempts(vulns) vulns.map.with_index do |vuln, index| + service_str = '' + if vuln.service.present? + service_str << "#{vuln.service.name} (port: #{vuln.service.port}, resource: #{vuln.service.resource.to_json})" + if vuln.service.parents.any? + service_str << "\nParent Services:\n".indent(5) + service_str << _print_service_parents(vuln.service).indent(7) + end + end + vuln_formatted = <<~EOF.strip.indent(2) #{index}. Vuln ID: #{vuln.id} Timestamp: #{vuln.created_at} @@ -2369,6 +2386,8 @@ def _format_vulns_and_vuln_attempts(vulns) Name: #{vuln.name} References: #{vuln.refs.map {|r| r.name}.join(',')} Information: #{_format_vuln_value(vuln.info)} + Resource: #{vuln.resource.to_json} + Service: #{service_str} EOF vuln_attempts_formatted = vuln.vuln_attempts.map.with_index do |vuln_attempt, i| @@ -2390,6 +2409,16 @@ def _format_vulns_and_vuln_attempts(vulns) end end + def _print_service_parents(service, indent_level = 0) + service.parents.map do |parent_service| + parent_service_str = "#{parent_service.name} (port: #{parent_service.port}, resource: #{parent_service.resource.to_json})".indent(indent_level * 2) + if parent_service.parents&.any? + parent_service_str << "\n#{_print_service_parents(parent_service, indent_level + 1)}" + end + parent_service_str + end.flatten.join("\n") + end + def _print_vulns_and_attempts(vulns_and_attempts) print_line("Vulnerabilities\n===============") vulns_and_attempts.each do |vuln_and_attempt| diff --git a/lib/msf/ui/console/module_command_dispatcher.rb b/lib/msf/ui/console/module_command_dispatcher.rb index e982ba63853d3..bc7fc1f6c2737 100644 --- a/lib/msf/ui/console/module_command_dispatcher.rb +++ b/lib/msf/ui/console/module_command_dispatcher.rb @@ -174,14 +174,25 @@ def cmd_check_help print_line end - def report_vuln(instance) - framework.db.report_vuln( + def report_vuln(instance, checkcode = nil) + opts = { workspace: instance.workspace, - host: instance.rhost, + host: instance.respond_to?(:target_host) && instance.target_host ? instance.target_host : instance.datastore['RHOST'], + proto: instance.datastore['PROTO'] || 'tcp', name: instance.name, info: "This was flagged as vulnerable by the explicit check of #{instance.fullname}.", refs: instance.references - ) + } + + if checkcode&.kind_of?(Msf::Exploit::CheckCode) && checkcode.vuln.present? + if checkcode.vuln.kind_of?(Array) + checkcode.vuln.each { |vuln| framework.db.report_vuln(opts.merge(vuln)) } + else + framework.db.report_vuln(opts.merge(checkcode.vuln)) + end + else + framework.db.report_vuln(opts) + end end def check_simple(instance=nil) @@ -214,7 +225,7 @@ def check_simple(instance=nil) print_good("#{peer_msg}#{code[1]}") # Restore RHOST for report_vuln instance.datastore['RHOST'] ||= rhost - report_vuln(instance) + report_vuln(instance, code) else print_status("#{peer_msg}#{code[1]}") end diff --git a/spec/api/json_rpc_spec.rb b/spec/api/json_rpc_spec.rb index ffe3d8fdf8964..0fe415bf40bec 100644 --- a/spec/api/json_rpc_spec.rb +++ b/spec/api/json_rpc_spec.rb @@ -225,6 +225,7 @@ def mock_rack_env(mock_rack_env_value) result: { code: 'safe', details: {}, + vuln: {}, message: 'The target is not exploitable.', reason: nil } diff --git a/spec/lib/msf/ui/console/command_dispatcher/db_spec.rb b/spec/lib/msf/ui/console/command_dispatcher/db_spec.rb index 07db725f3808a..31b93c3c42987 100644 --- a/spec/lib/msf/ui/console/command_dispatcher/db_spec.rb +++ b/spec/lib/msf/ui/console/command_dispatcher/db_spec.rb @@ -195,7 +195,69 @@ end - describe "#cmd_services" do + describe "#cmd_services", if: !ENV['REMOTE_DB'] do + context "with resource and parent" do + before(:example) do + framework.db.delete_host(ids: Mdm::Host.pluck(:id)) + + @services = [] + service3 = { + host: '192.168.0.1', + port: 1024, + name: 'service3', + proto: 'udp', + resource: {base_url: '/service3'}, + parents: nil + } + service2 = { + host: '192.168.0.1', + port: 1024, + name: 'service2', + proto: 'udp', + resource: {base_url: '/service2'}, + parents: service3 + } + service1 = { + host: '192.168.0.1', + port: 1024, + name: 'service1', + proto: 'udp', + resource: {base_url: '/service1'}, + parents: service2 + } + + framework.db.report_service(service1) + framework.db.report_service(service2) + framework.db.report_service(service3) + end + + after(:example) do + framework.db.delete_host(ids: Mdm::Host.pluck(:id)) + end + + it "should list services with their resource and parent" do + orig = RSpec::Support::ObjectFormatter.default_instance.max_formatted_output_length + RSpec::Expectations.configuration.max_formatted_output_length = nil + + db.cmd_services + expect(@output).to match_array [ + "Services", + "========", + "", + "host port proto name state info resource parents", + "---- ---- ----- ---- ----- ---- -------- -------", + "192.168.0.1 1024 udp service3 open {\"base_url\":\"/service3\"}", + "192.168.0.1 1024 udp service1 open {\"base_url\":\"/service1\"} service2 (1024/udp)", + "192.168.0.1 1024 udp service2 open {\"base_url\":\"/service2\"} service3 (1024/udp)" + ] + ensure + RSpec::Expectations.configuration.max_formatted_output_length = orig + end + end + + context "with some services" do + end + describe "-h" do it "should show a help message" do db.cmd_services "-h" @@ -244,10 +306,10 @@ "Services", "========", "", - "host port proto name state info", - "---- ---- ----- ---- ----- ----", - "192.168.0.1 1024 udp service1 open", - "192.168.0.1 1025 tcp service2 open" + "host port proto name state info resource parents", + "---- ---- ----- ---- ----- ---- -------- -------", + "192.168.0.1 1024 udp service1 open {}", + "192.168.0.1 1025 tcp service2 open {}" ] end end @@ -288,6 +350,14 @@ end describe "#cmd_vulns" do + before(:example) do + framework.db.delete_host(ids: Mdm::Host.pluck(:id)) + end + + after(:example) do + framework.db.delete_host(ids: Mdm::Host.pluck(:id)) + end + describe "-h" do it "should show a help message" do db.cmd_vulns "-h" @@ -314,59 +384,206 @@ end describe "-v" do - before(:example) do - vuln_opts = { - updated_at: Time.utc(2025, 6, 17, 9, 17, 37), - host: '192.168.0.1', - name: 'ThinkPHP Multiple PHP Injection RCEs', - info: 'Exploited by exploit/unix/webapp/thinkphp_rce to create Session 1', - refs: ["CVE-2018-20062"] - } - - vuln_attempt_opts = { - id: 3, - vuln_id: 1, - attempted_at: Time.utc(2025, 6, 17, 9, 17, 37), - exploited: true, - fail_reason: nil, - username: "foo", - module: "exploit/unix/webapp/thinkphp_rce", - session_id: 1, - loot_id: nil, - fail_detail: nil - } - - @vuln = framework.db.report_vuln(vuln_opts) - @vuln_attempt = framework.db.report_vuln_attempt(@vuln, vuln_attempt_opts) + context 'without service' do + before(:example) do + vuln_opts = { + updated_at: Time.utc(2025, 6, 17, 9, 17, 37), + host: '192.168.0.1', + name: 'ThinkPHP Multiple PHP Injection RCEs', + info: 'Exploited by exploit/unix/webapp/thinkphp_rce to create Session 1', + refs: ["CVE-2018-20062"] + } + + vuln_attempt_opts = { + id: 3, + vuln_id: 1, + attempted_at: Time.utc(2025, 6, 17, 9, 17, 37), + exploited: true, + fail_reason: nil, + username: "foo", + module: "exploit/unix/webapp/thinkphp_rce", + session_id: 1, + loot_id: nil, + fail_detail: nil + } + + @vuln = framework.db.report_vuln(vuln_opts) + @vuln_attempt = framework.db.report_vuln_attempt(@vuln, vuln_attempt_opts) + end + + after(:example) do + framework.db.delete_vuln({ids: [@vuln.id]}) + end + + it "should list vulns and vuln attempts" do + db.cmd_vulns "-v" + expect(@output).to match_array [ + "Vulnerabilities", + "===============", + " 0. Vuln ID: #{@vuln.id}", + " Timestamp: #{@vuln.created_at}", + " Host: 192.168.0.1", + " Name: ThinkPHP Multiple PHP Injection RCEs", + " References: CVE-2018-20062", + " Information: Exploited by exploit/unix/webapp/thinkphp_rce to create Session 1", + " Resource: {}", + " Service:", + " Vuln attempts:", + " 0. ID: #{@vuln_attempt.id}", + " Vuln ID: #{@vuln.id}", + " Timestamp: #{@vuln_attempt.attempted_at}", + " Exploit: true", + " Fail reason: nil", + " Username: foo", + " Module: exploit/unix/webapp/thinkphp_rce", + " Session ID: 1", + " Loot ID: nil", + " Fail Detail: nil", + ] + end end - after(:example) do - framework.db.delete_vuln({ids: [@vuln.id]}) - end + context 'with service' do + let(:myservice) do + { + name: 'SRV', + port: 80, + proto: 'tcp', + resource: {base_url: '/srv'} + } + end + + before(:example) do + vuln_opts = { + updated_at: Time.utc(2025, 6, 17, 9, 17, 37), + host: '192.168.0.1', + name: 'ThinkPHP Multiple PHP Injection RCEs', + info: 'Exploited by exploit/unix/webapp/thinkphp_rce to create Session 1', + refs: ["CVE-2018-20062"], + resource: {uri: '/thinkphp_rce'}, + service: myservice + } + + @vuln = framework.db.report_vuln(vuln_opts) + end + + after(:example) do + framework.db.delete_vuln({ids: [@vuln.id]}) + end + + it 'print the service with resource' do + db.cmd_vulns "-v" + expect(@output).to match_array [ + "Vulnerabilities", + "===============", + " 0. Vuln ID: #{@vuln.id}", + " Timestamp: #{@vuln.created_at}", + " Host: 192.168.0.1", + " Name: ThinkPHP Multiple PHP Injection RCEs", + " References: CVE-2018-20062", + " Information: Exploited by exploit/unix/webapp/thinkphp_rce to create Session 1", + " Resource: {\"uri\":\"/thinkphp_rce\"}", + " Service: srv (port: 80, resource: {\"base_url\":\"/srv\"})", + " Vuln attempts:", + ] + end + + context 'with parent services' do + let(:srv_0_0) do + { + name: 'SRV_0_0', + port: 80, + proto: 'tcp', + resource: {base_url: '/srv_0_0'}, + parents: nil + } + end + + let(:srv_0_1) do + { + name: 'SRV_0_1', + port: 80, + proto: 'tcp', + resource: {base_url: '/srv_0_1'}, + parents: nil + } + end + + let(:srv_0) do + { + name: 'SRV_0', + port: 80, + proto: 'tcp', + resource: {base_url: '/srv_0'}, + parents: [srv_0_0, srv_0_1] + } + end + + let(:srv_1_0_0) do + { + name: 'SRV_1_0_0', + port: 80, + proto: 'tcp', + resource: {base_url: '/srv_1_0_0'}, + parents: nil + } + end + + let(:srv_1_0) do + { + name: 'SRV_1_0', + port: 80, + proto: 'tcp', + resource: {base_url: '/srv_1_0'}, + parents: srv_1_0_0 + } + end + + let(:srv_1) do + { + name: 'SRV_1', + port: 80, + proto: 'tcp', + resource: {base_url: '/srv_1'}, + parents: srv_1_0 + } + end + + let(:myservice) do + { + name: 'SRV', + port: 80, + proto: 'tcp', + resource: {base_url: '/srv'}, + parents: [srv_0, srv_1] + } + end + + it 'print the service and the parent services' do + db.cmd_vulns "-v" + expect(@output).to match_array [ + "Vulnerabilities", + "===============", + " 0. Vuln ID: #{@vuln.id}", + " Timestamp: #{@vuln.created_at}", + " Host: 192.168.0.1", + " Name: ThinkPHP Multiple PHP Injection RCEs", + " References: CVE-2018-20062", + " Information: Exploited by exploit/unix/webapp/thinkphp_rce to create Session 1", + " Resource: {\"uri\":\"/thinkphp_rce\"}", + " Service: srv (port: 80, resource: {\"base_url\":\"/srv\"})", + " Parent Services:", + " srv_0 (port: 80, resource: {\"base_url\":\"/srv_0\"})", + " srv_0_0 (port: 80, resource: {\"base_url\":\"/srv_0_0\"})", + " srv_0_1 (port: 80, resource: {\"base_url\":\"/srv_0_1\"})", + " srv_1 (port: 80, resource: {\"base_url\":\"/srv_1\"})", + " srv_1_0 (port: 80, resource: {\"base_url\":\"/srv_1_0\"})", + " srv_1_0_0 (port: 80, resource: {\"base_url\":\"/srv_1_0_0\"})", + " Vuln attempts:", + ] + end + end - it "should list vulns and vuln attempts" do - db.cmd_vulns "-v" - expect(@output).to match_array [ - "Vulnerabilities", - "===============", - " 0. Vuln ID: #{@vuln.id}", - " Timestamp: #{@vuln.created_at}", - " Host: 192.168.0.1", - " Name: ThinkPHP Multiple PHP Injection RCEs", - " References: CVE-2018-20062", - " Information: Exploited by exploit/unix/webapp/thinkphp_rce to create Session 1", - " Vuln attempts:", - " 0. ID: #{@vuln_attempt.id}", - " Vuln ID: #{@vuln.id}", - " Timestamp: #{@vuln_attempt.attempted_at}", - " Exploit: true", - " Fail reason: nil", - " Username: foo", - " Module: exploit/unix/webapp/thinkphp_rce", - " Session ID: 1", - " Loot ID: nil", - " Fail Detail: nil", - ] end end end diff --git a/spec/lib/msf/ui/console/command_dispatcher/exploit_spec.rb b/spec/lib/msf/ui/console/command_dispatcher/exploit_spec.rb index 11312c6ba3a77..743cdb7f4f02b 100644 --- a/spec/lib/msf/ui/console/command_dispatcher/exploit_spec.rb +++ b/spec/lib/msf/ui/console/command_dispatcher/exploit_spec.rb @@ -13,6 +13,11 @@ def initialize 'Name' => 'mock module', 'Description' => 'mock module', 'Author' => ['Unknown'], + 'References' => [ + [ 'OSVDB', '12345' ], + [ 'EDB', '12345' ], + [ 'CVE', '1978-1234'] + ], 'License' => MSF_LICENSE, 'Arch' => ARCH_CMD, 'Platform' => ['unix'], @@ -170,6 +175,10 @@ def set_default_payload(mod) end describe '#cmd_check' do + after :example do + framework.db.delete_host(ids: Mdm::Host.pluck(:id)) + end + context 'when checking a remote exploit module' do let(:current_mod) { remote_exploit_mod } @@ -266,6 +275,76 @@ def set_default_payload(mod) expect(@combined_output).to match_array(expected_output) end + + context 'when the check returns CheckCode::Vulnerable' do + let(:port) { 80 } + let(:wordpress_service) do + { + name: 'Wordpress', + proto: 'tcp', + port: port, + resource: {base_url: '/wordpress'} + } + end + let(:http_service) do + { + name: 'HTTP', + proto: 'tcp', + port: port, + resource: {method: 'GET'} + } + end + let(:tcp_service) do + { + name: 'TCP', + proto: 'tcp', + port: port, + resource: {port: port} + } + end + let(:vuln) do + { + host: current_mod.datastore['RHOSTS'], + port: port, + proto: 'tcp', + name: 'Test Vulnerability - Web', + info: 'Mock Vulnerability', + refs: current_mod.references, + exploited_at: Time.now.utc, + resource: {uri: '/myapp/from_checkcode'}, + service: wordpress_service + } + end + + before :example do + current_mod.datastore['RHOSTS'] = '192.0.2.1' + current_mod.datastore['RPORT'] = port.to_s + allow(current_mod).to receive(:check).and_return(Msf::Exploit::CheckCode::Vulnerable('Vulnerable!', vuln: vuln)) + end + + it 'reports a vulnerability with the corresponding service' do + expect { subject.cmd_check }.to change { Mdm::Vuln.count }.by(1) + new_vuln = Mdm::Vuln.last + expect(new_vuln.name).to eq(vuln[:name]) + expect(new_vuln.info).to eq(vuln[:info]) + expect(new_vuln.exploited_at).to be_within(1.second).of(vuln[:exploited_at]) + expect(new_vuln.resource).to eq(vuln[:resource].transform_keys(&:to_s)) + expect(new_vuln.host.address.to_s).to eq(vuln[:host]) + end + + it 'reports each service layer associated to the vulnerability' do + wordpress_service[:parents] = http_service + http_service[:parents] = tcp_service + tcp_service[:parents] = nil + + expect { subject.cmd_check }.to change { Mdm::Service.count }.by(3) + new_vuln = Mdm::Vuln.last + service = new_vuln.service + expect(service.name).to eq(wordpress_service[:name].downcase) + expect(service.parents.first.name).to eq(http_service[:name].downcase) + expect(service.parents.first.parents.first.name).to eq(tcp_service[:name].downcase) + end + end end context 'when checking a non remote exploit module' do diff --git a/spec/support/shared/examples/msf/db_manager/service.rb b/spec/support/shared/examples/msf/db_manager/service.rb index fea896a40b2ca..a21e067cf3230 100644 --- a/spec/support/shared/examples/msf/db_manager/service.rb +++ b/spec/support/shared/examples/msf/db_manager/service.rb @@ -18,10 +18,12 @@ subject.report_task(workspace: workspace, user: 'test_user', info: 'info', path: 'mock/path') end + let(:host_addr) { '192.0.2.1' } + context 'without a task' do it 'creates a service' do service = subject.report_service( - host: '192.0.2.1', + host: host_addr, port: '5000', name: 'test_service', proto: 'tcp', @@ -33,7 +35,7 @@ expect(service.port).to eq 5000 expect(service.proto).to eq 'tcp' expect(service.info).to eq 'banner' - expect(service.host.address.to_s).to eq '192.0.2.1' + expect(service.host.address.to_s).to eq host_addr expect(service.host.workspace).to eq workspace expect(service.task_services).to be_empty expect(task.task_services).to be_empty @@ -44,7 +46,7 @@ it 'creates a service' do service = 3.times.map do |count| subject.report_service( - host: '192.0.2.1', + host: host_addr, port: '5000', name: 'test_service', proto: 'tcp', @@ -58,11 +60,374 @@ expect(service.port).to eq 5000 expect(service.proto).to eq 'tcp' expect(service.info).to eq 'banner 2' - expect(service.host.address.to_s).to eq '192.0.2.1' + expect(service.host.address.to_s).to eq host_addr expect(service.host.workspace).to eq workspace expect(service.task_services.length).to eq 1 expect(task.task_services.length).to eq 1 end end + + context 'when not active' do + let(:active) { false } + + it 'returns nil' do + expect(subject.report_service(host: host_addr, port: 80, proto: 'tcp', workspace: workspace)).to be_nil + end + end + + context 'when port is zero' do + it 'returns nil and logs skipping' do + expect(subject).to receive(:dlog).with(/Skipping port zero for service '.*' on host '#{host_addr}'/) + result = subject.report_service(host: host_addr, port: 0, proto: 'tcp', workspace: workspace) + expect(result).to be_nil + end + end + + context 'when creating a new service with required fields' do + let(:opts) do + { + workspace: workspace, + host: host_addr, + port: 8080, + proto: 'tcp' + } + end + + it 'creates and returns the service' do + service = subject.report_service(opts) + expect(service).to be_persisted + expect(service.port).to eq(opts[:port]) + expect(service.proto).to eq(opts[:proto]) + expect(service.host.address).to eq(host_addr) + expect(service.state).to eq(Msf::ServiceState::Open) + expect(service.info).to be_empty + end + + context 'with parent services' do + let(:service2) do + {name: 'service2', port: 8080, proto: 'tcp'} + end + let(:service1) do + {name: 'service1', port: 8080, proto: 'tcp'} + end + + it 'creates the service and its parent services' do + expect { + service1[:parents] = service2 + opts[:parents] = service1 + result = subject.report_service(opts) + + expect(result.parents.size).to eq(1) + expect(result.parents.first.name).to eq(service1[:name]) + expect(result.parents.first.port).to eq(service1[:port]) + expect(result.parents.first.proto).to eq(service1[:proto]) + + expect(result.parents.first.parents.size).to eq(1) + expect(result.parents.first.parents.first.name).to eq(service2[:name]) + }.to change(Mdm::Service, :count).by(3) + end + end + end + + context 'when :sname is present' do + it 'uses :sname as :name and downcases it' do + opts = { host: host_addr, port: 22, proto: 'tcp', workspace: workspace, sname: 'SSH' } + service = subject.report_service(opts) + expect(service.name).to eq('ssh') + end + end + + context 'when additional attributes are present' do + it 'sets them on the service' do + opts = { + host: host_addr, + port: 443, + proto: 'tcp', + workspace: workspace, + name: 'https', + info: 'nginx 1.18', + state: 'open', + resource: {uri: '/api'} + } + service = subject.report_service(opts) + expect(service.name).to eq('https') + expect(service.info).to eq('nginx 1.18') + expect(service.state).to eq('open') + expect(service.resource).to eq({'uri' => '/api'}) + end + end + + context 'when host is not an Mdm::Host' do + it 'calls #report_host and uses its result' do + opts = { host: host_addr, port: 21, proto: 'tcp', workspace: workspace } + expect(subject).to receive(:report_host).with({workspace: workspace, host: host_addr}).and_call_original + service = subject.report_service(opts) + expect(service.host.address).to eq(host_addr) + expect(service.host.workspace).to eq(workspace) + end + end + + context 'with framework events' do + before :example do + allow(subject.framework).to receive(:events).and_return( + double('events', on_db_host:nil, on_db_service: nil, on_db_service_state: nil) + ) + end + + context 'when a new service is created' do + it 'triggers framework events' do + opts = { host: host_addr, port: 3306, proto: 'tcp', workspace: workspace } + service = subject.report_service(opts) + expect(subject.framework.events).to have_received(:on_db_service).with(service) + expect(subject.framework.events).to have_received(:on_db_service_state).with(service, 3306, nil) + end + end + + context 'when service state changes' do + it 'triggers framework state change event' do + host = FactoryBot.create(:mdm_host, address: host_addr, workspace: workspace) + service = host.services.create!(port: 5432, proto: 'tcp', state: Msf::ServiceState::Closed) + opts = { host: host_addr, port: 5432, proto: 'tcp', workspace: workspace, state: Msf::ServiceState::Open } + subject.report_service(opts) + expect(subject.framework.events).to have_received(:on_db_service_state).with(service, 5432, Msf::ServiceState::Closed) + end + end + end + + context 'when service already exists' do + let(:host) { FactoryBot.create(:mdm_host, address: host_addr, workspace: workspace) } + let(:existing_service) do + host.services.create!(port: 80, proto: 'tcp', name: 'http', resource: {uri: '/api'}) + end + let(:opts) do + { + workspace: workspace, + host: host_addr, + port: existing_service.port, + proto: existing_service.proto, + name: existing_service.name + } + end + + context 'without resource' do + it 'returns the existing service' do + service = subject.report_service(opts) + expect(service).to eq(existing_service) + end + end + context 'with the same resource' do + it 'returns the existing service' do + opts[:resource] = existing_service.resource + service = subject.report_service(opts) + expect(service).to eq(existing_service) + end + end + context 'with a different resource' do + it 'creates a new service' do + opts[:resource] = {uri: '/new'} + service = subject.report_service(opts) + expect(service).not_to eq(existing_service) + expect(host.services.count).to eq 2 + end + end + + context 'with parent services' do + let(:service2) do + {name: 'service2', port: 8080, proto: 'tcp'} + end + let(:service1) do + {name: 'service1', port: 8080, proto: 'tcp'} + end + + it 'creates parent services and add them to the existing service' do + # Force existing service creation + existing_service + + expect { + service1[:parents] = service2 + opts[:parents] = service1 + result = subject.report_service(opts) + + expect(result).to eq(existing_service) + + expect(existing_service.parents.size).to eq(1) + expect(existing_service.parents.first.name).to eq(service1[:name]) + expect(existing_service.parents.first.port).to eq(service1[:port]) + expect(existing_service.parents.first.proto).to eq(service1[:proto]) + + expect(existing_service.parents.first.parents.size).to eq(1) + expect(existing_service.parents.first.parents.first.name).to eq(service2[:name]) + }.to change(Mdm::Service, :count).by(2) + end + end + + end + + end + + + describe '#process_service_chain', if: !ENV['REMOTE_DB'] do + let(:workspace) { subject.default_workspace } + let(:host) { FactoryBot.create(:mdm_host, workspace: workspace) } + + context 'when given valid service parameters' do + let(:service_hash) do + { + name: 'http', + port: 80, + proto: 'tcp' + } + end + + it 'creates a new service if none exists' do + expect { + service = subject.process_service_chain(host, service_hash) + expect(service).to be_a(Array) + expect(service.size).to eq(1) + expect(service.first).to be_a(Mdm::Service) + expect(service.first.name).to eq('http') + expect(service.first.port).to eq(80) + expect(service.first.proto).to eq('tcp') + expect(service.first.state).to eq(Msf::ServiceState::Open) + }.to change(Mdm::Service, :count).by(1) + end + + it 'returns existing service if it already exists' do + existing_service = FactoryBot.create( + :mdm_service, + host: host, + name: service_hash[:name], + port: service_hash[:port], + proto: service_hash[:proto] + ) + + expect { + service = subject.process_service_chain(host, service_hash).first + expect(service.id).to eq(existing_service.id) + }.not_to change(Mdm::Service, :count) + end + + it 'converts service parameters to expected types' do + service_hash = { + name: 'HTTP', # should be downcased + port: '80', # should be converted to integer + proto: 'TCP' # should be downcased + } + + service = subject.process_service_chain(host, service_hash).first + expect(service.name).to eq('http') + expect(service.port).to eq(80) + expect(service.proto).to eq('tcp') + end + + it 'sets the resource when provided' do + service_hash[:resource] = 'test_resource' + + service = subject.process_service_chain(host, service_hash).first + expect(service.resource).to eq('test_resource') + end + end + + context 'with parent services' do + it 'processes a single parent service' do + parent_hash = { + name: 'ssl', + port: 443, + proto: 'tcp' + } + + service_hash = { + name: 'https', + port: 443, + proto: 'tcp', + parents: parent_hash + } + + expect { + service = subject.process_service_chain(host, service_hash).first + expect(service.parents.count).to eq(1) + expect(service.parents.first.name).to eq('ssl') + expect(service.parents.first.port).to eq(443) + }.to change(Mdm::Service, :count).by(2) + end + + it 'processes multiple parent services' do + parent_hash1 = { + name: 'https', + port: 443, + proto: 'tcp' + } + + parent_hash2 = { + name: 'http', + port: 80, + proto: 'tcp' + } + + service_hash = { + name: 'webapp', + port: 80, + proto: 'tcp', + parents: [parent_hash1, parent_hash2] + } + + expect { + service = subject.process_service_chain(host, service_hash).first + expect(service.parents.count).to eq(2) + expect(service.parents.map(&:name)).to include('https', 'http') + }.to change(Mdm::Service, :count).by(3) + end + + it 'handles nested parent services' do + grandparent_hash = { + name: 'tcp', + port: 443, + proto: 'tcp' + } + + parent_hash = { + name: 'ssl', + port: 443, + proto: 'tcp', + parents: grandparent_hash + } + + service_hash = { + name: 'https', + port: 443, + proto: 'tcp', + parents: parent_hash + } + + expect { + service = subject.process_service_chain(host, service_hash).first + parent = service.parents.first + expect(parent.name).to eq('ssl') + expect(parent.parents.first.name).to eq('tcp') + }.to change(Mdm::Service, :count).by(3) + end + end + + context 'with invalid parameters' do + it 'returns nil if service hash is nil' do + expect(subject.process_service_chain(host, nil)).to be_nil + end + + it 'returns nil if host is nil' do + service_hash = { name: 'http', port: 80, proto: 'tcp' } + expect(subject.process_service_chain(nil, service_hash)).to be_nil + end + + it 'returns nil if required service parameters are missing' do + # Missing port + expect(subject.process_service_chain(host, { name: 'http', proto: 'tcp' })).to be_nil + + # Missing proto + expect(subject.process_service_chain(host, { name: 'http', port: 80 })).to be_nil + end + end end + + + end diff --git a/spec/support/shared/examples/msf/db_manager/session.rb b/spec/support/shared/examples/msf/db_manager/session.rb index 66688b7f3f0cb..7ef755dc55fa5 100644 --- a/spec/support/shared/examples/msf/db_manager/session.rb +++ b/spec/support/shared/examples/msf/db_manager/session.rb @@ -240,10 +240,19 @@ nil end + let(:service_details) { nil } + let(:target_port) { nil } + before(:example) do Timecop.freeze session.exploit_datastore['RPORT'] = rport + if service_details + allow(session.exploit).to receive(:service_details).and_return(service_details) + end + if target_port + allow(session).to receive(:target_port).and_return(target_port) + end report_session end @@ -322,6 +331,43 @@ context 'without RPORT' do it { expect(subject.service).to be_nil } end + + context 'with session.exploit implementing #service_details' do + let(:service_details) do + { + :service_name => 'HTTP', + :port => 80, + :protocol => 'tcp' + } + end + + it 'creates a Mdm::Service with the data provided by #service_details' do + expect(subject.service).to be_present + expect(subject.service).to be_a(Mdm::Service) + expect(subject.service.name).to eq(service_details[:service_name]) + expect(subject.service.port).to eq(service_details[:port]) + expect(subject.service.proto).to eq(service_details[:protocol]) + end + end + + context 'when service_details does not provide port information and session has a target_port' do + let(:service_details) do + { + :service_name => 'HTTP', + :protocol => 'tcp' + } + end + let(:target_port) { rand(2**16 - 1) } + + it 'create a service wth port from session.target_port' do + expect(subject.service).to be_present + expect(subject.service).to be_a(Mdm::Service) + expect(subject.service.name).to eq(service_details[:service_name]) + expect(subject.service.port).to eq(session.target_port) + expect(subject.service.proto).to eq(service_details[:protocol]) + end + end + end context 'created Mdm::ExploitAttempt' do diff --git a/spec/support/shared/examples/msf/db_manager/vuln.rb b/spec/support/shared/examples/msf/db_manager/vuln.rb index ccdbe6c94bac6..4b4026c91fdd4 100644 --- a/spec/support/shared/examples/msf/db_manager/vuln.rb +++ b/spec/support/shared/examples/msf/db_manager/vuln.rb @@ -49,4 +49,373 @@ end it { is_expected.to respond_to :vulns } + + + describe '#find_vuln_by_refs', if: !ENV['REMOTE_DB'] do + let(:workspace) { subject.default_workspace } + let(:host_addr) { '192.0.2.1' } + let(:host) { FactoryBot.create(:mdm_host, address: host_addr, workspace: workspace) } + let(:service1) { host.services.create!(port: 5432, proto: 'tcp') } + let(:service2) { host.services.create!(port: 80, proto: 'tcp') } + let(:ref_cve1) { FactoryBot.create(:mdm_ref, name: 'CVE-2023-0001') } + let(:ref_cve2) { FactoryBot.create(:mdm_ref, name: 'CVE-2023-0002') } + let(:ref_ms) { FactoryBot.create(:mdm_ref, name: 'MS-1234') } + let!(:vuln1) { service1.vulns.create!(host: host, name: 'Vuln1', resource: {uri: '/api'}, refs: [ref_cve1, ref_ms]) } + let!(:vuln2) { service1.vulns.create!(host: host, name: 'Vuln2', resource: {uri: '/other'}, refs: [ref_cve2]) } + let!(:vuln3) { FactoryBot.create(:mdm_vuln, host: host, name: 'Vuln3', refs: [ref_cve1, ref_cve2]) } + let!(:vuln4) { service2.vulns.create!(host: host, name: 'Vuln4', resource: {uri: '/api'}, refs: [ref_ms]) } + + context 'when cve_only is true' do + it 'finds vuln by service and CVE ref' do + expect(subject.find_vuln_by_refs([ref_cve1, ref_ms], host, service1, true)).to eq(vuln1) + end + + it 'returns nil if no CVE refs match' do + expect(subject.find_vuln_by_refs([ref_ms], host, service1, true)).to be_nil + end + + it 'finds vuln by CVE ref through host when no service is provided' do + expect(subject.find_vuln_by_refs([ref_cve2], host, nil, true)).to eq(vuln2) + end + end + + context 'when cve_only is false' do + it 'finds vuln by service and any ref' do + expect(subject.find_vuln_by_refs([ref_ms], host, service2, false)).to eq(vuln4) + end + + it 'finds vuln by any ref through host when no service is provided' do + expect(subject.find_vuln_by_refs([ref_ms], host, nil, false)).to eq(vuln1) + end + end + + context 'when resource is specified' do + it 'finds vuln by service, ref, and resource' do + expect(subject.find_vuln_by_refs([ref_cve1], host, service1, true, {uri: '/api'})).to eq(vuln1) + end + + it 'returns nil if resource does not match' do + expect(subject.find_vuln_by_refs([ref_cve1], host, service1, true, {uri: '/other'})).to be_nil + end + end + + context 'when no vulns match' do + it 'returns nil' do + ref_unknown = Mdm::Ref.new(name: 'CVE-9999-9999') + expect(subject.find_vuln_by_refs([ref_unknown], host, service1, true)).to be_nil + end + end + + context 'when refs is empty' do + it 'returns nil' do + expect(subject.find_vuln_by_refs([], host, service1, true)).to be_nil + end + end + end + + + describe '#report_vuln' , if: !ENV['REMOTE_DB']do + let(:workspace) { subject.default_workspace } + let(:host) { FactoryBot.create(:mdm_host, workspace: workspace) } + let(:service) { FactoryBot.create(:mdm_service, host: host) } + let(:ref1) { FactoryBot.create(:mdm_module_ref, name: 'CVE-2023-0001') } + let(:ref2) { FactoryBot.create(:mdm_module_ref, name: 'MS-1234') } + + context 'when :host is missing' do + it 'raises error' do + expect { subject.report_vuln(name: 'foo') }.to raise_error(ArgumentError, /Missing required option :host/) + end + end + + context 'when :data is present' do + it 'raises error' do + expect { subject.report_vuln(host: host, name: 'foo', data: 'deprecated') }.to raise_error(ArgumentError, /Deprecated data column/) + end + end + + context 'when not active' do + let(:active) { false } + + it 'returns nil' do + expect(subject.report_vuln(host: double('host'), name: 'foo')).to be_nil + end + end + + context 'when no vuln exists' do + it 'creates a new vuln' do + result = subject.report_vuln(host: host, name: 'foo', info: 'desc', workspace: workspace) + expect(result).to be_a(Mdm::Vuln) + expect(result.name).to eq('foo') + expect(result.info).to eq('desc') + expect(host.vulns).to include(result) + end + end + + context 'when the vuln already exists' do + let(:name) { 'existing vuln' } + let!(:existing_vuln) { FactoryBot.create(:mdm_vuln, host: host, name: name) } + + it 'returns the vuln with the same name' do + result = subject.report_vuln(host: host, name: name, workspace: workspace) + expect(result.id).to eq(existing_vuln.id) + expect(result.name).to eq(existing_vuln.name) + end + end + + context 'with refs' do + it 'adds `Mdm::Module::Ref` refs' do + result = subject.report_vuln(host: host, name: 'foo', workspace: workspace, refs: [ref1, ref2]) + expect(result.refs.size).to eq(2) + expect(result.refs.map(&:name)).to include(ref1.name, ref2.name) + end + + it 'adds `Msf::Module::SiteReference` refs' do + ref1 = Msf::Module::SiteReference.from_a(['CVE', '1978-1234']) + ref2 = Msf::Module::SiteReference.from_a(['URL', 'http://example.com']) + result = subject.report_vuln(host: host, name: 'foo', workspace: workspace, refs: [ref1, ref2]) + expect(result.refs.size).to eq(2) + expect(result.refs.map(&:name)).to include("#{ref1.ctx_id}-#{ref1.ctx_val}", "#{ref2.ctx_id}-#{ref2.ctx_val}") + end + + it 'adds refs as Hash' do + ref1 = {ctx_id: 'CVE', ctx_val: '1978-1234'} + ref2 = {ctx_id: 'URL', ctx_val: 'http://example.com'} + result = subject.report_vuln(host: host, name: 'foo', workspace: workspace, refs: [ref1, ref2]) + expect(result.refs.size).to eq(2) + expect(result.refs.map(&:name)).to include("#{ref1[:ctx_id]}-#{ref1[:ctx_val]}", "#{ref2[:ctx_id]}-#{ref2[:ctx_val]}") + end + + it 'adds refs as String' do + ref1 = 'CVE-1978-1234' + ref2 = 'http://example.com' + result = subject.report_vuln(host: host, name: 'foo', workspace: workspace, refs: [ref1, ref2]) + expect(result.refs.size).to eq(2) + expect(result.refs.map(&:name)).to include(ref1, ref2) + end + end + + context 'with name and info' do + it 'sets them and truncates if too long' do + long_info = 'a' * 70000 + long_name = 'b' * 300 + result = subject.report_vuln(host: host, name: long_name, info: long_info, workspace: workspace) + expect(result.name.length).to eq(255) + expect(result.info.length).to eq(65535) + end + end + + context 'with exploited_at' do + it 'sets exploited_at' do + now = Time.now + result = subject.report_vuln(host: host, name: 'foo', workspace: workspace, exploited_at: now) + expect(result.exploited_at).to eq(now) + end + end + + context 'with service as Mdm::Service' do + it 'sets service' do + result = subject.report_vuln(host: host, name: 'foo', workspace: workspace, service: service) + expect(result.service).to eq(service) + end + end + + context 'with service as Hash' do + let (:service_hash) { {name: service.name, port: service.port, proto: service.proto} } + + it 'sets service' do + expect { + result = subject.report_vuln(host: host, name: 'foo', workspace: workspace, service: service_hash) + expect(result.service).to eq(service) + }.to change(Mdm::Service, :count).by(1) + end + + context 'with parent services' do + let(:service2) do + {name: 'service2', port: 8080, proto: 'tcp'} + end + let(:service1) do + {name: 'service1', port: 8080, proto: 'tcp'} + end + + context 'with an existing service' do + it 'creates parent services and add them to the existing service' do + # Force service creation + service + + expect { + service1[:parents] = service2 + service_hash[:parents] = service1 + result = subject.report_vuln(host: host, name: 'foo', workspace: workspace, service: service_hash) + + expect(result.service).to eq(service) + + expect(service.parents.size).to eq(1) + expect(service.parents.first.name).to eq(service1[:name]) + expect(service.parents.first.port).to eq(service1[:port]) + expect(service.parents.first.proto).to eq(service1[:proto]) + + expect(service.parents.first.parents.size).to eq(1) + expect(service.parents.first.parents.first.name).to eq(service2[:name]) + }.to change(Mdm::Service, :count).by(2) + end + end + + context 'with a non-existing service' do + it 'creates the service and its parent services' do + expect { + service1[:parents] = service2 + service_hash = { + name: 'other service', + port: 8080, + proto: 'tcp', + parents: service1 + } + result = subject.report_vuln(host: host, name: 'foo', workspace: workspace, service: service_hash) + + expect(result.service).to be_a(Mdm::Service) + expect(result.service.name).to eq(service_hash[:name]) + expect(result.service.port).to eq(service_hash[:port]) + expect(result.service.proto).to eq(service_hash[:proto]) + + expect(result.service.parents.size).to eq(1) + expect(result.service.parents.first.name).to eq(service1[:name]) + + expect(result.service.parents.first.parents.size).to eq(1) + expect(result.service.parents.first.parents.first.name).to eq(service2[:name]) + }.to change(Mdm::Service, :count).by(3) + end + end + end + end + + context 'with service and refs' do + let!(:vuln) do + ref = FactoryBot.create(:mdm_ref, name: ref1.name) + service.vulns.create!(host: host, name: 'foo', refs: [ref]) + end + + it 'returns an existing vuln if service and refs match' do + result = subject.report_vuln(host: host, name: 'foo', workspace: workspace, service: service, refs: [ref1]) + expect(result).to eq(vuln) + end + + context 'with resource' do + let(:resource) { {uri: '/api'} } + + it 'returns an existing vuln with the same service, refs and resource' do + vuln.update!(resource: resource) + result = subject.report_vuln(host: host, name: 'foo', workspace: workspace, service: service, refs: [ref1], resource: resource) + expect(result).to eq(vuln) + end + + it 'creates a new vuln if resource does not match' do + new_resource = {uri: '/other'} + result = subject.report_vuln(host: host, name: 'foo', workspace: workspace, service: service, refs: [ref1], resource: new_resource) + expect(result).not_to eq(vuln) + expect(result.resource).to eq(new_resource.transform_keys(&:to_s)) + end + end + end + + context 'with service and resource' do + let(:resource) { {uri: '/api'} } + let!(:vuln) { service.vulns.create!(host: host, name: 'foo', resource: resource) } + + it 'returns an existing vuln if service, name and resource match' do + result = subject.report_vuln(host: host, name: 'foo', workspace: workspace, service: service, resource: resource) + expect(result).to eq(vuln) + end + end + + context 'without service' do + it 'does not set any service' do + result = subject.report_vuln(host: host, name: 'foo', workspace: workspace) + expect(result.service).to be_nil + end + + context 'with port' do + let(:port) { 8080 } + + it 'creates a service with the given port' do + result = subject.report_vuln(host: host, name: 'foo', workspace: workspace, port: port) + expect(result.service).not_to be_nil + expect(result.service.port).to eq(port) + expect(result.service.proto).to eq('tcp') # default proto + end + + context 'with proto' do + let(:proto) { 'udp' } + + it 'creates a service with the given port and proto' do + result = subject.report_vuln(host: host, name: 'foo', workspace: workspace, port: port, proto: proto) + expect(result.service).not_to be_nil + expect(result.service.port).to eq(port) + expect(result.service.proto).to eq(proto) + end + + context 'with sname' do + let(:sname) { 'myservice' } + + it 'creates a service with the given port, proto and sname' do + result = subject.report_vuln(host: host, name: 'foo', workspace: workspace, port: port, proto: proto, sname: sname) + expect(result.service).not_to be_nil + expect(result.service.port).to eq(port) + expect(result.service.proto).to eq(proto) + expect(result.service.name).to eq(sname) + end + + it 'returns the service if it already exists' do + existing_service = FactoryBot.create(:mdm_service, host: host, port: port, proto: proto, name: sname) + result = subject.report_vuln(host: host, name: 'foo', workspace: workspace, port: port, proto: proto, sname: sname) + expect(result.service).to eq(existing_service) + end + end + end + end + + context 'with resource' do + let(:resource) { {uri: '/api'} } + + it 'creates a vuln with the resource' do + result = subject.report_vuln(host: host, name: 'foo', workspace: workspace, resource: resource) + expect(result.resource).to eq(resource.transform_keys(&:to_s)) + end + + it 'returns an existing vuln if resource matches' do + existing_vuln = host.vulns.create!(name: 'foo', resource: resource) + result = subject.report_vuln(host: host, name: 'foo', workspace: workspace, resource: resource) + expect(result).to eq(existing_vuln) + end + end + end + + context 'with vuln details' do + let(:vuln_details) { { description: 'desc', proof: 'proof'} } + + it 'sets vuln details' do + result = subject.report_vuln(host: host, name: 'foo', workspace: workspace, details: vuln_details) + expect(result.vuln_details.size).to eq(1) + expect(result.vuln_details.first).to be_a(Mdm::VulnDetail) + expect(result.vuln_details.first.description).to eq(vuln_details[:description]) + expect(result.vuln_details.first.proof).to eq(vuln_details[:proof]) + end + end + + context 'with framework events' do + before :example do + allow(subject.framework).to receive(:events).and_return( + double('events', on_db_vuln:nil) + ) + end + + context 'when a new vuln is created' do + it 'triggers framework events' do + vuln = subject.report_vuln(host: host, name: 'foo', workspace: workspace) + expect(subject.framework.events).to have_received(:on_db_vuln).with(vuln) + end + end + end + + end + end