Skip to content

Commit 19112a0

Browse files
authored
Merge pull request #21231 from bcoles/msf-module-cache
Module metadata: Fix stale module detection and add per-type metadata index
2 parents 37ff9f8 + 785307f commit 19112a0

3 files changed

Lines changed: 239 additions & 11 deletions

File tree

lib/msf/core/module_manager/cache.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ def module_info_by_path_from_database!(allowed_paths=[""])
177177
reference_name = module_metadata.ref_name
178178

179179
# Skip cached modules that are not in our allowed load paths
180-
next if allowed_paths.select{|x| path.index(x) == 0}.empty?
180+
next unless allowed_paths.any? { |x| path.start_with?(x) }
181181

182182
parent_path = get_parent_path(path, type)
183183

lib/msf/core/modules/metadata/cache.rb

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,9 @@ def refresh_metadata(module_sets)
7878
end
7979
end
8080
end
81+
if has_changes
82+
rebuild_type_cache
83+
end
8184
}
8285
if has_changes
8386
update_store
@@ -89,8 +92,8 @@ def refresh_metadata(module_sets)
8992
def module_metadata(type)
9093
@mutex.synchronize do
9194
wait_for_load
92-
# TODO: Should probably figure out a way to cache this
93-
@module_metadata_cache.filter_map { |_, metadata| [metadata.ref_name, metadata] if metadata.type == type }.to_h
95+
type_hash = @metadata_type_index[type]
96+
type_hash ? type_hash.dup : {}
9497
end
9598
end
9699

@@ -129,7 +132,9 @@ def remove_from_cache(module_name)
129132
module_metadata.ref_name.eql? module_name
130133
}
131134

132-
return old_cache_size != @module_metadata_cache.size
135+
removed = old_cache_size != @module_metadata_cache.size
136+
rebuild_type_cache if removed
137+
removed
133138
end
134139

135140
def wait_for_load
@@ -141,29 +146,50 @@ def refresh_metadata_instance_internal(module_instance)
141146

142147
# Remove all instances of modules pointing to the same path. This prevents stale data hanging
143148
# around when modules are incorrectly typed (eg: Auxiliary that should be Exploit)
149+
had_type_mismatch_deletion = false
144150
@module_metadata_cache.delete_if {|_, module_metadata|
145-
module_metadata.path.eql? metadata_obj.path && module_metadata.type != module_metadata.type
151+
is_stale = module_metadata.path.eql?(metadata_obj.path) && module_metadata.type != metadata_obj.type
152+
had_type_mismatch_deletion = true if is_stale
153+
is_stale
146154
}
147155

148-
@module_metadata_cache[get_cache_key(module_instance)] = metadata_obj
156+
cache_key = get_cache_key(module_instance)
157+
@module_metadata_cache[cache_key] = metadata_obj
158+
159+
if had_type_mismatch_deletion
160+
# Type changed - full rebuild needed since we removed entries from other type buckets
161+
rebuild_type_cache
162+
else
163+
# Common case - just update the single entry in the type index
164+
type_hash = (@metadata_type_index[metadata_obj.type] ||= {})
165+
type_hash[metadata_obj.ref_name] = metadata_obj
166+
end
149167
end
150168

151169
def get_cache_key(module_instance)
152-
key = ''
153-
key << (module_instance.type.nil? ? '' : module_instance.type)
154-
key << '_'
155-
key << module_instance.class.refname
156-
return key
170+
"#{module_instance.type}_#{module_instance.class.refname}"
171+
end
172+
173+
# Rebuild the per-type index from the main cache.
174+
def rebuild_type_cache
175+
by_type = {}
176+
@module_metadata_cache.each_value do |metadata|
177+
type_hash = (by_type[metadata.type] ||= {})
178+
type_hash[metadata.ref_name] = metadata
179+
end
180+
@metadata_type_index = by_type
157181
end
158182

