diff --git a/Gemfile.lock b/Gemfile.lock index 1e44c8d709be8..9dc72bac93f0f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -353,17 +353,17 @@ GEM mutex_m railties (~> 7.0) metasploit-payloads (2.0.245) - metasploit_data_models (6.0.15) - activerecord (~> 7.0) - activesupport (~> 7.0) + metasploit_data_models (6.0.18) + activerecord (>= 7.0, < 8.1) + activesupport (>= 7.0, < 8.1) arel-helpers bigdecimal drb metasploit-concern - metasploit-model (~> 5.0.4) + metasploit-model (>= 5.0.4) mutex_m pg - railties (~> 7.0) + railties (>= 7.0, < 8.1) recog webrick metasploit_payloads-mettle (1.0.46) diff --git a/db/schema.rb b/db/schema.rb index 7ef2a2ef85e93..138a9d06ddcec 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: 2026_01_30_124052) do +ActiveRecord::Schema[7.2].define(version: 2026_04_11_000000) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -665,6 +665,8 @@ t.integer "session_id" t.integer "loot_id" t.text "fail_detail" + t.string "check_code" + t.text "check_detail" end create_table "vuln_details", id: :serial, force: :cascade do |t| diff --git a/lib/msf/base/simple/auxiliary.rb b/lib/msf/base/simple/auxiliary.rb index ff3577ffaba97..7cd7ddd7b087d 100644 --- a/lib/msf/base/simple/auxiliary.rb +++ b/lib/msf/base/simple/auxiliary.rb @@ -175,9 +175,13 @@ def self.job_run_proc(ctx, &block) begin begin job_listener.start run_uuid + mod.check_code = nil if mod.respond_to?(:check_code=) + mod.last_vuln_attempt = nil if mod.respond_to?(:last_vuln_attempt=) mod.setup mod.framework.events.on_module_run(mod) result = block.call(mod) + # Store the check result if the block returned a CheckCode + mod.check_code = result if result.is_a?(Msf::Exploit::CheckCode) job_listener.completed(run_uuid, result, mod) rescue ::Exception => e job_listener.failed(run_uuid, e, mod) diff --git a/lib/msf/core/auxiliary.rb b/lib/msf/core/auxiliary.rb index 1f25562c8563a..6cebbf7b52363 100644 --- a/lib/msf/core/auxiliary.rb +++ b/lib/msf/core/auxiliary.rb @@ -181,6 +181,18 @@ def fail_with(reason, msg = nil) # attr_accessor :fail_detail + # + # The result of the last check invocation (a Msf::Exploit::CheckCode), if any + # + attr_accessor :check_code + + # + # The VulnAttempt object created during this run, or nil/false if none + # was recorded. Used to prevent duplicate attempts when report_failure + # is called later and to enrich the attempt with check code details. + # + attr_accessor :last_vuln_attempt + attr_accessor :queue protected diff --git a/lib/msf/core/auxiliary/multiple_target_hosts.rb b/lib/msf/core/auxiliary/multiple_target_hosts.rb index 34284df750a33..61cf26590ecf6 100644 --- a/lib/msf/core/auxiliary/multiple_target_hosts.rb +++ b/lib/msf/core/auxiliary/multiple_target_hosts.rb @@ -20,7 +20,17 @@ def check return Exploit::CheckCode::Unsupported unless has_check? nmod = replicant - nmod.check_host(datastore['RHOST']) + result = nmod.check_host(datastore['RHOST']) + + # Propagate the last_vuln_attempt (which may be the actual VulnAttempt + # object) back from the replicant so that the ensure block in + # job_run_proc (which calls report_failure on the *original* instance) + # knows a vuln attempt was already created and can enrich it directly. + if nmod.respond_to?(:last_vuln_attempt) && nmod.last_vuln_attempt && respond_to?(:last_vuln_attempt=) + self.last_vuln_attempt = nmod.last_vuln_attempt + end + + result end end diff --git a/lib/msf/core/auxiliary/report.rb b/lib/msf/core/auxiliary/report.rb index 4918aa0f83070..fea20da5da334 100644 --- a/lib/msf/core/auxiliary/report.rb +++ b/lib/msf/core/auxiliary/report.rb @@ -314,11 +314,31 @@ def report_vuln(opts={}) :fail_detail => 'vulnerability identified', :fail_reason => 'Untried', # Mdm::VulnAttempt::Status::UNTRIED, avoiding direct dependency on Mdm, used elsewhere in this module :module => mname, - :username => username || "unknown" + :username => username || self.owner || "unknown" } + # Enrich attempt with check code details when available. + # Accept an explicit check_code in opts (useful when the module knows the + # result before the framework sets self.check_code), falling back to the + # module-level accessor. + check_code = opts[:check_code] + check_code = self.check_code if check_code.nil? && self.respond_to?(:check_code) + if check_code.is_a?(Msf::Exploit::CheckCode) + attempt_info[:check_code] = check_code.code + attempt_info[:check_detail] = check_code.reason || check_code.message + attempt_info[:fail_detail] = nil + mapped_reason = Msf::Module::Failure.fail_reason_from_check_code(check_code) + attempt_info[:fail_reason] = mapped_reason if mapped_reason + end + # TODO: figure out what opts are required and why the above logic doesn't match that of the db_manager method - framework.db.report_vuln_attempt(vuln, attempt_info) + attempt = framework.db.report_vuln_attempt(vuln, attempt_info) + + # Store the attempt object so that report_failure (called later by the + # job wrapper) can enrich it directly without re-querying the DB. + if self.respond_to?(:last_vuln_attempt=) + self.last_vuln_attempt = attempt || true + end vuln end diff --git a/lib/msf/core/auxiliary/scanner.rb b/lib/msf/core/auxiliary/scanner.rb index 23a5037b64c9b..721b0e113a551 100644 --- a/lib/msf/core/auxiliary/scanner.rb +++ b/lib/msf/core/auxiliary/scanner.rb @@ -15,6 +15,19 @@ module Auxiliary::Scanner class AttemptFailed < Msf::Auxiliary::Failed end +# Scanner modules handle per-host failure reporting through replicants +# inside their run_host/run_batch threads. Override the default +# report_failure so that the parent-level call from job_run_proc's +# ensure block does not create a duplicate or misattributed attempt +# after a scan. The check path (check_simple) still needs the +# default report_failure behaviour, so we only skip when the scanner's +# run method has executed. +def report_failure + return if @scanner_run_completed + + super +end + # # Initializes an instance of a recon auxiliary module # @@ -42,6 +55,7 @@ def peer # The command handler when launched from the console # def run + @scanner_run_completed = false @show_progress = datastore['ShowProgress'] @show_percent = datastore['ShowProgressPercent'].to_i @@ -260,6 +274,7 @@ def run print_status("Caught interrupt from the console...") return ensure + @scanner_run_completed = true seppuko!() end end diff --git a/lib/msf/core/db_manager/exploit_attempt.rb b/lib/msf/core/db_manager/exploit_attempt.rb index a55ea278f9bea..9ca8308e92141 100644 --- a/lib/msf/core/db_manager/exploit_attempt.rb +++ b/lib/msf/core/db_manager/exploit_attempt.rb @@ -79,9 +79,25 @@ def report_exploit_failure(opts) vuln = nil if rids.present? - # Try to find an existing vulnerability with the same service & references - # or, if svc is nil, with the same host & references - vuln = find_vuln_by_refs(rids, host, svc, false) + # Only perform vuln lookup when no check_code is present (normal + # exploit flow) or the check result positively indicates vulnerability. + # Safe, Unknown, and Detected results should not associate this attempt + # with an existing vuln. Only key off check_code — fail_reason alone + # is too broad (e.g. Failure::Unknown covers real exploit failures too). + vuln_check_codes = [Msf::Exploit::CheckCode::Appears.code, Msf::Exploit::CheckCode::Vulnerable.code] + if opts[:check_code].nil? || vuln_check_codes.include?(opts[:check_code]) + # Try to find an existing vulnerability with the same service & references + # or, if svc is nil, with the same host & references + vuln = find_vuln_by_refs(rids, host, svc, false) + + # Fall back to a host-only lookup when the service-scoped query found + # nothing. Only match vulns with no associated service to avoid + # misattributing attempts to a vuln on a different service. + if svc && vuln.nil? + fallback_vuln = find_vuln_by_refs(rids, host, nil, false) + vuln = fallback_vuln if fallback_vuln && fallback_vuln.service_id.nil? + end + end end opts[:service] = svc @@ -158,8 +174,20 @@ def do_report_failure_or_success(opts) # Create a references map from the module list ref_objs = ::Mdm::Ref.where(name: ref_names) - # Try find a matching vulnerability - vuln = find_vuln_by_refs(ref_objs, host, svc, false) + # Only perform vuln lookup when no check_code is present (normal + # exploit flow) or the check result positively indicates vulnerability. + # Safe, Unknown, and Detected results should not associate this attempt + # with an existing vuln. Only key off check_code — fail_reason alone + # is too broad (e.g. Failure::Unknown covers real exploit failures too). + vuln_check_codes = [Msf::Exploit::CheckCode::Appears.code, Msf::Exploit::CheckCode::Vulnerable.code] + if opts[:check_code].nil? || vuln_check_codes.include?(opts[:check_code]) + # Try find a matching vulnerability + vuln = find_vuln_by_refs(ref_objs, host, svc, false) + if svc && vuln.nil? + fallback_vuln = find_vuln_by_refs(ref_objs, host, nil, false) + vuln = fallback_vuln if fallback_vuln && fallback_vuln.service_id.nil? + end + end end attempt_info = { @@ -170,12 +198,17 @@ def do_report_failure_or_success(opts) :module => mname, :username => username || "unknown", } + attempt_info[:check_code] = opts[:check_code] if opts[:check_code] + attempt_info[:check_detail] = opts[:check_detail] if opts[:check_detail] attempt_info[:session_id] = opts[:session_id] if opts[:session_id] attempt_info[:loot_id] = opts[:loot_id] if opts[:loot_id] - # We have match, lets create a vuln_attempt record - if vuln + # We have match, lets create a vuln_attempt record. + # Skip if the caller already recorded a vuln attempt for this run + # (e.g. Auxiliary::Report#report_vuln sets skip_vuln_attempt via + # the last_vuln_attempt flag on the module). + if vuln && !opts[:skip_vuln_attempt] attempt_info[:vuln_id] = vuln.id vuln.vuln_attempts.create(attempt_info) @@ -200,7 +233,8 @@ def do_report_failure_or_success(opts) attempt_info[:proto] = prot || Msf::DBManager::DEFAULT_SERVICE_PROTO end - host.exploit_attempts.create(attempt_info) + # check_code and check_detail are valid for VulnAttempt but not ExploitAttempt + host.exploit_attempts.create(attempt_info.except(:check_code, :check_detail)) } end diff --git a/lib/msf/core/exploit.rb b/lib/msf/core/exploit.rb index 352ce3a70cfd8..f9bda8e624b7c 100644 --- a/lib/msf/core/exploit.rb +++ b/lib/msf/core/exploit.rb @@ -1493,6 +1493,13 @@ def handle_exception e # attr_accessor :fail_detail + # + # The VulnAttempt object created during this run, or nil/false if none + # was recorded. Used to prevent duplicate attempts when report_failure + # is called later and to enrich the attempt with check code details. + # + attr_accessor :last_vuln_attempt + # # The list of targets. # diff --git a/lib/msf/core/exploit/remote/auto_check.rb b/lib/msf/core/exploit/remote/auto_check.rb index b656360b3cfbc..bc71d16df97f9 100644 --- a/lib/msf/core/exploit/remote/auto_check.rb +++ b/lib/msf/core/exploit/remote/auto_check.rb @@ -51,7 +51,8 @@ def with_prepended_auto_check name: fullname, username: respond_to?(:owner) ? owner : nil, refs: references, - info: description.strip + info: description.strip, + check_code: check_code } if respond_to?(:session) && session.respond_to?(:session_host) diff --git a/lib/msf/core/module/failure.rb b/lib/msf/core/module/failure.rb index 0e2b8ef9602b2..09e1487c3c932 100644 --- a/lib/msf/core/module/failure.rb +++ b/lib/msf/core/module/failure.rb @@ -39,6 +39,23 @@ module Msf::Module::Failure # The exploit was interrupted by the user UserInterrupt = 'user-interrupt' + # Map a {Msf::Exploit::CheckCode} to the corresponding fail_reason constant. + # + # @param check_code [Msf::Exploit::CheckCode] + # @return [String, nil] a Failure constant, or nil if unmapped + def self.fail_reason_from_check_code(check_code) + return nil unless check_code.respond_to?(:code) + + case check_code.code + when Msf::Exploit::CheckCode::Vulnerable.code, Msf::Exploit::CheckCode::Appears.code + None + when Msf::Exploit::CheckCode::Safe.code + NotVulnerable + when Msf::Exploit::CheckCode::Detected.code, Msf::Exploit::CheckCode::Unknown.code + Unknown + end + end + def report_failure return unless framework.db and framework.db.active @@ -55,6 +72,14 @@ def report_failure } info[:target_name] = self.target.name if self.respond_to?(:target) + # Enrich attempt data with check result details when available + if self.respond_to?(:check_code) && self.check_code.is_a?(Msf::Exploit::CheckCode) + info[:check_code] = self.check_code.code + info[:check_detail] = self.check_code.reason || self.check_code.message + mapped_reason = Msf::Module::Failure.fail_reason_from_check_code(self.check_code) + info[:fail_reason] = mapped_reason if mapped_reason + end + if self.datastore['RHOST'] && (self.options['RHOST'] || self.options['RHOSTS']) # Only include RHOST if it's a single valid host, not a multi-value string or file path rhost = self.datastore['RHOST'].to_s @@ -63,15 +88,117 @@ def report_failure info[:host] = rhost end end - + if self.datastore['RPORT'] and self.options['RPORT'] info[:port] = self.datastore['RPORT'] if self.class.ancestors.include?(Msf::Exploit::Remote::Tcp) info[:proto] = 'tcp' + elsif self.class.ancestors.include?(Msf::Exploit::Remote::Udp) + info[:proto] = 'udp' end end + # When the check identified a vulnerability, ensure the vuln record exists + # before report_exploit_failure tries to look it up. The UI-level + # check_simple also calls report_vuln, but that happens *after* this + # ensure block, so the vuln wouldn't exist yet for the attempt lookup. + if info[:host] && self.respond_to?(:check_code) && + self.check_code.is_a?(Msf::Exploit::CheckCode) && + [Msf::Exploit::CheckCode::Vulnerable, Msf::Exploit::CheckCode::Appears].include?(self.check_code) + vuln_info = if self.check_code == Msf::Exploit::CheckCode::Appears + "Target appears vulnerable based on check of #{self.fullname}." + else + "Vulnerability confirmed by check of #{self.fullname}." + end + vuln_opts = { + workspace: info[:workspace], + host: info[:host], + name: self.name, + refs: self.references, + info: vuln_info + } + # Include port so that checks against different ports on the same + # host create distinct vuln records instead of collapsing into one. + vuln_opts[:port] = info[:port] if info[:port] + vuln_opts[:proto] = info[:proto] if info[:proto] + framework.db.report_vuln(vuln_opts) + end + + # Skip creating a duplicate vuln attempt if one was already recorded + # during this run (e.g. by Auxiliary::Report#report_vuln). When a + # check_code is available, update the existing attempt so it carries the + # check result details (the attempt created by report_vuln may not have + # had the check_code yet because it runs before job_run_proc stores it). + if self.respond_to?(:last_vuln_attempt) && self.last_vuln_attempt + if self.respond_to?(:check_code) && self.check_code.is_a?(Msf::Exploit::CheckCode) + _enrich_existing_vuln_attempt(info, self.last_vuln_attempt) + end + info[:skip_vuln_attempt] = true + end + framework.db.report_exploit_failure(info) end + private + + # Update the VulnAttempt for this module/host with check code details that + # were not available when report_vuln originally created it. + # + # @param info [Hash] enrichment data built by report_failure + # @param recorded_attempt [Mdm::VulnAttempt, true] the attempt object stored + # by report_vuln, or +true+ if only the flag was propagated (legacy/fallback). + def _enrich_existing_vuln_attempt(info, recorded_attempt = nil) + return unless framework.db&.active + + # Use the stored attempt directly when available — avoids a racy + # re-query that could match the wrong row under concurrency. + attempt = recorded_attempt if recorded_attempt.is_a?(::Mdm::VulnAttempt) + + # Fallback: re-query if we only have the boolean flag (e.g. propagated + # through a replicant that only forwarded +true+). + if attempt.nil? + host = info[:host] + return unless host + + host_obj = if host.is_a?(::Mdm::Host) + host + else + wspace = info[:workspace] || framework.db.find_workspace(workspace) + framework.db.get_host(workspace: wspace, address: host.to_s) + end + return unless host_obj + + scope = ::Mdm::VulnAttempt + .joins(:vuln) + .where(module: fullname, vulns: { host_id: host_obj.id }) + + # Narrow by service attributes when available so we don't match an + # attempt against a different service on the same host (e.g. port 80 + # vs 9200, or TCP vs UDP on the same port). + if info[:port] + service_conditions = { port: info[:port] } + service_conditions[:proto] = info[:proto].to_s.downcase if info[:proto] + + scope = scope.joins(vuln: :service) + .where(services: service_conditions) + end + + attempt = scope.order(attempted_at: :desc).first + end + + return unless attempt + + updates = {} + updates[:check_code] = info[:check_code] if info[:check_code] && attempt.check_code.blank? + updates[:check_detail] = info[:check_detail] if info[:check_detail] && attempt.check_detail.blank? + mapped_reason = Msf::Module::Failure.fail_reason_from_check_code(check_code) + updates[:fail_reason] = mapped_reason if mapped_reason && attempt.fail_reason == 'Untried' + # Clear the placeholder fail_detail set by report_vuln when we have a + # real check result. + updates[:fail_detail] = nil if updates[:fail_reason] && attempt.fail_detail == 'vulnerability identified' + + attempt.update(updates) if updates.any? + rescue ::StandardError => e + elog('Failed to enrich vuln attempt with check code', error: e) + end end diff --git a/lib/msf/ui/console/command_dispatcher/db.rb b/lib/msf/ui/console/command_dispatcher/db.rb index 107f4ec2e52cd..f09a6f47d9e66 100644 --- a/lib/msf/ui/console/command_dispatcher/db.rb +++ b/lib/msf/ui/console/command_dispatcher/db.rb @@ -2402,6 +2402,8 @@ def _format_vulns_and_vuln_attempts(vulns) Session ID: #{_format_vuln_value(vuln_attempt.session_id)} Loot ID: #{_format_vuln_value(vuln_attempt.loot_id)} Fail Detail: #{_format_vuln_value(vuln_attempt.fail_detail)} + Check Code: #{_format_vuln_value(vuln_attempt.check_code)} + Check Detail: #{_format_vuln_value(vuln_attempt.check_detail)} EOF end diff --git a/lib/msf/ui/console/module_command_dispatcher.rb b/lib/msf/ui/console/module_command_dispatcher.rb index bc7fc1f6c2737..8da619a95d2de 100644 --- a/lib/msf/ui/console/module_command_dispatcher.rb +++ b/lib/msf/ui/console/module_command_dispatcher.rb @@ -178,12 +178,27 @@ def report_vuln(instance, checkcode = nil) opts = { workspace: instance.workspace, host: instance.respond_to?(:target_host) && instance.target_host ? instance.target_host : instance.datastore['RHOST'], - proto: instance.datastore['PROTO'] || 'tcp', + proto: if instance.class.ancestors.include?(Msf::Exploit::Remote::Udp) + 'udp' + else + 'tcp' + end, name: instance.name, - info: "This was flagged as vulnerable by the explicit check of #{instance.fullname}.", + info: if checkcode == Msf::Exploit::CheckCode::Appears + "Target appears vulnerable based on the explicit check of #{instance.fullname}." + else + "Vulnerability confirmed by the explicit check of #{instance.fullname}." + end, refs: instance.references } + # Include port so that checks against different ports on the same host + # create distinct vuln records instead of collapsing into one. + if instance.datastore['RPORT'] + rport = instance.respond_to?(:rport) ? instance.rport : instance.datastore['RPORT'] + opts[:port] = rport.to_i if rport.to_i > 0 + end + 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)) } @@ -221,7 +236,7 @@ def check_simple(instance=nil) end if (code && code.kind_of?(Msf::Exploit::CheckCode)) - if (code == Msf::Exploit::CheckCode::Vulnerable) + if code == Msf::Exploit::CheckCode::Vulnerable || code == Msf::Exploit::CheckCode::Appears print_good("#{peer_msg}#{code[1]}") # Restore RHOST for report_vuln instance.datastore['RHOST'] ||= rhost diff --git a/modules/auxiliary/scanner/rdp/ms12_020_check.rb b/modules/auxiliary/scanner/rdp/ms12_020_check.rb index 6a013f61b5863..f18e5b88ac2b4 100644 --- a/modules/auxiliary/scanner/rdp/ms12_020_check.rb +++ b/modules/auxiliary/scanner/rdp/ms12_020_check.rb @@ -132,7 +132,7 @@ def check_rdp_vuln # check if rdp is open unless check_rdp vprint_status "Could not connect to RDP." - return Exploit::CheckCode::Unknown + return Exploit::CheckCode::Unknown('Could not connect to RDP') end # send connectInitial @@ -141,7 +141,7 @@ def check_rdp_vuln # send userRequest sock.put(user_request) res = sock.get_once(-1, 5) - return Exploit::CheckCode::Unknown unless res # nil due to a timeout + return Exploit::CheckCode::Unknown('No response to first userRequest') unless res # nil due to a timeout user1 = res[9, 2].unpack("n").first chan1 = user1 + 1001 @@ -149,7 +149,7 @@ def check_rdp_vuln # send 2nd userRequest sock.put(user_request) res = sock.get_once(-1, 5) - return Exploit::CheckCode::Unknown unless res # nil due to a timeout + return Exploit::CheckCode::Unknown('No response to second userRequest') unless res # nil due to a timeout user2 = res[9, 2].unpack("n").first chan2 = user2 + 1001 @@ -157,20 +157,17 @@ def check_rdp_vuln # send channel request one sock.put(channel_request << [user1, chan2].pack("nn")) res = sock.get_once(-1, 5) - return Exploit::CheckCode::Unknown unless res # nil due to a timeout + return Exploit::CheckCode::Unknown('No response to channel request') unless res # nil due to a timeout if res[7, 2] == "\x3e\x00" # send ChannelRequestTwo - prevent BSoD sock.put(channel_request << [user2, chan2].pack("nn")) report_goods - return Exploit::CheckCode::Vulnerable + return Exploit::CheckCode::Vulnerable('Response confirmed vulnerability presence') else - return Exploit::CheckCode::Safe + return Exploit::CheckCode::Safe('Not vulnerable') end - - # Can't determine, but at least I know the service is running - return Exploit::CheckCode::Detected end def check_host(ip) diff --git a/modules/exploits/multi/http/phpmyadmin_preg_replace.rb b/modules/exploits/multi/http/phpmyadmin_preg_replace.rb index ea6edf55abcfd..e2b3720f9fd3f 100644 --- a/modules/exploits/multi/http/phpmyadmin_preg_replace.rb +++ b/modules/exploits/multi/http/phpmyadmin_preg_replace.rb @@ -83,13 +83,13 @@ def check vprint_status("PHP Version: #{php_version}") if php_version =~ /PHP\/(\d)\.(\d)\.(\d)/ if $1.to_i > 5 - return CheckCode::Safe("PHP version #{php_version} is not vulnerable. Only PHP <= 5.4.6 supports preg_replace /e modifier.") + return CheckCode::Safe("PHP version #{php_version} is not vulnerable. The preg_replace /e modifier was deprecated in PHP 5.5 and removed in PHP 7.0.") else if $1.to_i == 5 and $2.to_i > 4 - return CheckCode::Safe("PHP version #{php_version} is not vulnerable. Only PHP <= 5.4.6 supports preg_replace /e modifier.") + return CheckCode::Safe("PHP version #{php_version} is not vulnerable. The preg_replace /e modifier was deprecated in PHP 5.5 and removed in PHP 7.0.") else if $1.to_i == 5 and $2.to_i == 4 and $3.to_i > 6 - return CheckCode::Safe("PHP version #{php_version} is not vulnerable. Only PHP <= 5.4.6 supports preg_replace /e modifier.") + return CheckCode::Safe("PHP version #{php_version} is not vulnerable. The preg_replace /e modifier was deprecated in PHP 5.5 and removed in PHP 7.0.") end end end @@ -114,7 +114,7 @@ def check end end - CheckCode::Safe('Could not determine phpMyAdmin version.') + CheckCode::Unknown('Could not determine phpMyAdmin version.') end def exploit diff --git a/spec/lib/msf/core/exploit/remote/auto_check_spec.rb b/spec/lib/msf/core/exploit/remote/auto_check_spec.rb index 91554c9989e48..f82ebf397645a 100644 --- a/spec/lib/msf/core/exploit/remote/auto_check_spec.rb +++ b/spec/lib/msf/core/exploit/remote/auto_check_spec.rb @@ -107,7 +107,8 @@ name: a_kind_of(String), info: a_kind_of(String), refs: a_kind_of(Array), - host: '192.0.2.2' + host: '192.0.2.2', + check_code: ::Msf::Exploit::CheckCode::Vulnerable )) end end @@ -137,7 +138,8 @@ refs: a_kind_of(Array), host: '192.0.2.2', port: 8080, - proto: 'tcp' + proto: 'tcp', + check_code: ::Msf::Exploit::CheckCode::Vulnerable )) end end diff --git a/spec/lib/msf/core/module/failure_spec.rb b/spec/lib/msf/core/module/failure_spec.rb index c185827f809fd..dee7599fff28f 100644 --- a/spec/lib/msf/core/module/failure_spec.rb +++ b/spec/lib/msf/core/module/failure_spec.rb @@ -99,4 +99,60 @@ it { is_expected.to eq('payload-failed') } end end -end \ No newline at end of file + + describe '.fail_reason_from_check_code' do + { + Msf::Exploit::CheckCode::Vulnerable => Msf::Module::Failure::None, + Msf::Exploit::CheckCode::Appears => Msf::Module::Failure::None, + Msf::Exploit::CheckCode::Safe => Msf::Module::Failure::NotVulnerable, + Msf::Exploit::CheckCode::Detected => Msf::Module::Failure::Unknown, + Msf::Exploit::CheckCode::Unknown => Msf::Module::Failure::Unknown + }.each do |check_code, expected_reason| + it "maps #{check_code.code} to #{expected_reason}" do + expect(described_class.fail_reason_from_check_code(check_code)).to eq(expected_reason) + end + end + + it 'returns nil for nil input' do + expect(described_class.fail_reason_from_check_code(nil)).to be_nil + end + + context 'with check codes carrying reason metadata' do + { + Msf::Exploit::CheckCode::Vulnerable('ThinkPHP 5.0.23 is a vulnerable version.') => Msf::Module::Failure::None, + Msf::Exploit::CheckCode::Appears('Version 5.0.23 appears vulnerable') => Msf::Module::Failure::None, + Msf::Exploit::CheckCode::Safe('Patched version detected') => Msf::Module::Failure::NotVulnerable, + Msf::Exploit::CheckCode::Detected('Service is running') => Msf::Module::Failure::Unknown, + Msf::Exploit::CheckCode::Unknown('Could not determine') => Msf::Module::Failure::Unknown + }.each do |check_code, expected_reason| + it "maps #{check_code.code} with reason '#{check_code.reason}' to #{expected_reason}" do + expect(described_class.fail_reason_from_check_code(check_code)).to eq(expected_reason) + end + end + end + end + + describe 'CheckCode equality with metadata (used by report_failure)' do + let(:vuln_codes) { [Msf::Exploit::CheckCode::Vulnerable, Msf::Exploit::CheckCode::Appears] } + + it 'matches Vulnerable instance with reason via include?' do + code = Msf::Exploit::CheckCode::Vulnerable('ThinkPHP 5.0.23 is vulnerable') + expect(vuln_codes.include?(code)).to be true + end + + it 'matches Appears instance with reason via include?' do + code = Msf::Exploit::CheckCode::Appears('Version appears vulnerable') + expect(vuln_codes.include?(code)).to be true + end + + it 'does not match Safe instance via include?' do + code = Msf::Exploit::CheckCode::Safe('Patched') + expect(vuln_codes.include?(code)).to be false + end + + it 'does not match Detected instance via include?' do + code = Msf::Exploit::CheckCode::Detected('Service running') + expect(vuln_codes.include?(code)).to be false + end + end +end diff --git a/spec/lib/msf/core/vuln_attempt_registration_spec.rb b/spec/lib/msf/core/vuln_attempt_registration_spec.rb new file mode 100644 index 0000000000000..06c54e3c7f939 --- /dev/null +++ b/spec/lib/msf/core/vuln_attempt_registration_spec.rb @@ -0,0 +1,1126 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# Integration-level specs verifying that vulns and vuln_attempts are correctly +# registered for auxiliary and exploit module execution scenarios. These tests +# exercise the real framework plumbing (cmd_check / cmd_run via the console +# dispatchers, job_run_proc, report_failure, etc.) +RSpec.describe 'Vuln and VulnAttempt registration', if: !ENV['REMOTE_DB'] do + include_context 'Msf::DBManager' + include_context 'Msf::UIDriver' + include_context 'Rex::Job#start run inline' + include_context 'Msf::Framework#threads cleaner', verify_cleanup_required: false + + let(:check_detail_message) { 'Service is vulnerable to CVE-2025-99999' } + let(:vulnerable_check_code) { Msf::Exploit::CheckCode::Vulnerable(check_detail_message) } + let(:safe_check_code) { Msf::Exploit::CheckCode::Safe('Not vulnerable') } + let(:appears_check_code) { Msf::Exploit::CheckCode::Appears('Likely vulnerable') } + + subject(:db) { Msf::Ui::Console::CommandDispatcher::Db.new(driver) } + + # Build a module from a class, wired to the test framework + def build_module(klass) + mod = klass.new + allow(mod).to receive(:framework).and_return(framework) + datastore = Msf::ModuleDataStore.new(mod) + mod.send(:datastore=, datastore) + datastore.import_options(mod.options) + Msf::Simple::Framework.simplify_module(mod) + mod + end + + let(:aux_dispatcher) do + Msf::Ui::Console::CommandDispatcher::Auxiliary.new(driver) + end + + let(:exploit_dispatcher) do + Msf::Ui::Console::CommandDispatcher::Exploit.new(driver) + end + + before(:each) do + run_rex_jobs_inline! + allow(driver).to receive(:input).and_return(driver_input) + allow(driver).to receive(:output).and_return(driver_output) + end + + # --------------------------------------------------------------------------- + # Scanner module classes used across multiple describe blocks + # --------------------------------------------------------------------------- + + # Base scanner module class — uses class-level accessor so check_host + # behavior survives module replication. + let(:scanner_module_class) do + klass = Class.new(Msf::Auxiliary) do + include Msf::Exploit::Remote::Tcp + include Msf::Auxiliary::Scanner + include Msf::Auxiliary::Report + + class << self + attr_accessor :injected_check_code + end + + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'Test Module', + 'Description' => 'Test', + 'Author' => ['test'], + 'License' => MSF_LICENSE, + 'References' => [['CVE', '2025-99999']], + 'Notes' => { 'SideEffects' => [], 'Stability' => [], 'Reliability' => [] } + ) + ) + register_options([Msf::Opt::RPORT(1234)]) + end + + def check_host(_ip) + self.class.injected_check_code + end + + def run_host(_ip) + raise 'should be stubbed' + end + + def cleanup; end + end + klass.refname = 'scanner/test/test_module' + klass + end + + # Scanner whose check_host calls report_vuln before returning a CheckCode, + # mimicking modules like ms12_020_check that call report_goods. + let(:check_reporting_scanner_class) do + klass = Class.new(Msf::Auxiliary) do + include Msf::Exploit::Remote::Tcp + include Msf::Auxiliary::Scanner + include Msf::Auxiliary::Report + + class << self + attr_accessor :injected_check_code + end + + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'Test Check Reporter', + 'Description' => 'Scanner whose check_host calls report_vuln', + 'Author' => ['test'], + 'License' => MSF_LICENSE, + 'References' => [['CVE', '2025-99999']], + 'Notes' => { 'SideEffects' => [], 'Stability' => [], 'Reliability' => [] } + ) + ) + register_options([Msf::Opt::RPORT(1234)]) + end + + def check_host(_ip) + report_vuln( + host: datastore['RHOST'], + port: rport, + proto: 'tcp', + name: self.name, + info: 'Response indicates a missing patch', + refs: self.references + ) + self.class.injected_check_code + end + + def run_host(_ip) + raise 'should not be called during check' + end + + def cleanup; end + end + klass.refname = 'scanner/test/check_reporter' + klass + end + + # Scanner whose run_host calls report_vuln with a check_code. + let(:vuln_reporting_scanner_class) do + klass = Class.new(Msf::Auxiliary) do + include Msf::Exploit::Remote::Tcp + include Msf::Auxiliary::Scanner + include Msf::Auxiliary::Report + + class << self + attr_accessor :injected_check_code + end + + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'Test Vuln Scanner', + 'Description' => 'Scanner that reports vulns from run_host', + 'Author' => ['test'], + 'License' => MSF_LICENSE, + 'References' => [['CVE', '2025-99999']], + 'Notes' => { 'SideEffects' => [], 'Stability' => [], 'Reliability' => [] } + ) + ) + + register_options([Msf::Opt::RPORT(22)]) + end + + def run_host(ip) + report_vuln( + host: ip, + port: rport, + proto: 'tcp', + name: name, + refs: self.references, + info: "SSH Host Key Encryption ecdsa-sha2-nistp256 is available, but should be deprecated", + check_code: self.class.injected_check_code + ) + end + + def cleanup; end + end + klass.refname = 'scanner/test/vuln_scanner' + klass + end + + # Scanner whose run_host does NOT call report_vuln. + let(:clean_scanner_class) do + klass = Class.new(Msf::Auxiliary) do + include Msf::Exploit::Remote::Tcp + include Msf::Auxiliary::Scanner + include Msf::Auxiliary::Report + + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'Test Clean Scanner', + 'Description' => 'Scanner that does not report vulns', + 'Author' => ['test'], + 'License' => MSF_LICENSE, + 'References' => [['CVE', '2025-99999']], + 'Notes' => { 'SideEffects' => [], 'Stability' => [], 'Reliability' => [] } + ) + ) + + register_options([Msf::Opt::RPORT(22)]) + end + + def run_host(_ip) + print_status("Scanned #{datastore['RHOST']}") + end + + def cleanup; end + end + klass.refname = 'scanner/test/clean_scanner' + klass + end + + # Scanner that only reports a vuln for hosts in the vulnerable_hosts set. + let(:selective_scanner_class) do + klass = Class.new(Msf::Auxiliary) do + include Msf::Exploit::Remote::Tcp + include Msf::Auxiliary::Scanner + include Msf::Auxiliary::Report + + class << self + attr_accessor :vulnerable_hosts, :injected_check_code + end + + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'Test Selective Scanner', + 'Description' => 'Reports vulns only for selected hosts', + 'Author' => ['test'], + 'License' => MSF_LICENSE, + 'References' => [['CVE', '2025-99999']], + 'Notes' => { 'SideEffects' => [], 'Stability' => [], 'Reliability' => [] } + ) + ) + + register_options([Msf::Opt::RPORT(22)]) + end + + def run_host(ip) + return unless self.class.vulnerable_hosts&.include?(ip) + + report_vuln( + host: ip, + port: rport, + proto: 'tcp', + name: name, + refs: self.references, + info: "Deprecated algorithm on #{ip}", + check_code: self.class.injected_check_code + ) + end + + def cleanup; end + end + klass.refname = 'scanner/test/selective_scanner' + klass + end + + # Non-scanner auxiliary module class for AutoCheck scenarios. + # Uses class-level accessors so behavior survives replication. + let(:simple_module_class) do + Class.new(Msf::Auxiliary) do + include Msf::Exploit::Remote::Tcp + include Msf::Auxiliary::Report + + class << self + attr_accessor :injected_check_code, :run_called + end + + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'Test AutoCheck Module', + 'Description' => 'Test', + 'Author' => ['test'], + 'License' => MSF_LICENSE, + 'References' => [['CVE', '2025-99999']], + 'Notes' => { 'SideEffects' => [], 'Stability' => [], 'Reliability' => [] } + ) + ) + register_options([Msf::Opt::RHOSTS, Msf::Opt::RPORT(1234)]) + end + + def check + self.class.injected_check_code + end + + def run + self.class.run_called = true + end + end + end + + # Exploit module class for AutoCheck scenarios. + # Uses class-level accessors so behavior survives replication. + let(:exploit_module_class) do + klass = Class.new(Msf::Exploit) do + include Msf::Exploit::Remote::Tcp + include Msf::Auxiliary::Report + + class << self + attr_accessor :injected_check_code, :exploit_called + end + + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'Test Exploit Module', + 'Description' => 'Test exploit for vuln attempt registration', + 'Author' => ['test'], + 'License' => MSF_LICENSE, + 'References' => [['CVE', '2025-99999']], + 'Targets' => [['Automatic', {}]], + 'DefaultTarget' => 0, + 'Arch' => ARCH_CMD, + 'Platform' => ['unix'], + 'DisclosureDate' => '2025-01-01', + 'Notes' => { 'SideEffects' => [], 'Stability' => [], 'Reliability' => [] } + ) + ) + register_options([Msf::Opt::RHOSTS, Msf::Opt::RPORT(1234)]) + end + + def check + self.class.injected_check_code + end + + def exploit + self.class.exploit_called = true + end + end + klass.refname = 'test/test_exploit_module' + klass + end + + + context 'when running an auxiliary module' do + # --------------------------------------------------------------------------- + # cmd_check — scanner module + # --------------------------------------------------------------------------- + describe 'cmd_check on a scanner module' do + let(:current_mod) do + scanner_module_class.injected_check_code = nil + build_module(scanner_module_class) + end + + before(:each) do + current_mod.init_ui(driver_input, driver_output) + allow(aux_dispatcher).to receive(:mod).and_return(current_mod) + end + + context 'when check returns Vulnerable' do + before do + scanner_module_class.injected_check_code = vulnerable_check_code + current_mod.datastore['RHOSTS'] = '192.0.2.1' + aux_dispatcher.cmd_check + end + + it 'creates exactly one vuln' do + expect(Mdm::Vuln.count).to eq(1) + end + + it 'creates exactly one vuln attempt' do + expect(Mdm::VulnAttempt.count).to eq(1) + end + + it 'sets check_code to vulnerable on the vuln attempt' do + expect(Mdm::VulnAttempt.last.check_code).to eq('vulnerable') + end + + it 'sets check_detail on the vuln attempt' do + expect(Mdm::VulnAttempt.last.check_detail).to eq(check_detail_message) + end + + it 'does not set exploited on the vuln attempt' do + expect(Mdm::VulnAttempt.last.exploited).to eq(false) + end + + it 'sets fail_reason to none' do + expect(Mdm::VulnAttempt.last.fail_reason).to eq(Msf::Module::Failure::None) + end + + it 'displays check details in vulns -v' do + vuln = Mdm::Vuln.last + vuln_attempt = Mdm::VulnAttempt.last + service_str = vuln.service.present? ? "#{vuln.service.name} (port: #{vuln.service.port}, resource: #{vuln.service.resource.to_json})" : '' + @output = [] + db.cmd_vulns "-v" + expect(@output.join("\n")).to match_table <<~TABLE + Vulnerabilities + =============== + 0. Vuln ID: #{vuln.id} + Timestamp: #{vuln.created_at} + Host: 192.0.2.1 + Name: Test Module + References: CVE-2025-99999 + Information: Vulnerability confirmed by check of auxiliary/scanner/test/test_module. + Resource: {} + Service: #{service_str} + Vuln attempts: + 0. ID: #{vuln_attempt.id} + Vuln ID: #{vuln.id} + Timestamp: #{vuln_attempt.attempted_at} + Exploit: false + Fail reason: none + Username: #{vuln_attempt.username} + Module: auxiliary/scanner/test/test_module + Session ID: nil + Loot ID: nil + Fail Detail: nil + Check Code: vulnerable + Check Detail: #{check_detail_message} + TABLE + end + end + + context 'when check returns Safe' do + before do + scanner_module_class.injected_check_code = safe_check_code + current_mod.datastore['RHOSTS'] = '192.0.2.1' + aux_dispatcher.cmd_check + end + + it 'does not create a vuln' do + expect(Mdm::Vuln.count).to eq(0) + end + + it 'does not create a vuln attempt' do + expect(Mdm::VulnAttempt.count).to eq(0) + end + end + + context 'when check returns Appears' do + before do + scanner_module_class.injected_check_code = appears_check_code + current_mod.datastore['RHOSTS'] = '192.0.2.1' + aux_dispatcher.cmd_check + end + + it 'creates exactly one vuln' do + expect(Mdm::Vuln.count).to eq(1) + end + + it 'creates exactly one vuln attempt' do + expect(Mdm::VulnAttempt.count).to eq(1) + end + + it 'sets check_code to appears on the vuln attempt' do + expect(Mdm::VulnAttempt.last.check_code).to eq('appears') + end + + it 'sets check_detail on the vuln attempt' do + expect(Mdm::VulnAttempt.last.check_detail).to eq('Likely vulnerable') + end + end + end + + # --------------------------------------------------------------------------- + # cmd_check — port-specific vuln registration + # --------------------------------------------------------------------------- + describe 'cmd_check with different ports on the same host' do + let(:current_mod) do + scanner_module_class.injected_check_code = nil + build_module(scanner_module_class) + end + + before(:each) do + current_mod.init_ui(driver_input, driver_output) + allow(aux_dispatcher).to receive(:mod).and_return(current_mod) + end + + context 'when check returns Vulnerable on two different ports' do + before do + scanner_module_class.injected_check_code = vulnerable_check_code + + current_mod.datastore['RHOSTS'] = '192.0.2.1' + current_mod.datastore['RPORT'] = 80 + aux_dispatcher.cmd_check + + current_mod.datastore['RPORT'] = 8080 + aux_dispatcher.cmd_check + end + + it 'creates two separate vulns (one per port)' do + expect(Mdm::Vuln.count).to eq(2) + end + + it 'creates two vuln attempts (one per vuln)' do + expect(Mdm::VulnAttempt.count).to eq(2) + end + + it 'associates each vuln with a different service port' do + ports = Mdm::Vuln.all.map { |v| v.service&.port }.compact.sort + expect(ports).to eq([80, 8080]) + end + end + + context 'when check returns Vulnerable on the same port twice' do + before do + scanner_module_class.injected_check_code = vulnerable_check_code + + current_mod.datastore['RHOSTS'] = '192.0.2.1' + current_mod.datastore['RPORT'] = 80 + aux_dispatcher.cmd_check + aux_dispatcher.cmd_check + end + + it 'creates only one vuln (deduplicates by host+port+name)' do + expect(Mdm::Vuln.count).to eq(1) + end + + it 'creates two vuln attempts against the same vuln' do + expect(Mdm::VulnAttempt.count).to eq(2) + expect(Mdm::VulnAttempt.pluck(:vuln_id).uniq.size).to eq(1) + end + end + + context 'when check returns Vulnerable on one port and Safe on another' do + before do + current_mod.datastore['RHOSTS'] = '192.0.2.1' + + # First check on port 9200 — vulnerable + current_mod.datastore['RPORT'] = 9200 + scanner_module_class.injected_check_code = vulnerable_check_code + aux_dispatcher.cmd_check + + # Second check on port 80 — safe (different service entirely) + current_mod.datastore['RPORT'] = 80 + scanner_module_class.injected_check_code = safe_check_code + aux_dispatcher.cmd_check + end + + it 'creates only one vuln (for the vulnerable port)' do + expect(Mdm::Vuln.count).to eq(1) + end + + it 'creates only one vuln attempt (Safe check on a different port does not attach to the existing vuln)' do + expect(Mdm::VulnAttempt.count).to eq(1) + end + + it 'associates the vuln with the vulnerable port' do + expect(Mdm::Vuln.last.service.port).to eq(9200) + end + + it 'sets check_code to vulnerable on the single vuln attempt' do + expect(Mdm::VulnAttempt.last.check_code).to eq('vulnerable') + end + end + end + + # --------------------------------------------------------------------------- + # cmd_check — scanner replicant check flow (ms12_020-style) + # + # When check_host calls report_vuln internally, MultipleTargetHosts#check + # creates a replicant. The last_vuln_attempt must propagate back + # so report_failure does not create a duplicate. + # --------------------------------------------------------------------------- + describe 'cmd_check — scanner replicant check flow (report_vuln inside check_host)' do + before(:each) do + current_mod.init_ui(driver_input, driver_output) + allow(aux_dispatcher).to receive(:mod).and_return(current_mod) + end + + context 'when check_host reports a vuln and returns Vulnerable' do + let(:current_mod) do + check_reporting_scanner_class.injected_check_code = vulnerable_check_code + build_module(check_reporting_scanner_class) + end + + before do + current_mod.datastore['RHOSTS'] = '192.0.2.1' + aux_dispatcher.cmd_check + end + + it 'creates exactly one vuln' do + expect(Mdm::Vuln.count).to eq(1) + end + + it 'creates exactly one vuln attempt (no duplicate from report_failure)' do + expect(Mdm::VulnAttempt.count).to eq(1) + end + + it 'sets check_code to vulnerable on the vuln attempt' do + expect(Mdm::VulnAttempt.last.check_code).to eq('vulnerable') + end + + it 'sets check_detail on the vuln attempt' do + expect(Mdm::VulnAttempt.last.check_detail).to eq(check_detail_message) + end + + it 'sets fail_reason to none (not Untried)' do + expect(Mdm::VulnAttempt.last.fail_reason).to eq(Msf::Module::Failure::None) + end + + it 'clears the placeholder fail_detail' do + expect(Mdm::VulnAttempt.last.fail_detail).to be_nil + end + + it 'does not set exploited on the vuln attempt' do + expect(Mdm::VulnAttempt.last.exploited).to be_falsey + end + end + + context 'when check_host reports a vuln and returns Appears' do + let(:current_mod) do + check_reporting_scanner_class.injected_check_code = appears_check_code + build_module(check_reporting_scanner_class) + end + + before do + current_mod.datastore['RHOSTS'] = '192.0.2.1' + aux_dispatcher.cmd_check + end + + it 'creates exactly one vuln' do + expect(Mdm::Vuln.count).to eq(1) + end + + it 'creates exactly one vuln attempt' do + expect(Mdm::VulnAttempt.count).to eq(1) + end + + it 'sets check_code to appears on the vuln attempt' do + expect(Mdm::VulnAttempt.last.check_code).to eq('appears') + end + + it 'sets check_detail on the vuln attempt' do + expect(Mdm::VulnAttempt.last.check_detail).to eq('Likely vulnerable') + end + + it 'sets fail_reason to none' do + expect(Mdm::VulnAttempt.last.fail_reason).to eq(Msf::Module::Failure::None) + end + end + + context 'when check_host returns Safe (no report_vuln call)' do + let(:safe_scanner_class) do + klass = Class.new(Msf::Auxiliary) do + include Msf::Exploit::Remote::Tcp + include Msf::Auxiliary::Scanner + include Msf::Auxiliary::Report + + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'Test Safe Check Reporter', + 'Description' => 'Scanner whose check_host returns Safe', + 'Author' => ['test'], + 'License' => MSF_LICENSE, + 'References' => [['CVE', '2025-99999']], + 'Notes' => { 'SideEffects' => [], 'Stability' => [], 'Reliability' => [] } + ) + ) + register_options([Msf::Opt::RPORT(1234)]) + end + + def check_host(_ip) + Msf::Exploit::CheckCode::Safe('Not vulnerable') + end + + def run_host(_ip) + raise 'should not be called during check' + end + + def cleanup; end + end + klass.refname = 'scanner/test/safe_check_reporter' + klass + end + + let(:current_mod) { build_module(safe_scanner_class) } + + before do + current_mod.datastore['RHOSTS'] = '192.0.2.1' + aux_dispatcher.cmd_check + end + + it 'does not create a vuln' do + expect(Mdm::Vuln.count).to eq(0) + end + + it 'does not create a vuln attempt' do + expect(Mdm::VulnAttempt.count).to eq(0) + end + end + + context 'when check_host reports a vuln on two different ports' do + let(:current_mod) do + check_reporting_scanner_class.injected_check_code = vulnerable_check_code + build_module(check_reporting_scanner_class) + end + + before do + current_mod.datastore['RHOSTS'] = '192.0.2.1' + + current_mod.datastore['RPORT'] = 3389 + aux_dispatcher.cmd_check + + current_mod.datastore['RPORT'] = 3390 + aux_dispatcher.cmd_check + end + + it 'creates two separate vulns (one per port)' do + expect(Mdm::Vuln.count).to eq(2) + end + + it 'creates two vuln attempts (one per vuln)' do + expect(Mdm::VulnAttempt.count).to eq(2) + end + + it 'associates each vuln attempt with the correct port' do + Mdm::VulnAttempt.find_each do |attempt| + vuln_port = attempt.vuln.service&.port + expect([3389, 3390]).to include(vuln_port) + end + end + + it 'sets check_code on both vuln attempts' do + Mdm::VulnAttempt.find_each do |attempt| + expect(attempt.check_code).to eq('vulnerable') + end + end + + it 'sets check_detail on both vuln attempts' do + Mdm::VulnAttempt.find_each do |attempt| + expect(attempt.check_detail).to eq(check_detail_message) + end + end + + it 'sets fail_reason to none on both vuln attempts' do + Mdm::VulnAttempt.find_each do |attempt| + expect(attempt.fail_reason).to eq(Msf::Module::Failure::None) + end + end + end + end + + # --------------------------------------------------------------------------- + # cmd_run — scanner module calls report_vuln from run_host + # --------------------------------------------------------------------------- + describe 'cmd_run — scanner replicant flow (report_vuln on replicant, report_failure on parent)' do + before(:each) do + current_mod.init_ui(driver_input, driver_output) + allow(aux_dispatcher).to receive(:mod).and_return(current_mod) + end + + context 'when run_host reports a vuln with Appears check code on a single host' do + let(:current_mod) do + vuln_reporting_scanner_class.injected_check_code = appears_check_code + build_module(vuln_reporting_scanner_class) + end + + before do + current_mod.datastore['RHOSTS'] = '192.0.2.1' + aux_dispatcher.cmd_run + end + + it 'creates exactly one vuln' do + expect(Mdm::Vuln.count).to eq(1) + end + + it 'creates exactly one vuln attempt (no duplicate from report_failure)' do + expect(Mdm::VulnAttempt.count).to eq(1) + end + + it 'sets check_code to appears on the vuln attempt' do + expect(Mdm::VulnAttempt.last.check_code).to eq('appears') + end + + it 'sets check_detail on the vuln attempt' do + expect(Mdm::VulnAttempt.last.check_detail).to eq('Likely vulnerable') + end + end + + context 'when run_host reports a vuln with Vulnerable check code on a single host' do + let(:current_mod) do + vuln_reporting_scanner_class.injected_check_code = vulnerable_check_code + build_module(vuln_reporting_scanner_class) + end + + before do + current_mod.datastore['RHOSTS'] = '192.0.2.1' + aux_dispatcher.cmd_run + end + + it 'creates exactly one vuln' do + expect(Mdm::Vuln.count).to eq(1) + end + + it 'creates exactly one vuln attempt (no duplicate from report_failure)' do + expect(Mdm::VulnAttempt.count).to eq(1) + end + + it 'sets check_code to vulnerable on the vuln attempt' do + expect(Mdm::VulnAttempt.last.check_code).to eq('vulnerable') + end + + it 'sets check_detail on the vuln attempt' do + expect(Mdm::VulnAttempt.last.check_detail).to eq(check_detail_message) + end + + it 'displays check details in vulns -v' do + vuln = Mdm::Vuln.last + vuln_attempt = Mdm::VulnAttempt.last + @output = [] + db.cmd_vulns "-v" + expect(@output.join("\n")).to match_table <<~TABLE + Vulnerabilities + =============== + 0. Vuln ID: #{vuln.id} + Timestamp: #{vuln.created_at} + Host: 192.0.2.1 + Name: Test Vuln Scanner + References: CVE-2025-99999 + Information: SSH Host Key Encryption ecdsa-sha2-nistp256 is available, but should be deprecated + Resource: {} + Service: (port: 22, resource: {}) + Vuln attempts: + 0. ID: #{vuln_attempt.id} + Vuln ID: #{vuln.id} + Timestamp: #{vuln_attempt.attempted_at} + Exploit: #{vuln_attempt.exploited} + Fail reason: #{vuln_attempt.fail_reason} + Username: #{vuln_attempt.username} + Module: #{vuln_attempt.module} + Session ID: nil + Loot ID: nil + Fail Detail: #{vuln_attempt.fail_detail || 'nil'} + Check Code: vulnerable + Check Detail: #{check_detail_message} + TABLE + end + end + + context 'when run_host reports vulns on multiple hosts' do + let(:current_mod) do + vuln_reporting_scanner_class.injected_check_code = appears_check_code + build_module(vuln_reporting_scanner_class) + end + + before do + current_mod.datastore['RHOSTS'] = '192.0.2.1 192.0.2.2' + aux_dispatcher.cmd_run + end + + it 'creates one vuln per host' do + expect(Mdm::Vuln.count).to eq(2) + end + + it 'creates one vuln attempt per host (no extra from report_failure)' do + expect(Mdm::VulnAttempt.count).to eq(2) + end + + it 'sets check_code on all vuln attempts' do + Mdm::VulnAttempt.find_each do |attempt| + expect(attempt.check_code).to eq('appears') + end + end + end + + context 'when run_host does not report a vuln' do + let(:current_mod) { build_module(clean_scanner_class) } + + before do + current_mod.datastore['RHOSTS'] = '192.0.2.1' + aux_dispatcher.cmd_run + end + + it 'does not create a vuln' do + expect(Mdm::Vuln.count).to eq(0) + end + + it 'does not create a vuln attempt' do + expect(Mdm::VulnAttempt.count).to eq(0) + end + end + + context 'when only one of two hosts has a vuln' do + let(:current_mod) do + selective_scanner_class.vulnerable_hosts = ['192.0.2.1'] + selective_scanner_class.injected_check_code = appears_check_code + build_module(selective_scanner_class) + end + + before do + current_mod.datastore['RHOSTS'] = '192.0.2.1 192.0.2.2' + aux_dispatcher.cmd_run + end + + it 'creates exactly one vuln (for the vulnerable host only)' do + expect(Mdm::Vuln.count).to eq(1) + end + + it 'creates exactly one vuln attempt (no spurious attempt for the clean host)' do + expect(Mdm::VulnAttempt.count).to eq(1) + end + + it 'associates the vuln with the correct host' do + expect(Mdm::Vuln.last.host.address).to eq('192.0.2.1') + end + end + end + + # --------------------------------------------------------------------------- + # cmd_run — non-scanner auxiliary with prepend AutoCheck + # --------------------------------------------------------------------------- + describe 'cmd_run with prepend AutoCheck' do + let(:auto_check_module_class) do + Class.new(simple_module_class) do + prepend Msf::Exploit::Remote::AutoCheck + end + end + + let(:current_mod) do + auto_check_module_class.injected_check_code = nil + auto_check_module_class.run_called = false + build_module(auto_check_module_class) + end + + before(:each) do + current_mod.init_ui(driver_input, driver_output) + allow(aux_dispatcher).to receive(:mod).and_return(current_mod) + end + + context 'when check returns Vulnerable' do + before do + auto_check_module_class.injected_check_code = vulnerable_check_code + auto_check_module_class.run_called = false + current_mod.datastore['RHOSTS'] = '192.0.2.1' + aux_dispatcher.cmd_run + end + + it 'creates exactly one vuln' do + expect(Mdm::Vuln.count).to eq(1) + end + + it 'creates exactly one vuln attempt' do + expect(Mdm::VulnAttempt.count).to eq(1) + end + + it 'sets check_code on the vuln attempt' do + expect(Mdm::VulnAttempt.last.check_code).to eq('vulnerable') + end + + it 'sets check_detail on the vuln attempt' do + expect(Mdm::VulnAttempt.last.check_detail).to eq(check_detail_message) + end + + it 'does not set exploited on the vuln attempt' do + expect(Mdm::VulnAttempt.last.exploited).to be_falsey + end + + it 'calls the original run method' do + expect(auto_check_module_class.run_called).to eq(true) + end + + it 'displays vuln attempt with check details in vulns -v' do + vuln = Mdm::Vuln.last + vuln_attempt = Mdm::VulnAttempt.last + service_str = vuln.service.present? ? "#{vuln.service.name} (port: #{vuln.service.port}, resource: #{vuln.service.resource.to_json})" : '' + @output = [] + db.cmd_vulns "-v" + expect(@output.join("\n")).to match_table <<~TABLE + Vulnerabilities + =============== + 0. Vuln ID: #{vuln.id} + Timestamp: #{vuln.created_at} + Host: 192.0.2.1 + Name: #{vuln.name} + References: CVE-2025-99999 + Information: #{vuln.info} + Resource: {} + Service: #{service_str} + Vuln attempts: + 0. ID: #{vuln_attempt.id} + Vuln ID: #{vuln.id} + Timestamp: #{vuln_attempt.attempted_at} + Exploit: #{vuln_attempt.exploited} + Fail reason: #{vuln_attempt.fail_reason || 'nil'} + Username: #{vuln_attempt.username} + Module: #{vuln_attempt.module} + Session ID: nil + Loot ID: nil + Fail Detail: #{vuln_attempt.fail_detail || 'nil'} + Check Code: vulnerable + Check Detail: #{check_detail_message} + TABLE + end + end + + context 'when check returns Safe' do + before do + auto_check_module_class.injected_check_code = safe_check_code + auto_check_module_class.run_called = false + current_mod.datastore['RHOSTS'] = '192.0.2.1' + current_mod.datastore['ForceExploit'] = false + end + + it 'does not call the original run method' do + aux_dispatcher.cmd_run + expect(auto_check_module_class.run_called).to eq(false) + end + + it 'does not create a vuln' do + aux_dispatcher.cmd_run + expect(Mdm::Vuln.count).to eq(0) + end + end + end + end + + context 'when running an exploit module' do + # --------------------------------------------------------------------------- + # cmd_exploit — exploit module with prepend AutoCheck + # --------------------------------------------------------------------------- + describe 'exploit module with prepend AutoCheck' do + let(:auto_check_exploit_class) do + Class.new(exploit_module_class) do + prepend Msf::Exploit::Remote::AutoCheck + end + end + + let(:current_mod) do + auto_check_exploit_class.injected_check_code = nil + auto_check_exploit_class.exploit_called = false + build_module(auto_check_exploit_class) + end + + before(:each) do + framework.modules.add_module_path(File.join(FILE_FIXTURES_PATH, 'modules')) + framework.modules.refresh_cache_from_module_files + current_mod.init_ui(driver_input, driver_output) + allow(exploit_dispatcher).to receive(:mod).and_return(current_mod) + current_mod.datastore['PAYLOAD'] = 'generic/no_session_payload' + current_mod.datastore['LHOST'] = '127.0.0.1' + end + + context 'when check returns Vulnerable and exploit runs' do + before do + auto_check_exploit_class.injected_check_code = vulnerable_check_code + auto_check_exploit_class.exploit_called = false + current_mod.datastore['RHOSTS'] = '192.0.2.1' + exploit_dispatcher.cmd_exploit + end + + it 'creates exactly one vuln' do + expect(Mdm::Vuln.count).to eq(1) + end + + it 'creates exactly one vuln attempt' do + expect(Mdm::VulnAttempt.count).to eq(1) + end + + it 'sets check_code on the vuln attempt' do + expect(Mdm::VulnAttempt.last.check_code).to eq('vulnerable') + end + + it 'sets check_detail on the vuln attempt' do + expect(Mdm::VulnAttempt.last.check_detail).to eq(check_detail_message) + end + + it 'does not set exploited on the vuln attempt' do + expect(Mdm::VulnAttempt.last.exploited).to be_falsey + end + + it 'calls the original exploit method' do + expect(auto_check_exploit_class.exploit_called).to eq(true) + end + end + + context 'when check returns Appears and exploit runs' do + before do + auto_check_exploit_class.injected_check_code = appears_check_code + auto_check_exploit_class.exploit_called = false + current_mod.datastore['RHOSTS'] = '192.0.2.1' + exploit_dispatcher.cmd_exploit + end + + it 'creates exactly one vuln' do + expect(Mdm::Vuln.count).to eq(1) + end + + it 'creates exactly one vuln attempt' do + expect(Mdm::VulnAttempt.count).to eq(1) + end + + it 'sets check_code to appears on the vuln attempt' do + expect(Mdm::VulnAttempt.last.check_code).to eq('appears') + end + + it 'sets check_detail on the vuln attempt' do + expect(Mdm::VulnAttempt.last.check_detail).to eq('Likely vulnerable') + end + end + + context 'when check returns Safe' do + before do + auto_check_exploit_class.injected_check_code = safe_check_code + auto_check_exploit_class.exploit_called = false + current_mod.datastore['RHOSTS'] = '192.0.2.1' + current_mod.datastore['ForceExploit'] = false + end + + it 'does not call the original exploit method' do + exploit_dispatcher.cmd_exploit + expect(auto_check_exploit_class.exploit_called).to eq(false) + end + + it 'does not create a vuln' do + exploit_dispatcher.cmd_exploit + expect(Mdm::Vuln.count).to eq(0) + end + end + end + end +end 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 e1077d28ce263..fa13dd9ef4d39 100644 --- a/spec/lib/msf/ui/console/command_dispatcher/db_spec.rb +++ b/spec/lib/msf/ui/console/command_dispatcher/db_spec.rb @@ -436,6 +436,8 @@ " Session ID: 1", " Loot ID: nil", " Fail Detail: nil", + " Check Code: nil", + " Check Detail: nil", ] 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 743cdb7f4f02b..a30665d21af83 100644 --- a/spec/lib/msf/ui/console/command_dispatcher/exploit_spec.rb +++ b/spec/lib/msf/ui/console/command_dispatcher/exploit_spec.rb @@ -345,6 +345,35 @@ def set_default_payload(mod) expect(service.parents.first.parents.first.name).to eq(tcp_service[:name].downcase) end end + + context 'when the check returns Vulnerable on different ports of the same host' do + before :example do + current_mod.datastore['RHOSTS'] = '192.0.2.1' + end + + it 'creates separate vulns for each port' do + current_mod.datastore['RPORT'] = '80' + allow(current_mod).to receive(:check).and_return(Msf::Exploit::CheckCode::Vulnerable('Vulnerable on port 80')) + subject.cmd_check + + current_mod.datastore['RPORT'] = '8080' + allow(current_mod).to receive(:check).and_return(Msf::Exploit::CheckCode::Vulnerable('Vulnerable on port 8080')) + subject.cmd_check + + expect(Mdm::Vuln.count).to eq(2) + ports = Mdm::Vuln.all.map { |v| v.service&.port }.compact.sort + expect(ports).to eq([80, 8080]) + end + + it 'does not duplicate vulns when checked on the same port twice' do + current_mod.datastore['RPORT'] = '80' + allow(current_mod).to receive(:check).and_return(Msf::Exploit::CheckCode::Vulnerable('Vulnerable on port 80')) + subject.cmd_check + subject.cmd_check + + expect(Mdm::Vuln.count).to eq(1) + end + end end context 'when checking a non remote exploit module' do