Skip to content

Commit 3936fc7

Browse files
Merge pull request #20424 from cdelafuente-r7/enh/MS-9930/vuln_report
Vulnerability Report Enhancement
2 parents 861700b + fbea976 commit 3936fc7

File tree

17 files changed

+1376
-101
lines changed

17 files changed

+1376
-101
lines changed

Gemfile.lock

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -353,12 +353,15 @@ GEM
353353
mutex_m
354354
railties (~> 7.0)
355355
metasploit-payloads (2.0.237)
356-
metasploit_data_models (6.0.9)
356+
metasploit_data_models (6.0.11)
357357
activerecord (~> 7.0)
358358
activesupport (~> 7.0)
359359
arel-helpers
360+
bigdecimal
361+
drb
360362
metasploit-concern
361363
metasploit-model (>= 3.1)
364+
mutex_m
362365
pg
363366
railties (~> 7.0)
364367
recog

db/schema.rb

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
#
1111
# It's strongly recommended that you check this file into your version control system.
1212

13-
ActiveRecord::Schema[7.2].define(version: 2025_02_04_172657) do
13+
ActiveRecord::Schema[7.2].define(version: 2025_07_21_114306) do
1414
# These are extensions that must be enabled in order to support this database
1515
enable_extension "plpgsql"
1616

@@ -521,6 +521,16 @@
521521
t.string "netmask"
522522
end
523523

524+
create_table "service_links", force: :cascade do |t|
525+
t.bigint "parent_id", null: false
526+
t.bigint "child_id", null: false
527+
t.datetime "created_at", null: false
528+
t.datetime "updated_at", null: false
529+
t.index ["child_id"], name: "index_service_links_on_child_id"
530+
t.index ["parent_id", "child_id"], name: "index_service_links_on_parent_id_and_child_id", unique: true
531+
t.index ["parent_id"], name: "index_service_links_on_parent_id"
532+
end
533+
524534
create_table "services", id: :serial, force: :cascade do |t|
525535
t.integer "host_id"
526536
t.datetime "created_at", precision: nil
@@ -530,7 +540,8 @@
530540
t.string "name"
531541
t.datetime "updated_at", precision: nil
532542
t.text "info"
533-
t.index ["host_id", "port", "proto"], name: "index_services_on_host_id_and_port_and_proto", unique: true
543+
t.jsonb "resource", default: {}, null: false
544+
t.index ["host_id", "port", "proto", "name", "resource"], name: "index_services_on_5_columns", unique: true
534545
t.index ["name"], name: "index_services_on_name"
535546
t.index ["port"], name: "index_services_on_port"
536547
t.index ["proto"], name: "index_services_on_proto"
@@ -686,6 +697,7 @@
686697
t.integer "vuln_attempt_count", default: 0
687698
t.integer "origin_id"
688699
t.string "origin_type"
700+
t.jsonb "resource", default: {}, null: false
689701
t.index ["name"], name: "index_vulns_on_name"
690702
t.index ["origin_id"], name: "index_vulns_on_origin_id"
691703
end
@@ -803,4 +815,7 @@
803815
t.boolean "limit_to_network", default: false, null: false
804816
t.boolean "import_fingerprint", default: false
805817
end
818+
819+
add_foreign_key "service_links", "services", column: "child_id"
820+
add_foreign_key "service_links", "services", column: "parent_id"
806821
end

lib/msf/core/db_manager/service.rb