159183
def initialize
160184
super
161185
@mutex = Mutex.new
162186
@module_metadata_cache = {}
187+
@metadata_type_index = {}
163188
@store_loaded = false
164189
@console = Rex::Ui::Text::Output::Stdio.new
165190
@load_thread = Thread.new {
166191
init_store
192+
rebuild_type_cache
167193
@store_loaded = true
168194
}
169195
end
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
# frozen_string_literal: true
2+
3+
require 'spec_helper'
4+
5+
RSpec.describe Msf::Modules::Metadata::Cache do
6+
# Build a testable Cache instance without triggering the Singleton constructor
7+
# (which spawns a thread and loads the store from disk).
8+
let(:cache) do
9+
obj = described_class.send(:allocate)
10+
obj.instance_variable_set(:@mutex, Mutex.new)
11+
obj.instance_variable_set(:@module_metadata_cache, {})
12+
obj.instance_variable_set(:@metadata_type_index, {})
13+
obj.instance_variable_set(:@store_loaded, true)
14+
obj.instance_variable_set(:@load_thread, Thread.new {})
15+
obj
16+
end
17+
18+
def make_metadata(type:, ref_name:, path: '/modules/test.rb')
19+
Msf::Modules::Metadata::Obj.from_hash({
20+
'name' => ref_name,
21+
'fullname' => "#{type}/#{ref_name}",
22+
'rank' => 300,
23+
'type' => type,
24+
'author' => ['rspec'],
25+
'description' => 'Test module',
26+
'references' => [],
27+
'mod_time' => '2024-01-01 00:00:00 +0000',
28+
'path' => path,
29+
'is_install_path' => false,
30+
'ref_name' => ref_name
31+
})
32+
end
33+
34+
def populate_cache(cache, *entries)
35+
entries.each do |entry|
36+
cache.instance_variable_get(:@module_metadata_cache)["#{entry.type}_#{entry.ref_name}"] = entry
37+
end
38+
cache.send(:rebuild_type_cache)
39+
end
40+
41+
# Fake module instance for refresh_metadata_instance_internal
42+
def make_module_instance(type:, refname:, path: '/modules/test.rb')
43+
mod = double('module_instance')
44+
klass = double('module_class', refname: refname)
45+
allow(mod).to receive(:type).and_return(type)
46+
allow(mod).to receive(:class).and_return(klass)
47+
allow(mod).to receive(:refname).and_return(refname)
48+
allow(mod).to receive(:realname).and_return("#{type}/#{refname}")
49+
allow(mod).to receive(:name).and_return(refname)
50+
allow(mod).to receive(:aliases).and_return([])
51+
allow(mod).to receive(:disclosure_date).and_return(nil)
52+
allow(mod).to receive(:rank).and_return(300)
53+
allow(mod).to receive(:description).and_return('Test')
54+
allow(mod).to receive(:author).and_return([])
55+
allow(mod).to receive(:references).and_return([])
56+
allow(mod).to receive(:post_auth?).and_return(false)
57+
allow(mod).to receive(:default_cred?).and_return(false)
58+
allow(mod).to receive(:platform_to_s).and_return('')
59+
allow(mod).to receive(:platform).and_return(nil)
60+
allow(mod).to receive(:arch_to_s).and_return('')
61+
allow(mod).to receive(:datastore).and_return({})
62+
allow(mod).to receive(:file_path).and_return(path)
63+
allow(mod).to receive(:has_check?).and_return(false)
64+
allow(mod).to receive(:notes).and_return({})
65+
66+
# Stub specific respond_to? checks used by Obj#initialize
67+
allow(mod).to receive(:respond_to?).with(:needs_cleanup).and_return(false)
68+
allow(mod).to receive(:respond_to?).with(:actions).and_return(false)
69+
allow(mod).to receive(:respond_to?).with(:autofilter_ports).and_return(false)
70+
allow(mod).to receive(:respond_to?).with(:autofilter_services).and_return(false)
71+
allow(mod).to receive(:respond_to?).with(:targets).and_return(false)
72+
allow(mod).to receive(:respond_to?).with(:session_types).and_return(false)
73+
allow(mod).to receive(:respond_to?).with(:payload_type).and_return(false)
74+
mod
75+
end
76+
77+
describe '#module_metadata' do
78+
it 'returns modules of the requested type' do
79+
exploit = make_metadata(type: 'exploit', ref_name: 'test/vuln')
80+
auxiliary = make_metadata(type: 'auxiliary', ref_name: 'scanner/test', path: '/modules/aux.rb')
81+
populate_cache(cache, exploit, auxiliary)
82+
83+
result = cache.module_metadata('exploit')
84+
expect(result.keys).to eq(['test/vuln'])
85+
expect(result['test/vuln']).to eq(exploit)
86+
end
87+
88+
it 'returns an empty hash for unknown types' do
89+
exploit = make_metadata(type: 'exploit', ref_name: 'test/vuln')
90+
populate_cache(cache, exploit)
91+
92+
expect(cache.module_metadata('post')).to eq({})
93+
end
94+
95+
it 'returns a copy that does not affect internal state' do
96+
exploit = make_metadata(type: 'exploit', ref_name: 'test/vuln')
97+
populate_cache(cache, exploit)
98+
99+
result = cache.module_metadata('exploit')
100+
result.delete('test/vuln')
101+
102+
expect(cache.module_metadata('exploit').keys).to eq(['test/vuln'])
103+
end
104+
end
105+
106+
describe '#rebuild_type_cache' do
107+
it 'groups all entries by type' do
108+
e1 = make_metadata(type: 'exploit', ref_name: 'test/a', path: '/modules/a.rb')
109+
e2 = make_metadata(type: 'exploit', ref_name: 'test/b', path: '/modules/b.rb')
110+
aux = make_metadata(type: 'auxiliary', ref_name: 'scan/c', path: '/modules/c.rb')
111+
populate_cache(cache, e1, e2, aux)
112+
113+
expect(cache.module_metadata('exploit').size).to eq(2)
114+
expect(cache.module_metadata('auxiliary').size).to eq(1)
115+
end
116+
end
117+
118+
describe '#refresh_metadata_instance_internal' do
119+
it 'adds a new module to the type index' do
120+
mod = make_module_instance(type: 'exploit', refname: 'test/new', path: '/modules/new.rb')
121+
cache.send(:rebuild_type_cache)
122+
cache.send(:refresh_metadata_instance_internal, mod)
123+
124+
result = cache.module_metadata('exploit')
125+
expect(result.keys).to eq(['test/new'])
126+
end
127+
128+
it 'updates an existing module in the type index' do
129+
old = make_metadata(type: 'exploit', ref_name: 'test/mod', path: '/modules/mod.rb')
130+
populate_cache(cache, old)
131+
132+
mod = make_module_instance(type: 'exploit', refname: 'test/mod', path: '/modules/mod.rb')
133+
cache.send(:refresh_metadata_instance_internal, mod)
134+
135+
result = cache.module_metadata('exploit')
136+
expect(result.size).to eq(1)
137+
expect(result['test/mod']).not_to eq(old)
138+
end
139+
140+
context 'when a module changes type' do
141+
it 'removes the old type entry and adds to the new type' do
142+
# Module starts as auxiliary
143+
old_aux = make_metadata(type: 'auxiliary', ref_name: 'test/mistyped', path: '/modules/mistyped.rb')
144+
other_aux = make_metadata(type: 'auxiliary', ref_name: 'scan/other', path: '/modules/other.rb')
145+
populate_cache(cache, old_aux, other_aux)
146+
147+
expect(cache.module_metadata('auxiliary').size).to eq(2)
148+
expect(cache.module_metadata('exploit')).to eq({})
149+
150+
# Now refresh it as an exploit (same path, different type)
151+
mod = make_module_instance(type: 'exploit', refname: 'test/mistyped', path: '/modules/mistyped.rb')
152+
cache.send(:refresh_metadata_instance_internal, mod)
153+
154+
# Old auxiliary entry should be gone, other auxiliary should remain
155+
aux_result = cache.module_metadata('auxiliary')
156+
expect(aux_result.size).to eq(1)
157+
expect(aux_result.keys).to eq(['scan/other'])
158+
159+
# New exploit entry should exist
160+
exploit_result = cache.module_metadata('exploit')
161+
expect(exploit_result.size).to eq(1)
162+
expect(exploit_result.keys).to eq(['test/mistyped'])
163+
end
164+
165+
it 'does not leave stale entries in the main cache' do
166+
old = make_metadata(type: 'auxiliary', ref_name: 'test/stale', path: '/modules/stale.rb')
167+
populate_cache(cache, old)
168+
169+
mod = make_module_instance(type: 'exploit', refname: 'test/stale', path: '/modules/stale.rb')
170+
cache.send(:refresh_metadata_instance_internal, mod)
171+
172+
main_cache = cache.instance_variable_get(:@module_metadata_cache)
173+
types = main_cache.values.map(&:type).uniq
174+
expect(types).to eq(['exploit'])
175+
end
176+
end
177+
end
178+
179+
describe '#remove_from_cache' do
180+
it 'removes the named module and returns true' do
181+
mod = make_metadata(type: 'exploit', ref_name: 'test/remove', path: '/modules/remove.rb')
182+
populate_cache(cache, mod)
183+
184+
result = cache.send(:remove_from_cache, 'test/remove')
185+
expect(result).to be true
186+
expect(cache.instance_variable_get(:@module_metadata_cache)).to be_empty
187+
expect(cache.module_metadata('exploit')).to eq({})
188+
end
189+
190+
it 'returns false when the module does not exist' do
191+
result = cache.send(:remove_from_cache, 'test/nonexistent')
192+
expect(result).to be false
193+
end
194+
end
195+
196+
describe '#get_cache_key' do
197+
it 'returns type_refname' do
198+
mod = make_module_instance(type: 'exploit', refname: 'test/key')
199+
expect(cache.send(:get_cache_key, mod)).to eq('exploit_test/key')
200+
end
201+
end
202+
end

0 commit comments

Comments
 (0)