Vulnerability Report enhancement

- update `#report_service` and `#report_vuln`
- update vulnerability report when a session is established
- update CheckCode and `#cmd_check` to report a vulnerability when
  Vulnerable checkcode is returned
- update `vulns` and `services` commands to display the `resource` and
  parent services
- specs
This commit is contained in:
Christophe De La Fuente
2025-07-16 17:11:38 +02:00
parent 861700b1f6
commit 40ac35c02a
16 changed files with 1358 additions and 104 deletions
+2
View File
@@ -3,6 +3,8 @@ source 'https://rubygems.org'
# spec.add_runtime_dependency '<name>', [<version requirements>]
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
+20 -10
View File
@@ -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:
@@ -353,16 +372,6 @@ GEM
mutex_m
railties (~> 7.0)
metasploit-payloads (2.0.237)
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.7.0)
@@ -685,6 +694,7 @@ DEPENDENCIES
license_finder (= 5.11.1)
memory_profiler
metasploit-framework!
metasploit_data_models!
octokit
pry-byebug
rake
+17 -2
View File
@@ -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 @@ ActiveRecord::Schema[7.2].define(version: 2025_02_04_172657) do
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 @@ ActiveRecord::Schema[7.2].define(version: 2025_02_04_172657) do
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 @@ ActiveRecord::Schema[7.2].define(version: 2025_02_04_172657) do
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 @@ ActiveRecord::Schema[7.2].define(version: 2025_02_04_172657) do
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
+65 -6
View File
@@ -43,11 +43,15 @@ module Msf::DBManager::Service
# +: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 @@ module Msf::DBManager::Service
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 @@ module Msf::DBManager::Service
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 @@ module Msf::DBManager::Service
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 @@ module Msf::DBManager::Service
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
+14 -5
View File
@@ -250,10 +250,20 @@ module Msf::DBManager::Session
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 @@ module Msf::DBManager::Session
host: host,
module: mod_fullname,
refs: refs,
service: service,
session_id: s.id,
timestamp: Time.now.utc,
username: session.username,
+52 -10
View File
@@ -44,11 +44,15 @@ module Msf::DBManager::Vuln
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 @@ module Msf::DBManager::Vuln
# 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 @@ module Msf::DBManager::Vuln
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 @@ module Msf::DBManager::Vuln
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 @@ module Msf::DBManager::Vuln
# 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 @@ module Msf::DBManager::Vuln
# 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 @@ module Msf::DBManager::Vuln
}
vinf[:service_id] = service.id if service
vinf[:resource] = opts[:resource] if opts[:resource]
vuln = Mdm::Vuln.create(vinf)
begin
+5 -5
View File
@@ -49,7 +49,7 @@ class Exploit < Msf::Module
# 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 @@ class Exploit < Msf::Module
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 @@ class Exploit < Msf::Module
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 @@ class Exploit < Msf::Module
else
''
end
super(code, "#{msg} #{reason}".strip, reason, details)
super(code, "#{msg} #{reason}".strip, reason, details, vuln)
end
#
@@ -26,7 +26,7 @@ module Msf::WebServices::ServiceServlet
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 @@ module Msf::WebServices::ServiceServlet
lambda {
warden.authenticate!
job = lambda { |opts| get_db.report_service(opts) }
includes = [:host]
includes = [:host, :parents]
exec_report_job(request, includes, &job)
}
end
+31 -2
View File
@@ -846,6 +846,7 @@ class Db
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 @@ class Db
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 @@ class Db
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 @@ class Db
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 @@ class Db
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 @@ class Db
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 @@ class Db
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 @@ class Db
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 @@ class Db
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|
@@ -174,14 +174,25 @@ module ModuleCommandDispatcher
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 @@ module ModuleCommandDispatcher
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
+1
View File
@@ -225,6 +225,7 @@ RSpec.describe "Metasploit's json-rpc" do
result: {
code: 'safe',
details: {},
vuln: {},
message: 'The target is not exploitable.',
reason: nil
}
@@ -195,7 +195,69 @@ RSpec.describe Msf::Ui::Console::CommandDispatcher::Db do
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 @@ RSpec.describe Msf::Ui::Console::CommandDispatcher::Db do
"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 @@ RSpec.describe Msf::Ui::Console::CommandDispatcher::Db do
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 @@ RSpec.describe Msf::Ui::Console::CommandDispatcher::Db do
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"]
}
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_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)
@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
@@ -13,6 +13,11 @@ RSpec.describe Msf::Ui::Console::CommandDispatcher::Exploit do
'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 @@ RSpec.describe Msf::Ui::Console::CommandDispatcher::Exploit do
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 @@ RSpec.describe Msf::Ui::Console::CommandDispatcher::Exploit do
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
@@ -18,10 +18,12 @@ RSpec.shared_examples_for 'Msf::DBManager::Service' do
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 @@ RSpec.shared_examples_for 'Msf::DBManager::Service' do
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 @@ RSpec.shared_examples_for 'Msf::DBManager::Service' do
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 @@ RSpec.shared_examples_for 'Msf::DBManager::Service' do
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
@@ -240,10 +240,19 @@ RSpec.shared_examples_for 'Msf::DBManager::Session' do
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 @@ RSpec.shared_examples_for 'Msf::DBManager::Session' do
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
@@ -49,4 +49,373 @@ RSpec.shared_examples_for 'Msf::DBManager::Vuln' do
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