Lines changed: 73 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,14 @@ def delete_service(opts)
66
::ApplicationRecord.connection_pool.with_connection {
77
deleted = []
88
opts[:ids].each do |service_id|
9-
service = Mdm::Service.find(service_id)
9+
begin
10+
service = Mdm::Service.find(service_id)
11+
rescue ActiveRecord::RecordNotFound
12+
# This happens when the service was the child of another service we have already deleted
13+
# Deletion of children is automatic via dependent: :destroy on the association
14+
dlog("Service with id #{service_id} already deleted")
15+
next
16+
end
1017
begin
1118
deleted << service.destroy
1219
rescue
@@ -43,11 +50,15 @@ def find_or_create_service(opts)
4350
# +:workspace+:: the workspace for the service
4451
#
4552
# opts may contain
46-
# +:name+:: the application layer protocol (e.g. ssh, mssql, smb)
47-
# +:sname+:: an alias for the above
48-
# +:info+:: Detailed information about the service such as name and version information
49-
# +:state+:: The current listening state of the service (one of: open, closed, filtered, unknown)
50-
#
53+
# +:name+:: the application layer protocol (e.g. ssh, mssql, smb)
54+
# +:sname+:: an alias for the above
55+
# +:info+:: detailed information about the service such as name and version information
56+
# +:state+:: the current listening state of the service (one of: open, closed, filtered, unknown)
57+
# +:resource+:: the resource this service is associated with, such as a a DN for an an LDAP object
58+
# base URI for a web application, pipe name for DCERPC service, etc.
59+
# +:parents+:: a single service Hash or an Array of service Hash representing the parent services this
60+
# service is associated with, such as a HTTP service for a web application.
61+
#`
5162
# @return [Mdm::Service,nil]
5263
def report_service(opts)
5364
return if !active
@@ -69,6 +80,7 @@ def report_service(opts)
6980
if opts[:sname]
7081
opts[:name] = opts.delete(:sname)
7182
end
83+
opts[:name] = opts[:name].to_s.downcase if opts[:name]
7284

7385
if addr.kind_of? ::Mdm::Host
7486
host = addr
@@ -84,7 +96,14 @@ def report_service(opts)
8496

8597
proto = opts[:proto] || Msf::DBManager::DEFAULT_SERVICE_PROTO
8698

87-
service = host.services.where(port: opts[:port].to_i, proto: proto).first_or_initialize
99+
sopts = {
100+
port: opts[:port].to_i,
101+
proto: proto
102+
}
103+
sopts[:name] = opts[:name] if opts[:name]
104+
sopts[:resource] = opts[:resource] if opts[:resource]
105+
service = host.services.where(sopts).first_or_initialize
106+
88107
ostate = service.state
89108
opts.each { |k,v|
90109
if (service.attribute_names.include?(k.to_s))
@@ -93,8 +112,15 @@ def report_service(opts)
93112
dlog("Unknown attribute for Service: #{k}")
94113
end
95114
}
115+
96116
service.state ||= Msf::ServiceState::Open
97117
service.info ||= ""
118+
parents = process_service_chain(host, opts.delete(:parents)) if opts[:parents]
119+
if parents
120+
parents.each do |parent|
121+
service.parents << parent if parent && !service.parents.include?(parent)
122+
end
123+
end
98124

99125
begin
100126
framework.events.on_db_service(service) if service.new_record?
@@ -163,4 +189,44 @@ def update_service(opts)
163189
return service
164190
}
165191
end
192+
193+
def process_service_chain(host, services)
194+
return if services.nil? || host.nil?
195+
return unless services.is_a?(Hash) || services.is_a?(::Array)
196+
return unless host.is_a?(Mdm::Host)
197+
198+
services = [services] unless services.is_a?(Array)
199+
services.map do |service|
200+
return unless service.is_a?(Hash)
201+
return if service[:port].nil? || service[:proto].nil?
202+
203+
parents = nil
204+
if service[:parents]&.any?
205+
parents = process_service_chain(host, service[:parents])
206+
end
207+
208+
service_info = {
209+
port: service[:port].to_i,
210+
proto: service[:proto].to_s.downcase,
211+
}
212+
service_info[:name] = service[:name].downcase if service[:name]
213+
service_info[:resource] = service[:resource] if service[:resource]
214+
service_obj = host.services.find_or_create_by(service_info)
215+
if service_obj.id.nil?
216+
elog("Failed to create service #{service_info.inspect} for host #{host.name} (#{host.address})")
217+
return
218+
end
219+
service_obj.state ||= Msf::ServiceState::Open
220+
service_obj.info = service[:info] ? service[:info] : ''
221+
222+
if parents
223+
parents.each do |parent|
224+
service_obj.parents << parent if parent && !service_obj.parents.include?(parent)
225+
end
226+
end
227+
228+
service_obj
229+
end
230+
231+
end
166232
end

lib/msf/core/db_manager/session.rb

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -250,18 +250,27 @@ def infer_vuln_from_session(session, wspace)
250250
workspace: wspace,
251251
}
252252

253-
port = session.exploit_datastore["RPORT"]
254-
service = (port ? host.services.find_by_port(port.to_i) : nil)
255-
256-
vuln_info[:service] = service if service
253+
if session.exploit.respond_to?(:service_details) && session.exploit.service_details
254+
service_details = session.exploit.service_details
255+
service_name = service_details[:service_name]
256+
port = service_details[:port]
257+
if port.nil?
258+
port = session.respond_to?(:target_port) && session.target_port ? session.target_port : session.exploit_datastore["RPORT"]
259+
end
260+
proto = service_details[:protocol]
261+
vuln_info[:service] = host.services.find_or_create_by(name: service_name, port: port.to_i, proto: proto, state: 'open')
262+
end
263+
unless vuln_info[:service]
264+
port = session.respond_to?(:target_port) && session.target_port ? session.target_port : session.exploit_datastore["RPORT"]
265+
vuln_info[:service] = host.services.find_by_port(port.to_i) if port
266+
end
257267

258268
vuln = report_vuln(vuln_info)
259269

260270
attempt_info = {
261271
host: host,
262272
module: mod_fullname,
263273
refs: refs,
264-
service: service,
265274
session_id: s.id,
266275
timestamp: Time.now.utc,
267276
username: session.username,

lib/msf/core/db_manager/vuln.rb

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,15 @@ def find_vuln_by_details(details_map, host, service=nil)
4444
other_vulns.empty? ? nil : other_vulns.first
4545
end
4646

47-
def find_vuln_by_refs(refs, host, service = nil, cve_only = true)
47+
def find_vuln_by_refs(refs, host, service = nil, cve_only = true, resource = nil)
4848
ref_ids = cve_only ? refs.find_all { |ref| ref.name.starts_with? 'CVE-'} : refs
4949
relation = host.vulns.joins(:refs)
5050
if !service.try(:id).nil?
51-
return relation.where(service_id: service.try(:id), refs: { id: ref_ids}).first
51+
if resource
52+
return relation.where(service_id: service.try(:id), refs: { id: ref_ids}, resource: resource).first
53+
else
54+
return relation.where(service_id: service.try(:id), refs: { id: ref_ids}).first
55+
end
5256
end
5357
return relation.where(refs: { id: ref_ids}).first
5458
end
@@ -80,12 +84,20 @@ def has_vuln?(name)
8084
# opts MUST contain
8185
# +:host+:: the host where this vulnerability resides
8286
# +:name+:: the friendly name for this vulnerability (title)
87+
# +:workspace+:: the workspace to report this vulnerability in
8388
#
8489
# opts can contain
8590
# +:info+:: a human readable description of the vuln, free-form text
8691
# +:refs+:: an array of Ref objects or string names of references
8792
# +:details+:: a hash with :key pointed to a find criteria hash and the rest containing VulnDetail fields
8893
# +:sname+:: the name of the service this vulnerability relates to, used to associate it or create it.
94+
# +:exploited_at+:: a timestamp indicating when this vulnerability was exploited, if applicable
95+
# +:ref_ids+:: an array of reference IDs to associate with this vulnerability
96+
# +:service+:: a Mdm::Service object or a Hash with service attributes to associate this vulnerability with
97+
# +:port+:: the port number of the service this vulnerability relates to, if applicable
98+
# +:proto+:: the transport layer protocol of the service this vulnerability relates to, if applicable
99+
# +:details_match+:: a Mdm:VulnDetail with details related to this vulnerability
100+
# +:resource+:: a resource hash to associate with this vulnerability, such as a URI or pipe name
89101
#
90102
def report_vuln(opts)
91103
return if not active
@@ -141,7 +153,16 @@ def report_vuln(opts)
141153
vuln = nil
142154

143155
# Identify the associated service
144-
service = opts.delete(:service)
156+
service_opt = opts.delete(:service)
157+
case service_opt
158+
when Mdm::Service
159+
service = service_opt
160+
when Hash
161+
service = report_service(service_opt.merge(workspace: wspace, host: host))
162+
else
163+
dlog("Skipping service since it is not a Hash or Mdm::Service: #{service.class}")
164+
service = nil
165+
end
145166

146167
# Treat port zero as no service
147168
if service or opts[:port].to_i > 0
@@ -160,9 +181,17 @@ def report_vuln(opts)
160181
sname = opts[:proto]
161182
end
162183

163-
services = host.services.where(port: opts[:port].to_i, proto: proto)
164-
services = services.where(name: sname) if sname.present?
165-
service = services.first_or_create
184+
# If sname and proto are not provided, this will assign the first service
185+
# registered in the database for this host with the given port and proto.
186+
# This is likely to be the TCP service.
187+
sopts = {
188+
workspace: wspace,
189+
host: host,
190+
port: opts[:port].to_i,
191+
proto: proto
192+
}
193+
sopts[:name] = sname if sname.present?
194+
service = report_service(sopts)
166195
end
167196

168197
# Try to find an existing vulnerability with the same service & references
@@ -172,8 +201,12 @@ def report_vuln(opts)
172201
# prevent dupes of the same vuln found by both local patch and
173202
# service detection.
174203
if rids and rids.length > 0
175-
vuln = find_vuln_by_refs(rids, host, service)
176-
vuln.service = service if vuln
204+
if opts[:resource]
205+
vuln = find_vuln_by_refs(rids, host, service, nil, opts[:resource])
206+
else
207+
vuln = find_vuln_by_refs(rids, host, service)
208+
end
209+
vuln.service = service if vuln && !vuln.service_id?
177210
end
178211
else
179212
# Try to find an existing vulnerability with the same host & references
@@ -194,9 +227,17 @@ def report_vuln(opts)
194227
# No matches, so create a new vuln record
195228
unless vuln
196229
if service
197-
vuln = service.vulns.find_by_name(name)
230+
if opts[:resource]
231+
vuln = service.vulns.find_by(name: name, resource: opts[:resource])
232+
else
233+
vuln = service.vulns.find_by_name(name)
234+
end
198235
else
199-
vuln = host.vulns.find_by_name(name)
236+
if opts[:resource]
237+
vuln = host.vulns.find_by(name: name, resource: opts[:resource])
238+
else
239+
vuln = host.vulns.find_by_name(name)
240+
end
200241
end
201242

202243
unless vuln
@@ -208,6 +249,7 @@ def report_vuln(opts)
208249
}
209250

210251
vinf[:service_id] = service.id if service
252+
vinf[:resource] = opts[:resource] if opts[:resource]
211253
vuln = Mdm::Vuln.create(vinf)
212254

213255
begin

lib/msf/core/exploit.rb

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ module CompatDefaults
4949
# https://docs.metasploit.com/docs/development/developing-modules/guides/how-to-write-a-check-method.html
5050
#
5151
##
52-
class CheckCode < Struct.new(:code, :message, :reason, :details)
52+
class CheckCode < Struct.new(:code, :message, :reason, :details, :vuln)
5353
# Do customization here because we need class constants and special
5454
# optional values and the block mode of Struct.new does not support that.
5555
#
@@ -77,8 +77,8 @@ def Appears(reason = nil, details: {})
7777
self.new('appears', reason, details: details)
7878
end
7979

80-
def Vulnerable(reason = nil, details: {})
81-
self.new('vulnerable', reason, details: details)
80+
def Vulnerable(reason = nil, details: {}, vuln: {})
81+
self.new('vulnerable', reason, details: details, vuln: vuln)
8282
end
8383

8484
def Unsupported(reason = nil, details: {})
@@ -100,7 +100,7 @@ def ===(other)
100100
other.is_a?(self.class) && self.code == other.code
101101
end
102102

103-
def initialize(code, reason, details: {})
103+
def initialize(code, reason, details: {}, vuln: {})
104104
msg = case code
105105
when 'unknown'; 'Cannot reliably check exploitability.'
106106
when 'safe'; 'The target is not exploitable.'
@@ -111,7 +111,7 @@ def initialize(code, reason, details: {})
111111
else
112112
''
113113
end
114-
super(code, "#{msg} #{reason}".strip, reason, details)
114+
super(code, "#{msg} #{reason}".strip, reason, details, vuln)
115115
end
116116

117117
#

0 commit comments

Comments
 (0)