From 03c8af7c0bc72d50a777074d08a9ee14c83f06c5 Mon Sep 17 00:00:00 2001 From: Christophe De La Fuente Date: Tue, 7 Jul 2026 10:35:44 +0200 Subject: [PATCH 1/2] Track run_uuid/job_id & fix issues Adds a job_listener: kwarg to run_simple / check_simple for exploit, auxiliary, post and evasion modules, exposes Msf::Module#run_uuid, and threads the resulting UUID through Msf::ExploitDriver and Msf::EvasionDriver so RunAsJob paths return [uuid, job_id] uniformly. Fix RPC module.check NotImplementedError handling and JSON-RPC regression Fix RPC session.interactive_read/write for non-meterpreter sessions --- lib/msf/base/sessions/scriptable.rb | 8 +- lib/msf/base/simple/auxiliary.rb | 11 +- lib/msf/base/simple/evasion.rb | 10 +- lib/msf/base/simple/exploit.rb | 14 +- lib/msf/base/simple/post.rb | 28 ++- lib/msf/core/evasion_driver.rb | 38 +++- lib/msf/core/exploit/local.rb | 4 +- .../core/exploit/remote/browser_autopwn2.rb | 8 +- lib/msf/core/exploit/remote/check_module.rb | 4 +- lib/msf/core/exploit_driver.rb | 38 +++- lib/msf/core/module.rb | 8 + lib/msf/core/post/session_upgrade.rb | 4 +- .../core/rpc/v10/rpc_job_status_tracker.rb | 19 +- lib/msf/core/rpc/v10/rpc_module.rb | 58 ++++-- lib/msf/core/rpc/v10/rpc_session.rb | 28 ++- lib/msf/core/rpc/v10/service.rb | 4 +- .../console/command_dispatcher/auxiliary.rb | 8 +- lib/msf/ui/console/command_dispatcher/core.rb | 4 +- lib/msf/ui/console/command_dispatcher/post.rb | 4 +- .../ui/console/module_command_dispatcher.rb | 4 +- .../ui/console/command_dispatcher/core.rb | 4 +- .../ui/console/command_dispatcher/core.rb | 4 +- .../simple/auxiliary_job_tracking_spec.rb | 80 ++++++++ .../base/simple/evasion_job_tracking_spec.rb | 65 +++++++ .../base/simple/exploit_job_tracking_spec.rb | 158 +++++++++++++++ .../msf/base/simple/post_job_tracking_spec.rb | 117 ++++++++++++ .../rpc/v10/rpc_job_status_tracker_spec.rb | 91 +++++++++ .../msf/core/rpc/v10/rpc_module_check_spec.rb | 78 ++++++++ .../core/rpc/v10/rpc_module_execute_spec.rb | 180 ++++++++++++++++++ spec/lib/msf/core/rpc/v10/rpc_session_spec.rb | 70 +++++-- spec/lib/msf/exploit/local_spec.rb | 22 +++ 31 files changed, 1079 insertions(+), 94 deletions(-) create mode 100644 spec/lib/msf/base/simple/auxiliary_job_tracking_spec.rb create mode 100644 spec/lib/msf/base/simple/evasion_job_tracking_spec.rb create mode 100644 spec/lib/msf/base/simple/exploit_job_tracking_spec.rb create mode 100644 spec/lib/msf/base/simple/post_job_tracking_spec.rb create mode 100644 spec/lib/msf/core/rpc/v10/rpc_module_check_spec.rb create mode 100644 spec/lib/msf/core/rpc/v10/rpc_module_execute_spec.rb diff --git a/lib/msf/base/sessions/scriptable.rb b/lib/msf/base/sessions/scriptable.rb index 410b5e9dadbdd..200e0eb3c1477 100644 --- a/lib/msf/base/sessions/scriptable.rb +++ b/lib/msf/base/sessions/scriptable.rb @@ -129,7 +129,7 @@ def execute_script(script_name, *args) opts[k.downcase] = v end if mod.type == "post" - mod.run_simple( + mod.run_simple({ # Run with whatever the default stance is for now. At some # point in the future, we'll probably want a way to force a # module to run in the background @@ -137,7 +137,7 @@ def execute_script(script_name, *args) 'LocalInput' => self.user_input, 'LocalOutput' => self.user_output, 'Options' => opts - ) + }) elsif mod.type == "exploit" # well it must be a local, we're not currently supporting anything else if mod.exploit_type == "local" @@ -160,13 +160,13 @@ def execute_script(script_name, *args) # session local_exploit_opts = local_exploit_opts.merge(opts) - mod.exploit_simple( + mod.exploit_simple({ 'Payload' => local_exploit_opts.delete('payload'), 'Target' => local_exploit_opts.delete('target'), 'LocalInput' => self.user_input, 'LocalOutput' => self.user_output, 'Options' => local_exploit_opts - ) + }) end # end if local end # end if exploit diff --git a/lib/msf/base/simple/auxiliary.rb b/lib/msf/base/simple/auxiliary.rb index 7cd7ddd7b087d..b33746b8d0971 100644 --- a/lib/msf/base/simple/auxiliary.rb +++ b/lib/msf/base/simple/auxiliary.rb @@ -70,6 +70,8 @@ def self.run_simple(omod, opts = {}, job_listener: Msf::Simple::NoopJobListener. end run_uuid = Rex::Text.rand_text_alphanumeric(24) + mod.run_uuid = run_uuid + omod.run_uuid = run_uuid job_listener.waiting run_uuid ctx = [mod, run_uuid, job_listener] run_as_job = opts['RunAsJob'].nil? ? mod.passive? : opts['RunAsJob'] @@ -94,8 +96,8 @@ def self.run_simple(omod, opts = {}, job_listener: Msf::Simple::NoopJobListener. # # Calls the class method. # - def run_simple(opts = {}, &block) - Msf::Simple::Auxiliary.run_simple(self, opts, &block) + def run_simple(opts = {}, job_listener: Msf::Simple::NoopJobListener.instance, &block) + Msf::Simple::Auxiliary.run_simple(self, opts, job_listener: job_listener, &block) end # @@ -128,6 +130,7 @@ def self.check_simple(mod, opts, job_listener: Msf::Simple::NoopJobListener.inst mod.validate run_uuid = Rex::Text.rand_text_alphanumeric(24) + mod.run_uuid = run_uuid job_listener.waiting run_uuid ctx = [mod, run_uuid, job_listener] @@ -158,8 +161,8 @@ def self.check_simple(mod, opts, job_listener: Msf::Simple::NoopJobListener.inst # # Calls the class method. # - def check_simple(opts = {}) - Msf::Simple::Auxiliary.check_simple(self, opts) + def check_simple(opts = {}, job_listener: Msf::Simple::NoopJobListener.instance) + Msf::Simple::Auxiliary.check_simple(self, opts, job_listener: job_listener) end diff --git a/lib/msf/base/simple/evasion.rb b/lib/msf/base/simple/evasion.rb index 9bbd3fc74f29f..693256446e52b 100644 --- a/lib/msf/base/simple/evasion.rb +++ b/lib/msf/base/simple/evasion.rb @@ -7,7 +7,7 @@ module Evasion include Module - def self.run_simple(oevasion, opts, &block) + def self.run_simple(oevasion, opts, job_listener: Msf::Simple::NoopJobListener.instance, &block) evasion = oevasion.replicant # Trap and print errors here (makes them UI-independent) begin @@ -28,7 +28,7 @@ def self.run_simple(oevasion, opts, &block) evasion.options.validate(evasion.datastore) # Start it up - driver = EvasionDriver.new(evasion.framework) + driver = EvasionDriver.new(evasion.framework, job_listener: job_listener) # Initialize the driver instance driver.evasion = evasion @@ -85,9 +85,11 @@ def self.run_simple(oevasion, opts, &block) # Save the job identifier this evasion is running as evasion.job_id = driver.job_id + evasion.run_uuid = driver.run_uuid # Propagate this back to the caller for console mgmt oevasion.job_id = evasion.job_id + oevasion.run_uuid = evasion.run_uuid rescue ::Interrupt evasion.error = $! raise $! @@ -103,8 +105,8 @@ def self.run_simple(oevasion, opts, &block) nil end - def run_simple(opts = {}, &block) - Msf::Simple::Evasion.run_simple(self, opts, &block) + def run_simple(opts = {}, job_listener: Msf::Simple::NoopJobListener.instance, &block) + Msf::Simple::Evasion.run_simple(self, opts, job_listener: job_listener, &block) end end diff --git a/lib/msf/base/simple/exploit.rb b/lib/msf/base/simple/exploit.rb index 479239a20a510..1c71873619e04 100644 --- a/lib/msf/base/simple/exploit.rb +++ b/lib/msf/base/simple/exploit.rb @@ -54,7 +54,7 @@ module Exploit # Whether or not the exploit should be run in the context of a background # job. # - def self.exploit_simple(oexploit, opts, &block) + def self.exploit_simple(oexploit, opts, job_listener: Msf::Simple::NoopJobListener.instance, &block) exploit = oexploit.replicant # Trap and print errors here (makes them UI-independent) begin @@ -83,7 +83,7 @@ def self.exploit_simple(oexploit, opts, &block) exploit.validate # Start it up - driver = Msf::ExploitDriver.new(exploit.framework) + driver = Msf::ExploitDriver.new(exploit.framework, job_listener: job_listener) # Keep the handler of driver running if exploiting multiple targets. driver.keep_handler = true if opts['multi'] @@ -146,9 +146,11 @@ def self.exploit_simple(oexploit, opts, &block) # Save the job identifier this exploit is running as exploit.job_id = driver.job_id + exploit.run_uuid = driver.run_uuid # Propagate this back to the caller for console mgmt oexploit.job_id = exploit.job_id + oexploit.run_uuid = exploit.run_uuid rescue ::Interrupt exploit.error = $! raise $! @@ -169,8 +171,8 @@ def self.exploit_simple(oexploit, opts, &block) # # Calls the class method. # - def exploit_simple(opts, &block) - Msf::Simple::Exploit.exploit_simple(self, opts, &block) + def exploit_simple(opts, job_listener: Msf::Simple::NoopJobListener.instance, &block) + Msf::Simple::Exploit.exploit_simple(self, opts, job_listener: job_listener, &block) end alias run_simple exploit_simple @@ -223,8 +225,8 @@ def self.check_simple(mod, opts, job_listener: Msf::Simple::NoopJobListener.inst # # Calls the class method. # - def check_simple(opts = {}) - Msf::Simple::Exploit.check_simple(self, opts) + def check_simple(opts = {}, job_listener: Msf::Simple::NoopJobListener.instance) + Msf::Simple::Exploit.check_simple(self, opts, job_listener: job_listener) end protected diff --git a/lib/msf/base/simple/post.rb b/lib/msf/base/simple/post.rb index 6e329c79aca44..b643ff5e2b1dd 100644 --- a/lib/msf/base/simple/post.rb +++ b/lib/msf/base/simple/post.rb @@ -37,7 +37,7 @@ module Post # Whether or not the module should be run in the context of a background # job. # - def self.run_simple(omod, opts = {}, &block) + def self.run_simple(omod, opts = {}, job_listener: Msf::Simple::NoopJobListener.instance, &block) # Clone the module to prevent changes to the original instance mod = omod.replicant @@ -64,11 +64,16 @@ def self.run_simple(omod, opts = {}, &block) mod.init_ui(nil, nil) end + run_uuid = Rex::Text.rand_text_alphanumeric(24) + mod.run_uuid = run_uuid + omod.run_uuid = run_uuid + job_listener.waiting run_uuid + ctx = [mod, run_uuid, job_listener] + # # Disable this until we can test background stuff a little better # if(mod.passive? or opts['RunAsJob']) - ctx = [ mod.replicant ] mod.job_id = mod.framework.jobs.start_bg_job( "Post: #{mod.refname}", ctx, @@ -78,7 +83,6 @@ def self.run_simple(omod, opts = {}, &block) # Propagate this back to the caller for console mgmt omod.job_id = mod.job_id else - ctx = [ mod ] self.job_run_proc(ctx) self.job_cleanup_proc(ctx) end @@ -87,8 +91,8 @@ def self.run_simple(omod, opts = {}, &block) # # Calls the class method. # - def run_simple(opts = {}, &block) - Msf::Simple::Post.run_simple(self, opts, &block) + def run_simple(opts = {}, job_listener: Msf::Simple::NoopJobListener.instance, &block) + Msf::Simple::Post.run_simple(self, opts, job_listener: job_listener, &block) end protected @@ -99,8 +103,9 @@ def run_simple(opts = {}, &block) # XXX: Mostly Copy/pasted from simple/auxiliary.rb # def self.job_run_proc(ctx) - mod = ctx[0] + mod, run_uuid, job_listener = ctx begin + job_listener.start run_uuid mod.setup mod.framework.events.on_module_run(mod) # Grab the session object since we need to fire an event for not @@ -109,33 +114,41 @@ def self.job_run_proc(ctx) s = mod.framework.sessions.get(mod.datastore["SESSION"]) if s mod.framework.events.on_session_module_run(s, mod) - mod.run + result = mod.run + job_listener.completed(run_uuid, result, mod) + return result else mod.print_error("Session not found") + job_listener.failed(run_uuid, 'Session not found', mod) mod.cleanup return end rescue Msf::Post::Complete + job_listener.completed(run_uuid, nil, mod) mod.cleanup return rescue Msf::Post::Failed => e mod.error = e mod.print_error("Post aborted due to failure: #{e.message}") + job_listener.failed(run_uuid, e, mod) mod.cleanup return rescue ::Timeout::Error => e mod.error = e mod.print_error("Post triggered a timeout exception") + job_listener.failed(run_uuid, e, mod) mod.cleanup return rescue ::Interrupt => e mod.error = e mod.print_error("Post interrupted by the console user") + job_listener.failed(run_uuid, e, mod) mod.cleanup return rescue ::Msf::OptionValidateError => e mod.error = e ::Msf::Ui::Formatter::OptionValidateError.print_error(mod, e) + job_listener.failed(run_uuid, e, mod) rescue ::Exception => e mod.error = e mod.print_error("Post failed: #{e.class} #{e}") @@ -148,6 +161,7 @@ def self.job_run_proc(ctx) end elog('Post failed', error: e) + job_listener.failed(run_uuid, e, mod) mod.cleanup return diff --git a/lib/msf/core/evasion_driver.rb b/lib/msf/core/evasion_driver.rb index 87d8dfefee6df..bad5bb85acecf 100644 --- a/lib/msf/core/evasion_driver.rb +++ b/lib/msf/core/evasion_driver.rb @@ -7,13 +7,14 @@ class EvasionDriver # # Initializes the evasion driver using the supplied framework instance. # - def initialize(framework) + def initialize(framework, job_listener: Msf::Simple::NoopJobListener.instance) self.payload = nil self.evasion = nil self.use_job = false self.job_id = nil self.force_wait_for_session = false self.semaphore = Mutex.new + self.job_listener = job_listener end def target_idx=(target_idx) @@ -80,15 +81,20 @@ def run # evasion module instance evasion.generate_payload(payload) + self.run_uuid = Rex::Text.rand_text_alphanumeric(24) + evasion.run_uuid = self.run_uuid + self.job_listener.waiting self.run_uuid + # No need to copy since we aren't creating a job. We wait until # they're finished running to do anything else with them, so # nothing should be able to modify their datastore or other # settings until after they're done. - ctx = [ evasion, payload ] + ctx = [ evasion, payload, self.run_uuid, self.job_listener ] job_run_proc(ctx) job_cleanup_proc(ctx) + nil end attr_accessor :evasion # :nodoc: @@ -99,24 +105,40 @@ def run # job. # attr_accessor :job_id + # + # The run UUID generated for the most recent #run invocation. Used by the + # job listener and reported back to RPC clients via the module attribute of + # the same name. + # + attr_accessor :run_uuid attr_accessor :force_wait_for_session # :nodoc: attr_accessor :session # :nodoc: # To synchronize threads cleaning up the evasion attr_accessor :semaphore + attr_accessor :job_listener + protected # # Job run proc, sets up the eevasion and kicks it off. # def job_run_proc(ctx) - evasion, payload = ctx - evasion.setup - evasion.framework.events.on_module_run(evasion) - - # Launch the evasion module - evasion.run + evasion, payload, run_uuid, job_listener = ctx + job_listener ||= self.job_listener + begin + job_listener.start run_uuid + evasion.setup + evasion.framework.events.on_module_run(evasion) + + # Launch the evasion module + result = evasion.run + job_listener.completed(run_uuid, result, evasion) + rescue ::Exception => e + job_listener.failed(run_uuid, e, evasion) + raise + end end # diff --git a/lib/msf/core/exploit/local.rb b/lib/msf/core/exploit/local.rb index 66480e1f52ad6..2f3678c524758 100644 --- a/lib/msf/core/exploit/local.rb +++ b/lib/msf/core/exploit/local.rb @@ -19,7 +19,7 @@ def exploit_type Msf::Exploit::Type::Local end - def run_simple(opts = {}, &block) - Msf::Simple::Exploit.exploit_simple(self, opts, &block) + def run_simple(opts = {}, job_listener: Msf::Simple::NoopJobListener.instance, &block) + Msf::Simple::Exploit.exploit_simple(self, opts, job_listener: job_listener, &block) end end diff --git a/lib/msf/core/exploit/remote/browser_autopwn2.rb b/lib/msf/core/exploit/remote/browser_autopwn2.rb index 8b53769918e8d..fa00ad915c6a8 100644 --- a/lib/msf/core/exploit/remote/browser_autopwn2.rb +++ b/lib/msf/core/exploit/remote/browser_autopwn2.rb @@ -339,12 +339,12 @@ def start_payload_listeners multi_handler.register_parent(self) # Now we're ready to start the handler - multi_handler.exploit_simple( + multi_handler.exploit_simple({ 'LocalInput' => nil, 'LocalOutput' => nil, 'Payload' => payload_name, 'RunAsJob' => true - ) + }) @payload_job_ids << multi_handler.job_id end end @@ -445,14 +445,14 @@ def select_payload(m) def start_exploits bap_exploits.each do |m| set_exploit_options(m) - m.exploit_simple( + m.exploit_simple({ 'LocalInput' => nil, 'LocalOutput' => nil, 'Quiet' => true, 'Target' => 0, 'Payload' => m.datastore['PAYLOAD'], 'RunAsJob' => true - ) + }) @exploit_job_ids << m.job_id end end diff --git a/lib/msf/core/exploit/remote/check_module.rb b/lib/msf/core/exploit/remote/check_module.rb index f27d79d65d7db..df2b874df629c 100644 --- a/lib/msf/core/exploit/remote/check_module.rb +++ b/lib/msf/core/exploit/remote/check_module.rb @@ -46,12 +46,12 @@ def check print_status("Using #{check_module} as check") # Retrieve the module's return value - res = mod.run_simple( + res = mod.run_simple({ 'LocalInput' => user_input, 'LocalOutput' => user_output, 'Options' => datastore.merge(check_options), 'RunAsJob' => false - ) + }) # Ensure return value is a CheckCode case res diff --git a/lib/msf/core/exploit_driver.rb b/lib/msf/core/exploit_driver.rb index f0e51b8bb5d24..b7e5d7e3e3ba9 100644 --- a/lib/msf/core/exploit_driver.rb +++ b/lib/msf/core/exploit_driver.rb @@ -15,7 +15,7 @@ class ExploitDriver # # Initializes the exploit driver using the supplied framework instance. # - def initialize(framework) + def initialize(framework, job_listener: Msf::Simple::NoopJobListener.instance) self.payload = nil self.exploit = nil self.use_job = false @@ -23,6 +23,7 @@ def initialize(framework) self.force_wait_for_session = false self.keep_handler = false self.semaphore = Mutex.new + self.job_listener = job_listener end # @@ -138,6 +139,9 @@ def run # run exploit.job_id = nil + self.run_uuid = Rex::Text.rand_text_alphanumeric(24) + self.job_listener.waiting self.run_uuid + # If we are being instructed to run as a job then let's create that job # like a good person. if (use_job or exploit.passive?) @@ -153,7 +157,8 @@ def run # Generate the encoded version of the supplied payload for the # newly copied exploit module instance e.generate_payload(p) - ctx = [ e, p ] + e.run_uuid = self.run_uuid + ctx = [ e, p, self.run_uuid, self.job_listener ] e.job_id = e.framework.jobs.start_bg_job( "Exploit: #{e.refname}", @@ -171,7 +176,8 @@ def run # they're finished running to do anything else with them, so # nothing should be able to modify their datastore or other # settings until after they're done. - ctx = [ exploit, payload ] + exploit.run_uuid = self.run_uuid + ctx = [ exploit, payload, self.run_uuid, self.job_listener ] begin job_run_proc(ctx) @@ -185,7 +191,7 @@ def run end end - return session + self.session end attr_accessor :exploit # :nodoc: @@ -196,6 +202,12 @@ def run # job. # attr_accessor :job_id + # + # The run UUID generated for the most recent #run invocation. Used by the + # job listener and reported back to RPC clients via the module attribute of + # the same name. + # + attr_accessor :run_uuid attr_accessor :force_wait_for_session # :nodoc: attr_accessor :session # :nodoc: attr_accessor :keep_handler # :nodoc: @@ -203,6 +215,8 @@ def run # To synchronize threads cleaning up the exploit and the handler attr_accessor :semaphore + attr_accessor :job_listener + protected # @@ -210,11 +224,12 @@ def run # def job_run_proc(ctx) begin - exploit, payload = ctx + exploit, payload, run_uuid, job_listener = ctx # Default session wait time.. delay = payload.wfs_delay + exploit.wfs_delay delay = nil if exploit.passive? + job_listener.start run_uuid # Set the exploit up the bomb exploit.setup @@ -229,7 +244,15 @@ def job_run_proc(ctx) delay = 0.01 end + iout = StringIO.new + rex_out = Rex::Ui::Text::Output::Stdio.new(io: iout) + rex_out.disable_color + old_user_output = exploit.user_output + exploit.user_output = rex_out fail_reason = exploit.handle_exception(e) + iout.close_write + exploit.user_output = old_user_output + job_listener.failed(run_uuid, iout.string.strip, exploit) end # Start bind handlers after exploit completion @@ -243,6 +266,10 @@ def job_run_proc(ctx) (exploit.passive? == false and exploit.handler_enabled?) begin self.session = payload.wait_for_session(delay) + # Update the result to reflect the session creation + if self.session + job_listener.completed(run_uuid, "#{session.desc} session #{session.name} opened (#{session.tunnel_to_s}) at #{Time.now}", exploit) + end rescue ::Interrupt, Timeout::ExitException # Don't let interrupt pass upward # We also want report a failure when the Timeout expires @@ -255,6 +282,7 @@ def job_run_proc(ctx) exploit.fail_reason = Msf::Exploit::Failure::PayloadFailed exploit.fail_detail = "No session created" exploit.report_failure + job_listener.failed(run_uuid, exploit.fail_detail, exploit) end if fail_reason && fail_reason == Msf::Exploit::Failure::UserInterrupt diff --git a/lib/msf/core/module.rb b/lib/msf/core/module.rb index 3eb8b3d4115b8..a47bbc8525873 100644 --- a/lib/msf/core/module.rb +++ b/lib/msf/core/module.rb @@ -423,6 +423,14 @@ def default_cred? # attr_accessor :job_id + # + # The run UUID produced by the most recent invocation of this module via + # one of the Simple wrappers (exploit_simple / run_simple). Mirrors the + # value reported to the job listener and tracked by + # Msf::RPC::RpcJobStatusTracker. + # + attr_accessor :run_uuid + # # The last exception to occur using this module # diff --git a/lib/msf/core/post/session_upgrade.rb b/lib/msf/core/post/session_upgrade.rb index 8b8be534a2338..132a1d44adee1 100644 --- a/lib/msf/core/post/session_upgrade.rb +++ b/lib/msf/core/post/session_upgrade.rb @@ -143,12 +143,12 @@ def start_upgrade_handler(lhost) handler_mod.datastore['LPORT'] = lport handler_mod.datastore['ExitOnSession'] = true - handler_mod.exploit_simple( + handler_mod.exploit_simple({ 'Payload' => payload_name, 'LocalInput' => user_input, 'LocalOutput' => user_output, 'RunAsJob' => true - ) + }) # Allow handler time to bind; if it fails, the job disappears Rex::ThreadSafe.sleep(2) diff --git a/lib/msf/core/rpc/v10/rpc_job_status_tracker.rb b/lib/msf/core/rpc/v10/rpc_job_status_tracker.rb index 5e7b126bb234b..296b74075453d 100644 --- a/lib/msf/core/rpc/v10/rpc_job_status_tracker.rb +++ b/lib/msf/core/rpc/v10/rpc_job_status_tracker.rb @@ -101,7 +101,8 @@ def data private def add_result(id, result, mod) - string = result.to_json + sanitized = result.transform_values { |v| json_safe(v) } + string = sanitized.to_json results.write(id, string) rescue ::Exception => e wlog("Job with id: #{id} finished but the result could not be stored") @@ -111,6 +112,22 @@ def add_result(id, result, mod) running.delete(id) end + # Framework objects (Session, LoginScanner::Result) hold cyclic refs that blow to_json's stack. + def json_safe(value) + case value + when nil, true, false, Numeric, String, Symbol + value + when Msf::Exploit::CheckCode + value + when Hash + value.each_with_object({}) { |(k, v), h| h[json_safe(k)] = json_safe(v) } + when Array + value.map { |v| json_safe(v) } + else + value.to_s + end + end + def add_fallback_result(id, mod) string = { error: { diff --git a/lib/msf/core/rpc/v10/rpc_module.rb b/lib/msf/core/rpc/v10/rpc_module.rb index 3aa73bb34ee2a..8180085d31ea9 100644 --- a/lib/msf/core/rpc/v10/rpc_module.rb +++ b/lib/msf/core/rpc/v10/rpc_module.rb @@ -496,6 +496,7 @@ def rpc_options(mtype, mname) # opts = {'LHOST' => '0.0.0.0', 'LPORT'=>6669, 'PAYLOAD'=>'windows/meterpreter/reverse_tcp'} # rpc.call('module.execute', 'exploit', 'multi/handler', opts) def rpc_execute(mtype, mname, opts) + _validate_options!(opts) mod = _find_module(mtype,mname) case mtype @@ -523,6 +524,7 @@ def rpc_execute(mtype, mname, opts) # @raise [Msf::RPC::Exception] Module not found (either wrong type or name). # @return def rpc_check(mtype, mname, opts) + _validate_options!(opts) mod = _find_module(mtype,mname) case mtype when 'exploit' @@ -556,7 +558,7 @@ def rpc_results(uuid) elsif self.job_status_tracker.waiting? uuid {"status" => "ready"} else - error(404, "Results not found for module instance #{uuid}") + error(500, "Results not found for module instance #{uuid}") end end @@ -728,6 +730,40 @@ def rpc_encode(data, encoder, options) private + # Structural validation for the datastore options hash forwarded to + # module.execute / module.check. Rejects obviously abusive input + # (non-hash, nested structures, oversized values, malformed keys) at the + # RPC boundary so callers other than the MCP transport -- which performs + # additional policy-level validation of its own -- still have basic + # defence-in-depth against DoS-style payloads. + RPC_MODULE_OPTIONS_MAX_KEYS = 100 + RPC_MODULE_OPTIONS_KEY_MAX_LENGTH = 128 + RPC_MODULE_OPTIONS_VALUE_MAX_BYTES = 8 * 1024 + RPC_MODULE_OPTION_SCALAR_TYPES = [String, Integer, Float, TrueClass, FalseClass, NilClass].freeze + + def _validate_options!(opts) + error(400, 'Module options must be a Hash') unless opts.is_a?(Hash) + + if opts.size > RPC_MODULE_OPTIONS_MAX_KEYS + error(400, "Module options has too many keys (max #{RPC_MODULE_OPTIONS_MAX_KEYS})") + end + + opts.each do |key, value| + unless key.is_a?(String) || key.is_a?(Symbol) + error(400, "Invalid module option key type: #{key.class}") + end + if key.to_s.length > RPC_MODULE_OPTIONS_KEY_MAX_LENGTH + error(400, "Module option key too long (max #{RPC_MODULE_OPTIONS_KEY_MAX_LENGTH} chars)") + end + unless RPC_MODULE_OPTION_SCALAR_TYPES.any? { |klass| value.is_a?(klass) } + error(400, "Invalid module option value for #{key}: must be a scalar") + end + if value.is_a?(String) && value.bytesize > RPC_MODULE_OPTIONS_VALUE_MAX_BYTES + error(400, "Module option #{key} string value exceeds #{RPC_MODULE_OPTIONS_VALUE_MAX_BYTES} bytes") + end + end + end + # @param [String] mtype The module type # @param [String] mname The module name # @return [Msf::Module] The module if found @@ -751,27 +787,27 @@ def _run_exploit(mod, opts) opts['PAYLOAD'] = Msf::Payload.choose_payload(mod) end - s = Msf::Simple::Exploit.exploit_simple(mod, { + Msf::Simple::Exploit.exploit_simple(mod, { 'Payload' => opts['PAYLOAD'], 'Target' => opts['TARGET'], 'RunAsJob' => true, 'Options' => opts - }) + }, job_listener: self.job_status_tracker) { "job_id" => mod.job_id, - "uuid" => mod.uuid + "uuid" => mod.run_uuid } end def _run_auxiliary(mod, opts) - uuid, job = Msf::Simple::Auxiliary.run_simple(mod,{ + Msf::Simple::Auxiliary.run_simple(mod,{ 'Action' => opts['ACTION'], 'RunAsJob' => true, 'Options' => opts }, job_listener: self.job_status_tracker) { - "job_id" => job, - "uuid" => uuid + "job_id" => mod.job_id, + "uuid" => mod.run_uuid } end @@ -802,10 +838,10 @@ def _run_post(mod, opts) Msf::Simple::Post.run_simple(mod, { 'RunAsJob' => true, 'Options' => opts - }) + }, job_listener: self.job_status_tracker) { "job_id" => mod.job_id, - "uuid" => mod.uuid + "uuid" => mod.run_uuid } end @@ -815,11 +851,11 @@ def _run_evasion(mod, opts) 'Target' => opts['TARGET'], 'RunAsJob' => true, 'Options' => opts - }) + }, job_listener: self.job_status_tracker) { 'job_id' => mod.job_id, - 'uuid' => mod.uuid + 'uuid' => mod.run_uuid } end diff --git a/lib/msf/core/rpc/v10/rpc_session.rb b/lib/msf/core/rpc/v10/rpc_session.rb index 54e8d1f6bfdea..c1093e93327f1 100644 --- a/lib/msf/core/rpc/v10/rpc_session.rb +++ b/lib/msf/core/rpc/v10/rpc_session.rb @@ -181,7 +181,7 @@ def rpc_meterpreter_read(sid) rpc_interactive_read(sid) end - # Reads the output from an interactive session (meterpreter, DB sessions, SMB) + # Reads the output from an interactive session (meterpreter, DB sessions, SMB, shell, powershell) # # @note Multiple concurrent callers writing and reading the same Meterperter session can lead to # a conflict, where one caller gets the others output and vice versa. Concurrent access to a @@ -190,6 +190,7 @@ def rpc_meterpreter_read(sid) # @raise [Msf::RPC::Exception] An error that could be one of these: # * 500 Unknown Session ID. # * 500 Session doesn't support interactive operations. + # * 500 Session is disconnected. # @return [Hash] It contains the following key: # * 'data' [String] Data read. # @example Here's how you would use this from the client: @@ -197,6 +198,14 @@ def rpc_meterpreter_read(sid) def rpc_interactive_read(sid) session = _valid_interactive_session(sid) + if SHELL_SESSION_TYPES.include?(session.type) + begin + return { 'data' => session.shell_read.to_s } + rescue ::Exception => e + error(500, "Session Disconnected: #{e.class} #{e}") + end + end + unless session.user_output.respond_to?(:dump_buffer) session.init_ui(Rex::Ui::Text::Input::Buffer.new, Rex::Ui::Text::Output::Buffer.new) end @@ -302,7 +311,7 @@ def rpc_meterpreter_write(sid, data) rpc_interactive_write(sid, data) end - # Sends an input to an interactive prompt (meterpreter, DB sessions, SMB) + # Sends an input to an interactive prompt (meterpreter, DB sessions, SMB, shell, powershell) # You may want to use #rpc_interactive_read to retrieve the output. # @note Multiple concurrent callers writing and reading the same Meterperter session can lead to # a conflict, where one caller gets the others output and vice versa. Concurrent access to @@ -312,6 +321,7 @@ def rpc_meterpreter_write(sid, data) # @raise [Msf::RPC::Exception] An error that could be one of these: # * 500 Unknown Session ID. # * 500 Session doesn't support interactive operations. + # * 500 Session is disconnected. # @return [Hash] A hash indicating the action was successful or not. It contains the following key: # * 'result' [String] Either 'success' or 'failure'. # @example Here's how you would use this from the client: @@ -319,6 +329,16 @@ def rpc_meterpreter_write(sid, data) def rpc_interactive_write(sid, data) session = _valid_interactive_session(sid) + if SHELL_SESSION_TYPES.include?(session.type) + begin + payload = data.end_with?("\n") ? data : "#{data}\n" + session.shell_write(payload) + return { 'result' => 'success' } + rescue ::Exception => e + error(500, "Session Disconnected: #{e.class} #{e}") + end + end + unless session.user_output.respond_to? :dump_buffer session.init_ui(Rex::Ui::Text::Input::Buffer.new, Rex::Ui::Text::Output::Buffer.new) end @@ -532,8 +552,12 @@ def rpc_compatible_modules(sid) mysql smb ldap + shell + powershell ].freeze + SHELL_SESSION_TYPES = %w[shell powershell].freeze + def _find_module(_mtype, mname) mod = framework.modules.create(mname) error(500, 'Invalid Module') if mod.nil? diff --git a/lib/msf/core/rpc/v10/service.rb b/lib/msf/core/rpc/v10/service.rb index 4c4ebe5471bb8..8348abacffa23 100644 --- a/lib/msf/core/rpc/v10/service.rb +++ b/lib/msf/core/rpc/v10/service.rb @@ -84,8 +84,8 @@ def on_request_uri(cli, req) res.body = process_exception(e).to_msgpack res.code = e.code res.message = e.http_msg - rescue ::StandardError => e - elog('Unknown Exception', error: e) + rescue ::StandardError, ::ScriptError => e + elog('Unhandled Exception', error: e) res.body = process_exception(e).to_msgpack res.code = 500 res.message = 'Internal Server Error' diff --git a/lib/msf/ui/console/command_dispatcher/auxiliary.rb b/lib/msf/ui/console/command_dispatcher/auxiliary.rb index 49f4214ddec50..1fee93a649164 100644 --- a/lib/msf/ui/console/command_dispatcher/auxiliary.rb +++ b/lib/msf/ui/console/command_dispatcher/auxiliary.rb @@ -65,13 +65,13 @@ def cmd_run(*args, action: nil, opts: {}) if rhosts.blank? || mod.class.included_modules.include?(Msf::Auxiliary::MultipleTargetHosts) || !mod.datastore.options.key?('RHOSTS') - mod_with_opts.run_simple( + mod_with_opts.run_simple({ 'Action' => args[:action], 'LocalInput' => driver.input, 'LocalOutput' => driver.output, 'RunAsJob' => jobify, 'Quiet' => args[:quiet] - ) + }) # For multi target attempts with non-scanner modules. else # When RHOSTS is split, the validation changes slightly, so perform it reports the host the validation failed for @@ -80,13 +80,13 @@ def cmd_run(*args, action: nil, opts: {}) mod_with_opts = mod.replicant mod_with_opts.datastore.merge!(datastore) print_status("Running module against #{datastore['RHOSTS']}") - mod_with_opts.run_simple( + mod_with_opts.run_simple({ 'Action' => args[:action], 'LocalInput' => driver.input, 'LocalOutput' => driver.output, 'RunAsJob' => false, 'Quiet' => args[:quiet] - ) + }) end end rescue ::Timeout::Error diff --git a/lib/msf/ui/console/command_dispatcher/core.rb b/lib/msf/ui/console/command_dispatcher/core.rb index 16e996968d47c..0858d26c8467c 100644 --- a/lib/msf/ui/console/command_dispatcher/core.rb +++ b/lib/msf/ui/console/command_dispatcher/core.rb @@ -1834,11 +1834,11 @@ def cmd_sessions(*args) opts[key] = session.exploit_datastore[key] if session.exploit_datastore[key] end end - mod.run_simple( + mod.run_simple({ 'LocalInput' => driver.input, 'LocalOutput' => driver.output, 'Options' => opts - ) + }) else print_status("Executing 'post/multi/manage/shell_to_meterpreter' on " \ "session: [#{sess_id}]") diff --git a/lib/msf/ui/console/command_dispatcher/post.rb b/lib/msf/ui/console/command_dispatcher/post.rb index 7f0063d3cef4a..d0f754d551773 100644 --- a/lib/msf/ui/console/command_dispatcher/post.rb +++ b/lib/msf/ui/console/command_dispatcher/post.rb @@ -85,14 +85,14 @@ def cmd_run(*args, action: nil, opts: {}) end begin - mod.run_simple( + mod.run_simple({ 'Action' => args[:action], 'Options' => args[:datastore_options], 'LocalInput' => driver.input, 'LocalOutput' => driver.output, 'RunAsJob' => jobify, 'Quiet' => args[:quiet] - ) + }) rescue ::Timeout::Error print_error("Post triggered a timeout exception") rescue ::Interrupt diff --git a/lib/msf/ui/console/module_command_dispatcher.rb b/lib/msf/ui/console/module_command_dispatcher.rb index 8da619a95d2de..1386706a53e1e 100644 --- a/lib/msf/ui/console/module_command_dispatcher.rb +++ b/lib/msf/ui/console/module_command_dispatcher.rb @@ -226,10 +226,10 @@ def check_simple(instance=nil) begin if instance.respond_to?(:check_simple) - code = instance.check_simple( + code = instance.check_simple({ 'LocalInput' => driver.input, 'LocalOutput' => driver.output - ) + }) else msg = "Check failed: #{instance.type.capitalize} modules do not support check." raise NotImplementedError, msg diff --git a/lib/rex/post/hwbridge/ui/console/command_dispatcher/core.rb b/lib/rex/post/hwbridge/ui/console/command_dispatcher/core.rb index 2b8fb19728ad5..53db258fbd8cd 100644 --- a/lib/rex/post/hwbridge/ui/console/command_dispatcher/core.rb +++ b/lib/rex/post/hwbridge/ui/console/command_dispatcher/core.rb @@ -391,12 +391,12 @@ def cmd_run(*args) end opts = (args + [ "SESSION=#{client.sid}" ]).join(',') - reloaded_mod.run_simple( + reloaded_mod.run_simple({ #'RunAsJob' => true, 'LocalInput' => shell.input, 'LocalOutput' => shell.output, 'OptionStr' => opts - ) + }) else # the rest of the arguments get passed in through the binding client.execute_script(script_name, args) diff --git a/lib/rex/post/meterpreter/ui/console/command_dispatcher/core.rb b/lib/rex/post/meterpreter/ui/console/command_dispatcher/core.rb index f507abb91b08f..977bde66100a6 100644 --- a/lib/rex/post/meterpreter/ui/console/command_dispatcher/core.rb +++ b/lib/rex/post/meterpreter/ui/console/command_dispatcher/core.rb @@ -1372,12 +1372,12 @@ def cmd_run(*args) end opts << (args + [ "SESSION=#{client.sid}" ]).join(',') - result = reloaded_mod.run_simple( + result = reloaded_mod.run_simple({ #'RunAsJob' => true, 'LocalInput' => shell.input, 'LocalOutput' => shell.output, 'OptionStr' => opts - ) + }) print_status("Session #{result.sid} created in the background.") if result.is_a?(Msf::Session) else diff --git a/spec/lib/msf/base/simple/auxiliary_job_tracking_spec.rb b/spec/lib/msf/base/simple/auxiliary_job_tracking_spec.rb new file mode 100644 index 0000000000000..45097ac217b11 --- /dev/null +++ b/spec/lib/msf/base/simple/auxiliary_job_tracking_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'msf/base/simple/auxiliary' +require 'msf/base/simple/noop_job_listener' + +RSpec.describe Msf::Simple::Auxiliary, '#job_run_proc job tracking' do + let(:run_uuid) { 'aux-run-uuid' } + let(:job_listener) { double('JobListener', waiting: nil, start: nil, completed: nil, failed: nil) } + let(:events) { double('EventDispatcher', on_module_run: nil, on_module_complete: nil, on_session_module_run: nil) } + let(:framework) { double('Framework', events: events) } + let(:mod) do + double( + 'AuxiliaryModule', + framework: framework, + respond_to?: true, + :check_code= => nil, + :last_vuln_attempt= => nil, + setup: nil, + cleanup: nil, + report_failure: nil, + :error= => nil, + :fail_reason= => nil, + :fail_detail= => nil, + print_error: nil, + print_status: nil, + fail_reason: Msf::Module::Failure::None, + fail_detail: nil + ) + end + let(:ctx) { [mod, run_uuid, job_listener] } + + context 'when the block completes successfully' do + it 'reports start then completed with the block result' do + result = 'aux-result' + described_class.job_run_proc(ctx) { |_m| result } + expect(job_listener).to have_received(:start).with(run_uuid) + expect(job_listener).to have_received(:completed).with(run_uuid, result, mod) + expect(job_listener).not_to have_received(:failed) + end + end + + context 'when the block raises a generic exception' do + it 'reports failed with the exception and propagates' do + err = RuntimeError.new('boom') + described_class.job_run_proc(ctx) { |_m| raise err } + expect(job_listener).to have_received(:start).with(run_uuid) + expect(job_listener).to have_received(:failed).with(run_uuid, err, mod) + expect(job_listener).not_to have_received(:completed) + end + end + + context 'when the block raises Msf::Auxiliary::Complete' do + it 'is reported via failed (mirrors existing aux behavior) then is swallowed' do + described_class.job_run_proc(ctx) { |_m| raise Msf::Auxiliary::Complete } + expect(job_listener).to have_received(:failed).with(run_uuid, kind_of(Msf::Auxiliary::Complete), mod) + end + end +end + +RSpec.describe Msf::Simple::Auxiliary, 'job_listener keyword signature' do + it '.run_simple accepts a job_listener kwarg' do + expect(described_class.method(:run_simple).parameters).to include(%i[key job_listener]) + end + + it '.check_simple accepts a job_listener kwarg' do + expect(described_class.method(:check_simple).parameters).to include(%i[key job_listener]) + end + + it 'the instance-level run_simple accepts a job_listener kwarg' do + expect(described_class.instance_method(:run_simple).parameters).to include(%i[key job_listener]) + end + + it 'the instance-level run_simple forwards job_listener: to the class method' do + mod = Class.new { include Msf::Simple::Auxiliary }.new + listener = double('JobListener') + expect(described_class).to receive(:run_simple).with(mod, { opts: 1 }, job_listener: listener) + mod.run_simple({ opts: 1 }, job_listener: listener) + end +end diff --git a/spec/lib/msf/base/simple/evasion_job_tracking_spec.rb b/spec/lib/msf/base/simple/evasion_job_tracking_spec.rb new file mode 100644 index 0000000000000..63b7ea747b86e --- /dev/null +++ b/spec/lib/msf/base/simple/evasion_job_tracking_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'msf/base/simple/evasion' +require 'msf/base/simple/noop_job_listener' +require 'msf/core/evasion_driver' + +RSpec.describe Msf::EvasionDriver, '#job_run_proc job tracking' do + let(:run_uuid) { 'evasion-run-uuid' } + let(:job_listener) { double('JobListener', waiting: nil, start: nil, completed: nil, failed: nil) } + let(:events) { double('EventDispatcher', on_module_run: nil, on_module_complete: nil) } + let(:framework) { double('Framework', events: events) } + let(:evasion_mod) { double('EvasionModule', setup: nil, framework: framework, run: 'evasion-output', cleanup: nil) } + let(:payload) { double('Payload') } + let(:driver) { described_class.new(framework) } + + before do + driver.job_listener = job_listener if driver.respond_to?(:job_listener=) + driver.evasion = evasion_mod + driver.payload = payload + end + + context 'when the module runs successfully' do + it 'reports start then completed' do + driver.send(:job_run_proc, [evasion_mod, payload, run_uuid, job_listener]) + expect(job_listener).to have_received(:start).with(run_uuid) + expect(job_listener).to have_received(:completed).with(run_uuid, 'evasion-output', evasion_mod) + expect(job_listener).not_to have_received(:failed) + end + end + + context 'when the module raises' do + it 'reports failed with the exception' do + err = RuntimeError.new('evasion-boom') + allow(evasion_mod).to receive(:run).and_raise(err) + expect do + driver.send(:job_run_proc, [evasion_mod, payload, run_uuid, job_listener]) + end.to raise_error(RuntimeError, 'evasion-boom') + expect(job_listener).to have_received(:failed).with(run_uuid, err, evasion_mod) + end + end +end + +RSpec.describe Msf::EvasionDriver, '#initialize' do + let(:framework) { double('Framework') } + + it 'accepts a job_listener keyword argument' do + listener = double('JobListener') + driver = described_class.new(framework, job_listener: listener) + expect(driver.job_listener).to eq(listener) + end + + it 'defaults to the noop job listener' do + driver = described_class.new(framework) + expect(driver.job_listener).to eq(Msf::Simple::NoopJobListener.instance) + end +end + +RSpec.describe Msf::Simple::Evasion, '.run_simple' do + let(:job_listener) { double('JobListener', waiting: nil, start: nil, completed: nil, failed: nil) } + + it 'is a singleton method that accepts a job_listener keyword' do + expect(described_class.method(:run_simple).parameters).to include(%i[key job_listener]) + end +end diff --git a/spec/lib/msf/base/simple/exploit_job_tracking_spec.rb b/spec/lib/msf/base/simple/exploit_job_tracking_spec.rb new file mode 100644 index 0000000000000..09e1b05ab81c3 --- /dev/null +++ b/spec/lib/msf/base/simple/exploit_job_tracking_spec.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'msf/base/simple/exploit' +require 'msf/base/simple/noop_job_listener' +require 'msf/core/exploit_driver' + +RSpec.describe Msf::ExploitDriver, 'job listener integration' do + let(:framework) { double('Framework') } + let(:custom_listener) { double('JobListener', waiting: nil, start: nil, completed: nil, failed: nil) } + + describe '#initialize' do + it 'accepts an explicit job_listener kwarg' do + driver = described_class.new(framework, job_listener: custom_listener) + expect(driver.job_listener).to eq(custom_listener) + end + + it 'defaults to the noop job listener' do + driver = described_class.new(framework) + expect(driver.job_listener).to eq(Msf::Simple::NoopJobListener.instance) + end + end + + describe '#job_run_proc lifecycle' do + let(:run_uuid) { 'exploit-run-uuid' } + let(:events) { double('EventDispatcher', on_module_run: nil, on_module_complete: nil) } + let(:framework_for_exploit) { double('Framework', events: events) } + let(:exploit_mod) do + double( + 'ExploitModule', + setup: nil, + framework: framework_for_exploit, + exploit: nil, + passive?: false, + wfs_delay: 0, + handler_enabled?: false, + handler_bind?: false, + fail_reason: Msf::Exploit::Failure::None, + fail_detail: 'No session created', + :fail_reason= => nil, + :fail_detail= => nil, + report_failure: nil, + cleanup: nil + ) + end + let(:payload_mod) { double('Payload', wfs_delay: 0, start_handler: nil, wait_for_session: nil) } + let(:driver) { described_class.new(framework_for_exploit, job_listener: custom_listener) } + + it 'always fires start(uuid) before running the exploit' do + driver.send(:job_run_proc, [exploit_mod, payload_mod, run_uuid, custom_listener]) + expect(custom_listener).to have_received(:start).with(run_uuid) + end + + context 'when the exploit runs without raising and no session opens' do + it 'fires failed(uuid) with the "No session created" detail' do + driver.send(:job_run_proc, [exploit_mod, payload_mod, run_uuid, custom_listener]) + expect(custom_listener).to have_received(:failed).with(run_uuid, 'No session created', exploit_mod) + end + + it 'reports the failure to the database via exploit.report_failure' do + driver.send(:job_run_proc, [exploit_mod, payload_mod, run_uuid, custom_listener]) + expect(exploit_mod).to have_received(:report_failure) + end + + it 'does not fire completed on this path' do + driver.send(:job_run_proc, [exploit_mod, payload_mod, run_uuid, custom_listener]) + expect(custom_listener).not_to have_received(:completed) + end + end + + context 'when a session opens' do + let(:session) do + double('Session', desc: 'meterpreter', name: '1', tunnel_to_s: '192.0.2.1:4444') + end + + before do + allow(exploit_mod).to receive(:handler_enabled?).and_return(true) + allow(payload_mod).to receive(:wait_for_session).and_return(session) + end + + it 'fires completed(uuid) with the session-opened message' do + driver.send(:job_run_proc, [exploit_mod, payload_mod, run_uuid, custom_listener]) + expect(custom_listener).to have_received(:completed) + .with(run_uuid, a_string_matching(/session 1 opened/), exploit_mod) + end + + it 'does not fire failed on the success path' do + driver.send(:job_run_proc, [exploit_mod, payload_mod, run_uuid, custom_listener]) + expect(custom_listener).not_to have_received(:failed) + end + + it 'does not call report_failure on the success path' do + driver.send(:job_run_proc, [exploit_mod, payload_mod, run_uuid, custom_listener]) + expect(exploit_mod).not_to have_received(:report_failure) + end + end + + it 'reports failed when the exploit raises' do + err = RuntimeError.new('exploit-boom') + allow(exploit_mod).to receive(:exploit).and_raise(err) + allow(exploit_mod).to receive(:handle_exception).and_return(Msf::Exploit::Failure::Unknown) + # handle_exception sets fail_reason on the exploit instance in real code + allow(exploit_mod).to receive(:fail_reason).and_return(Msf::Exploit::Failure::Unknown) + allow(exploit_mod).to receive(:user_output).and_return(nil) + allow(exploit_mod).to receive(:user_output=) + driver.send(:job_run_proc, [exploit_mod, payload_mod, run_uuid, custom_listener]) + expect(custom_listener).to have_received(:failed).with(run_uuid, kind_of(String), exploit_mod) + end + + it 'does not double-fire failed on the exception path (rescue path skips the no-session tail)' do + err = RuntimeError.new('exploit-boom') + allow(exploit_mod).to receive(:exploit).and_raise(err) + allow(exploit_mod).to receive(:handle_exception).and_return(Msf::Exploit::Failure::Unknown) + # After handle_exception, the exploit's fail_reason is non-None, so the tail if-block is skipped. + allow(exploit_mod).to receive(:fail_reason).and_return(Msf::Exploit::Failure::Unknown) + allow(exploit_mod).to receive(:user_output).and_return(nil) + allow(exploit_mod).to receive(:user_output=) + driver.send(:job_run_proc, [exploit_mod, payload_mod, run_uuid, custom_listener]) + expect(custom_listener).to have_received(:failed).once + expect(exploit_mod).not_to have_received(:report_failure) + end + end +end + +RSpec.describe Msf::Simple::Exploit, 'job_listener keyword signature' do + it '.exploit_simple accepts a job_listener kwarg defaulting to the noop listener' do + expect(described_class.method(:exploit_simple).parameters).to include(%i[key job_listener]) + end + + it '.check_simple accepts a job_listener kwarg defaulting to the noop listener' do + expect(described_class.method(:check_simple).parameters).to include(%i[key job_listener]) + end + + it 'the instance-level exploit_simple accepts a job_listener kwarg' do + expect(described_class.instance_method(:exploit_simple).parameters).to include(%i[key job_listener]) + end + + it 'the instance-level check_simple accepts a job_listener kwarg' do + expect(described_class.instance_method(:check_simple).parameters).to include(%i[key job_listener]) + end + + describe 'forwarding' do + let(:framework) { double('Framework') } + let(:job_listener) { double('JobListener') } + + it 'the instance-level exploit_simple forwards job_listener: to the class method' do + mod = Class.new { include Msf::Simple::Exploit }.new + expect(described_class).to receive(:exploit_simple).with(mod, { opts: 1 }, job_listener: job_listener) + mod.exploit_simple({ opts: 1 }, job_listener: job_listener) + end + + it 'the instance-level check_simple forwards job_listener: to the class method' do + mod = Class.new { include Msf::Simple::Exploit }.new + expect(described_class).to receive(:check_simple).with(mod, { opts: 1 }, job_listener: job_listener) + mod.check_simple({ opts: 1 }, job_listener: job_listener) + end + end +end diff --git a/spec/lib/msf/base/simple/post_job_tracking_spec.rb b/spec/lib/msf/base/simple/post_job_tracking_spec.rb new file mode 100644 index 0000000000000..ca872ac3f3161 --- /dev/null +++ b/spec/lib/msf/base/simple/post_job_tracking_spec.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'msf/base/simple/post' +require 'msf/base/simple/noop_job_listener' + +RSpec.describe Msf::Simple::Post, '#job_run_proc job tracking' do + let(:run_uuid) { 'post-run-uuid' } + let(:job_listener) { double('JobListener', waiting: nil, start: nil, completed: nil, failed: nil) } + let(:events) { double('EventDispatcher', on_module_run: nil, on_module_complete: nil, on_session_module_run: nil) } + let(:sessions) { double('SessionManager', get: session) } + let(:session) { double('Session') } + let(:framework) { double('Framework', events: events, sessions: sessions) } + let(:datastore) { { 'SESSION' => 1 } } + let(:mod) do + double( + 'PostModule', + framework: framework, + datastore: datastore, + setup: nil, + cleanup: nil, + print_error: nil, + :error= => nil + ) + end + let(:ctx) { [mod, run_uuid, job_listener] } + + context 'when the module run succeeds' do + it 'reports start then completed with the run result' do + result = 'post-result' + allow(mod).to receive(:run).and_return(result) + described_class.job_run_proc(ctx) + expect(job_listener).to have_received(:start).with(run_uuid) + expect(job_listener).to have_received(:completed).with(run_uuid, result, mod) + expect(job_listener).not_to have_received(:failed) + end + end + + context 'when the session is missing' do + let(:session) { nil } + it 'reports failed and does not invoke mod.run' do + expect(mod).not_to receive(:run) + described_class.job_run_proc(ctx) + expect(job_listener).to have_received(:start).with(run_uuid) + expect(job_listener).to have_received(:failed).with(run_uuid, kind_of(String), mod) + expect(job_listener).not_to have_received(:completed) + end + end + + context 'when the module run raises' do + it 'reports failed with the exception' do + err = RuntimeError.new('post-boom') + allow(mod).to receive(:run).and_raise(err) + described_class.job_run_proc(ctx) + expect(job_listener).to have_received(:start).with(run_uuid) + expect(job_listener).to have_received(:failed).with(run_uuid, err, mod) + expect(job_listener).not_to have_received(:completed) + end + end + + context 'when the module raises Msf::Post::Complete' do + it 'reports completed via the listener' do + allow(mod).to receive(:run).and_raise(Msf::Post::Complete) + described_class.job_run_proc(ctx) + expect(job_listener).to have_received(:completed).with(run_uuid, nil, mod) + end + end +end + +RSpec.describe Msf::Simple::Post, '.run_simple' do + let(:job_listener) { double('JobListener', waiting: nil, start: nil, completed: nil, failed: nil) } + let(:datastore) { double('Datastore', :[]= => nil, :[] => nil) } + let(:framework) { double('Framework', jobs: jobs) } + let(:jobs) { double('JobManager') } + let(:mod) do + mod = double( + 'PostModule', + replicant: nil, + refname: 'multi/general/execute', + framework: framework, + datastore: datastore, + actions: [], + action: nil, + passive?: true, + validate: nil, + init_ui: nil, + user_input: nil, + user_output: nil, + _import_extra_options: nil, + :job_id= => nil, + job_id: 42, + :run_uuid= => nil, + run_uuid: nil + ) + allow(mod).to receive(:replicant).and_return(mod) + allow(mod).to receive(:extend) + mod + end + + before do + allow(Msf::Simple::Framework).to receive(:simplify_module) + allow(jobs).to receive(:start_bg_job).and_return(42) + require 'rex/text' + end + + it 'generates a run uuid, notifies the listener, and assigns mod.run_uuid / mod.job_id' do + captured_uuid = nil + expect(job_listener).to receive(:waiting) { |uuid| captured_uuid = uuid } + expect(mod).to receive(:run_uuid=).with(kind_of(String)).at_least(:once) + expect(mod).to receive(:job_id=).with(42).at_least(:once) + + described_class.run_simple(mod, { 'RunAsJob' => true }, job_listener: job_listener) + + expect(captured_uuid).to be_a(String) + expect(captured_uuid.length).to be >= 8 + end +end diff --git a/spec/lib/msf/core/rpc/v10/rpc_job_status_tracker_spec.rb b/spec/lib/msf/core/rpc/v10/rpc_job_status_tracker_spec.rb index 6b5716acbdecf..a9a6b287ed3aa 100644 --- a/spec/lib/msf/core/rpc/v10/rpc_job_status_tracker_spec.rb +++ b/spec/lib/msf/core/rpc/v10/rpc_job_status_tracker_spec.rb @@ -170,6 +170,97 @@ ) end end + + context 'The job result contains framework objects that cannot be JSON-serialized' do + let(:framework_object) do + klass = Class.new do + def initialize + @self_ref = self + end + + def to_s + 'framework-object-summary' + end + end + klass.new + end + let(:host_result) do + { + '192.0.2.10' => { + successful_logins: [framework_object], + successful_sessions: [framework_object], + banner: 'SMB 3.0' + } + } + end + + before(:each) do + job_status_tracker.completed(job_id, host_result, mod) + end + + it 'preserves the outer hash structure' do + stored = job_status_tracker.result(job_id) + expect(stored['result']['192.0.2.10']).to be_a(Hash) + expect(stored['result']['192.0.2.10'].keys) + .to include('successful_logins', 'successful_sessions', 'banner') + end + + it 'stringifies non-primitive leaves via to_s' do + stored = job_status_tracker.result(job_id) + expect(stored['result']['192.0.2.10']['successful_logins']) + .to eq(['framework-object-summary']) + expect(stored['result']['192.0.2.10']['successful_sessions']) + .to eq(['framework-object-summary']) + end + + it 'preserves JSON-primitive leaves as-is' do + stored = job_status_tracker.result(job_id) + expect(stored['result']['192.0.2.10']['banner']).to eq('SMB 3.0') + end + + it 'does not fall through to the "could not be stored" fallback' do + expect(job_status_tracker.result(job_id)).not_to have_key('error') + end + end + + context 'The job result contains a Msf::Exploit::CheckCode' do + let(:check_result) { Msf::Exploit::CheckCode::Vulnerable } + let(:mock_result) { { host: '192.0.2.10', check: check_result } } + + before(:each) do + job_status_tracker.completed(job_id, mock_result, mod) + end + + it 'preserves the CheckCode round-trip through JSON' do + stored = job_status_tracker.result(job_id) + expect(stored['result']['check']).to eq(check_result.to_json.then { |s| ::JSON.parse(s) }) + end + end + + context 'The job result preserves JSON primitive types' do + let(:mock_result) do + { + count: 3, + rate: 1.5, + found: true, + missing: nil, + tag: :scanner + } + end + + before(:each) do + job_status_tracker.completed(job_id, mock_result, mod) + end + + it 'preserves numbers, booleans, nil, and stringifies symbols per JSON conventions' do + stored = job_status_tracker.result(job_id)['result'] + expect(stored['count']).to eq(3) + expect(stored['rate']).to eq(1.5) + expect(stored['found']).to be true + expect(stored['missing']).to be_nil + expect(stored['tag']).to eq('scanner') + end + end end end end diff --git a/spec/lib/msf/core/rpc/v10/rpc_module_check_spec.rb b/spec/lib/msf/core/rpc/v10/rpc_module_check_spec.rb new file mode 100644 index 0000000000000..c352a53e31bb2 --- /dev/null +++ b/spec/lib/msf/core/rpc/v10/rpc_module_check_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'msf/core/rpc/v10/rpc_module' +require 'msf/core/rpc/v10/rpc_job_status_tracker' + +RSpec.describe Msf::RPC::RPC_Module, '#rpc_check propagates NotImplementedError from check_simple' do + let(:service) { double('Service') } + let(:job_status_tracker) { Msf::RPC::RpcJobStatusTracker.new } + let(:framework) { double('Framework', modules: modules) } + let(:modules) { double('ModuleManager') } + let(:rpc) do + instance = described_class.allocate + allow(instance).to receive(:framework).and_return(framework) + allow(instance).to receive(:job_status_tracker).and_return(job_status_tracker) + instance + end + + context 'for an exploit module without a check method' do + it 'lets ::NotImplementedError propagate so the transport layer can surface a 500 with backtrace' do + mod = double('ExploitModule') + allow(modules).to receive(:create).with('exploit/multi/handler').and_return(mod) + allow(mod).to receive(:type).and_return('exploit') + + unsupported_msg = Msf::Exploit::CheckCode::Unsupported.message + allow(Msf::Simple::Exploit).to receive(:check_simple).and_raise(::NotImplementedError.new(unsupported_msg)) + + expect { rpc.rpc_check('exploit', 'multi/handler', {}) } + .to raise_error(::NotImplementedError, unsupported_msg) + end + end + + context 'for an auxiliary module without a check method' do + it 'lets ::NotImplementedError propagate so the transport layer can surface a 500 with backtrace' do + mod = double('AuxiliaryModule') + allow(modules).to receive(:create).with('auxiliary/scanner/portscan/tcp').and_return(mod) + allow(mod).to receive(:type).and_return('auxiliary') + + unsupported_msg = Msf::Exploit::CheckCode::Unsupported.message + allow(Msf::Simple::Auxiliary).to receive(:check_simple).and_raise(::NotImplementedError.new(unsupported_msg)) + + expect { rpc.rpc_check('auxiliary', 'scanner/portscan/tcp', {}) } + .to raise_error(::NotImplementedError, unsupported_msg) + end + end + + context 'job_listener wiring' do + it 'passes the RPC job_status_tracker via the job_listener: kwarg for exploit checks' do + mod = double('ExploitModule') + allow(modules).to receive(:create).with('exploit/multi/handler').and_return(mod) + allow(mod).to receive(:type).and_return('exploit') + allow(Msf::Simple::Exploit).to receive(:check_simple).and_return(['uuid', 1]) + + rpc.rpc_check('exploit', 'multi/handler', {}) + + expect(Msf::Simple::Exploit).to have_received(:check_simple).with( + mod, + hash_excluding('JobListener'), + job_listener: job_status_tracker + ) + end + + it 'passes the RPC job_status_tracker via the job_listener: kwarg for auxiliary checks' do + mod = double('AuxiliaryModule') + allow(modules).to receive(:create).with('auxiliary/scanner/portscan/tcp').and_return(mod) + allow(mod).to receive(:type).and_return('auxiliary') + allow(Msf::Simple::Auxiliary).to receive(:check_simple).and_return(['uuid', 1]) + + rpc.rpc_check('auxiliary', 'scanner/portscan/tcp', {}) + + expect(Msf::Simple::Auxiliary).to have_received(:check_simple).with( + mod, + hash_excluding('JobListener'), + job_listener: job_status_tracker + ) + end + end +end diff --git a/spec/lib/msf/core/rpc/v10/rpc_module_execute_spec.rb b/spec/lib/msf/core/rpc/v10/rpc_module_execute_spec.rb new file mode 100644 index 0000000000000..d08b0bcf84671 --- /dev/null +++ b/spec/lib/msf/core/rpc/v10/rpc_module_execute_spec.rb @@ -0,0 +1,180 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'msf/core/rpc/v10/rpc_module' +require 'msf/core/rpc/v10/rpc_job_status_tracker' + +RSpec.describe Msf::RPC::RPC_Module, '#rpc_execute returns uuid/job_id for all module types' do + let(:service) { double('Service') } + let(:job_status_tracker) { Msf::RPC::RpcJobStatusTracker.new } + let(:framework) { double('Framework', modules: modules) } + let(:modules) { double('ModuleManager') } + let(:rpc) do + instance = described_class.allocate + allow(instance).to receive(:framework).and_return(framework) + allow(instance).to receive(:job_status_tracker).and_return(job_status_tracker) + instance + end + + shared_examples 'returns a hash with job_id and uuid' do |mtype, mname, simple_klass, simple_method| + it "returns { job_id, uuid } for #{mtype} modules" do + mod = double('Module', uuid: 'mod-uuid', job_id: 99, run_uuid: 'run-uuid') + allow(modules).to receive(:create).with("#{mtype}/#{mname}").and_return(mod) + allow(mod).to receive(:type).and_return(mtype) + + allow(simple_klass).to receive(simple_method) + + opts = mtype == 'exploit' ? { 'PAYLOAD' => 'generic/shell_reverse_tcp' } : {} + response = rpc.rpc_execute(mtype, mname, opts) + expect(response).to include('job_id', 'uuid') + expect(response['job_id']).to eq(99) + expect(response['uuid']).to eq('run-uuid') + end + + it "passes the RPC job_status_tracker via the job_listener: kwarg for #{mtype} modules" do + mod = double('Module', uuid: 'mod-uuid', job_id: 99, run_uuid: 'run-uuid') + allow(modules).to receive(:create).with("#{mtype}/#{mname}").and_return(mod) + allow(mod).to receive(:type).and_return(mtype) + allow(simple_klass).to receive(simple_method) + + opts = mtype == 'exploit' ? { 'PAYLOAD' => 'generic/shell_reverse_tcp' } : {} + rpc.rpc_execute(mtype, mname, opts) + + expect(simple_klass).to have_received(simple_method).with( + mod, + hash_excluding('JobListener'), + job_listener: job_status_tracker + ) + end + end + + include_examples 'returns a hash with job_id and uuid', + 'exploit', 'multi/handler', Msf::Simple::Exploit, :exploit_simple + include_examples 'returns a hash with job_id and uuid', + 'auxiliary', 'scanner/portscan/tcp', Msf::Simple::Auxiliary, :run_simple + + context 'for post modules' do + it 'returns the run uuid recorded on the module (not just mod.uuid)' do + mod = double('PostModule', uuid: 'mod-uuid', job_id: 11, run_uuid: 'run-uuid-post') + allow(modules).to receive(:create).with('post/multi/general/execute').and_return(mod) + allow(mod).to receive(:type).and_return('post') + allow(Msf::Simple::Post).to receive(:run_simple) + + response = rpc.rpc_execute('post', 'multi/general/execute', {}) + expect(response['uuid']).to eq('run-uuid-post') + expect(response['job_id']).to eq(11) + end + + it 'passes the RPC job_status_tracker via the job_listener: kwarg' do + mod = double('PostModule', uuid: 'mod-uuid', job_id: 11, run_uuid: 'run-uuid-post') + allow(modules).to receive(:create).with('post/multi/general/execute').and_return(mod) + allow(mod).to receive(:type).and_return('post') + allow(Msf::Simple::Post).to receive(:run_simple) + + rpc.rpc_execute('post', 'multi/general/execute', {}) + + expect(Msf::Simple::Post).to have_received(:run_simple).with( + mod, + hash_excluding('JobListener'), + job_listener: job_status_tracker + ) + end + end + + context 'for evasion modules' do + it 'returns the run uuid recorded on the module (not just mod.uuid)' do + mod = double('EvasionModule', uuid: 'mod-uuid', job_id: 22, run_uuid: 'run-uuid-evasion') + allow(modules).to receive(:create).with('evasion/windows/applocker_evasion_msbuild').and_return(mod) + allow(mod).to receive(:type).and_return('evasion') + allow(Msf::Simple::Evasion).to receive(:run_simple) + + response = rpc.rpc_execute('evasion', 'windows/applocker_evasion_msbuild', {}) + expect(response['uuid']).to eq('run-uuid-evasion') + expect(response['job_id']).to eq(22) + end + + it 'passes the RPC job_status_tracker via the job_listener: kwarg' do + mod = double('EvasionModule', uuid: 'mod-uuid', job_id: 22, run_uuid: 'run-uuid-evasion') + allow(modules).to receive(:create).with('evasion/windows/applocker_evasion_msbuild').and_return(mod) + allow(mod).to receive(:type).and_return('evasion') + allow(Msf::Simple::Evasion).to receive(:run_simple) + + rpc.rpc_execute('evasion', 'windows/applocker_evasion_msbuild', {}) + + expect(Msf::Simple::Evasion).to have_received(:run_simple).with( + mod, + hash_excluding('JobListener'), + job_listener: job_status_tracker + ) + end + end + + context 'when run_uuid is not set on the module' do + it 'returns nil for the uuid field' do + mod = double('PostModule', uuid: 'mod-uuid', job_id: 11, run_uuid: nil) + allow(modules).to receive(:create).with('post/multi/general/execute').and_return(mod) + allow(mod).to receive(:type).and_return('post') + allow(Msf::Simple::Post).to receive(:run_simple) + + response = rpc.rpc_execute('post', 'multi/general/execute', {}) + expect(response['uuid']).to be_nil + end + end + + context 'structural options validation (defence-in-depth for non-MCP callers)' do + it 'accepts a well-formed options hash with scalar values' do + mod = double('Module', uuid: 'u', job_id: 1, run_uuid: 'r', type: 'auxiliary') + allow(modules).to receive(:create).with('auxiliary/scanner/portscan/tcp').and_return(mod) + allow(Msf::Simple::Auxiliary).to receive(:run_simple) + + opts = { 'RHOSTS' => '192.0.2.1', 'RPORT' => 445, 'VERBOSE' => true, 'TIMEOUT' => 3.5, 'NOTES' => nil } + expect { rpc.rpc_execute('auxiliary', 'scanner/portscan/tcp', opts) }.not_to raise_error + end + + it 'rejects non-Hash options with a 400 error' do + expect { rpc.rpc_execute('exploit', 'multi/handler', 'not-a-hash') } + .to raise_error(Msf::RPC::Exception, /Module options must be a Hash/) + end + + it 'rejects a nested Hash value' do + opts = { 'RHOSTS' => { 'nested' => 'value' } } + expect { rpc.rpc_execute('auxiliary', 'scanner/portscan/tcp', opts) } + .to raise_error(Msf::RPC::Exception, /must be a scalar/) + end + + it 'rejects an Array value' do + opts = { 'RHOSTS' => ['a', 'b'] } + expect { rpc.rpc_execute('auxiliary', 'scanner/portscan/tcp', opts) } + .to raise_error(Msf::RPC::Exception, /must be a scalar/) + end + + it 'rejects a non-String/Symbol key' do + opts = { 42 => 'x' } + expect { rpc.rpc_execute('auxiliary', 'scanner/portscan/tcp', opts) } + .to raise_error(Msf::RPC::Exception, /Invalid module option key type/) + end + + it 'rejects an oversized key' do + opts = { ('K' * 200) => 'x' } + expect { rpc.rpc_execute('auxiliary', 'scanner/portscan/tcp', opts) } + .to raise_error(Msf::RPC::Exception, /key too long/) + end + + it 'rejects an oversized String value' do + opts = { 'BIG' => 'x' * (Msf::RPC::RPC_Module::RPC_MODULE_OPTIONS_VALUE_MAX_BYTES + 1) } + expect { rpc.rpc_execute('auxiliary', 'scanner/portscan/tcp', opts) } + .to raise_error(Msf::RPC::Exception, /exceeds .* bytes/) + end + + it 'rejects an options hash with too many keys' do + opts = Array.new(Msf::RPC::RPC_Module::RPC_MODULE_OPTIONS_MAX_KEYS + 1) { |i| ["K#{i}", 'v'] }.to_h + expect { rpc.rpc_execute('auxiliary', 'scanner/portscan/tcp', opts) } + .to raise_error(Msf::RPC::Exception, /too many keys/) + end + + it 'rejects nil options with a 400 error' do + expect { rpc.rpc_execute('auxiliary', 'scanner/portscan/tcp', nil) } + .to raise_error(Msf::RPC::Exception, /Module options must be a Hash/) + end + end +end diff --git a/spec/lib/msf/core/rpc/v10/rpc_session_spec.rb b/spec/lib/msf/core/rpc/v10/rpc_session_spec.rb index cbc0d33f81bc2..83766e3162966 100644 --- a/spec/lib/msf/core/rpc/v10/rpc_session_spec.rb +++ b/spec/lib/msf/core/rpc/v10/rpc_session_spec.rb @@ -158,6 +158,36 @@ def core end end + shared_examples 'interactive shell read' do + let(:expected_data) { 'test response' } + + before do + allow(rstream).to receive(:get_once).and_return(expected_data) + end + + it 'returns expected data' do + expect(response).to eq({ 'data' => expected_data }) + end + end + + shared_examples 'interactive shell write' do + let(:test_command) { 'help' } + + it 'writes the command with a trailing newline and returns success' do + expect(rstream).to receive(:write).with("#{test_command}\n").and_return(test_command.length + 1) + expect(response).to eq({ 'result' => 'success' }) + end + + context 'when the input already ends with a newline' do + let(:test_command) { "help\n" } + + it 'does not append an extra newline' do + expect(rstream).to receive(:write).with(test_command).and_return(test_command.length) + expect(response).to eq({ 'result' => 'success' }) + end + end + end + describe '#rpc_compatible_modules' do context 'when the session does not exist' do let(:session) { meterpreter_session } @@ -195,11 +225,13 @@ def core end context 'with shell session' do - let(:session) { shell_session } - - it 'raises an error' do - expect { response }.to raise_error(Msf::RPC::Exception) + let(:session) do + session = Msf::Sessions::CommandShell.new(rstream) + session.framework = framework + session end + + it_behaves_like 'interactive shell read' end end @@ -221,11 +253,13 @@ def core end context 'with shell session' do - let(:session) { shell_session } - - it 'raises an error' do - expect { response }.to raise_error(Msf::RPC::Exception) + let(:session) do + session = Msf::Sessions::CommandShell.new(rstream) + session.framework = framework + session end + + it_behaves_like 'interactive shell read' end end @@ -303,11 +337,13 @@ def core subject(:response) { rpc.rpc_interactive_write(target_sid, test_command) } context 'with shell session' do - let(:session) { shell_session } - - it 'raises error' do - expect { response }.to raise_error(Msf::RPC::Exception) + let(:session) do + session = Msf::Sessions::CommandShell.new(rstream) + session.framework = framework + session end + + it_behaves_like 'interactive shell write' end context 'with meterpreter session' do @@ -329,11 +365,13 @@ def core subject(:response) { rpc.rpc_meterpreter_write(target_sid, test_command) } context 'with shell session' do - let(:session) { shell_session } - - it 'raises error' do - expect { response }.to raise_error(Msf::RPC::Exception) + let(:session) do + session = Msf::Sessions::CommandShell.new(rstream) + session.framework = framework + session end + + it_behaves_like 'interactive shell write' end context 'with meterpreter session' do diff --git a/spec/lib/msf/exploit/local_spec.rb b/spec/lib/msf/exploit/local_spec.rb index 795f34fcff52a..52ed69ee3b3a7 100644 --- a/spec/lib/msf/exploit/local_spec.rb +++ b/spec/lib/msf/exploit/local_spec.rb @@ -53,4 +53,26 @@ end end end + + describe '#run_simple' do + it 'accepts a job_listener keyword argument defaulting to the noop listener' do + expect(described_class.instance_method(:run_simple).parameters).to include(%i[key job_listener]) + end + + it 'forwards opts and job_listener: to Msf::Simple::Exploit.exploit_simple' do + listener = double('JobListener') + opts = { 'SESSION' => 1 } + expect(Msf::Simple::Exploit).to receive(:exploit_simple).with(subject, opts, job_listener: listener) + subject.run_simple(opts, job_listener: listener) + end + + it 'defaults job_listener to Msf::Simple::NoopJobListener.instance when omitted' do + expect(Msf::Simple::Exploit).to receive(:exploit_simple).with( + subject, + {}, + job_listener: Msf::Simple::NoopJobListener.instance + ) + subject.run_simple + end + end end From b66f6247815a253831b22ec660da0de08d1ac564 Mon Sep 17 00:00:00 2001 From: Christophe De La Fuente Date: Tue, 7 Jul 2026 11:57:48 +0200 Subject: [PATCH 2/2] Add new MCP tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit — Add 8 new MCP tools + input validator + client wrappers — Add dangerous_actions gate — Fix DB info tools required-field metadata — Update wiki documentation — Add integration test script — MCP plugin (plugins/mcp.rb) updates + specs --- config/mcp_config.yaml.example | 7 + config/mcp_config_jsonrpc.yaml.example | 7 + .../How-to-use-Metasploit-MCP-Server.md | 157 ++- lib/msf/core/mcp.rb | 8 + lib/msf/core/mcp/application.rb | 14 +- lib/msf/core/mcp/config/defaults.rb | 6 + lib/msf/core/mcp/config/loader.rb | 6 + lib/msf/core/mcp/config/validator.rb | 4 + lib/msf/core/mcp/logging/sinks/sanitizing.rb | 2 +- lib/msf/core/mcp/metasploit/client.rb | 5 +- lib/msf/core/mcp/metasploit/jsonrpc_client.rb | 59 + .../core/mcp/metasploit/messagepack_client.rb | 59 + lib/msf/core/mcp/security/input_validator.rb | 122 ++ lib/msf/core/mcp/server.rb | 18 +- lib/msf/core/mcp/tools/credential_info.rb | 13 +- lib/msf/core/mcp/tools/host_info.rb | 10 +- lib/msf/core/mcp/tools/loot_info.rb | 13 +- lib/msf/core/mcp/tools/module_check.rb | 151 +++ lib/msf/core/mcp/tools/module_execute.rb | 130 ++ lib/msf/core/mcp/tools/module_info.rb | 10 +- lib/msf/core/mcp/tools/module_results.rb | 98 ++ lib/msf/core/mcp/tools/note_info.rb | 13 +- lib/msf/core/mcp/tools/running_stats.rb | 87 ++ lib/msf/core/mcp/tools/search_modules.rb | 10 +- lib/msf/core/mcp/tools/service_info.rb | 13 +- lib/msf/core/mcp/tools/session_list.rb | 79 ++ lib/msf/core/mcp/tools/session_read.rb | 94 ++ lib/msf/core/mcp/tools/session_stop.rb | 97 ++ lib/msf/core/mcp/tools/session_write.rb | 107 ++ lib/msf/core/mcp/tools/tool_helper.rb | 24 + lib/msf/core/mcp/tools/vulnerability_info.rb | 13 +- plugins/mcp.rb | 65 +- spec/lib/msf/core/mcp/application_spec.rb | 59 +- spec/lib/msf/core/mcp/config/loader_spec.rb | 64 + .../lib/msf/core/mcp/config/validator_spec.rb | 57 +- .../core/mcp/logging/sinks/sanitizing_spec.rb | 11 + .../msf/core/mcp/metasploit/client_spec.rb | 40 + .../mcp/metasploit/messagepack_client_spec.rb | 78 ++ .../core/mcp/security/input_validator_spec.rb | 194 +++ spec/lib/msf/core/mcp/server_spec.rb | 51 +- .../core/mcp/tools/credential_info_spec.rb | 4 +- spec/lib/msf/core/mcp/tools/loot_info_spec.rb | 4 +- .../msf/core/mcp/tools/module_check_spec.rb | 132 ++ .../msf/core/mcp/tools/module_execute_spec.rb | 175 +++ .../msf/core/mcp/tools/module_results_spec.rb | 87 ++ spec/lib/msf/core/mcp/tools/note_info_spec.rb | 4 +- .../msf/core/mcp/tools/running_stats_spec.rb | 81 ++ .../msf/core/mcp/tools/service_info_spec.rb | 4 +- .../msf/core/mcp/tools/session_list_spec.rb | 92 ++ .../msf/core/mcp/tools/session_read_spec.rb | 83 ++ .../msf/core/mcp/tools/session_stop_spec.rb | 103 ++ .../msf/core/mcp/tools/session_write_spec.rb | 116 ++ .../msf/core/mcp/tools/tool_helper_spec.rb | 35 + .../core/mcp/tools/vulnerability_info_spec.rb | 4 +- spec/plugins/mcp/console_dispatcher_spec.rb | 22 + spec/plugins/mcp/option_validator_spec.rb | 30 + spec/plugins/mcp/rpc_resolver_spec.rb | 81 ++ spec/plugins/mcp_spec.rb | 58 + tools/dev/msfmcp_integration_test.rb | 1164 +++++++++++++++++ 59 files changed, 4233 insertions(+), 101 deletions(-) create mode 100644 lib/msf/core/mcp/tools/module_check.rb create mode 100644 lib/msf/core/mcp/tools/module_execute.rb create mode 100644 lib/msf/core/mcp/tools/module_results.rb create mode 100644 lib/msf/core/mcp/tools/running_stats.rb create mode 100644 lib/msf/core/mcp/tools/session_list.rb create mode 100644 lib/msf/core/mcp/tools/session_read.rb create mode 100644 lib/msf/core/mcp/tools/session_stop.rb create mode 100644 lib/msf/core/mcp/tools/session_write.rb create mode 100644 spec/lib/msf/core/mcp/tools/module_check_spec.rb create mode 100644 spec/lib/msf/core/mcp/tools/module_execute_spec.rb create mode 100644 spec/lib/msf/core/mcp/tools/module_results_spec.rb create mode 100644 spec/lib/msf/core/mcp/tools/running_stats_spec.rb create mode 100644 spec/lib/msf/core/mcp/tools/session_list_spec.rb create mode 100644 spec/lib/msf/core/mcp/tools/session_read_spec.rb create mode 100644 spec/lib/msf/core/mcp/tools/session_stop_spec.rb create mode 100644 spec/lib/msf/core/mcp/tools/session_write_spec.rb create mode 100755 tools/dev/msfmcp_integration_test.rb diff --git a/config/mcp_config.yaml.example b/config/mcp_config.yaml.example index 03bbc869e71f4..b95051f1303be 100644 --- a/config/mcp_config.yaml.example +++ b/config/mcp_config.yaml.example @@ -21,6 +21,13 @@ mcp: # max_threads: 5 # Maximum thread pool size (default: 5) # workers: 0 # Number of worker processes (default: 0, single-process mode) + # Dangerous actions gate (default: false) + # When false, destructive tools (msf_module_execute, msf_module_check, + # msf_session_stop, msf_session_write) return an error. Override at runtime + # with the --enable-dangerous-actions CLI flag or by setting the + # MSF_MCP_DANGEROUS_ACTIONS=true environment variable. + dangerous_actions: false + # Rate limiting (optional - defaults shown) rate_limit: enabled: true diff --git a/config/mcp_config_jsonrpc.yaml.example b/config/mcp_config_jsonrpc.yaml.example index 8c55186be0323..33fcc8574e9ef 100644 --- a/config/mcp_config_jsonrpc.yaml.example +++ b/config/mcp_config_jsonrpc.yaml.example @@ -19,6 +19,13 @@ mcp: # max_threads: 5 # Maximum thread pool size (default: 5) # workers: 0 # Number of worker processes (default: 0, single-process mode) + # Dangerous actions gate (default: false) + # When false, destructive tools (msf_module_execute, msf_module_check, + # msf_session_stop, msf_session_write) return an error. Override at runtime + # with the --enable-dangerous-actions CLI flag or by setting the + # MSF_MCP_DANGEROUS_ACTIONS=true environment variable. + dangerous_actions: false + # Rate limiting (optional - defaults shown) rate_limit: enabled: true diff --git a/docs/metasploit-framework.wiki/How-to-use-Metasploit-MCP-Server.md b/docs/metasploit-framework.wiki/How-to-use-Metasploit-MCP-Server.md index 8837be746ceb7..bb627a2d1fe09 100644 --- a/docs/metasploit-framework.wiki/How-to-use-Metasploit-MCP-Server.md +++ b/docs/metasploit-framework.wiki/How-to-use-Metasploit-MCP-Server.md @@ -1,6 +1,9 @@ -The Metasploit MCP Server (`msfmcpd`) provides AI applications with secure, structured access to Metasploit Framework data through the [Model Context Protocol](https://modelcontextprotocol.io/) (MCP). It acts as a middleware layer between AI clients (such as Claude, Cursor, or custom agents) and Metasploit, exposing 8 standardized tools for querying reconnaissance data and searching modules. +The Metasploit MCP Server (`msfmcpd`) provides AI applications with secure, structured access to Metasploit Framework data and execution capabilities through the [Model Context Protocol](https://modelcontextprotocol.io/) (MCP). It acts as a middleware layer between AI clients (such as Claude, Cursor, or custom agents) and Metasploit, exposing 16 standardized tools for searching modules, querying reconnaissance data, executing modules, and interacting with sessions. -This initial implementation is **read-only**. Only tools that query data (modules, hosts, services, vulnerabilities, etc.) are available. Tools for module execution, session interaction, and database modifications will be added in a future iteration. +Tools are split into two classes: + +- **Read-only tools** (always enabled): query modules, hosts, services, vulnerabilities, notes, credentials, loot, jobs, and sessions. +- **Dangerous tools** (gated, disabled by default): run module execution and check methods, stop sessions, and write to interactive sessions. These tools must be explicitly enabled by the operator via the `--enable-dangerous-actions` CLI flag, the `MSF_MCP_DANGEROUS_ACTIONS` environment variable, or the `mcp.dangerous_actions` configuration key. See [Dangerous Actions Mode](#dangerous-actions-mode) below. ## Architecture @@ -9,7 +12,7 @@ flowchart TD ai_app["AI Application
(Claude, Cursor, etc.)"] subgraph msfmcp_server["MsfMcp Server"] - mcp_layer["MCP Layer (8 Tools)
Input Validation / Rate Limiting / Response Transformation"] + mcp_layer["MCP Layer (16 Tools)
Input Validation / Rate Limiting / Response Transformation
Dangerous Actions Gate"] rpc_manager["RPC Manager
Auto-detect / Auto-start / Lifecycle Management"] api_client["Metasploit API Client
MessagePack RPC (port 55553) / JSON-RPC (port 8081)
Session Management"] @@ -95,6 +98,8 @@ Options: --password PASS MSF API password (for MessagePack auth) --no-auto-start-rpc Disable automatic RPC server startup --mcp-transport TRANSPORT MCP server transport type ('stdio' or 'http') + --enable-dangerous-actions Enable dangerous tools (module execute/check, + session stop/write). Disabled by default. -h, --help Show this help message -v, --version Show version information ``` @@ -118,6 +123,7 @@ All configuration settings can be overridden by environment variables: | `MSF_MCP_HOST` | MCP server host (for HTTP transport) | | `MSF_MCP_PORT` | MCP server port (for HTTP transport) | | `MSF_MCP_AUTH_TOKEN` | MCP server Bearer token for authentication (for HTTP transport) | +| `MSF_MCP_DANGEROUS_ACTIONS` | Enable dangerous tools (`true`/`1`/`yes`/`on` to enable, anything else to disable) | Example using environment variables: @@ -225,9 +231,26 @@ The same can be achieved through the environment variable, e.g. `MSF_MCP_AUTH_TO ## MCP Tools -The server exposes 8 tools to AI applications via the MCP protocol. +The server exposes 16 tools to AI applications via the MCP protocol. Tools marked **dangerous** are gated by [Dangerous Actions Mode](#dangerous-actions-mode) and return a tool error unless the operator has explicitly enabled it. + +### Response envelope + +Every successful tool response is wrapped in the same envelope: + +```jsonc +{ + "metadata": { "query_time": /* plus tool-specific keys */ }, + "data": +} +``` + +`metadata.query_time` is the wall-clock time (in seconds) the MCP server spent handling the call. Individual tools may add fields to `metadata` (for example, database tools include `workspace`, `total_items`, `returned_items`, `limit`, and `offset`; `msf_session_list` includes `total_sessions`). The shapes documented per tool below describe `data` only. -### msf_search_modules +Errors surface as MCP tool errors (`isError: true` responses with a `text` content). Validation errors, rate-limit rejections, authentication failures, and Metasploit API errors all follow that path. + +### Read-only Tools + +#### msf_search_modules Search for Metasploit modules by keywords, CVE IDs, or module names. @@ -235,16 +258,16 @@ Search for Metasploit modules by keywords, CVE IDs, or module names. - `limit` (integer, optional): Max results (1-1000, default: 100) - `offset` (integer, optional): Pagination offset (default: 0) -### msf_module_info +#### msf_module_info Get detailed information about a specific Metasploit module. -- `type` (string, required): Module type (`exploit`, `auxiliary`, `post`, `payload`, `encoder`, `nop`) +- `type` (string, required): Module type (`exploit`, `auxiliary`, `post`, `payload`, `encoder`, `evasion`, `nop`) - `name` (string, required): Module path (e.g., `windows/smb/ms17_010_eternalblue`) Returns complete module details including options, targets, references, and authors. -### msf_host_info +#### msf_host_info Query discovered hosts from the Metasploit database. @@ -254,7 +277,7 @@ Query discovered hosts from the Metasploit database. - `limit` (integer, optional): Max results (1-1000, default: 100) - `offset` (integer, optional): Pagination offset (default: 0) -### msf_service_info +#### msf_service_info Query discovered services on hosts. @@ -267,7 +290,7 @@ Query discovered services on hosts. - `limit` (integer, optional): Max results (1-1000, default: 100) - `offset` (integer, optional): Pagination offset (default: 0) -### msf_vulnerability_info +#### msf_vulnerability_info Query discovered vulnerabilities. @@ -279,7 +302,7 @@ Query discovered vulnerabilities. - `limit` (integer, optional): Max results (1-1000, default: 100) - `offset` (integer, optional): Pagination offset (default: 0) -### msf_note_info +#### msf_note_info Query notes stored in the database. @@ -291,7 +314,7 @@ Query notes stored in the database. - `limit` (integer, optional): Max results (1-1000, default: 100) - `offset` (integer, optional): Pagination offset (default: 0) -### msf_credential_info +#### msf_credential_info Query discovered credentials. @@ -299,7 +322,7 @@ Query discovered credentials. - `limit` (integer, optional): Max results (1-1000, default: 100) - `offset` (integer, optional): Pagination offset (default: 0) -### msf_loot_info +#### msf_loot_info Query collected loot (files, data dumps). @@ -307,6 +330,108 @@ Query collected loot (files, data dumps). - `limit` (integer, optional): Max results (1-1000, default: 100) - `offset` (integer, optional): Pagination offset (default: 0) +#### msf_module_results + +Retrieve the result of a previously executed module run by its UUID. + +- `uuid` (string, required): 24-character alphanumeric run UUID returned by `msf_module_execute` or `msf_module_check` + +`data` is one of: + +- `{ "status": "ready" }` — job is queued but has not started yet +- `{ "status": "running" }` — job is still executing +- `{ "status": "completed", "result": ... }` — job finished; payload depends on module type (a check returns a `CheckCode` payload, a scanner returns per-host results, etc.) +- `{ "status": "errored", "error": "..." }` — job finished with an error + +#### msf_running_stats + +Return a snapshot of running and waiting module runs known to the framework. + +Takes no parameters. `data` is an object with: + +- `waiting`: list of run UUIDs queued but not yet started +- `running`: list of run UUIDs currently executing +- `results`: list of run UUIDs whose results are available via `msf_module_results` + +#### msf_session_list + +List active Metasploit sessions. + +Takes no parameters. `data` is a hash keyed by session id (serialised as a string when transported over JSON), where each value describes the session type (e.g. `meterpreter`, `shell`), tunnel endpoints, the target host, and the originating module. `metadata.total_sessions` reports the number of entries in `data`. + +#### msf_session_read + +Non-destructively read pending output from an interactive session (meterpreter, database, or SMB). + +- `session_id` (integer, required): Session id from `msf_session_list` + +Returns `data: { "data": "..." }`. The MCP server does not buffer; clients are responsible for reassembling reads. Returns a tool error if the session id is not active. + +### Dangerous Tools + +These four tools can change the state of the framework, remote targets, or running sessions. They are gated by [Dangerous Actions Mode](#dangerous-actions-mode) and return a tool error unless the operator has explicitly enabled it. + +#### msf_module_execute + +Run a Metasploit module as a background job. + +- `type` (string, required): Module type (`exploit`, `auxiliary`, `post`, `payload`, `evasion`) +- `name` (string, required): Module path (e.g., `multi/handler`) +- `options` (object, required): Datastore options as a flat JSON object. Keys are Metasploit option names; namespaced (`HTTP::compression`) and hyphenated (`BEARER-TOKEN`) names are supported. Values must be scalars (string, integer, number, boolean, or null). No nested objects or arrays. + +Returns `data: { "job_id": N, "uuid": "<24 chars>" }`. Poll `msf_module_results` with the returned UUID to retrieve the outcome. + +This tool does not implement check-before-exploit. If a module supports `Msf::Exploit::Remote::AutoCheck`, the framework runs the check automatically; otherwise, run `msf_module_check` first. + +#### msf_module_check + +Run the `check` method of an exploit or auxiliary module to determine whether a target is vulnerable, without exploiting it. + +- `type` (string, required): Module type (`exploit` or `auxiliary`) +- `name` (string, required): Module path +- `options` (object, required): Datastore options (same shape as `msf_module_execute`) + +Returns `data: { "job_id": N, "uuid": "<24 chars>" }`. Poll `msf_module_results` for the `CheckCode` (e.g. `Vulnerable`, `Safe`, `Detected`, `Appears`, `Unknown`). + +When the target module does not implement a `check` method, the tool returns a **successful** response with `data: { "status": "unsupported", "message": "Module does not implement a check method" }` rather than a tool error, so clients can distinguish "not supported" from a transport or runtime failure. + +#### msf_session_stop + +Terminate an active session. + +- `session_id` (integer, required): Session id from `msf_session_list` + +Returns `data: { "result": "success" }`. Returns a tool error if the session id is not active. + +#### msf_session_write + +Send data to an interactive session (meterpreter, database, or SMB). Use `msf_session_read` afterwards to retrieve any output produced. + +- `session_id` (integer, required): Session id from `msf_session_list` +- `data` (string, required): Bytes to write to the session, capped at 10,000 characters per call + +Returns `data: { "result": "" }`. + +## Dangerous Actions Mode + +The four tools listed under [Dangerous Tools](#dangerous-tools) are disabled by default and must be explicitly enabled. When the gate is closed, calls to any of those tools return an MCP tool error and the underlying RPC call is never issued. + +### Enabling + +Dangerous mode can be enabled through any of three mechanisms, in order of precedence (highest first): + +1. **CLI flag**: pass `--enable-dangerous-actions` to `msfmcpd`. +2. **Environment variable**: set `MSF_MCP_DANGEROUS_ACTIONS=true` (also accepts `1`, `yes`, `on`). +3. **Configuration file**: set `mcp.dangerous_actions: true` in `mcp_config.yaml`. + +When the gate is open, `msfmcpd` prints a warning banner on startup: + +``` +WARNING: dangerous actions mode is ENABLED. Destructive tools (module +execute/check, session stop/write) are now callable from connected MCP +clients. +``` + ## Integration with AI Applications Add the MCP server to your AI application configuration. The exact format depends on the client. @@ -352,9 +477,13 @@ If you use RVM to manage Ruby versions, specify the full path to RVM so the corr ## Security Considerations +### Dangerous Actions Gate + +Tools that execute modules or interact with sessions are disabled by default. They must be explicitly enabled per session via CLI flag, environment variable, or configuration key — see [Dangerous Actions Mode](#dangerous-actions-mode). When the gate is closed, the RPC client is never invoked for a gated call. + ### Input Validation -All tool parameters are validated against strict JSON schemas. IP addresses are validated using Ruby's `IPAddr` class with CIDR support, workspace names are restricted to alphanumeric characters plus underscore/hyphen, port ranges are validated (1-65535), and search queries are limited to 500 characters. +All tool parameters are validated against strict JSON schemas. IP addresses are validated using Ruby's `IPAddr` class with CIDR support, workspace names are restricted to alphanumeric characters plus underscore/hyphen, port ranges are validated (1-65535), and search queries are limited to 500 characters. Module datastore option keys must match Metasploit's option-name shape (identifier segments joined by `::` or `-`, up to 128 characters); option payloads are capped at 100 keys, 8 KB per string value, and 64 KB aggregate. Session data writes are capped at 64 KB per call. ### Credential Management diff --git a/lib/msf/core/mcp.rb b/lib/msf/core/mcp.rb index d301892addf7f..8ef13b0460c90 100644 --- a/lib/msf/core/mcp.rb +++ b/lib/msf/core/mcp.rb @@ -65,12 +65,20 @@ module MCP require_relative 'mcp/tools/tool_helper' require_relative 'mcp/tools/search_modules' require_relative 'mcp/tools/module_info' +require_relative 'mcp/tools/module_execute' +require_relative 'mcp/tools/module_check' +require_relative 'mcp/tools/module_results' +require_relative 'mcp/tools/running_stats' require_relative 'mcp/tools/host_info' require_relative 'mcp/tools/service_info' require_relative 'mcp/tools/vulnerability_info' require_relative 'mcp/tools/note_info' require_relative 'mcp/tools/credential_info' require_relative 'mcp/tools/loot_info' +require_relative 'mcp/tools/session_list' +require_relative 'mcp/tools/session_stop' +require_relative 'mcp/tools/session_read' +require_relative 'mcp/tools/session_write' require_relative 'mcp/server' # Application Layer diff --git a/lib/msf/core/mcp/application.rb b/lib/msf/core/mcp/application.rb index 97545819f6f84..540e518243995 100644 --- a/lib/msf/core/mcp/application.rb +++ b/lib/msf/core/mcp/application.rb @@ -116,6 +116,10 @@ def parse_arguments @options[:mcp_transport] = transport end + opts.on('--enable-dangerous-actions', 'Enable destructive MCP tools (module execution, session control)') do + @options[:enable_dangerous_actions_cli] = true + end + opts.on('-h', '--help', 'Show this help message') do @output.puts opts exit 0 @@ -201,6 +205,9 @@ def load_configuration if @options[:mcp_transport] @config[:mcp][:transport] = @options[:mcp_transport] end + if @options[:enable_dangerous_actions_cli] + @config[:mcp][:dangerous_actions] = true + end end # Validate the loaded configuration @@ -266,9 +273,14 @@ def authenticate_metasploit # @return [void] def initialize_mcp_server @output.puts "Initializing MCP server..." + dangerous_actions = @config&.dig(:mcp, :dangerous_actions) == true + if dangerous_actions + @output.puts "WARNING: dangerous actions mode is ENABLED. Destructive tools (module execution, session control) are accessible." + end @mcp_server = Msf::MCP::Server.new( msf_client: @msf_client, - rate_limiter: @rate_limiter + rate_limiter: @rate_limiter, + dangerous_actions: dangerous_actions ) end diff --git a/lib/msf/core/mcp/config/defaults.rb b/lib/msf/core/mcp/config/defaults.rb index 86378e1336bad..c86590e7a5178 100644 --- a/lib/msf/core/mcp/config/defaults.rb +++ b/lib/msf/core/mcp/config/defaults.rb @@ -22,6 +22,12 @@ module Defaults # Rate limiting RATE_LIMIT_REQUESTS_PER_MINUTE = 60 + + # MCP feature flags + # + # When false, destructive MCP tools (module execution, session control) + # are gated and return an error. Enable explicitly to allow them. + DANGEROUS_ACTIONS = false end end end diff --git a/lib/msf/core/mcp/config/loader.rb b/lib/msf/core/mcp/config/loader.rb index a501f3e5c61ca..25e2b4cc4ec65 100644 --- a/lib/msf/core/mcp/config/loader.rb +++ b/lib/msf/core/mcp/config/loader.rb @@ -68,6 +68,7 @@ def self.apply_defaults(config) end config[:mcp][:transport] ||= 'stdio' + config[:mcp][:dangerous_actions] = config[:mcp].fetch(:dangerous_actions, false) if config[:mcp][:transport] == 'http' config[:mcp][:host] ||= Defaults::MCP_HOST @@ -135,6 +136,11 @@ def self.apply_env_overrides(config) config[:mcp][:min_threads] = ENV['MSF_MCP_MIN_THREADS'].to_i if ENV['MSF_MCP_MIN_THREADS'] config[:mcp][:max_threads] = ENV['MSF_MCP_MAX_THREADS'].to_i if ENV['MSF_MCP_MAX_THREADS'] config[:mcp][:workers] = ENV['MSF_MCP_WORKERS'].to_i if ENV['MSF_MCP_WORKERS'] + + # Dangerous actions gate override + if ENV['MSF_MCP_DANGEROUS_ACTIONS'] && !ENV['MSF_MCP_DANGEROUS_ACTIONS'].empty? + config[:mcp][:dangerous_actions] = parse_boolean(ENV['MSF_MCP_DANGEROUS_ACTIONS']) + end end # Parse a string value into a boolean diff --git a/lib/msf/core/mcp/config/validator.rb b/lib/msf/core/mcp/config/validator.rb index fb613417d17dc..edebd4650fe7d 100644 --- a/lib/msf/core/mcp/config/validator.rb +++ b/lib/msf/core/mcp/config/validator.rb @@ -101,6 +101,10 @@ def validate!(config) errors[:'mcp.min_threads'] = "must be less than or equal to mcp.max_threads" end end + + if config[:mcp].key?(:dangerous_actions) && ![true, false].include?(config[:mcp][:dangerous_actions]) + errors[:'mcp.dangerous_actions'] = "must be boolean (true or false)" + end end # Validate conditional requirements based on API type diff --git a/lib/msf/core/mcp/logging/sinks/sanitizing.rb b/lib/msf/core/mcp/logging/sinks/sanitizing.rb index 666ce5f802104..480282ea22a6c 100644 --- a/lib/msf/core/mcp/logging/sinks/sanitizing.rb +++ b/lib/msf/core/mcp/logging/sinks/sanitizing.rb @@ -55,7 +55,7 @@ def sanitize(data) data.each_with_object({}) do |(k, v), result| result[k] = if k.to_s.match?(SENSITIVE_KEYS) v.is_a?(Hash) || v.is_a?(Array) ? sanitize(v) : REDACTED - elsif k.to_sym == :exception && v.is_a?(Exception) + elsif k.to_s.casecmp?('exception') && v.is_a?(Exception) ex_msg = { class: v.class.name, message: sanitize(v.message) } if get_log_level(LOG_SOURCE) >= BACKTRACE_LOG_LEVEL bt = v.backtrace&.first(5) || [] diff --git a/lib/msf/core/mcp/metasploit/client.rb b/lib/msf/core/mcp/metasploit/client.rb index 2d7c91ca2e3c9..f85ccc293bf03 100644 --- a/lib/msf/core/mcp/metasploit/client.rb +++ b/lib/msf/core/mcp/metasploit/client.rb @@ -9,7 +9,10 @@ module Metasploit class Client extend Forwardable - def_delegators :@client, :authenticate, :search_modules, :module_info, :db_hosts, :db_services, :db_vulns, :db_notes, :db_creds, :db_loot, :shutdown + def_delegators :@client, :authenticate, :search_modules, :module_info, + :db_hosts, :db_services, :db_vulns, :db_notes, :db_creds, :db_loot, + :module_execute, :module_check, :module_results, :running_stats, + :session_list, :session_stop, :session_read, :session_write, :shutdown ## # Initialize Metasploit client with explicit parameters diff --git a/lib/msf/core/mcp/metasploit/jsonrpc_client.rb b/lib/msf/core/mcp/metasploit/jsonrpc_client.rb index ac626859d9224..64ae4acde01d8 100644 --- a/lib/msf/core/mcp/metasploit/jsonrpc_client.rb +++ b/lib/msf/core/mcp/metasploit/jsonrpc_client.rb @@ -129,6 +129,65 @@ def db_loot(options = {}) call_api('db.loots', [options]) end + # Execute a Metasploit module + # @param type [String] Module type ('exploit', 'auxiliary', 'post', 'payload', 'evasion') + # @param name [String] Module name (e.g. 'multi/handler') + # @param options [Hash] Module datastore options + # @return [Hash] Response containing 'job_id' and 'uuid' keys + def module_execute(type, name, options = {}) + call_api('module.execute', [type, name, options]) + end + + # Run the check method of a Metasploit module + # @param type [String] Module type ('exploit' or 'auxiliary') + # @param name [String] Module name + # @param options [Hash] Module datastore options + # @return [Hash] Response containing 'job_id' and 'uuid' keys + def module_check(type, name, options = {}) + call_api('module.check', [type, name, options]) + end + + # Get module execution results by UUID + # @param uuid [String] Module run UUID returned by module_execute / module_check + # @return [Hash] Result hash with 'status' key (and 'result' / 'error' as applicable) + def module_results(uuid) + call_api('module.results', [uuid]) + end + + # Get currently running module stats (waiting / running / results) + # @return [Hash] Hash with 'waiting', 'running', 'results' arrays of UUIDs + def running_stats + call_api('module.running_stats', []) + end + + # List active sessions + # @return [Hash] Hash keyed by session ID with session info hashes as values + def session_list + call_api('session.list', []) + end + + # Stop (kill) an active session + # @param session_id [Integer] Session ID + # @return [Hash] Response with 'result' key set to 'success' + def session_stop(session_id) + call_api('session.stop', [session_id]) + end + + # Read buffered output from an interactive session (meterpreter, DB, SMB) + # @param session_id [Integer] Session ID + # @return [Hash] Response with 'data' key containing the buffered output + def session_read(session_id) + call_api('session.interactive_read', [session_id]) + end + + # Send input to an interactive session + # @param session_id [Integer] Session ID + # @param data [String] Data to send to the session + # @return [Hash] Response with 'result' key + def session_write(session_id, data) + call_api('session.interactive_write', [session_id, data]) + end + # Shutdown client def shutdown @http&.finish if @http&.started? diff --git a/lib/msf/core/mcp/metasploit/messagepack_client.rb b/lib/msf/core/mcp/metasploit/messagepack_client.rb index ef96c99b1fe49..b81aa6da350f9 100644 --- a/lib/msf/core/mcp/metasploit/messagepack_client.rb +++ b/lib/msf/core/mcp/metasploit/messagepack_client.rb @@ -165,6 +165,65 @@ def db_loot(options = {}) call_api('db.loots', [options]) end + # Execute a Metasploit module + # @param type [String] Module type ('exploit', 'auxiliary', 'post', 'payload', 'evasion') + # @param name [String] Module name (e.g. 'multi/handler') + # @param options [Hash] Module datastore options + # @return [Hash] Response containing 'job_id' and 'uuid' keys + def module_execute(type, name, options = {}) + call_api('module.execute', [type, name, options]) + end + + # Run the check method of a Metasploit module + # @param type [String] Module type ('exploit' or 'auxiliary') + # @param name [String] Module name + # @param options [Hash] Module datastore options + # @return [Hash] Response containing 'job_id' and 'uuid' keys + def module_check(type, name, options = {}) + call_api('module.check', [type, name, options]) + end + + # Get module execution results by UUID + # @param uuid [String] Module run UUID returned by module_execute / module_check + # @return [Hash] Result hash with 'status' key (and 'result' / 'error' as applicable) + def module_results(uuid) + call_api('module.results', [uuid]) + end + + # Get currently running module stats (waiting / running / results) + # @return [Hash] Hash with 'waiting', 'running', 'results' arrays of UUIDs + def running_stats + call_api('module.running_stats', []) + end + + # List active sessions + # @return [Hash] Hash keyed by session ID with session info hashes as values + def session_list + call_api('session.list', []) + end + + # Stop (kill) an active session + # @param session_id [Integer] Session ID + # @return [Hash] Response with 'result' key set to 'success' + def session_stop(session_id) + call_api('session.stop', [session_id]) + end + + # Read buffered output from an interactive session (meterpreter, DB, SMB) + # @param session_id [Integer] Session ID + # @return [Hash] Response with 'data' key containing the buffered output + def session_read(session_id) + call_api('session.interactive_read', [session_id]) + end + + # Send input to an interactive session + # @param session_id [Integer] Session ID + # @param data [String] Data to send to the session + # @return [Hash] Response with 'result' key + def session_write(session_id, data) + call_api('session.interactive_write', [session_id, data]) + end + # Shutdown client and cleanup def shutdown @token = nil diff --git a/lib/msf/core/mcp/security/input_validator.rb b/lib/msf/core/mcp/security/input_validator.rb index 7b047f52b2f9b..5d46dbb64b09c 100644 --- a/lib/msf/core/mcp/security/input_validator.rb +++ b/lib/msf/core/mcp/security/input_validator.rb @@ -192,6 +192,128 @@ def self.validate_only_up!(only_up) def self.validate_protocol!(protocol) validate_parameter!('Protocol', protocol.to_s.downcase, %w[tcp udp], allow_nil: true) end + + # Module datastore option limits. + # + # Bounds an oversized options hash before it reaches the Metasploit + # datastore. Real modules register on the order of 10-60 options across + # all inherited mixins, so 100 is a comfortable ceiling. This is a + # defence-in-depth cap against DoS-style input abuse from an MCP client, + # not a transport limit (MCP itself does not restrict payload size). + MODULE_OPTIONS_MAX_KEYS = 100 + # Matches the option name conventions used across the Framework: standard + # SCREAMING_SNAKE_CASE (RHOSTS, LPORT), CamelCase advanced options + # (SSLVersion, BasicAuth), namespaced mixin options separated by `::` + # (HTTP::compression, CMDSTAGER::FLAVOR, EXE::Custom), multi-level + # namespaces (HTML::javascript::escape), and hyphenated header-style + # names (BEARER-TOKEN). Each segment must start with a letter or + # underscore. Leading/trailing separators and double separators + # (`:::`, `--`) are rejected. + MODULE_OPTIONS_KEY_REGEX = /\A[A-Za-z_][A-Za-z0-9_]*(?:(?:::|-)[A-Za-z_][A-Za-z0-9_]*)*\z/ + MODULE_OPTIONS_KEY_MAX_LENGTH = 128 + MODULE_OPTIONS_VALUE_MAX_BYTES = 8 * 1024 + MODULE_OPTIONS_TOTAL_MAX_BYTES = 64 * 1024 + + # Allowed scalar types for datastore values. The Metasploit datastore is flat, + # so no nested Hashes / Arrays are permitted. + MODULE_OPTION_SCALAR_TYPES = [String, Integer, Float, TrueClass, FalseClass, NilClass].freeze + + # UUID format produced by Metasploit's RPC layer via Rex::Text.rand_text_alphanumeric(24). + # The generator emits exactly 24 characters drawn from a mixed-case alphanumeric pool. + MODULE_RUN_UUID_REGEX = /\A[A-Za-z0-9]{24}\z/ + + SESSION_ID_RANGE = 1..65535 + SESSION_DATA_MAX_BYTES = 64 * 1024 + + # Validate module datastore options hash. + # + # Enforces structural limits before any RPC call so a misbehaving client + # cannot push an unbounded payload through to the framework. + # + # Keys may be Strings or Symbols (the MCP transport deep-symbolizes JSON + # input). Either form is validated against MODULE_OPTIONS_KEY_REGEX via + # `to_s`; the tool layer is responsible for normalizing to String keys + # before forwarding to the Metasploit datastore. + # + # @param options [Hash] Module datastore options + # @return [true] If valid + # @raise [ValidationError] If invalid + def self.validate_module_options!(options) + raise ValidationError, 'Module options must be a Hash' unless options.is_a?(Hash) + + if options.size > MODULE_OPTIONS_MAX_KEYS + raise ValidationError, "Module options has too many keys (max #{MODULE_OPTIONS_MAX_KEYS})" + end + + total_bytes = 0 + options.each do |key, value| + unless key.is_a?(String) || key.is_a?(Symbol) + raise ValidationError, "Invalid module option key: #{key.inspect}" + end + + key_str = key.to_s + if key_str.length > MODULE_OPTIONS_KEY_MAX_LENGTH + raise ValidationError, "Module option key exceeds #{MODULE_OPTIONS_KEY_MAX_LENGTH} chars: #{key.inspect}" + end + unless key_str.match?(MODULE_OPTIONS_KEY_REGEX) + raise ValidationError, "Invalid module option key: #{key.inspect}" + end + + unless MODULE_OPTION_SCALAR_TYPES.any? { |klass| value.is_a?(klass) } + raise ValidationError, "Invalid module option value for #{key}: must be a scalar (String, Integer, Float, Boolean, or nil)" + end + + if value.is_a?(String) && value.bytesize > MODULE_OPTIONS_VALUE_MAX_BYTES + raise ValidationError, "Module option #{key} string value exceeds #{MODULE_OPTIONS_VALUE_MAX_BYTES} bytes" + end + + total_bytes += key.to_s.bytesize + total_bytes += value.bytesize if value.is_a?(String) + if total_bytes > MODULE_OPTIONS_TOTAL_MAX_BYTES + raise ValidationError, "Module options total payload exceeds #{MODULE_OPTIONS_TOTAL_MAX_BYTES} bytes" + end + end + + true + end + + # Validate a module run UUID returned by module.execute / module.check. + # + # @param uuid [String] UUID string + # @return [true] If valid + # @raise [ValidationError] If invalid + def self.validate_uuid!(uuid) + raise ValidationError, 'UUID must be a String' unless uuid.is_a?(String) + + validate_parameter!('UUID', uuid, MODULE_RUN_UUID_REGEX, max_size: 24) + end + + # Validate a session identifier. + # + # @param session_id [Integer] Session ID + # @return [true] If valid + # @raise [ValidationError] If invalid + def self.validate_session_id!(session_id) + raise ValidationError, 'Session ID must be an Integer' unless session_id.is_a?(Integer) + + validate_parameter!('Session ID', session_id, SESSION_ID_RANGE) + end + + # Validate session input data for session.interactive_write. + # + # @param data [String] Data to send to the session + # @return [true] If valid + # @raise [ValidationError] If invalid + def self.validate_session_data!(data) + raise ValidationError, 'Session data must be a String' unless data.is_a?(String) + raise ValidationError, 'Session data cannot be empty' if data.empty? + + if data.bytesize > SESSION_DATA_MAX_BYTES + raise ValidationError, "Session data exceeds #{SESSION_DATA_MAX_BYTES} bytes" + end + + true + end end end end diff --git a/lib/msf/core/mcp/server.rb b/lib/msf/core/mcp/server.rb index 864a3c45c9d9d..c2a72959a8aee 100644 --- a/lib/msf/core/mcp/server.rb +++ b/lib/msf/core/mcp/server.rb @@ -24,15 +24,17 @@ class Server # # @param msf_client [Metasploit::Client] Configured and authenticated Metasploit client # @param rate_limiter [Security::RateLimiter] Configured rate limiter + # @param dangerous_actions [Boolean] Whether dangerous (destructive) tools are permitted # - def initialize(msf_client:, rate_limiter:) + def initialize(msf_client:, rate_limiter:, dangerous_actions: false) @msf_client = msf_client # Create server context (passed to all tool calls) - # Tools only need msf_client and rate_limiter + # Tools only need msf_client, rate_limiter, and the dangerous_actions gate. @server_context = { msf_client: @msf_client, - rate_limiter: rate_limiter + rate_limiter: rate_limiter, + dangerous_actions: dangerous_actions == true } # Create MCP configuration with request lifecycle callbacks @@ -47,12 +49,20 @@ def initialize(msf_client:, rate_limiter:) tools: [ Tools::SearchModules, Tools::ModuleInfo, + Tools::ModuleExecute, + Tools::ModuleCheck, + Tools::ModuleResults, + Tools::RunningStats, Tools::HostInfo, Tools::ServiceInfo, Tools::VulnerabilityInfo, Tools::NoteInfo, Tools::CredentialInfo, - Tools::LootInfo + Tools::LootInfo, + Tools::SessionList, + Tools::SessionStop, + Tools::SessionRead, + Tools::SessionWrite ], server_context: @server_context, configuration: mcp_config diff --git a/lib/msf/core/mcp/tools/credential_info.rb b/lib/msf/core/mcp/tools/credential_info.rb index 9d003bab7ec64..78e2e43e8dc22 100644 --- a/lib/msf/core/mcp/tools/credential_info.rb +++ b/lib/msf/core/mcp/tools/credential_info.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'rex/stopwatch' + module Msf::MCP module Tools ## @@ -33,8 +35,7 @@ class CredentialInfo < ::MCP::Tool minimum: 0, default: 0 } - }, - required: [:workspace] + } ) output_schema( @@ -89,8 +90,6 @@ class << self # @return [MCP::Tool::Response] Structured response with credential information # def call(workspace: 'default', limit: Msf::MCP::Security::InputValidator::LIMIT_DEFAULT, offset: 0, server_context:) - start_time = Time.now - # Extract dependencies from server context msf_client = server_context[:msf_client] rate_limiter = server_context[:rate_limiter] @@ -105,7 +104,9 @@ def call(workspace: 'default', limit: Msf::MCP::Security::InputValidator::LIMIT_ # Note that `workspace` is optional in the MSF API, the default workspace is used if not provided. # The default value is sent anyway for clarity. options = { workspace: workspace } - raw_creds = msf_client.db_creds(options) + raw_creds, elapsed = Rex::Stopwatch.elapsed_time do + msf_client.db_creds(options) + end # Transform response transformed = Metasploit::ResponseTransformer.transform_creds(raw_creds) @@ -121,7 +122,7 @@ def call(workspace: 'default', limit: Msf::MCP::Security::InputValidator::LIMIT_ # Build metadata metadata = { workspace: workspace, - query_time: (Time.now - start_time).round(3), + query_time: elapsed.round(3), total_items: total_items, returned_items: paginated_data.size, limit: limit, diff --git a/lib/msf/core/mcp/tools/host_info.rb b/lib/msf/core/mcp/tools/host_info.rb index 38b1237123aed..7475e0153126b 100644 --- a/lib/msf/core/mcp/tools/host_info.rb +++ b/lib/msf/core/mcp/tools/host_info.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'rex/stopwatch' + module Msf::MCP module Tools ## @@ -104,8 +106,6 @@ class << self # @return [MCP::Tool::Response] Structured response with host information # def call(workspace: 'default', addresses: nil, only_up: false, limit: Msf::MCP::Security::InputValidator::LIMIT_DEFAULT, offset: 0, server_context:) - start_time = Time.now - # Extract dependencies from server context msf_client = server_context[:msf_client] rate_limiter = server_context[:rate_limiter] @@ -124,7 +124,9 @@ def call(workspace: 'default', addresses: nil, only_up: false, limit: Msf::MCP:: options = { workspace: workspace } options[:addresses] = addresses if addresses options[:only_up] = only_up if only_up - raw_hosts = msf_client.db_hosts(options) + raw_hosts, elapsed = Rex::Stopwatch.elapsed_time do + msf_client.db_hosts(options) + end # Transform response transformed = Metasploit::ResponseTransformer.transform_hosts(raw_hosts) @@ -140,7 +142,7 @@ def call(workspace: 'default', addresses: nil, only_up: false, limit: Msf::MCP:: # Build metadata metadata = { workspace: workspace, - query_time: (Time.now - start_time).round(3), + query_time: elapsed.round(3), total_items: total_items, returned_items: paginated_data.size, limit: limit, diff --git a/lib/msf/core/mcp/tools/loot_info.rb b/lib/msf/core/mcp/tools/loot_info.rb index 62a56e29058f0..027ff425f1940 100644 --- a/lib/msf/core/mcp/tools/loot_info.rb +++ b/lib/msf/core/mcp/tools/loot_info.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'rex/stopwatch' + module Msf::MCP module Tools ## @@ -34,8 +36,7 @@ class LootInfo < ::MCP::Tool minimum: 0, default: 0 } - }, - required: [:workspace] + } ) output_schema( @@ -91,8 +92,6 @@ class << self # @return [MCP::Tool::Response] Structured response with loot information # def call(workspace: 'default', limit: Msf::MCP::Security::InputValidator::LIMIT_DEFAULT, offset: 0, server_context:) - start_time = Time.now - # Extract dependencies from server context msf_client = server_context[:msf_client] rate_limiter = server_context[:rate_limiter] @@ -107,7 +106,9 @@ def call(workspace: 'default', limit: Msf::MCP::Security::InputValidator::LIMIT_ # Note that `workspace` is optional in the MSF API, the default workspace is used if not provided. # The default value is sent anyway for clarity. options = { workspace: workspace } - raw_loot = msf_client.db_loot(options) + raw_loot, elapsed = Rex::Stopwatch.elapsed_time do + msf_client.db_loot(options) + end # Transform response transformed = Metasploit::ResponseTransformer.transform_loot(raw_loot) @@ -123,7 +124,7 @@ def call(workspace: 'default', limit: Msf::MCP::Security::InputValidator::LIMIT_ # Build metadata metadata = { workspace: workspace, - query_time: (Time.now - start_time).round(3), + query_time: elapsed.round(3), total_items: total_items, returned_items: paginated_data.size, limit: limit, diff --git a/lib/msf/core/mcp/tools/module_check.rb b/lib/msf/core/mcp/tools/module_check.rb new file mode 100644 index 0000000000000..3b6ee32e26df4 --- /dev/null +++ b/lib/msf/core/mcp/tools/module_check.rb @@ -0,0 +1,151 @@ +# frozen_string_literal: true + +require 'rex/stopwatch' + +module Msf::MCP + module Tools + ## + # MCP Tool: Run a Module's Check Method + # + # Invokes the Metasploit Framework `module.check` RPC endpoint. Returns the + # job_id and run UUID for polling via {ModuleResults}. When the module does + # not implement a check method, returns a structured `status: unsupported` + # response instead of an error. + # + class ModuleCheck < ::MCP::Tool + # Message fragment emitted by Msf::Exploit::CheckCode::Unsupported when + # a target module does not implement #check. + UNSUPPORTED_MESSAGE_PATTERN = /module does not support check/i + + tool_name 'msf_module_check' + description 'Run the check method of a Metasploit exploit or auxiliary module. '\ + 'Returns a job_id and run UUID; use msf_module_results to retrieve the CheckCode result.' + + input_schema( + properties: { + type: { + type: 'string', + description: 'Module type', + enum: %w[exploit auxiliary] + }, + name: { + type: 'string', + description: 'Module path/name (e.g., windows/smb/ms17_010_eternalblue)', + minLength: 1, + maxLength: 500 + }, + options: { + type: 'object', + description: 'Module datastore options as a JSON object. Keys are Metasploit option ' \ + 'names (e.g. RHOSTS, RPORT). Namespaced mixin options that use the `::` ' \ + 'separator are also accepted (e.g. HTTP::compression, SMB::ChunkSize), ' \ + 'as are hyphenated identifiers (e.g. BEARER-TOKEN). ' \ + 'Values must be scalars (string, integer, float, boolean, or null). ' \ + 'Example: {"RHOSTS": "192.0.2.10", "RPORT": 445}. ' \ + 'No nested objects or arrays.', + additionalProperties: { type: %w[string integer number boolean null] } + } + }, + required: [:type, :name, :options] + ) + + output_schema( + properties: { + metadata: { + properties: { + query_time: { type: 'number' } + } + }, + data: { + properties: { + job_id: { type: 'integer' }, + uuid: { type: 'string' }, + status: { type: 'string' }, + message: { type: 'string' } + } + } + }, + required: [:metadata, :data] + ) + + annotations( + read_only_hint: false, + idempotent_hint: false, + destructive_hint: true + ) + + meta({ source: 'metasploit_framework' }) + + class << self + include ToolHelper + + ## + # Execute module check + # + # @param type [String] Module type ('exploit' or 'auxiliary') + # @param name [String] Module path/name + # @param options [Hash] Datastore options forwarded to module.check + # @param server_context [Hash] Server context with msf_client, rate_limiter + # @return [MCP::Tool::Response] Structured response with job_id and uuid, + # or { status: 'unsupported' } when the module has no check method + # + def call(type:, name:, options:, server_context:) + dangerous_mode_required!(server_context) + + msf_client = server_context[:msf_client] + rate_limiter = server_context[:rate_limiter] + + rate_limiter.check_rate_limit!('module_check') + + Msf::MCP::Security::InputValidator.validate_parameter!('Module type', type, %w[exploit auxiliary]) + Msf::MCP::Security::InputValidator.validate_module_name!(name) + Msf::MCP::Security::InputValidator.validate_module_options!(options) + + # MCP deep-symbolizes JSON input; the Metasploit datastore is keyed by Strings. + stringified_options = options.transform_keys(&:to_s) + + raw_result = nil + api_error = nil + _, elapsed = Rex::Stopwatch.elapsed_time do + raw_result = msf_client.module_check(type, name, stringified_options) + rescue Msf::MCP::Metasploit::APIError => e + api_error = e + end + + if api_error + raise api_error unless api_error.message.to_s.match?(UNSUPPORTED_MESSAGE_PATTERN) + + metadata = { query_time: elapsed.round(3) } + data = { status: 'unsupported', message: 'Module does not implement a check method' } + return ::MCP::Tool::Response.new( + [{ type: 'text', text: JSON.generate(metadata: metadata, data: data) }], + structured_content: { metadata: metadata, data: data } + ) + end + + data = { + job_id: raw_result['job_id'], + uuid: raw_result['uuid'] + } + + metadata = { query_time: elapsed.round(3) } + + ::MCP::Tool::Response.new( + [{ type: 'text', text: JSON.generate(metadata: metadata, data: data) }], + structured_content: { metadata: metadata, data: data } + ) + rescue Msf::MCP::Tools::DangerousModeDisabledError => e + tool_error_response(e.message) + rescue Msf::MCP::Security::RateLimitExceededError => e + tool_error_response("Rate limit exceeded: #{e.message}") + rescue Msf::MCP::Metasploit::AuthenticationError => e + tool_error_response("Authentication failed: #{e.message}") + rescue Msf::MCP::Metasploit::APIError => e + tool_error_response("Metasploit API error: #{e.message}") + rescue Msf::MCP::Security::ValidationError => e + tool_error_response(e.message) + end + end + end + end +end diff --git a/lib/msf/core/mcp/tools/module_execute.rb b/lib/msf/core/mcp/tools/module_execute.rb new file mode 100644 index 0000000000000..fd4dcd7aa08eb --- /dev/null +++ b/lib/msf/core/mcp/tools/module_execute.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +require 'rex/stopwatch' + +module Msf::MCP + module Tools + ## + # MCP Tool: Execute a Metasploit Module + # + # Invokes the Metasploit Framework `module.execute` RPC endpoint to start + # an exploit, auxiliary, post, payload, or evasion module. Returns the + # job_id and run UUID, which can later be polled via {ModuleResults}. + # + class ModuleExecute < ::MCP::Tool + tool_name 'msf_module_execute' + description 'Execute a Metasploit module (exploit, auxiliary, post, payload, or evasion). '\ + 'Returns a job_id and run UUID; use msf_module_results to retrieve the outcome.' + + input_schema( + properties: { + type: { + type: 'string', + description: 'Module type', + enum: %w[exploit auxiliary post payload evasion] + }, + name: { + type: 'string', + description: 'Module path/name (e.g., windows/smb/ms17_010_eternalblue)', + minLength: 1, + maxLength: 500 + }, + options: { + type: 'object', + description: 'Module datastore options as a JSON object. Keys are Metasploit option ' \ + 'names (e.g. RHOSTS, RPORT, LHOST, LPORT, PAYLOAD, TARGET, ACTION). ' \ + 'Namespaced mixin options that use the `::` separator are also accepted ' \ + '(e.g. HTTP::compression, CMDSTAGER::FLAVOR, EXE::Custom), as are ' \ + 'hyphenated identifiers (e.g. BEARER-TOKEN). ' \ + 'Values must be scalars (string, integer, float, boolean, or null). ' \ + 'Example: {"RHOSTS": "192.0.2.10", "RPORT": 4444, "PAYLOAD": "windows/meterpreter/reverse_tcp"}. ' \ + 'No nested objects or arrays.', + additionalProperties: { type: %w[string integer number boolean null] } + } + }, + required: [:type, :name, :options] + ) + + output_schema( + properties: { + metadata: { + properties: { + query_time: { type: 'number' } + } + }, + data: { + properties: { + # Both are null when module.execute swallowed an early driver failure (no job to poll). + job_id: { type: %w[integer null] }, + uuid: { type: %w[string null] } + } + } + }, + required: [:metadata, :data] + ) + + annotations( + read_only_hint: false, + idempotent_hint: false, + destructive_hint: true + ) + + meta({ source: 'metasploit_framework' }) + + class << self + include ToolHelper + + ## + # Execute module run + # + # @param type [String] Module type (exploit/auxiliary/post/payload/evasion) + # @param name [String] Module path/name + # @param options [Hash] Datastore options forwarded unchanged to module.execute + # @param server_context [Hash] Server context with msf_client, rate_limiter + # @return [MCP::Tool::Response] Structured response with job_id and uuid + # + def call(type:, name:, options:, server_context:) + dangerous_mode_required!(server_context) + + msf_client = server_context[:msf_client] + rate_limiter = server_context[:rate_limiter] + + rate_limiter.check_rate_limit!('module_execute') + + Msf::MCP::Security::InputValidator.validate_module_type!(type) + Msf::MCP::Security::InputValidator.validate_module_name!(name) + Msf::MCP::Security::InputValidator.validate_module_options!(options) + + # MCP deep-symbolizes JSON input; the Metasploit datastore is keyed by Strings. + stringified_options = options.transform_keys(&:to_s) + + raw_result, elapsed = Rex::Stopwatch.elapsed_time do + msf_client.module_execute(type, name, stringified_options) + end + + data = { + job_id: raw_result['job_id'], + uuid: raw_result['uuid'] + } + + metadata = { query_time: elapsed.round(3) } + + ::MCP::Tool::Response.new( + [{ type: 'text', text: JSON.generate(metadata: metadata, data: data) }], + structured_content: { metadata: metadata, data: data } + ) + rescue Msf::MCP::Tools::DangerousModeDisabledError => e + tool_error_response(e.message) + rescue Msf::MCP::Security::RateLimitExceededError => e + tool_error_response("Rate limit exceeded: #{e.message}") + rescue Msf::MCP::Metasploit::AuthenticationError => e + tool_error_response("Authentication failed: #{e.message}") + rescue Msf::MCP::Metasploit::APIError => e + tool_error_response("Metasploit API error: #{e.message}") + rescue Msf::MCP::Security::ValidationError => e + tool_error_response(e.message) + end + end + end + end +end diff --git a/lib/msf/core/mcp/tools/module_info.rb b/lib/msf/core/mcp/tools/module_info.rb index cdf93e248f34b..875a401807692 100644 --- a/lib/msf/core/mcp/tools/module_info.rb +++ b/lib/msf/core/mcp/tools/module_info.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'rex/stopwatch' + module Msf::MCP module Tools ## @@ -93,8 +95,6 @@ class << self # @return [MCP::Tool::Response] Structured response with module details # def call(type:, name:, server_context:) - start_time = Time.now - # Extract dependencies from server context msf_client = server_context[:msf_client] rate_limiter = server_context[:rate_limiter] @@ -107,14 +107,16 @@ def call(type:, name:, server_context:) Msf::MCP::Security::InputValidator.validate_module_name!(name) # Call Metasploit API - raw_module_info = msf_client.module_info(type, name) + raw_module_info, elapsed = Rex::Stopwatch.elapsed_time do + msf_client.module_info(type, name) + end # Transform response transformed = Metasploit::ResponseTransformer.transform_module_info(raw_module_info) # Build metadata metadata = { - query_time: (Time.now - start_time).round(3) + query_time: elapsed.round(3) } # Return MCP response diff --git a/lib/msf/core/mcp/tools/module_results.rb b/lib/msf/core/mcp/tools/module_results.rb new file mode 100644 index 0000000000000..7f57eb744fa71 --- /dev/null +++ b/lib/msf/core/mcp/tools/module_results.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require 'rex/stopwatch' + +module Msf::MCP + module Tools + ## + # MCP Tool: Retrieve Module Run Results + # + # Polls the `module.results` RPC endpoint with a UUID returned by + # {ModuleExecute} or {ModuleCheck} and returns the current status. + # + class ModuleResults < ::MCP::Tool + tool_name 'msf_module_results' + description 'Retrieve the result of a previously started module run by UUID. '\ + 'Returns the run status (ready, running, completed, or errored) and any associated result data.' + + input_schema( + properties: { + uuid: { + type: 'string', + description: 'Module run UUID returned by msf_module_execute or msf_module_check', + minLength: 24, + maxLength: 24 + } + }, + required: [:uuid] + ) + + output_schema( + properties: { + metadata: { + properties: { + query_time: { type: 'number' } + } + }, + data: { + properties: { + status: { type: 'string', enum: %w[ready running completed errored] }, + result: {}, + error: { type: 'string' } + } + } + }, + required: [:metadata, :data] + ) + + annotations( + read_only_hint: true, + idempotent_hint: true, + destructive_hint: false + ) + + meta({ source: 'metasploit_framework' }) + + class << self + include ToolHelper + + ## + # Execute results lookup + # + # @param uuid [String] Module run UUID + # @param server_context [Hash] Server context with msf_client, rate_limiter + # @return [MCP::Tool::Response] Structured response with run status + # + def call(uuid:, server_context:) + msf_client = server_context[:msf_client] + rate_limiter = server_context[:rate_limiter] + + rate_limiter.check_rate_limit!('module_results') + + Msf::MCP::Security::InputValidator.validate_uuid!(uuid) + + raw_result, elapsed = Rex::Stopwatch.elapsed_time do + msf_client.module_results(uuid) + end + + data = raw_result.each_with_object({}) { |(k, v), h| h[k.to_sym] = v } + + metadata = { query_time: elapsed.round(3) } + + ::MCP::Tool::Response.new( + [{ type: 'text', text: JSON.generate(metadata: metadata, data: data) }], + structured_content: { metadata: metadata, data: data } + ) + rescue Msf::MCP::Security::RateLimitExceededError => e + tool_error_response("Rate limit exceeded: #{e.message}") + rescue Msf::MCP::Metasploit::AuthenticationError => e + tool_error_response("Authentication failed: #{e.message}") + rescue Msf::MCP::Metasploit::APIError => e + tool_error_response("Metasploit API error: #{e.message}") + rescue Msf::MCP::Security::ValidationError => e + tool_error_response(e.message) + end + end + end + end +end diff --git a/lib/msf/core/mcp/tools/note_info.rb b/lib/msf/core/mcp/tools/note_info.rb index 776288e6dedbc..6b808fa47f53f 100644 --- a/lib/msf/core/mcp/tools/note_info.rb +++ b/lib/msf/core/mcp/tools/note_info.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'rex/stopwatch' + module Msf::MCP module Tools ## @@ -51,8 +53,7 @@ class NoteInfo < ::MCP::Tool minimum: 0, default: 0 } - }, - required: [:workspace] + } ) output_schema( @@ -108,8 +109,6 @@ class << self # @return [MCP::Tool::Response] Structured response with note information # def call(workspace: 'default', host: nil, type: nil, ports: nil, protocol: nil, limit: Msf::MCP::Security::InputValidator::LIMIT_DEFAULT, offset: 0, server_context:) - start_time = Time.now - # Extract dependencies from server context msf_client = server_context[:msf_client] rate_limiter = server_context[:rate_limiter] @@ -131,7 +130,9 @@ def call(workspace: 'default', host: nil, type: nil, ports: nil, protocol: nil, options[:ntype] = type if type options[:ports] = ports if ports options[:proto] = protocol if protocol - raw_notes = msf_client.db_notes(options) + raw_notes, elapsed = Rex::Stopwatch.elapsed_time do + msf_client.db_notes(options) + end # Transform response transformed = Metasploit::ResponseTransformer.transform_notes(raw_notes) @@ -147,7 +148,7 @@ def call(workspace: 'default', host: nil, type: nil, ports: nil, protocol: nil, # Build metadata metadata = { workspace: workspace, - query_time: (Time.now - start_time).round(3), + query_time: elapsed.round(3), total_items: total_items, returned_items: paginated_data.size, limit: limit, diff --git a/lib/msf/core/mcp/tools/running_stats.rb b/lib/msf/core/mcp/tools/running_stats.rb new file mode 100644 index 0000000000000..80c5b0dba95a8 --- /dev/null +++ b/lib/msf/core/mcp/tools/running_stats.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require 'rex/stopwatch' + +module Msf::MCP + module Tools + ## + # MCP Tool: List Running Module Stats + # + # Wraps the `module.running_stats` RPC endpoint and returns the UUIDs of + # module runs currently in each lifecycle state (waiting, running, results). + # + class RunningStats < ::MCP::Tool + tool_name 'msf_running_stats' + description 'List the UUIDs of currently waiting, running, and completed module runs. '\ + 'Use msf_module_results with one of these UUIDs to retrieve details.' + + input_schema(properties: {}) + + output_schema( + properties: { + metadata: { + properties: { + query_time: { type: 'number' } + } + }, + data: { + properties: { + waiting: { type: 'array', items: { type: 'string' } }, + running: { type: 'array', items: { type: 'string' } }, + results: { type: 'array', items: { type: 'string' } } + } + } + }, + required: [:metadata, :data] + ) + + annotations( + read_only_hint: true, + idempotent_hint: true, + destructive_hint: false + ) + + meta({ source: 'metasploit_framework' }) + + class << self + include ToolHelper + + ## + # Execute running stats lookup + # + # @param server_context [Hash] Server context with msf_client, rate_limiter + # @return [MCP::Tool::Response] Structured response with running stats + # + def call(server_context:) + msf_client = server_context[:msf_client] + rate_limiter = server_context[:rate_limiter] + + rate_limiter.check_rate_limit!('running_stats') + + raw_result, elapsed = Rex::Stopwatch.elapsed_time do + msf_client.running_stats + end + + data = { + waiting: raw_result['waiting'] || [], + running: raw_result['running'] || [], + results: raw_result['results'] || [] + } + + metadata = { query_time: elapsed.round(3) } + + ::MCP::Tool::Response.new( + [{ type: 'text', text: JSON.generate(metadata: metadata, data: data) }], + structured_content: { metadata: metadata, data: data } + ) + rescue Msf::MCP::Security::RateLimitExceededError => e + tool_error_response("Rate limit exceeded: #{e.message}") + rescue Msf::MCP::Metasploit::AuthenticationError => e + tool_error_response("Authentication failed: #{e.message}") + rescue Msf::MCP::Metasploit::APIError => e + tool_error_response("Metasploit API error: #{e.message}") + end + end + end + end +end diff --git a/lib/msf/core/mcp/tools/search_modules.rb b/lib/msf/core/mcp/tools/search_modules.rb index 2af666efbb3f0..8745e13d0e553 100644 --- a/lib/msf/core/mcp/tools/search_modules.rb +++ b/lib/msf/core/mcp/tools/search_modules.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'rex/stopwatch' + module Msf::MCP module Tools ## @@ -90,8 +92,6 @@ class << self # @return [MCP::Tool::Response] Structured response with search results # def call(query:, limit: Msf::MCP::Security::InputValidator::LIMIT_DEFAULT, offset: 0, server_context:) - start_time = Time.now - # Extract dependencies from server context msf_client = server_context[:msf_client] rate_limiter = server_context[:rate_limiter] @@ -104,7 +104,9 @@ def call(query:, limit: Msf::MCP::Security::InputValidator::LIMIT_DEFAULT, offse Msf::MCP::Security::InputValidator.validate_pagination!(limit, offset) # Call Metasploit API - raw_modules = msf_client.search_modules(query) + raw_modules, elapsed = Rex::Stopwatch.elapsed_time do + msf_client.search_modules(query) + end # Transform response transformed = Metasploit::ResponseTransformer.transform_modules(raw_modules) @@ -120,7 +122,7 @@ def call(query:, limit: Msf::MCP::Security::InputValidator::LIMIT_DEFAULT, offse # Build metadata metadata = { query: query, - query_time: (Time.now - start_time).round(3), + query_time: elapsed.round(3), total_items: total_items, returned_items: paginated_data.size, limit: limit, diff --git a/lib/msf/core/mcp/tools/service_info.rb b/lib/msf/core/mcp/tools/service_info.rb index 053e017de4e58..425a2dc1830e8 100644 --- a/lib/msf/core/mcp/tools/service_info.rb +++ b/lib/msf/core/mcp/tools/service_info.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'rex/stopwatch' + module Msf::MCP module Tools ## @@ -56,8 +58,7 @@ class ServiceInfo < ::MCP::Tool minimum: 0, default: 0 } - }, - required: [:workspace] + } ) output_schema( @@ -117,8 +118,6 @@ class << self # @return [MCP::Tool::Response] Structured response with service information # def call(workspace: 'default', names: nil, ports: nil, host: nil, protocol: nil, only_up: false, limit: Msf::MCP::Security::InputValidator::LIMIT_DEFAULT, offset: 0, server_context:) - start_time = Time.now - # Extract dependencies from server context msf_client = server_context[:msf_client] rate_limiter = server_context[:rate_limiter] @@ -143,7 +142,9 @@ def call(workspace: 'default', names: nil, ports: nil, host: nil, protocol: nil, options[:addresses] = host if host options[:ports] = ports if ports options[:names] = names if names - raw_services = msf_client.db_services(options) + raw_services, elapsed = Rex::Stopwatch.elapsed_time do + msf_client.db_services(options) + end # Transform response transformed = Metasploit::ResponseTransformer.transform_services(raw_services) @@ -159,7 +160,7 @@ def call(workspace: 'default', names: nil, ports: nil, host: nil, protocol: nil, # Build metadata metadata = { workspace: workspace, - query_time: (Time.now - start_time).round(3), + query_time: elapsed.round(3), total_items: total_items, returned_items: paginated_data.size, limit: limit, diff --git a/lib/msf/core/mcp/tools/session_list.rb b/lib/msf/core/mcp/tools/session_list.rb new file mode 100644 index 0000000000000..36d7caa22d6ce --- /dev/null +++ b/lib/msf/core/mcp/tools/session_list.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require 'rex/stopwatch' + +module Msf::MCP + module Tools + ## + # MCP Tool: List Active Sessions + # + # Wraps the `session.list` RPC endpoint and returns a hash of active + # framework sessions keyed by session ID. + # + class SessionList < ::MCP::Tool + tool_name 'msf_session_list' + description 'List all active Metasploit sessions. '\ + 'Returns session details including type, transport endpoints, target, and platform.' + + input_schema(properties: {}) + + output_schema( + properties: { + metadata: { + properties: { + query_time: { type: 'number' }, + total_sessions: { type: 'integer' } + } + }, + data: { type: 'object' } + }, + required: [:metadata, :data] + ) + + annotations( + read_only_hint: true, + idempotent_hint: true, + destructive_hint: false + ) + + meta({ source: 'metasploit_framework' }) + + class << self + include ToolHelper + + ## + # Execute session listing + # + # @param server_context [Hash] Server context with msf_client, rate_limiter + # @return [MCP::Tool::Response] Structured response with sessions hash + # + def call(server_context:) + msf_client = server_context[:msf_client] + rate_limiter = server_context[:rate_limiter] + + rate_limiter.check_rate_limit!('session_list') + + raw_result, elapsed = Rex::Stopwatch.elapsed_time do + msf_client.session_list || {} + end + + metadata = { + query_time: elapsed.round(3), + total_sessions: raw_result.size + } + + ::MCP::Tool::Response.new( + [{ type: 'text', text: JSON.generate(metadata: metadata, data: raw_result) }], + structured_content: { metadata: metadata, data: raw_result } + ) + rescue Msf::MCP::Security::RateLimitExceededError => e + tool_error_response("Rate limit exceeded: #{e.message}") + rescue Msf::MCP::Metasploit::AuthenticationError => e + tool_error_response("Authentication failed: #{e.message}") + rescue Msf::MCP::Metasploit::APIError => e + tool_error_response("Metasploit API error: #{e.message}") + end + end + end + end +end diff --git a/lib/msf/core/mcp/tools/session_read.rb b/lib/msf/core/mcp/tools/session_read.rb new file mode 100644 index 0000000000000..5df044105930b --- /dev/null +++ b/lib/msf/core/mcp/tools/session_read.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require 'rex/stopwatch' + +module Msf::MCP + module Tools + ## + # MCP Tool: Read Output from an Interactive Session + # + # Wraps the `session.interactive_read` RPC endpoint. Works for meterpreter, + # DB, SMB, LDAP, and shell/powershell session types. + # + class SessionRead < ::MCP::Tool + tool_name 'msf_session_read' + description 'Read buffered output from an interactive Metasploit session '\ + '(meterpreter, database, SMB, LDAP, shell, powershell).' + + input_schema( + properties: { + session_id: { + type: 'integer', + description: 'Session ID to read from', + minimum: 1 + } + }, + required: [:session_id] + ) + + output_schema( + properties: { + metadata: { + properties: { + query_time: { type: 'number' } + } + }, + data: { + properties: { + data: { type: 'string' } + } + } + }, + required: [:metadata, :data] + ) + + annotations( + read_only_hint: true, + idempotent_hint: true, + destructive_hint: false + ) + + meta({ source: 'metasploit_framework' }) + + class << self + include ToolHelper + + ## + # Read from an interactive session + # + # @param session_id [Integer] Session ID + # @param server_context [Hash] Server context with msf_client, rate_limiter + # @return [MCP::Tool::Response] Structured response with session output + # + def call(session_id:, server_context:) + msf_client = server_context[:msf_client] + rate_limiter = server_context[:rate_limiter] + + rate_limiter.check_rate_limit!('session_read') + + Msf::MCP::Security::InputValidator.validate_session_id!(session_id) + + raw_result, elapsed = Rex::Stopwatch.elapsed_time do + msf_client.session_read(session_id) + end + + data = { data: raw_result['data'].to_s } + metadata = { query_time: elapsed.round(3) } + + ::MCP::Tool::Response.new( + [{ type: 'text', text: JSON.generate(metadata: metadata, data: data) }], + structured_content: { metadata: metadata, data: data } + ) + rescue Msf::MCP::Security::RateLimitExceededError => e + tool_error_response("Rate limit exceeded: #{e.message}") + rescue Msf::MCP::Metasploit::AuthenticationError => e + tool_error_response("Authentication failed: #{e.message}") + rescue Msf::MCP::Metasploit::APIError => e + tool_error_response("Metasploit API error: #{e.message}") + rescue Msf::MCP::Security::ValidationError => e + tool_error_response(e.message) + end + end + end + end +end diff --git a/lib/msf/core/mcp/tools/session_stop.rb b/lib/msf/core/mcp/tools/session_stop.rb new file mode 100644 index 0000000000000..0af442ca8cdce --- /dev/null +++ b/lib/msf/core/mcp/tools/session_stop.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require 'rex/stopwatch' + +module Msf::MCP + module Tools + ## + # MCP Tool: Stop an Active Session + # + # Wraps the `session.stop` RPC endpoint to terminate a running Metasploit + # session. + # + class SessionStop < ::MCP::Tool + tool_name 'msf_session_stop' + description 'Stop (kill) an active Metasploit session by its session ID.' + + input_schema( + properties: { + session_id: { + type: 'integer', + description: 'Session ID to stop', + minimum: 1 + } + }, + required: [:session_id] + ) + + output_schema( + properties: { + metadata: { + properties: { + query_time: { type: 'number' } + } + }, + data: { + properties: { + result: { type: 'string' } + } + } + }, + required: [:metadata, :data] + ) + + annotations( + read_only_hint: false, + idempotent_hint: false, + destructive_hint: true + ) + + meta({ source: 'metasploit_framework' }) + + class << self + include ToolHelper + + ## + # Stop a session + # + # @param session_id [Integer] Session ID + # @param server_context [Hash] Server context with msf_client, rate_limiter + # @return [MCP::Tool::Response] Structured response with stop result + # + def call(session_id:, server_context:) + dangerous_mode_required!(server_context) + + msf_client = server_context[:msf_client] + rate_limiter = server_context[:rate_limiter] + + rate_limiter.check_rate_limit!('session_stop') + + Msf::MCP::Security::InputValidator.validate_session_id!(session_id) + + raw_result, elapsed = Rex::Stopwatch.elapsed_time do + msf_client.session_stop(session_id) + end + + data = { result: raw_result['result'] } + metadata = { query_time: elapsed.round(3) } + + ::MCP::Tool::Response.new( + [{ type: 'text', text: JSON.generate(metadata: metadata, data: data) }], + structured_content: { metadata: metadata, data: data } + ) + rescue Msf::MCP::Tools::DangerousModeDisabledError => e + tool_error_response(e.message) + rescue Msf::MCP::Security::RateLimitExceededError => e + tool_error_response("Rate limit exceeded: #{e.message}") + rescue Msf::MCP::Metasploit::AuthenticationError => e + tool_error_response("Authentication failed: #{e.message}") + rescue Msf::MCP::Metasploit::APIError => e + tool_error_response("Metasploit API error: #{e.message}") + rescue Msf::MCP::Security::ValidationError => e + tool_error_response(e.message) + end + end + end + end +end diff --git a/lib/msf/core/mcp/tools/session_write.rb b/lib/msf/core/mcp/tools/session_write.rb new file mode 100644 index 0000000000000..26bd4e2a9f8c0 --- /dev/null +++ b/lib/msf/core/mcp/tools/session_write.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require 'rex/stopwatch' + +module Msf::MCP + module Tools + ## + # MCP Tool: Send Input to an Interactive Session + # + # Wraps the `session.interactive_write` RPC endpoint. The provided data is + # forwarded to the session for processing (meterpreter, database, SMB, LDAP, + # shell, powershell). + # + class SessionWrite < ::MCP::Tool + tool_name 'msf_session_write' + description 'Send input data to an interactive Metasploit session '\ + '(meterpreter, database, SMB, LDAP, shell, powershell). Use msf_session_read to retrieve output.' + + input_schema( + properties: { + session_id: { + type: 'integer', + description: 'Session ID to write to', + minimum: 1 + }, + data: { + type: 'string', + description: 'Input data to send to the session', + minLength: 1, + maxLength: 10_000 + } + }, + required: [:session_id, :data] + ) + + output_schema( + properties: { + metadata: { + properties: { + query_time: { type: 'number' } + } + }, + data: { + properties: { + result: { type: 'string' } + } + } + }, + required: [:metadata, :data] + ) + + annotations( + read_only_hint: false, + idempotent_hint: false, + destructive_hint: true + ) + + meta({ source: 'metasploit_framework' }) + + class << self + include ToolHelper + + ## + # Write to an interactive session + # + # @param session_id [Integer] Session ID + # @param data [String] Data to write to the session + # @param server_context [Hash] Server context with msf_client, rate_limiter + # @return [MCP::Tool::Response] Structured response with write result + # + def call(session_id:, data:, server_context:) + dangerous_mode_required!(server_context) + + msf_client = server_context[:msf_client] + rate_limiter = server_context[:rate_limiter] + + rate_limiter.check_rate_limit!('session_write') + + Msf::MCP::Security::InputValidator.validate_session_id!(session_id) + Msf::MCP::Security::InputValidator.validate_session_data!(data) + + raw_result, elapsed = Rex::Stopwatch.elapsed_time do + msf_client.session_write(session_id, data) + end + + response_data = { result: raw_result['result'] } + metadata = { query_time: elapsed.round(3) } + + ::MCP::Tool::Response.new( + [{ type: 'text', text: JSON.generate(metadata: metadata, data: response_data) }], + structured_content: { metadata: metadata, data: response_data } + ) + rescue Msf::MCP::Tools::DangerousModeDisabledError => e + tool_error_response(e.message) + rescue Msf::MCP::Security::RateLimitExceededError => e + tool_error_response("Rate limit exceeded: #{e.message}") + rescue Msf::MCP::Metasploit::AuthenticationError => e + tool_error_response("Authentication failed: #{e.message}") + rescue Msf::MCP::Metasploit::APIError => e + tool_error_response("Metasploit API error: #{e.message}") + rescue Msf::MCP::Security::ValidationError => e + tool_error_response(e.message) + end + end + end + end +end diff --git a/lib/msf/core/mcp/tools/tool_helper.rb b/lib/msf/core/mcp/tools/tool_helper.rb index 56cc84b788120..d3ddc43c02186 100644 --- a/lib/msf/core/mcp/tools/tool_helper.rb +++ b/lib/msf/core/mcp/tools/tool_helper.rb @@ -2,6 +2,12 @@ module Msf::MCP module Tools + ## + # Raised when a tool annotated as dangerous is invoked but the server is + # not running with dangerous actions mode enabled. + # + class DangerousModeDisabledError < ::Msf::MCP::Error; end + ## # Shared helper methods for MCP tools. # @@ -10,6 +16,10 @@ module Tools # of raising exceptions that the MCP server would wrap as internal errors. # module ToolHelper + DANGEROUS_MODE_DISABLED_MESSAGE = 'This tool requires dangerous actions mode to be enabled. ' \ + 'Enable it with: --enable-dangerous-actions flag, MSF_MCP_DANGEROUS_ACTIONS=true environment ' \ + 'variable, or mcp.dangerous_actions: true in config file.' + ## # Build a standard MCP error response. # @@ -22,6 +32,20 @@ def tool_error_response(message) error: true ) end + + ## + # Guard a dangerous tool invocation by checking the dangerous_actions + # flag in the server context. + # + # @param server_context [Hash] Server context with :dangerous_actions key + # @raise [DangerousModeDisabledError] If dangerous mode is not enabled + # @return [void] + # + def dangerous_mode_required!(server_context) + return if server_context[:dangerous_actions] == true + + raise DangerousModeDisabledError, DANGEROUS_MODE_DISABLED_MESSAGE + end end end end diff --git a/lib/msf/core/mcp/tools/vulnerability_info.rb b/lib/msf/core/mcp/tools/vulnerability_info.rb index 8a548139366fa..13f3db1d37e6b 100644 --- a/lib/msf/core/mcp/tools/vulnerability_info.rb +++ b/lib/msf/core/mcp/tools/vulnerability_info.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'rex/stopwatch' + module Msf::MCP module Tools ## @@ -54,8 +56,7 @@ class VulnerabilityInfo < ::MCP::Tool minimum: 0, default: 0 } - }, - required: [:workspace] + } ) output_schema( @@ -112,8 +113,6 @@ class << self # @return [MCP::Tool::Response] Structured response with vulnerability information # def call(workspace: 'default', host: nil, names: nil, ports: nil, protocol: nil, limit: Msf::MCP::Security::InputValidator::LIMIT_DEFAULT, offset: 0, server_context:) - start_time = Time.now - # Extract dependencies from server context msf_client = server_context[:msf_client] rate_limiter = server_context[:rate_limiter] @@ -135,7 +134,9 @@ def call(workspace: 'default', host: nil, names: nil, ports: nil, protocol: nil, options[:names] = names.join(',') if names && names.is_a?(Array) && names.any? options[:ports] = ports if ports options[:proto] = protocol if protocol - raw_vulns = msf_client.db_vulns(options) + raw_vulns, elapsed = Rex::Stopwatch.elapsed_time do + msf_client.db_vulns(options) + end # Transform response transformed = Metasploit::ResponseTransformer.transform_vulns(raw_vulns) @@ -151,7 +152,7 @@ def call(workspace: 'default', host: nil, names: nil, ports: nil, protocol: nil, # Build metadata metadata = { workspace: workspace, - query_time: (Time.now - start_time).round(3), + query_time: elapsed.round(3), total_items: total_items, returned_items: paginated_data.size, limit: limit, diff --git a/plugins/mcp.rb b/plugins/mcp.rb index 7145c98eec975..ddf1c57ecb8b6 100644 --- a/plugins/mcp.rb +++ b/plugins/mcp.rb @@ -23,7 +23,7 @@ class McpCommandDispatcher # Valid option keys accepted by `mcp start` and `mcp restart` unless defined?(VALID_OPTIONS) VALID_OPTIONS = %w[ - ServerHost ServerPort + ServerHost ServerPort DangerousActions RpcHost RpcPort RpcUser RpcPass RpcSSL RateLimit ].freeze @@ -67,14 +67,15 @@ def cmd_mcp_help print_line(' help - Show this help message') print_line print_line('Options (for start/restart):') - print_line(' ServerHost= MCP server bind address (default: localhost)') - print_line(' ServerPort= MCP server port (default: 3000)') - print_line(' RpcHost= RPC server host (default: 127.0.0.1)') - print_line(' RpcPort= RPC server port (default: 55552)') - print_line(' RpcUser= RPC username (default: msf)') - print_line(' RpcPass= RPC password') - print_line(' RpcSSL= Use SSL for RPC (default: false)') - print_line(' RateLimit= Requests per minute (default: 60)') + print_line(' ServerHost= MCP server bind address (default: localhost)') + print_line(' ServerPort= MCP server port (default: 3000)') + print_line(' DangerousActions= Enable destructive tools like module execution and session control (default: false)') + print_line(' RpcHost= RPC server host (default: 127.0.0.1)') + print_line(' RpcPort= RPC server port (default: 55552)') + print_line(' RpcUser= RPC username (default: msf)') + print_line(' RpcPass= RPC password') + print_line(' RpcSSL= Use SSL for RPC (default: false)') + print_line(' RateLimit= Requests per minute (default: 60)') print_line print_line('Examples:') print_line(' mcp start') @@ -281,7 +282,8 @@ def start_mcp_server(rpc, config) @mcp_server = Msf::MCP::Server.new( msf_client: @msf_client, - rate_limiter: @rate_limiter + rate_limiter: @rate_limiter, + dangerous_actions: mcp_config[:dangerous_actions] ) host = mcp_config[:host] @@ -303,6 +305,11 @@ def start_mcp_server(rpc, config) def print_server_status(mcp_config) print_status("MCP server started on #{Rex::Socket.to_authority(mcp_config[:host], mcp_config[:port])} (transport: http)") + if mcp_config[:dangerous_actions] + print_warning('Dangerous actions mode is ENABLED. Destructive tools (module execution, session control) are accessible.') + else + print_status('Dangerous actions mode is disabled') + end end def format_uptime @@ -386,7 +393,8 @@ def unload_auto_started_rpc def validate_options!(opts) validate_port_option!(opts, 'ServerPort') validate_port_option!(opts, 'RpcPort') - validate_rpc_ssl_option!(opts) + validate_boolean_option!(opts, 'RpcSSL') + validate_boolean_option!(opts, 'DangerousActions') validate_rate_limit_option!(opts) validate_rpc_credentials!(opts) end @@ -400,11 +408,11 @@ def validate_port_option!(opts, key) end end - def validate_rpc_ssl_option!(opts) - return unless opts['RpcSSL'] + def validate_boolean_option!(opts, key) + return unless opts[key] - unless %w[true false].include?(opts['RpcSSL']) - option_error('RpcSSL', '"true" or "false"') + unless %w[true false].include?(opts[key].to_s.downcase) + option_error(key, '"true" or "false"') end end @@ -439,7 +447,9 @@ def resolve_config(opts) mcp_config = { transport: 'http', host: opts['ServerHost'] || Msf::MCP::Config::Defaults::MCP_HOST, - port: Integer(opts['ServerPort'] || Msf::MCP::Config::Defaults::MCP_PORT) + port: Integer(opts['ServerPort'] || Msf::MCP::Config::Defaults::MCP_PORT), + dangerous_actions: parse_bool(opts['DangerousActions'], + default: Msf::MCP::Config::Defaults::DANGEROUS_ACTIONS) } rate_limit_value = Integer(opts['RateLimit'] || Msf::MCP::Config::Defaults::RATE_LIMIT_REQUESTS_PER_MINUTE) @@ -482,7 +492,7 @@ def resolve_explicit_rpc(opts) port: Integer(opts['RpcPort'] || Msf::MCP::Config::Defaults::MSGRPC_PORT), user: opts['RpcUser'] || Msf::MCP::Config::Defaults::RPC_USER, pass: opts['RpcPass'], - ssl: (opts['RpcSSL'] || 'false') == 'true' + ssl: parse_bool(opts['RpcSSL'], default: Msf::MCP::Config::Defaults::RPC_SSL) } end @@ -505,11 +515,9 @@ def introspect_msgrpc(plugin, opts) end def resolve_ssl(opts, server) - if opts['RpcSSL'] - opts['RpcSSL'] == 'true' - else - server.options[:ssl] ? true : false - end + return parse_bool(opts['RpcSSL'], default: false) if opts['RpcSSL'] + + server.options[:ssl] ? true : false end # No msgrpc loaded and no explicit creds — start one automatically @@ -518,14 +526,14 @@ def auto_start_msgrpc(opts) user = Msf::MCP::Config::Defaults::RPC_USER host = opts['RpcHost'] || Msf::MCP::Config::Defaults::RPC_HOST port = opts['RpcPort'] || Msf::MCP::Config::Defaults::MSGRPC_PORT - ssl = opts['RpcSSL'] || 'false' + ssl_bool = parse_bool(opts['RpcSSL'], default: Msf::MCP::Config::Defaults::RPC_SSL) msgrpc_opts = { 'Pass' => pass, 'User' => user, 'ServerHost' => host, 'ServerPort' => port, - 'SSL' => ssl + 'SSL' => ssl_bool.to_s } framework.plugins.load('msgrpc', msgrpc_opts) @@ -538,7 +546,7 @@ def auto_start_msgrpc(opts) port: Integer(port), user: user, pass: pass, - ssl: ssl == 'true' + ssl: ssl_bool } end @@ -547,5 +555,12 @@ def option_error(option_name, expected_format) raise Msf::MCP::Config::ValidationError, { option_name => error_detail } end + # Case-insensitive boolean-string parser. Returns +default+ when +value+ is nil. + def parse_bool(value, default:) + return default if value.nil? + + value.to_s.casecmp?('true') + end + end end diff --git a/spec/lib/msf/core/mcp/application_spec.rb b/spec/lib/msf/core/mcp/application_spec.rb index cbaeae1fe6489..432442dca0e62 100644 --- a/spec/lib/msf/core/mcp/application_spec.rb +++ b/spec/lib/msf/core/mcp/application_spec.rb @@ -118,6 +118,20 @@ expect(app.options[:no_auto_start_rpc]).to be_nil end + + it 'parses --enable-dangerous-actions argument' do + app = described_class.new(['--enable-dangerous-actions'], output: output) + app.send(:parse_arguments) + + expect(app.options[:enable_dangerous_actions_cli]).to be true + end + + it 'does not set enable_dangerous_actions_cli by default' do + app = described_class.new([], output: output) + app.send(:parse_arguments) + + expect(app.options[:enable_dangerous_actions_cli]).to be_nil + end end describe '#initialize_logger' do @@ -363,7 +377,8 @@ expect(Msf::MCP::Server).to receive(:new).with( msf_client: mock_client, - rate_limiter: mock_rate_limiter + rate_limiter: mock_rate_limiter, + dangerous_actions: false ).and_return(mock_mcp_server) app.send(:initialize_mcp_server) @@ -371,6 +386,48 @@ expect(app.mcp_server).to eq(mock_mcp_server) expect(output.string).to include('Initializing MCP server...') end + + it 'forwards dangerous_actions: true when the config enables it' do + mock_client = instance_double(Msf::MCP::Metasploit::Client) + mock_rate_limiter = instance_double(Msf::MCP::Security::RateLimiter) + mock_mcp_server = instance_double(Msf::MCP::Server) + + app = described_class.new([], output: output) + app.instance_variable_set(:@msf_client, mock_client) + app.instance_variable_set(:@rate_limiter, mock_rate_limiter) + app.instance_variable_set(:@config, { mcp: { dangerous_actions: true } }) + + expect(Msf::MCP::Server).to receive(:new).with( + msf_client: mock_client, + rate_limiter: mock_rate_limiter, + dangerous_actions: true + ).and_return(mock_mcp_server) + + app.send(:initialize_mcp_server) + + expect(output.string).to include('WARNING: dangerous actions mode is ENABLED') + end + + it 'forwards dangerous_actions: false when the config is absent' do + mock_client = instance_double(Msf::MCP::Metasploit::Client) + mock_rate_limiter = instance_double(Msf::MCP::Security::RateLimiter) + mock_mcp_server = instance_double(Msf::MCP::Server) + + app = described_class.new([], output: output) + app.instance_variable_set(:@msf_client, mock_client) + app.instance_variable_set(:@rate_limiter, mock_rate_limiter) + app.instance_variable_set(:@config, { mcp: {} }) + + expect(Msf::MCP::Server).to receive(:new).with( + msf_client: mock_client, + rate_limiter: mock_rate_limiter, + dangerous_actions: false + ).and_return(mock_mcp_server) + + app.send(:initialize_mcp_server) + + expect(output.string).not_to include('WARNING: dangerous actions mode is ENABLED') + end end describe '#start_mcp_server' do diff --git a/spec/lib/msf/core/mcp/config/loader_spec.rb b/spec/lib/msf/core/mcp/config/loader_spec.rb index 989b2c914b5f9..ea5be67e812cf 100644 --- a/spec/lib/msf/core/mcp/config/loader_spec.rb +++ b/spec/lib/msf/core/mcp/config/loader_spec.rb @@ -881,4 +881,68 @@ end end end + + describe 'dangerous_actions defaults' do + let(:config_hash) { {} } + + it 'defaults mcp.dangerous_actions to false' do + config = described_class.load_from_hash(config_hash) + expect(config[:mcp][:dangerous_actions]).to be false + end + + it 'preserves explicit true' do + config = described_class.load_from_hash(mcp: { dangerous_actions: true }) + expect(config[:mcp][:dangerous_actions]).to be true + end + + it 'preserves explicit false' do + config = described_class.load_from_hash(mcp: { dangerous_actions: false }) + expect(config[:mcp][:dangerous_actions]).to be false + end + end + + describe 'MSF_MCP_DANGEROUS_ACTIONS env override' do + around do |example| + original = ENV['MSF_MCP_DANGEROUS_ACTIONS'] + example.run + ensure + ENV['MSF_MCP_DANGEROUS_ACTIONS'] = original + end + + it 'enables dangerous_actions when env is "true"' do + ENV['MSF_MCP_DANGEROUS_ACTIONS'] = 'true' + config = described_class.load_from_hash({}) + expect(config[:mcp][:dangerous_actions]).to be true + end + + it 'enables dangerous_actions when env is "1"' do + ENV['MSF_MCP_DANGEROUS_ACTIONS'] = '1' + config = described_class.load_from_hash({}) + expect(config[:mcp][:dangerous_actions]).to be true + end + + it 'enables dangerous_actions when env is "yes"' do + ENV['MSF_MCP_DANGEROUS_ACTIONS'] = 'yes' + config = described_class.load_from_hash({}) + expect(config[:mcp][:dangerous_actions]).to be true + end + + it 'disables dangerous_actions when env is "false"' do + ENV['MSF_MCP_DANGEROUS_ACTIONS'] = 'false' + config = described_class.load_from_hash(mcp: { dangerous_actions: true }) + expect(config[:mcp][:dangerous_actions]).to be false + end + + it 'overrides an explicit hash value' do + ENV['MSF_MCP_DANGEROUS_ACTIONS'] = 'true' + config = described_class.load_from_hash(mcp: { dangerous_actions: false }) + expect(config[:mcp][:dangerous_actions]).to be true + end + + it 'ignores empty env value' do + ENV['MSF_MCP_DANGEROUS_ACTIONS'] = '' + config = described_class.load_from_hash(mcp: { dangerous_actions: true }) + expect(config[:mcp][:dangerous_actions]).to be true + end + end end diff --git a/spec/lib/msf/core/mcp/config/validator_spec.rb b/spec/lib/msf/core/mcp/config/validator_spec.rb index 73d894762f2d8..f288fde94659b 100644 --- a/spec/lib/msf/core/mcp/config/validator_spec.rb +++ b/spec/lib/msf/core/mcp/config/validator_spec.rb @@ -1016,6 +1016,61 @@ expect(described_class.validate!(config)).to be true end end + + context 'with dangerous_actions validation' do + let(:base_config) do + { + msf_api: { + type: 'messagepack', + host: 'localhost', + user: 'msf', + password: 'password' + } + } + end + + it 'accepts dangerous_actions set to true' do + config = base_config.merge(mcp: { dangerous_actions: true }) + expect(described_class.validate!(config)).to be true + end + + it 'accepts dangerous_actions set to false' do + config = base_config.merge(mcp: { dangerous_actions: false }) + expect(described_class.validate!(config)).to be true + end + + it 'does not raise when dangerous_actions is not present' do + config = base_config.merge(mcp: { transport: 'stdio' }) + expect(described_class.validate!(config)).to be true + end + + it 'rejects non-boolean dangerous_actions (string)' do + config = base_config.merge(mcp: { dangerous_actions: 'true' }) + expect { + described_class.validate!(config) + }.to raise_error(Msf::MCP::Config::ValidationError) do |error| + expect(error.errors[:'mcp.dangerous_actions']).to eq('must be boolean (true or false)') + end + end + + it 'rejects non-boolean dangerous_actions (integer)' do + config = base_config.merge(mcp: { dangerous_actions: 1 }) + expect { + described_class.validate!(config) + }.to raise_error(Msf::MCP::Config::ValidationError) do |error| + expect(error.errors[:'mcp.dangerous_actions']).to eq('must be boolean (true or false)') + end + end + + it 'rejects non-boolean dangerous_actions (nil)' do + config = base_config.merge(mcp: { dangerous_actions: nil }) + expect { + described_class.validate!(config) + }.to raise_error(Msf::MCP::Config::ValidationError) do |error| + expect(error.errors[:'mcp.dangerous_actions']).to eq('must be boolean (true or false)') + end + end + end end describe '#credentials_can_be_generated?' do @@ -1101,4 +1156,4 @@ end end end -end +end \ No newline at end of file diff --git a/spec/lib/msf/core/mcp/logging/sinks/sanitizing_spec.rb b/spec/lib/msf/core/mcp/logging/sinks/sanitizing_spec.rb index 5b560a81b5c26..65eea6d7ea242 100644 --- a/spec/lib/msf/core/mcp/logging/sinks/sanitizing_spec.rb +++ b/spec/lib/msf/core/mcp/logging/sinks/sanitizing_spec.rb @@ -193,6 +193,17 @@ def last_log_entry expect(items[1]).not_to include('secret') expect(items[2]).to eq('also safe') end + + it 'handles hashes with non-symbolizable keys (e.g. Integer keys from session.list)' do + msg = { message: 'response', context: { body: { 1 => { 'type' => 'meterpreter' }, 2 => { 'type' => 'shell' } } } } + expect { + sink.log(:info, 'mcp', 0, msg) + }.not_to raise_error + + body = last_log_entry['context']['body'] + expect(body['1']['type']).to eq('meterpreter') + expect(body['2']['type']).to eq('shell') + end end describe 'exception handling' do diff --git a/spec/lib/msf/core/mcp/metasploit/client_spec.rb b/spec/lib/msf/core/mcp/metasploit/client_spec.rb index c1291cdd4cdd7..c3e568258c4ff 100644 --- a/spec/lib/msf/core/mcp/metasploit/client_spec.rb +++ b/spec/lib/msf/core/mcp/metasploit/client_spec.rb @@ -170,6 +170,46 @@ expect(client.db_notes({ workspace: 'default' })).to eq({ 'notes' => [] }) end + it 'delegates module_execute to underlying client' do + allow(jsonrpc_client).to receive(:module_execute).with('exploit', 'multi/handler', {}).and_return({ 'job_id' => 1, 'uuid' => 'abc' }) + expect(client.module_execute('exploit', 'multi/handler', {})).to eq({ 'job_id' => 1, 'uuid' => 'abc' }) + end + + it 'delegates module_check to underlying client' do + allow(jsonrpc_client).to receive(:module_check).with('exploit', 'multi/handler', {}).and_return({ 'job_id' => 2, 'uuid' => 'def' }) + expect(client.module_check('exploit', 'multi/handler', {})).to eq({ 'job_id' => 2, 'uuid' => 'def' }) + end + + it 'delegates module_results to underlying client' do + allow(jsonrpc_client).to receive(:module_results).with('abc').and_return({ 'status' => 'completed' }) + expect(client.module_results('abc')).to eq({ 'status' => 'completed' }) + end + + it 'delegates running_stats to underlying client' do + allow(jsonrpc_client).to receive(:running_stats).and_return({ 'waiting' => [], 'running' => [], 'results' => [] }) + expect(client.running_stats).to eq({ 'waiting' => [], 'running' => [], 'results' => [] }) + end + + it 'delegates session_list to underlying client' do + allow(jsonrpc_client).to receive(:session_list).and_return({}) + expect(client.session_list).to eq({}) + end + + it 'delegates session_stop to underlying client' do + allow(jsonrpc_client).to receive(:session_stop).with(3).and_return({ 'result' => 'success' }) + expect(client.session_stop(3)).to eq({ 'result' => 'success' }) + end + + it 'delegates session_read to underlying client' do + allow(jsonrpc_client).to receive(:session_read).with(3).and_return({ 'data' => 'output' }) + expect(client.session_read(3)).to eq({ 'data' => 'output' }) + end + + it 'delegates session_write to underlying client' do + allow(jsonrpc_client).to receive(:session_write).with(3, 'cmd').and_return({ 'result' => 'success' }) + expect(client.session_write(3, 'cmd')).to eq({ 'result' => 'success' }) + end + it 'delegates shutdown to underlying client' do allow(jsonrpc_client).to receive(:shutdown) client.shutdown diff --git a/spec/lib/msf/core/mcp/metasploit/messagepack_client_spec.rb b/spec/lib/msf/core/mcp/metasploit/messagepack_client_spec.rb index 7f5fec20730db..46c547ea4a569 100644 --- a/spec/lib/msf/core/mcp/metasploit/messagepack_client_spec.rb +++ b/spec/lib/msf/core/mcp/metasploit/messagepack_client_spec.rb @@ -403,4 +403,82 @@ expect(call_sequence).to eq([:search_call, :reauth, :search_retry, :hosts_call, :reauth, :hosts_retry]) end end + + describe 'module and session methods' do + before do + client.instance_variable_set(:@token, 'tok') + end + + describe '#module_execute' do + it 'calls module.execute with type, name, and options' do + expect(client).to receive(:send_request) + .with(['module.execute', 'tok', 'exploit', 'multi/handler', { 'RHOSTS' => '192.0.2.10' }]) + .and_return({ 'job_id' => 1, 'uuid' => 'abc' }) + client.module_execute('exploit', 'multi/handler', { 'RHOSTS' => '192.0.2.10' }) + end + end + + describe '#module_check' do + it 'calls module.check with type, name, and options' do + expect(client).to receive(:send_request) + .with(['module.check', 'tok', 'exploit', 'multi/handler', {}]) + .and_return({ 'job_id' => 2, 'uuid' => 'def' }) + client.module_check('exploit', 'multi/handler', {}) + end + end + + describe '#module_results' do + it 'calls module.results with the UUID' do + expect(client).to receive(:send_request) + .with(['module.results', 'tok', 'abc123']) + .and_return({ 'status' => 'completed' }) + client.module_results('abc123') + end + end + + describe '#running_stats' do + it 'calls module.running_stats with no arguments' do + expect(client).to receive(:send_request) + .with(['module.running_stats', 'tok']) + .and_return({ 'waiting' => [], 'running' => [], 'results' => [] }) + client.running_stats + end + end + + describe '#session_list' do + it 'calls session.list with no arguments' do + expect(client).to receive(:send_request) + .with(['session.list', 'tok']) + .and_return({}) + client.session_list + end + end + + describe '#session_stop' do + it 'calls session.stop with the session id' do + expect(client).to receive(:send_request) + .with(['session.stop', 'tok', 3]) + .and_return({ 'result' => 'success' }) + client.session_stop(3) + end + end + + describe '#session_read' do + it 'calls session.interactive_read with the session id' do + expect(client).to receive(:send_request) + .with(['session.interactive_read', 'tok', 5]) + .and_return({ 'data' => 'hello' }) + client.session_read(5) + end + end + + describe '#session_write' do + it 'calls session.interactive_write with the session id and data' do + expect(client).to receive(:send_request) + .with(['session.interactive_write', 'tok', 5, 'sysinfo']) + .and_return({ 'result' => 'success' }) + client.session_write(5, 'sysinfo') + end + end + end end diff --git a/spec/lib/msf/core/mcp/security/input_validator_spec.rb b/spec/lib/msf/core/mcp/security/input_validator_spec.rb index b75ed1ba2707b..b06d83d35e894 100644 --- a/spec/lib/msf/core/mcp/security/input_validator_spec.rb +++ b/spec/lib/msf/core/mcp/security/input_validator_spec.rb @@ -660,6 +660,200 @@ end end + describe '.validate_module_options!' do + it 'accepts an empty hash' do + expect(described_class.validate_module_options!({})).to be true + end + + it 'accepts a hash of valid scalar values' do + options = { 'RHOSTS' => '192.0.2.10', 'RPORT' => 445, 'VERBOSE' => true, 'RATIO' => 1.5, 'NULL_KEY' => nil } + expect(described_class.validate_module_options!(options)).to be true + end + + it 'accepts Symbol keys (MCP transport deep-symbolizes JSON input)' do + options = { RHOSTS: '192.0.2.10', RPORT: 445, VERBOSE: true } + expect(described_class.validate_module_options!(options)).to be true + end + + it 'rejects non-Hash input' do + expect { + described_class.validate_module_options!('not a hash') + }.to raise_error(Msf::MCP::Security::ValidationError, /Module options must be a Hash/) + end + + it 'rejects hashes with too many keys' do + options = (1..(described_class::MODULE_OPTIONS_MAX_KEYS + 1)).map { |i| ["KEY_#{i}", i] }.to_h + expect { + described_class.validate_module_options!(options) + }.to raise_error(Msf::MCP::Security::ValidationError, /too many keys/) + end + + it 'rejects keys that do not match the datastore convention' do + expect { + described_class.validate_module_options!('1BAD' => 'x') + }.to raise_error(Msf::MCP::Security::ValidationError, /Invalid module option key/) + end + + it 'accepts namespaced mixin option keys with the `::` separator (HTTP::compression, CMDSTAGER::FLAVOR)' do + options = { + 'HTTP::compression' => 'gzip', + 'CMDSTAGER::FLAVOR' => 'wget', + 'EXE::Custom' => '/tmp/x.exe', + 'SMB::ChunkSize' => 500, + 'DCERPC::ReadTimeout' => 10, + 'HTML::javascript::escape' => true + } + expect(described_class.validate_module_options!(options)).to be true + end + + it 'accepts hyphenated header-style option keys (BEARER-TOKEN)' do + expect(described_class.validate_module_options!('BEARER-TOKEN' => 'abc')).to be true + end + + it 'rejects keys containing whitespace' do + expect { + described_class.validate_module_options!('Domain SID' => 'x') + }.to raise_error(Msf::MCP::Security::ValidationError, /Invalid module option key/) + end + + it 'rejects keys with leading separators' do + expect { + described_class.validate_module_options!('::HTTP' => 'x') + }.to raise_error(Msf::MCP::Security::ValidationError, /Invalid module option key/) + end + + it 'rejects keys with trailing separators' do + expect { + described_class.validate_module_options!('HTTP::' => 'x') + }.to raise_error(Msf::MCP::Security::ValidationError, /Invalid module option key/) + end + + it 'rejects keys exceeding the length cap' do + oversized_key = 'A' * (described_class::MODULE_OPTIONS_KEY_MAX_LENGTH + 1) + expect { + described_class.validate_module_options!(oversized_key => 'x') + }.to raise_error(Msf::MCP::Security::ValidationError, /exceeds.*chars/) + end + + it 'rejects non-scalar values (Array)' do + expect { + described_class.validate_module_options!('RHOSTS' => ['192.0.2.10']) + }.to raise_error(Msf::MCP::Security::ValidationError, /must be a scalar/) + end + + it 'rejects non-scalar values (Hash)' do + expect { + described_class.validate_module_options!('OPTS' => { nested: 1 }) + }.to raise_error(Msf::MCP::Security::ValidationError, /must be a scalar/) + end + + it 'rejects string values exceeding the per-value cap' do + oversized = 'A' * (described_class::MODULE_OPTIONS_VALUE_MAX_BYTES + 1) + expect { + described_class.validate_module_options!('RHOSTS' => oversized) + }.to raise_error(Msf::MCP::Security::ValidationError, /exceeds.*bytes/) + end + + it 'rejects payloads exceeding the total byte cap' do + max_value = 'A' * (described_class::MODULE_OPTIONS_VALUE_MAX_BYTES) + options = {} + ((described_class::MODULE_OPTIONS_TOTAL_MAX_BYTES / described_class::MODULE_OPTIONS_VALUE_MAX_BYTES) + 1).times do |i| + options["KEY_#{i}"] = max_value + end + expect { + described_class.validate_module_options!(options) + }.to raise_error(Msf::MCP::Security::ValidationError, /total payload exceeds/) + end + end + + describe '.validate_uuid!' do + it 'accepts a 24-char mixed-case alphanumeric UUID (matches Rex::Text.rand_text_alphanumeric(24))' do + expect(described_class.validate_uuid!('Q72h3dKD5nALt39rT0AXpDCR')).to be true + end + + it 'accepts an all-lowercase 24-char alphanumeric UUID' do + expect(described_class.validate_uuid!('abcdef0123456789abcdef01')).to be true + end + + it 'accepts an all-uppercase 24-char alphanumeric UUID' do + expect(described_class.validate_uuid!('ABCDEF0123456789ABCDEF01')).to be true + end + + it 'rejects UUIDs with special characters' do + expect { + described_class.validate_uuid!('abc-1234abcdef0123456789') + }.to raise_error(Msf::MCP::Security::ValidationError, /UUID/) + end + + it 'rejects UUIDs shorter than 24 characters' do + expect { + described_class.validate_uuid!('a1b2c3d4') + }.to raise_error(Msf::MCP::Security::ValidationError, /UUID/) + end + + it 'rejects UUIDs longer than 24 characters' do + expect { + described_class.validate_uuid!('a' * 25) + }.to raise_error(Msf::MCP::Security::ValidationError, /UUID/) + end + + it 'rejects non-String input' do + expect { + described_class.validate_uuid!(12345) + }.to raise_error(Msf::MCP::Security::ValidationError, /UUID must be a String/) + end + end + + describe '.validate_session_id!' do + it 'accepts a valid integer session id' do + expect(described_class.validate_session_id!(1)).to be true + expect(described_class.validate_session_id!(65535)).to be true + end + + it 'rejects 0 and negative ids' do + expect { + described_class.validate_session_id!(0) + }.to raise_error(Msf::MCP::Security::ValidationError, /Session ID/) + end + + it 'rejects ids above the range' do + expect { + described_class.validate_session_id!(65536) + }.to raise_error(Msf::MCP::Security::ValidationError, /Session ID/) + end + + it 'rejects non-Integer input' do + expect { + described_class.validate_session_id!('1') + }.to raise_error(Msf::MCP::Security::ValidationError, /Session ID must be an Integer/) + end + end + + describe '.validate_session_data!' do + it 'accepts non-empty strings' do + expect(described_class.validate_session_data!('sysinfo')).to be true + end + + it 'rejects empty strings' do + expect { + described_class.validate_session_data!('') + }.to raise_error(Msf::MCP::Security::ValidationError, /Session data cannot be empty/) + end + + it 'rejects non-String input' do + expect { + described_class.validate_session_data!(12345) + }.to raise_error(Msf::MCP::Security::ValidationError, /Session data must be a String/) + end + + it 'rejects data exceeding the size cap' do + oversized = 'A' * (described_class::SESSION_DATA_MAX_BYTES + 1) + expect { + described_class.validate_session_data!(oversized) + }.to raise_error(Msf::MCP::Security::ValidationError, /Session data exceeds/) + end + end + describe 'fuzzing tests' do it 'handles random IP-like strings' do 1000.times do diff --git a/spec/lib/msf/core/mcp/server_spec.rb b/spec/lib/msf/core/mcp/server_spec.rb index aa9036f5b0c55..c2d29a4cda55a 100644 --- a/spec/lib/msf/core/mcp/server_spec.rb +++ b/spec/lib/msf/core/mcp/server_spec.rb @@ -84,12 +84,20 @@ tools: array_including( Msf::MCP::Tools::SearchModules, Msf::MCP::Tools::ModuleInfo, + Msf::MCP::Tools::ModuleExecute, + Msf::MCP::Tools::ModuleCheck, + Msf::MCP::Tools::ModuleResults, + Msf::MCP::Tools::RunningStats, Msf::MCP::Tools::HostInfo, Msf::MCP::Tools::ServiceInfo, Msf::MCP::Tools::VulnerabilityInfo, Msf::MCP::Tools::NoteInfo, Msf::MCP::Tools::CredentialInfo, - Msf::MCP::Tools::LootInfo + Msf::MCP::Tools::LootInfo, + Msf::MCP::Tools::SessionList, + Msf::MCP::Tools::SessionStop, + Msf::MCP::Tools::SessionRead, + Msf::MCP::Tools::SessionWrite ) ) ).and_return(mock_mcp_server) @@ -129,6 +137,47 @@ ) end + it 'defaults dangerous_actions to false in server_context' do + expect(::MCP::Server).to receive(:new).with( + hash_including( + server_context: hash_including(dangerous_actions: false) + ) + ).and_return(mock_mcp_server) + + described_class.new( + msf_client: mock_msf_client, + rate_limiter: rate_limiter + ) + end + + it 'propagates dangerous_actions: true into server_context' do + expect(::MCP::Server).to receive(:new).with( + hash_including( + server_context: hash_including(dangerous_actions: true) + ) + ).and_return(mock_mcp_server) + + described_class.new( + msf_client: mock_msf_client, + rate_limiter: rate_limiter, + dangerous_actions: true + ) + end + + it 'coerces non-true dangerous_actions values to false' do + expect(::MCP::Server).to receive(:new).with( + hash_including( + server_context: hash_including(dangerous_actions: false) + ) + ).and_return(mock_mcp_server) + + described_class.new( + msf_client: mock_msf_client, + rate_limiter: rate_limiter, + dangerous_actions: 'yes' + ) + end + it 'passes a configuration object with around_request and exception_reporter' do expect(::MCP::Server).to receive(:new) do |args| config = args[:configuration] diff --git a/spec/lib/msf/core/mcp/tools/credential_info_spec.rb b/spec/lib/msf/core/mcp/tools/credential_info_spec.rb index e4a229033f95a..02a4d8378392a 100644 --- a/spec/lib/msf/core/mcp/tools/credential_info_spec.rb +++ b/spec/lib/msf/core/mcp/tools/credential_info_spec.rb @@ -52,9 +52,9 @@ end describe 'Input Schema Validation' do - it 'defines workspace as required parameter' do + it 'does not require workspace (defaults to "default")' do input_schema = described_class.input_schema - expect(input_schema.schema[:required]).to include('workspace') + expect(Array(input_schema.schema[:required])).not_to include('workspace') end it 'supports pagination parameters' do diff --git a/spec/lib/msf/core/mcp/tools/loot_info_spec.rb b/spec/lib/msf/core/mcp/tools/loot_info_spec.rb index 73032ec523e7b..ed23c0e21712d 100644 --- a/spec/lib/msf/core/mcp/tools/loot_info_spec.rb +++ b/spec/lib/msf/core/mcp/tools/loot_info_spec.rb @@ -58,9 +58,9 @@ end describe 'Input Schema Validation' do - it 'defines workspace as required parameter' do + it 'does not require workspace (defaults to "default")' do input_schema = described_class.input_schema - expect(input_schema.schema[:required]).to include('workspace') + expect(Array(input_schema.schema[:required])).not_to include('workspace') end it 'supports pagination parameters' do diff --git a/spec/lib/msf/core/mcp/tools/module_check_spec.rb b/spec/lib/msf/core/mcp/tools/module_check_spec.rb new file mode 100644 index 0000000000000..f7dbf637d8e80 --- /dev/null +++ b/spec/lib/msf/core/mcp/tools/module_check_spec.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' + +RSpec.describe Msf::MCP::Tools::ModuleCheck do + let(:msf_client) { double('Msf::MCP::Metasploit::Client') } + let(:rate_limiter) { double('Msf::MCP::Security::RateLimiter') } + let(:server_context) do + { + msf_client: msf_client, + rate_limiter: rate_limiter, + config: {}, + dangerous_actions: true + } + end + + let(:msf_response) { { 'job_id' => 4, 'uuid' => 'aaaa1111bbbb2222cccc3333' } } + + before do + allow(rate_limiter).to receive(:check_rate_limit!) + allow(msf_client).to receive(:module_check).and_return(msf_response) + end + + describe 'Tool Name' do + it 'has the correct tool name' do + expect(described_class.tool_name).to eq('msf_module_check') + end + end + + describe 'Input Schema' do + it 'requires type, name, and options' do + expect(described_class.input_schema.schema[:required]).to match_array(%w[type name options]) + end + + it 'restricts type to exploit and auxiliary' do + expect(described_class.input_schema.schema[:properties][:type][:enum]).to match_array(%w[exploit auxiliary]) + end + end + + describe 'Annotations' do + it 'is marked as destructive' do + annotations = described_class.annotations_value + expect(annotations.destructive_hint).to eq(true) + expect(annotations.read_only_hint).to eq(false) + expect(annotations.idempotent_hint).to eq(false) + end + end + + describe '.call' do + it 'checks rate limit' do + described_class.call(type: 'exploit', name: 'multi/handler', options: {}, server_context: server_context) + expect(rate_limiter).to have_received(:check_rate_limit!).with('module_check') + end + + it 'forwards type, name, options to msf_client.module_check' do + options = { 'RHOSTS' => '192.0.2.10' } + described_class.call(type: 'exploit', name: 'multi/handler', options: options, server_context: server_context) + expect(msf_client).to have_received(:module_check).with('exploit', 'multi/handler', options) + end + + it 'stringifies Symbol option keys before forwarding (MCP deep-symbolizes JSON input)' do + described_class.call( + type: 'exploit', + name: 'multi/handler', + options: { RHOSTS: '192.0.2.10', RPORT: 445 }, + server_context: server_context + ) + expect(msf_client).to have_received(:module_check).with( + 'exploit', + 'multi/handler', + { 'RHOSTS' => '192.0.2.10', 'RPORT' => 445 } + ) + end + + it 'returns a structured response with job_id and uuid' do + result = described_class.call(type: 'exploit', name: 'multi/handler', options: {}, server_context: server_context) + expect(result.structured_content[:data][:job_id]).to eq(4) + expect(result.structured_content[:data][:uuid]).to eq('aaaa1111bbbb2222cccc3333') + end + + it 'rejects non-exploit/auxiliary types' do + result = described_class.call(type: 'post', name: 'foo/bar', options: {}, server_context: server_context) + expect(result.error?).to be true + end + + it 'returns an unsupported structured response when the framework reports no check method' do + allow(msf_client).to receive(:module_check).and_raise( + Msf::MCP::Metasploit::APIError.new(Msf::Exploit::CheckCode::Unsupported.message) + ) + + result = described_class.call(type: 'exploit', name: 'multi/handler', options: {}, server_context: server_context) + expect(result.error?).to be false + expect(result.structured_content[:data][:status]).to eq('unsupported') + expect(result.structured_content[:data][:message]).to match(/does not implement a check method/i) + end + + it 'returns a normal API error response when the framework reports a different failure' do + allow(msf_client).to receive(:module_check).and_raise(Msf::MCP::Metasploit::APIError.new('Module not found')) + result = described_class.call(type: 'exploit', name: 'multi/handler', options: {}, server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Module not found/) + end + end + + describe '.call with dangerous mode disabled' do + let(:disabled_context) do + { + msf_client: msf_client, + rate_limiter: rate_limiter, + config: {}, + dangerous_actions: false + } + end + + it 'returns an error response when dangerous_actions is false' do + result = described_class.call(type: 'exploit', name: 'multi/handler', options: {}, server_context: disabled_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/dangerous actions mode/i) + expect(result.content.first[:text]).to include('--enable-dangerous-actions') + end + + it 'does not call msf_client.module_check when blocked' do + described_class.call(type: 'exploit', name: 'multi/handler', options: {}, server_context: disabled_context) + expect(msf_client).not_to have_received(:module_check) + end + + it 'does not consume rate limit when blocked' do + described_class.call(type: 'exploit', name: 'multi/handler', options: {}, server_context: disabled_context) + expect(rate_limiter).not_to have_received(:check_rate_limit!) + end + end +end diff --git a/spec/lib/msf/core/mcp/tools/module_execute_spec.rb b/spec/lib/msf/core/mcp/tools/module_execute_spec.rb new file mode 100644 index 0000000000000..cad34bc49a046 --- /dev/null +++ b/spec/lib/msf/core/mcp/tools/module_execute_spec.rb @@ -0,0 +1,175 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' + +RSpec.describe Msf::MCP::Tools::ModuleExecute do + let(:msf_client) { double('Msf::MCP::Metasploit::Client') } + let(:rate_limiter) { double('Msf::MCP::Security::RateLimiter') } + let(:server_context) do + { + msf_client: msf_client, + rate_limiter: rate_limiter, + config: {}, + dangerous_actions: true + } + end + + let(:msf_response) { { 'job_id' => 7, 'uuid' => 'abc123def456ghi789jkl012' } } + + before do + allow(rate_limiter).to receive(:check_rate_limit!) + allow(msf_client).to receive(:module_execute).and_return(msf_response) + end + + describe 'Tool Name' do + it 'has the correct tool name' do + expect(described_class.tool_name).to eq('msf_module_execute') + end + end + + describe 'Input Schema' do + it 'requires type, name and options' do + expect(described_class.input_schema.schema[:required]).to match_array(%w[type name options]) + end + + it 'restricts type to the supported enum' do + expect(described_class.input_schema.schema[:properties][:type][:enum]) + .to match_array(%w[exploit auxiliary post payload evasion]) + end + end + + describe 'Output Schema' do + it 'declares job_id as nullable integer' do + job_id_schema = described_class.output_schema.schema[:properties][:data][:properties][:job_id] + expect(job_id_schema[:type]).to match_array(%w[integer null]) + end + + it 'declares uuid as nullable string' do + uuid_schema = described_class.output_schema.schema[:properties][:data][:properties][:uuid] + expect(uuid_schema[:type]).to match_array(%w[string null]) + end + end + + describe 'Annotations' do + it 'is marked as a destructive, non-idempotent tool' do + annotations = described_class.annotations_value + expect(annotations.destructive_hint).to eq(true) + expect(annotations.read_only_hint).to eq(false) + expect(annotations.idempotent_hint).to eq(false) + end + end + + describe '.call' do + it 'checks rate limit' do + described_class.call(type: 'exploit', name: 'multi/handler', options: {}, server_context: server_context) + expect(rate_limiter).to have_received(:check_rate_limit!).with('module_execute') + end + + it 'forwards type, name, and options unchanged to msf_client.module_execute (does not bypass AutoCheck)' do + options = { 'RHOSTS' => '192.0.2.10', 'PAYLOAD' => 'windows/meterpreter/reverse_tcp' } + described_class.call(type: 'exploit', name: 'multi/handler', options: options, server_context: server_context) + expect(msf_client).to have_received(:module_execute).with('exploit', 'multi/handler', options) + end + + it 'stringifies Symbol option keys before forwarding (MCP deep-symbolizes JSON input)' do + described_class.call( + type: 'exploit', + name: 'multi/handler', + options: { RHOSTS: '192.0.2.10', PAYLOAD: 'windows/meterpreter/reverse_tcp' }, + server_context: server_context + ) + expect(msf_client).to have_received(:module_execute).with( + 'exploit', + 'multi/handler', + { 'RHOSTS' => '192.0.2.10', 'PAYLOAD' => 'windows/meterpreter/reverse_tcp' } + ) + end + + it 'returns a structured response with job_id and uuid' do + result = described_class.call(type: 'auxiliary', name: 'scanner/smb/smb_version', options: {}, server_context: server_context) + + expect(result).to be_a(MCP::Tool::Response) + expect(result.structured_content[:data][:job_id]).to eq(7) + expect(result.structured_content[:data][:uuid]).to eq('abc123def456ghi789jkl012') + expect(result.structured_content[:metadata][:query_time]).to be_a(Float) + end + + it 'propagates null job_id and uuid when the RPC swallowed an early driver failure' do + allow(msf_client).to receive(:module_execute).and_return({ 'job_id' => nil, 'uuid' => nil }) + + result = described_class.call(type: 'exploit', name: 'multi/handler', options: {}, server_context: server_context) + + expect(result.error?).to be false + expect(result.structured_content[:data][:job_id]).to be_nil + expect(result.structured_content[:data][:uuid]).to be_nil + end + + it 'rejects invalid module types' do + result = described_class.call(type: 'invalid_type', name: 'foo', options: {}, server_context: server_context) + expect(result.error?).to be true + end + + it 'rejects invalid module names' do + result = described_class.call(type: 'exploit', name: 'invalid name!', options: {}, server_context: server_context) + expect(result.error?).to be true + end + + it 'rejects non-Hash options' do + result = described_class.call(type: 'exploit', name: 'multi/handler', options: 'string', server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Module options/) + end + + it 'returns API error response' do + allow(msf_client).to receive(:module_execute).and_raise(Msf::MCP::Metasploit::APIError.new('Module not found')) + result = described_class.call(type: 'exploit', name: 'multi/handler', options: {}, server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Metasploit API error/) + end + + it 'returns rate limit error response' do + allow(rate_limiter).to receive(:check_rate_limit!) + .and_raise(Msf::MCP::Security::RateLimitExceededError.new(60)) + result = described_class.call(type: 'exploit', name: 'multi/handler', options: {}, server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Rate limit exceeded/) + end + end + + describe '.call with dangerous mode disabled' do + let(:disabled_context) do + { + msf_client: msf_client, + rate_limiter: rate_limiter, + config: {}, + dangerous_actions: false + } + end + + it 'returns an error response when dangerous_actions is false' do + result = described_class.call(type: 'exploit', name: 'multi/handler', options: {}, server_context: disabled_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/dangerous actions mode/i) + expect(result.content.first[:text]).to include('--enable-dangerous-actions') + end + + it 'returns an error response when dangerous_actions key is missing' do + result = described_class.call( + type: 'exploit', name: 'multi/handler', options: {}, + server_context: { msf_client: msf_client, rate_limiter: rate_limiter, config: {} } + ) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/dangerous actions mode/i) + end + + it 'does not call msf_client.module_execute when blocked' do + described_class.call(type: 'exploit', name: 'multi/handler', options: {}, server_context: disabled_context) + expect(msf_client).not_to have_received(:module_execute) + end + + it 'does not consume rate limit when blocked' do + described_class.call(type: 'exploit', name: 'multi/handler', options: {}, server_context: disabled_context) + expect(rate_limiter).not_to have_received(:check_rate_limit!) + end + end +end diff --git a/spec/lib/msf/core/mcp/tools/module_results_spec.rb b/spec/lib/msf/core/mcp/tools/module_results_spec.rb new file mode 100644 index 0000000000000..20f10b1be1a52 --- /dev/null +++ b/spec/lib/msf/core/mcp/tools/module_results_spec.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' + +RSpec.describe Msf::MCP::Tools::ModuleResults do + let(:msf_client) { double('Msf::MCP::Metasploit::Client') } + let(:rate_limiter) { double('Msf::MCP::Security::RateLimiter') } + let(:server_context) do + { + msf_client: msf_client, + rate_limiter: rate_limiter, + config: {} + } + end + + let(:valid_uuid) { 'abc12345def67890ghi12345' } + + before do + allow(rate_limiter).to receive(:check_rate_limit!) + allow(msf_client).to receive(:module_results).and_return({ 'status' => 'completed', 'result' => 'ok' }) + end + + describe 'Tool Name' do + it 'has the correct tool name' do + expect(described_class.tool_name).to eq('msf_module_results') + end + end + + describe 'Annotations' do + it 'is marked as read-only and idempotent' do + annotations = described_class.annotations_value + expect(annotations.read_only_hint).to eq(true) + expect(annotations.idempotent_hint).to eq(true) + expect(annotations.destructive_hint).to eq(false) + end + end + + describe '.call' do + it 'checks rate limit' do + described_class.call(uuid: valid_uuid, server_context: server_context) + expect(rate_limiter).to have_received(:check_rate_limit!).with('module_results') + end + + it 'forwards UUID to msf_client.module_results' do + described_class.call(uuid: valid_uuid, server_context: server_context) + expect(msf_client).to have_received(:module_results).with(valid_uuid) + end + + it 'returns the status and result as structured content' do + result = described_class.call(uuid: valid_uuid, server_context: server_context) + expect(result.structured_content[:data][:status]).to eq('completed') + expect(result.structured_content[:data][:result]).to eq('ok') + end + + it 'rejects invalid UUID format' do + result = described_class.call(uuid: 'BAD-UUID!', server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/UUID/) + end + + it 'rejects non-string UUID' do + result = described_class.call(uuid: 123, server_context: server_context) + expect(result.error?).to be true + end + + it 'returns an error response for API errors' do + allow(msf_client).to receive(:module_results).and_raise(Msf::MCP::Metasploit::APIError.new('Not found')) + result = described_class.call(uuid: valid_uuid, server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Metasploit API error/) + end + + context 'dangerous_actions gate' do + it 'returns a successful response even when dangerous_actions is missing or false' do + contexts = [ + { msf_client: msf_client, rate_limiter: rate_limiter, config: {} }, + { msf_client: msf_client, rate_limiter: rate_limiter, config: {}, dangerous_actions: false } + ] + contexts.each do |ctx| + result = described_class.call(uuid: valid_uuid, server_context: ctx) + expect(result.error?).to be false + expect(result.structured_content[:data][:status]).to eq('completed') + end + end + end + end +end diff --git a/spec/lib/msf/core/mcp/tools/note_info_spec.rb b/spec/lib/msf/core/mcp/tools/note_info_spec.rb index 0f1a4f576472b..c93e4c6d6a017 100644 --- a/spec/lib/msf/core/mcp/tools/note_info_spec.rb +++ b/spec/lib/msf/core/mcp/tools/note_info_spec.rb @@ -64,9 +64,9 @@ end describe 'Input Schema Validation' do - it 'defines workspace as required parameter' do + it 'does not require workspace (defaults to "default")' do input_schema = described_class.input_schema - expect(input_schema.schema[:required]).to include('workspace') + expect(Array(input_schema.schema[:required])).not_to include('workspace') end end diff --git a/spec/lib/msf/core/mcp/tools/running_stats_spec.rb b/spec/lib/msf/core/mcp/tools/running_stats_spec.rb new file mode 100644 index 0000000000000..145ffab36bfb6 --- /dev/null +++ b/spec/lib/msf/core/mcp/tools/running_stats_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' + +RSpec.describe Msf::MCP::Tools::RunningStats do + let(:msf_client) { double('Msf::MCP::Metasploit::Client') } + let(:rate_limiter) { double('Msf::MCP::Security::RateLimiter') } + let(:server_context) do + { + msf_client: msf_client, + rate_limiter: rate_limiter, + config: {} + } + end + + let(:msf_response) do + { + 'waiting' => ['uuid_w_1'], + 'running' => ['uuid_r_1', 'uuid_r_2'], + 'results' => ['uuid_d_1'] + } + end + + before do + allow(rate_limiter).to receive(:check_rate_limit!) + allow(msf_client).to receive(:running_stats).and_return(msf_response) + end + + describe 'Tool Name' do + it 'has the correct tool name' do + expect(described_class.tool_name).to eq('msf_running_stats') + end + end + + describe 'Annotations' do + it 'is marked as read-only and idempotent' do + annotations = described_class.annotations_value + expect(annotations.read_only_hint).to eq(true) + expect(annotations.idempotent_hint).to eq(true) + expect(annotations.destructive_hint).to eq(false) + end + end + + describe '.call' do + it 'checks rate limit' do + described_class.call(server_context: server_context) + expect(rate_limiter).to have_received(:check_rate_limit!).with('running_stats') + end + + it 'returns the running stats arrays as structured content' do + result = described_class.call(server_context: server_context) + expect(result.structured_content[:data][:waiting]).to eq(['uuid_w_1']) + expect(result.structured_content[:data][:running]).to eq(['uuid_r_1', 'uuid_r_2']) + expect(result.structured_content[:data][:results]).to eq(['uuid_d_1']) + end + + it 'tolerates missing keys in the upstream response' do + allow(msf_client).to receive(:running_stats).and_return({}) + result = described_class.call(server_context: server_context) + expect(result.structured_content[:data][:waiting]).to eq([]) + expect(result.structured_content[:data][:running]).to eq([]) + expect(result.structured_content[:data][:results]).to eq([]) + end + + it 'returns an error response for API errors' do + allow(msf_client).to receive(:running_stats).and_raise(Msf::MCP::Metasploit::APIError.new('boom')) + result = described_class.call(server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Metasploit API error/) + end + + context 'dangerous_actions gate' do + it 'returns a successful response even when dangerous_actions is false' do + ctx = { msf_client: msf_client, rate_limiter: rate_limiter, config: {}, dangerous_actions: false } + result = described_class.call(server_context: ctx) + expect(result.error?).to be false + expect(result.structured_content[:data][:waiting]).to eq(['uuid_w_1']) + end + end + end +end diff --git a/spec/lib/msf/core/mcp/tools/service_info_spec.rb b/spec/lib/msf/core/mcp/tools/service_info_spec.rb index 530a83c09348a..88bcc2c1fb5fc 100644 --- a/spec/lib/msf/core/mcp/tools/service_info_spec.rb +++ b/spec/lib/msf/core/mcp/tools/service_info_spec.rb @@ -52,9 +52,9 @@ end describe 'Input Schema Validation' do - it 'defines workspace as required parameter' do + it 'does not require workspace (defaults to "default")' do input_schema = described_class.input_schema - expect(input_schema.schema[:required]).to include('workspace') + expect(Array(input_schema.schema[:required])).not_to include('workspace') end it 'supports multiple filter parameters' do diff --git a/spec/lib/msf/core/mcp/tools/session_list_spec.rb b/spec/lib/msf/core/mcp/tools/session_list_spec.rb new file mode 100644 index 0000000000000..a81337b28a15f --- /dev/null +++ b/spec/lib/msf/core/mcp/tools/session_list_spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' + +RSpec.describe Msf::MCP::Tools::SessionList do + let(:msf_client) { double('Msf::MCP::Metasploit::Client') } + let(:rate_limiter) { double('Msf::MCP::Security::RateLimiter') } + let(:server_context) do + { + msf_client: msf_client, + rate_limiter: rate_limiter, + config: {} + } + end + + let(:msf_response) do + { + 1 => { + 'type' => 'meterpreter', + 'tunnel_local' => '192.0.2.10:4444', + 'tunnel_peer' => '192.0.2.20:52341', + 'via_exploit' => 'exploit/windows/smb/ms17_010_eternalblue', + 'platform' => 'windows' + }, + 2 => { + 'type' => 'shell', + 'tunnel_local' => '192.0.2.10:4445', + 'tunnel_peer' => '192.0.2.30:33333' + } + } + end + + before do + allow(rate_limiter).to receive(:check_rate_limit!) + allow(msf_client).to receive(:session_list).and_return(msf_response) + end + + describe 'Tool Name' do + it 'has the correct tool name' do + expect(described_class.tool_name).to eq('msf_session_list') + end + end + + describe 'Annotations' do + it 'is marked as read-only and idempotent' do + annotations = described_class.annotations_value + expect(annotations.read_only_hint).to eq(true) + expect(annotations.idempotent_hint).to eq(true) + expect(annotations.destructive_hint).to eq(false) + end + end + + describe '.call' do + it 'checks rate limit' do + described_class.call(server_context: server_context) + expect(rate_limiter).to have_received(:check_rate_limit!).with('session_list') + end + + it 'returns the upstream session hash as structured content' do + result = described_class.call(server_context: server_context) + expect(result.structured_content[:data]).to eq(msf_response) + end + + it 'reports total_sessions in metadata' do + result = described_class.call(server_context: server_context) + expect(result.structured_content[:metadata][:total_sessions]).to eq(2) + end + + it 'tolerates an empty session list' do + allow(msf_client).to receive(:session_list).and_return({}) + result = described_class.call(server_context: server_context) + expect(result.structured_content[:data]).to eq({}) + expect(result.structured_content[:metadata][:total_sessions]).to eq(0) + end + + it 'returns an error response for API errors' do + allow(msf_client).to receive(:session_list).and_raise(Msf::MCP::Metasploit::APIError.new('boom')) + result = described_class.call(server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Metasploit API error/) + end + + context 'dangerous_actions gate' do + it 'returns a successful response even when dangerous_actions is false' do + ctx = { msf_client: msf_client, rate_limiter: rate_limiter, config: {}, dangerous_actions: false } + result = described_class.call(server_context: ctx) + expect(result.error?).to be false + expect(result.structured_content[:data]).to eq(msf_response) + end + end + end +end diff --git a/spec/lib/msf/core/mcp/tools/session_read_spec.rb b/spec/lib/msf/core/mcp/tools/session_read_spec.rb new file mode 100644 index 0000000000000..05941597c3506 --- /dev/null +++ b/spec/lib/msf/core/mcp/tools/session_read_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' + +RSpec.describe Msf::MCP::Tools::SessionRead do + let(:msf_client) { double('Msf::MCP::Metasploit::Client') } + let(:rate_limiter) { double('Msf::MCP::Security::RateLimiter') } + let(:server_context) do + { + msf_client: msf_client, + rate_limiter: rate_limiter, + config: {} + } + end + + before do + allow(rate_limiter).to receive(:check_rate_limit!) + allow(msf_client).to receive(:session_read).and_return({ 'data' => 'meterpreter > sysinfo\n' }) + end + + describe 'Tool Name' do + it 'has the correct tool name' do + expect(described_class.tool_name).to eq('msf_session_read') + end + end + + describe 'Annotations' do + it 'is marked as read-only and idempotent' do + annotations = described_class.annotations_value + expect(annotations.read_only_hint).to eq(true) + expect(annotations.idempotent_hint).to eq(true) + expect(annotations.destructive_hint).to eq(false) + end + end + + describe '.call' do + it 'checks rate limit' do + described_class.call(session_id: 1, server_context: server_context) + expect(rate_limiter).to have_received(:check_rate_limit!).with('session_read') + end + + it 'forwards session_id to msf_client.session_read' do + described_class.call(session_id: 5, server_context: server_context) + expect(msf_client).to have_received(:session_read).with(5) + end + + it 'returns the buffered output as structured content' do + result = described_class.call(session_id: 1, server_context: server_context) + expect(result.structured_content[:data][:data]).to eq('meterpreter > sysinfo\n') + end + + it 'rejects non-integer session_id' do + result = described_class.call(session_id: 'abc', server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Session ID/) + end + + it 'rejects out-of-range session_id' do + result = described_class.call(session_id: 0, server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Session ID/) + end + + it 'returns a clear error response when the framework reports the session is unknown' do + allow(msf_client).to receive(:session_read).and_raise( + Msf::MCP::Metasploit::APIError.new('Unknown Session ID') + ) + + result = described_class.call(session_id: 9999, server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Unknown Session ID/) + end + + context 'dangerous_actions gate' do + it 'returns a successful response even when dangerous_actions is false' do + ctx = { msf_client: msf_client, rate_limiter: rate_limiter, config: {}, dangerous_actions: false } + result = described_class.call(session_id: 1, server_context: ctx) + expect(result.error?).to be false + expect(result.structured_content[:data][:data]).to eq('meterpreter > sysinfo\n') + end + end + end +end diff --git a/spec/lib/msf/core/mcp/tools/session_stop_spec.rb b/spec/lib/msf/core/mcp/tools/session_stop_spec.rb new file mode 100644 index 0000000000000..fbe6cf3f900dd --- /dev/null +++ b/spec/lib/msf/core/mcp/tools/session_stop_spec.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' + +RSpec.describe Msf::MCP::Tools::SessionStop do + let(:msf_client) { double('Msf::MCP::Metasploit::Client') } + let(:rate_limiter) { double('Msf::MCP::Security::RateLimiter') } + let(:server_context) do + { + msf_client: msf_client, + rate_limiter: rate_limiter, + config: {}, + dangerous_actions: true + } + end + + before do + allow(rate_limiter).to receive(:check_rate_limit!) + allow(msf_client).to receive(:session_stop).and_return({ 'result' => 'success' }) + end + + describe 'Tool Name' do + it 'has the correct tool name' do + expect(described_class.tool_name).to eq('msf_session_stop') + end + end + + describe 'Annotations' do + it 'is marked as destructive' do + annotations = described_class.annotations_value + expect(annotations.destructive_hint).to eq(true) + expect(annotations.read_only_hint).to eq(false) + expect(annotations.idempotent_hint).to eq(false) + end + end + + describe '.call' do + it 'checks rate limit' do + described_class.call(session_id: 1, server_context: server_context) + expect(rate_limiter).to have_received(:check_rate_limit!).with('session_stop') + end + + it 'forwards session_id to msf_client.session_stop' do + described_class.call(session_id: 3, server_context: server_context) + expect(msf_client).to have_received(:session_stop).with(3) + end + + it 'returns the success result as structured content' do + result = described_class.call(session_id: 1, server_context: server_context) + expect(result.structured_content[:data][:result]).to eq('success') + end + + it 'rejects non-integer session_id' do + result = described_class.call(session_id: 'abc', server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Session ID/) + end + + it 'rejects out-of-range session_id' do + result = described_class.call(session_id: 0, server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Session ID/) + end + + it 'returns a clear error response when the framework reports the session is unknown' do + allow(msf_client).to receive(:session_stop).and_raise( + Msf::MCP::Metasploit::APIError.new('Unknown Session ID') + ) + + result = described_class.call(session_id: 9999, server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Unknown Session ID/) + end + end + + describe '.call with dangerous mode disabled' do + let(:disabled_context) do + { + msf_client: msf_client, + rate_limiter: rate_limiter, + config: {}, + dangerous_actions: false + } + end + + it 'returns an error response when dangerous_actions is false' do + result = described_class.call(session_id: 1, server_context: disabled_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/dangerous actions mode/i) + expect(result.content.first[:text]).to include('--enable-dangerous-actions') + end + + it 'does not call msf_client.session_stop when blocked' do + described_class.call(session_id: 1, server_context: disabled_context) + expect(msf_client).not_to have_received(:session_stop) + end + + it 'does not consume rate limit when blocked' do + described_class.call(session_id: 1, server_context: disabled_context) + expect(rate_limiter).not_to have_received(:check_rate_limit!) + end + end +end diff --git a/spec/lib/msf/core/mcp/tools/session_write_spec.rb b/spec/lib/msf/core/mcp/tools/session_write_spec.rb new file mode 100644 index 0000000000000..282a292ce03c2 --- /dev/null +++ b/spec/lib/msf/core/mcp/tools/session_write_spec.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' + +RSpec.describe Msf::MCP::Tools::SessionWrite do + let(:msf_client) { double('Msf::MCP::Metasploit::Client') } + let(:rate_limiter) { double('Msf::MCP::Security::RateLimiter') } + let(:server_context) do + { + msf_client: msf_client, + rate_limiter: rate_limiter, + config: {}, + dangerous_actions: true + } + end + + before do + allow(rate_limiter).to receive(:check_rate_limit!) + allow(msf_client).to receive(:session_write).and_return({ 'result' => 'success' }) + end + + describe 'Tool Name' do + it 'has the correct tool name' do + expect(described_class.tool_name).to eq('msf_session_write') + end + end + + describe 'Annotations' do + it 'is marked as destructive' do + annotations = described_class.annotations_value + expect(annotations.destructive_hint).to eq(true) + expect(annotations.read_only_hint).to eq(false) + expect(annotations.idempotent_hint).to eq(false) + end + end + + describe '.call' do + it 'checks rate limit' do + described_class.call(session_id: 1, data: 'sysinfo', server_context: server_context) + expect(rate_limiter).to have_received(:check_rate_limit!).with('session_write') + end + + it 'forwards session_id and data to msf_client.session_write' do + described_class.call(session_id: 7, data: 'whoami', server_context: server_context) + expect(msf_client).to have_received(:session_write).with(7, 'whoami') + end + + it 'returns the success result as structured content' do + result = described_class.call(session_id: 1, data: 'sysinfo', server_context: server_context) + expect(result.structured_content[:data][:result]).to eq('success') + end + + it 'rejects non-integer session_id' do + result = described_class.call(session_id: 'abc', data: 'sysinfo', server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Session ID/) + end + + it 'rejects non-string data' do + result = described_class.call(session_id: 1, data: 12345, server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Session data/) + end + + it 'rejects empty data' do + result = described_class.call(session_id: 1, data: '', server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Session data/) + end + + it 'rejects data exceeding the size cap' do + oversized = 'A' * (Msf::MCP::Security::InputValidator::SESSION_DATA_MAX_BYTES + 1) + result = described_class.call(session_id: 1, data: oversized, server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Session data/) + end + + it 'returns a clear error response when the framework reports the session is unknown' do + allow(msf_client).to receive(:session_write).and_raise( + Msf::MCP::Metasploit::APIError.new('Unknown Session ID') + ) + + result = described_class.call(session_id: 9999, data: 'sysinfo', server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Unknown Session ID/) + end + end + + describe '.call with dangerous mode disabled' do + let(:disabled_context) do + { + msf_client: msf_client, + rate_limiter: rate_limiter, + config: {}, + dangerous_actions: false + } + end + + it 'returns an error response when dangerous_actions is false' do + result = described_class.call(session_id: 1, data: 'sysinfo', server_context: disabled_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/dangerous actions mode/i) + expect(result.content.first[:text]).to include('--enable-dangerous-actions') + end + + it 'does not call msf_client.session_write when blocked' do + described_class.call(session_id: 1, data: 'sysinfo', server_context: disabled_context) + expect(msf_client).not_to have_received(:session_write) + end + + it 'does not consume rate limit when blocked' do + described_class.call(session_id: 1, data: 'sysinfo', server_context: disabled_context) + expect(rate_limiter).not_to have_received(:check_rate_limit!) + end + end +end diff --git a/spec/lib/msf/core/mcp/tools/tool_helper_spec.rb b/spec/lib/msf/core/mcp/tools/tool_helper_spec.rb index 5c118a4566e08..1f785e3db76c8 100644 --- a/spec/lib/msf/core/mcp/tools/tool_helper_spec.rb +++ b/spec/lib/msf/core/mcp/tools/tool_helper_spec.rb @@ -48,4 +48,39 @@ class << self expect(result.error?).to be true end end + + describe '#dangerous_mode_required!' do + it 'returns without raising when dangerous_actions is true' do + expect { + tool_class.dangerous_mode_required!(dangerous_actions: true) + }.not_to raise_error + end + + it 'raises DangerousModeDisabledError when dangerous_actions is false' do + expect { + tool_class.dangerous_mode_required!(dangerous_actions: false) + }.to raise_error(Msf::MCP::Tools::DangerousModeDisabledError) do |error| + expect(error.message).to match(/dangerous actions mode/i) + expect(error.message).to include('--enable-dangerous-actions') + expect(error.message).to include('MSF_MCP_DANGEROUS_ACTIONS') + expect(error.message).to include('mcp.dangerous_actions') + end + end + + it 'raises DangerousModeDisabledError when dangerous_actions key is missing' do + expect { + tool_class.dangerous_mode_required!({}) + }.to raise_error(Msf::MCP::Tools::DangerousModeDisabledError) + end + + it 'raises DangerousModeDisabledError when dangerous_actions is a truthy non-boolean value' do + expect { + tool_class.dangerous_mode_required!(dangerous_actions: 'true') + }.to raise_error(Msf::MCP::Tools::DangerousModeDisabledError) + + expect { + tool_class.dangerous_mode_required!(dangerous_actions: 1) + }.to raise_error(Msf::MCP::Tools::DangerousModeDisabledError) + end + end end diff --git a/spec/lib/msf/core/mcp/tools/vulnerability_info_spec.rb b/spec/lib/msf/core/mcp/tools/vulnerability_info_spec.rb index ec99f0aa9b1f5..d46f17150d8fc 100644 --- a/spec/lib/msf/core/mcp/tools/vulnerability_info_spec.rb +++ b/spec/lib/msf/core/mcp/tools/vulnerability_info_spec.rb @@ -48,9 +48,9 @@ end describe 'Input Schema Validation' do - it 'defines workspace as required parameter' do + it 'does not require workspace (defaults to "default")' do input_schema = described_class.input_schema - expect(input_schema.schema[:required]).to include('workspace') + expect(Array(input_schema.schema[:required])).not_to include('workspace') end end diff --git a/spec/plugins/mcp/console_dispatcher_spec.rb b/spec/plugins/mcp/console_dispatcher_spec.rb index b7533715e2f12..4a8a1ed408771 100644 --- a/spec/plugins/mcp/console_dispatcher_spec.rb +++ b/spec/plugins/mcp/console_dispatcher_spec.rb @@ -114,6 +114,21 @@ def shutdown; end expect(result).to include('ServerHost=', 'RpcHost=', 'RpcPass=') end + it 'includes DangerousActions= among the start option completions' do + result = plugin.cmd_mcp_tabs('', ['mcp', 'start']) + expect(result).to include('DangerousActions=') + end + + it 'includes DangerousActions= among the restart option completions' do + result = plugin.cmd_mcp_tabs('', ['mcp', 'restart']) + expect(result).to include('DangerousActions=') + end + + it 'filters DangerousActions= via a Dang prefix (case-insensitive)' do + result = plugin.cmd_mcp_tabs('dang', ['mcp', 'start']) + expect(result).to contain_exactly('DangerousActions=') + end + it 'returns empty array for status subcommand' do # User typed: "mcp status " → words = ['mcp', 'status'], str = '' expect(plugin.cmd_mcp_tabs('', ['mcp', 'status'])).to eq([]) @@ -211,6 +226,13 @@ def shutdown; end expect(combined).to include('restart') expect(combined).to include('help') end + + it 'documents DangerousActions with its accepted values and default' do + plugin.cmd_mcp_help + combined = @output.join("\n") + expect(combined).to match(/DangerousActions=/) + expect(combined).to match(/default: false/) + end end describe '#cmd_mcp routing' do diff --git a/spec/plugins/mcp/option_validator_spec.rb b/spec/plugins/mcp/option_validator_spec.rb index 7ac558c58ea64..b2b8ff6915d86 100644 --- a/spec/plugins/mcp/option_validator_spec.rb +++ b/spec/plugins/mcp/option_validator_spec.rb @@ -123,6 +123,14 @@ def shutdown; end expect { plugin.send(:validate_options!, { 'RpcSSL' => 'false' }) }.not_to raise_error end + it 'accepts "TRUE" case-insensitively' do + expect { plugin.send(:validate_options!, { 'RpcSSL' => 'TRUE' }) }.not_to raise_error + end + + it 'accepts "False" case-insensitively' do + expect { plugin.send(:validate_options!, { 'RpcSSL' => 'False' }) }.not_to raise_error + end + it 'rejects "yes"' do expect { plugin.send(:validate_options!, { 'RpcSSL' => 'yes' }) }.to raise_error(Msf::MCP::Config::ValidationError, /RpcSSL/) end @@ -136,6 +144,28 @@ def shutdown; end end end + describe 'DangerousActions' do + it 'accepts "true"' do + expect { plugin.send(:validate_options!, { 'DangerousActions' => 'true' }) }.not_to raise_error + end + + it 'accepts "false"' do + expect { plugin.send(:validate_options!, { 'DangerousActions' => 'false' }) }.not_to raise_error + end + + it 'accepts "TRUE" case-insensitively' do + expect { plugin.send(:validate_options!, { 'DangerousActions' => 'TRUE' }) }.not_to raise_error + end + + it 'accepts "False" case-insensitively' do + expect { plugin.send(:validate_options!, { 'DangerousActions' => 'False' }) }.not_to raise_error + end + + it 'rejects "yes"' do + expect { plugin.send(:validate_options!, { 'DangerousActions' => 'yes' }) }.to raise_error(Msf::MCP::Config::ValidationError, /DangerousActions/) + end + end + describe 'RateLimit' do it 'accepts value 1' do expect { plugin.send(:validate_options!, { 'RateLimit' => '1' }) }.not_to raise_error diff --git a/spec/plugins/mcp/rpc_resolver_spec.rb b/spec/plugins/mcp/rpc_resolver_spec.rb index 616c15314aa72..f65d47195a5ab 100644 --- a/spec/plugins/mcp/rpc_resolver_spec.rb +++ b/spec/plugins/mcp/rpc_resolver_spec.rb @@ -279,5 +279,86 @@ def shutdown; end expect(config[:user]).to eq('custom') end end + + describe 'RpcSSL default and case-insensitive parsing' do + let(:plugins_collection) do + instance_double('Msf::PluginManager').tap do |pm| + allow(pm).to receive(:find).and_return(nil) + allow(pm).to receive(:load).and_return(true) + end + end + + before do + allow(framework).to receive(:plugins).and_return(plugins_collection) + allow(Rex::Text).to receive(:rand_text_alphanumeric).with(12).and_return('abcdefghijkl') + end + + it 'defaults explicit-RPC ssl to Msf::MCP::Config::Defaults::RPC_SSL when RpcSSL is not set' do + config = plugin.send(:resolve_rpc_config, { 'RpcHost' => '192.0.2.0', 'RpcPass' => 'remote_pass' }) + expect(config[:ssl]).to eq(Msf::MCP::Config::Defaults::RPC_SSL) + end + + it 'accepts RpcSSL=TRUE on the explicit RPC path (case-insensitive)' do + config = plugin.send(:resolve_rpc_config, + { 'RpcHost' => '192.0.2.0', 'RpcPass' => 'remote_pass', 'RpcSSL' => 'TRUE' }) + expect(config[:ssl]).to eq(true) + end + + it 'accepts RpcSSL=False on the explicit RPC path (case-insensitive)' do + config = plugin.send(:resolve_rpc_config, + { 'RpcHost' => '192.0.2.0', 'RpcPass' => 'remote_pass', 'RpcSSL' => 'False' }) + expect(config[:ssl]).to eq(false) + end + + it 'defaults auto-started msgrpc ssl to Msf::MCP::Config::Defaults::RPC_SSL when RpcSSL is not set' do + config = plugin.send(:resolve_rpc_config, {}) + expect(config[:ssl]).to eq(Msf::MCP::Config::Defaults::RPC_SSL) + end + + it 'forwards the resolved ssl as a "true"/"false" string to the msgrpc plugin loader' do + expected = Msf::MCP::Config::Defaults::RPC_SSL.to_s + expect(plugins_collection).to receive(:load) + .with('msgrpc', hash_including('SSL' => expected)) + plugin.send(:resolve_rpc_config, {}) + end + + it 'forwards the requested ssl value case-insensitively to the msgrpc plugin loader' do + expect(plugins_collection).to receive(:load) + .with('msgrpc', hash_including('SSL' => 'true')) + plugin.send(:resolve_rpc_config, { 'RpcSSL' => 'TRUE' }) + end + end + + describe 'RpcSSL case-insensitive parsing on the introspection path' do + let(:msgrpc_server) do + instance_double( + 'Msf::RPC::Service', + srvhost: '127.0.0.1', + srvport: 55552, + users: { 'msf' => 'p' }, + options: { ssl: false } + ) + end + + let(:msgrpc_plugin) do + instance_double('Msf::Plugin::MSGRPC', name: 'msgrpc', server: msgrpc_server) + end + + let(:plugins_collection) { [msgrpc_plugin] } + + before do + allow(framework).to receive(:plugins).and_return(plugins_collection) + end + + it 'accepts RpcSSL=TRUE and overrides the introspected value' do + config = plugin.send(:resolve_rpc_config, { 'RpcSSL' => 'TRUE' }) + expect(config[:ssl]).to eq(true) + end + + it 'accepts RpcSSL=False and overrides the introspected value' do + config = plugin.send(:resolve_rpc_config, { 'RpcSSL' => 'False' }) + expect(config[:ssl]).to eq(false) + end + end end end diff --git a/spec/plugins/mcp_spec.rb b/spec/plugins/mcp_spec.rb index 6e79a961f77a7..2ac1920288f8b 100644 --- a/spec/plugins/mcp_spec.rb +++ b/spec/plugins/mcp_spec.rb @@ -141,6 +141,64 @@ def shutdown; end end end + context 'dangerous_actions startup indication' do + it 'prints a status line noting dangerous mode is disabled by default' do + plugin.start_server({}) + expect(@output.join("\n")).to include('Dangerous actions mode is disabled') + end + + it 'prints a warning banner when DangerousActions=true' do + plugin.start_server('DangerousActions' => 'true') + expect(@error.join("\n")).to match(/Dangerous actions mode is ENABLED/) + end + + it 'accepts DangerousActions case-insensitively' do + plugin.start_server('DangerousActions' => 'TRUE') + expect(plugin.server_config[:mcp][:dangerous_actions]).to eq(true) + expect(@error.join("\n")).to match(/Dangerous actions mode is ENABLED/) + end + + it 'defaults dangerous_actions from Msf::MCP::Config::Defaults::DANGEROUS_ACTIONS when unset' do + plugin.start_server({}) + expect(plugin.server_config[:mcp][:dangerous_actions]) + .to eq(Msf::MCP::Config::Defaults::DANGEROUS_ACTIONS) + end + end + + context 'wiring dangerous_actions into Msf::MCP::Server' do + it 'passes dangerous_actions: false to Msf::MCP::Server.new by default' do + expect(mock_server_class).to receive(:new) + .with(hash_including(dangerous_actions: false)) + .and_call_original + plugin.start_server({}) + end + + it 'passes dangerous_actions: true to Msf::MCP::Server.new when enabled' do + expect(mock_server_class).to receive(:new) + .with(hash_including(dangerous_actions: true)) + .and_call_original + plugin.start_server('DangerousActions' => 'true') + end + end + + context 'RpcSSL case-insensitive parsing (end-to-end)' do + it 'accepts RpcSSL=TRUE and resolves ssl to true' do + plugin.start_server('RpcHost' => '192.0.2.10', + 'RpcUser' => 'msf', + 'RpcPass' => 'remote_pass', + 'RpcSSL' => 'TRUE') + expect(plugin.server_config[:rpc][:ssl]).to eq(true) + end + + it 'accepts RpcSSL=False and resolves ssl to false' do + plugin.start_server('RpcHost' => '192.0.2.10', + 'RpcUser' => 'msf', + 'RpcPass' => 'remote_pass', + 'RpcSSL' => 'False') + expect(plugin.server_config[:rpc][:ssl]).to eq(false) + end + end + context 'with stdio transport' do it 'rejects stdio as an unknown option since Transport is no longer accepted' do dispatcher = Msf::Plugin::MCP::McpCommandDispatcher.new(driver) diff --git a/tools/dev/msfmcp_integration_test.rb b/tools/dev/msfmcp_integration_test.rb new file mode 100755 index 0000000000000..a9f920ca8522d --- /dev/null +++ b/tools/dev/msfmcp_integration_test.rb @@ -0,0 +1,1164 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# End-to-end integration test for the Metasploit MCP server (msfmcpd). +# +# Acts as an HTTP client that drives the MCP Streamable HTTP transport, +# exercising the full request lifecycle (initialize, tools/list, tools/call) +# against every registered tool. Mirrors what the MCP Inspector does, but +# scripted and assertion-driven so it can run in CI or as a smoke test. +# +# The msfmcpd HTTP transport and the Metasploit RPC server are expected to +# already be running. This script will not start either of them. +# +# Examples: +# +# # Smoke test against a freshly started msfmcpd printing its token +# ruby tools/dev/msfmcp_integration_test.rb \ +# --url http://127.0.0.1:3000 \ +# --token "$MSF_MCP_TOKEN" +# +# # Include the dangerous-tool execution paths +# ruby tools/dev/msfmcp_integration_test.rb \ +# --url http://127.0.0.1:3000 \ +# --token "$MSF_MCP_TOKEN" \ +# --enable-dangerous \ +# --rhost 192.0.2.10 --lhost 192.0.2.20 --lport 4444 +# +# # Override the modules / datastore options used by the dangerous tests +# ruby tools/dev/msfmcp_integration_test.rb \ +# --token "$MSF_MCP_TOKEN" --enable-dangerous \ +# --check-module exploit:linux/http/some_module \ +# --check-option RHOSTS=192.0.2.10 --check-option RPORT=8080 \ +# --execute-module auxiliary/scanner/http/http_version \ +# --execute-option RHOSTS=192.0.2.0/24 --execute-option THREADS=10 +# +# # Run only tests whose name matches a pattern. The filter matches against +# # "
:: ", so you can filter by section or by test. +# ruby tools/dev/msfmcp_integration_test.rb --token "$T" --filter session +# ruby tools/dev/msfmcp_integration_test.rb --token "$T" --filter 'Full exploit lifecycle' +# + +require 'json' +require 'net/http' +require 'optparse' +require 'securerandom' +require 'uri' + +# ---------------------------------------------------------------------------- +# Minimal MCP Streamable HTTP client +# ---------------------------------------------------------------------------- +class McpHttpClient + PROTOCOL_VERSION = '2025-11-25' + ACCEPT = 'application/json, text/event-stream' + CONTENT_TYPE = 'application/json' + + attr_reader :session_id, :server_info + attr_accessor :debug + + def initialize(url:, token: nil, open_timeout: 5, read_timeout: 60, debug: false) + @uri = URI(url) + raise ArgumentError, "URL must be http(s): #{url}" unless %w[http https].include?(@uri.scheme) + + @http = Net::HTTP.new(@uri.host, @uri.port) + @http.use_ssl = (@uri.scheme == 'https') + @http.open_timeout = open_timeout + @http.read_timeout = read_timeout + @token = token + @next_id = 0 + @debug = debug + end + + def initialize_session! + payload = { + jsonrpc: '2.0', + id: next_id, + method: 'initialize', + params: { + protocolVersion: PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: 'msfmcp-integration-test', version: '1.0' } + } + } + response = post_json(payload) + @session_id = response.http_response['mcp-session-id'] || response.http_response['Mcp-Session-Id'] + raise 'Server did not return Mcp-Session-Id header on initialize' unless @session_id + + body = response.parsed_body + raise "initialize failed: #{body.inspect}" unless body.is_a?(Hash) && body['result'] + + @server_info = body['result'] + send_notification('notifications/initialized') + @server_info + end + + def list_tools + call_method('tools/list') + end + + def call_tool(name, arguments = {}) + response = call_method('tools/call', { name: name, arguments: arguments }) + # The MCP server uses a single shared token bucket (burst defaults to 10). + # Integration tests fire calls back to back, so we transparently retry + # once when we hit the limit instead of forcing every test to think + # about pacing. + retry_after = rate_limit_retry_after(response) + if retry_after + sleep(retry_after) + response = call_method('tools/call', { name: name, arguments: arguments }) + end + response + end + + def send_notification(method, params = nil) + payload = { jsonrpc: '2.0', method: method } + payload[:params] = params if params + post_json(payload, allow_empty: true) + end + + def call_method(method, params = nil) + payload = { jsonrpc: '2.0', id: next_id, method: method } + payload[:params] = params if params + post_json(payload).parsed_body + end + + # Returns the number of seconds to wait, or nil if the response is not a + # rate-limit error. Rate-limit failures come back as tool responses with + # `isError: true` and a message of the form "Rate limit exceeded. Retry + # after N seconds.". + def rate_limit_retry_after(response) + return nil unless response.is_a?(Hash) + return nil unless response.dig('result', 'isError') + + text = response.dig('result', 'content')&.first&.dig('text').to_s + return nil unless text.include?('Rate limit exceeded') + + match = text.match(/Retry after (\d+) seconds?/i) + match ? match[1].to_i + 1 : 2 + end + + def terminate_session! + return unless @session_id + + req = Net::HTTP::Delete.new(@uri.request_uri) + req['Mcp-Session-Id'] = @session_id + req['Authorization'] = "Bearer #{@token}" if @token + @http.request(req) + @session_id = nil + end + + # Issue a raw request without the convenience defaults (used to test auth + # failures, malformed payloads, etc.). + def raw_post(body, headers: {}) + req = Net::HTTP::Post.new(@uri.request_uri) + { 'Accept' => ACCEPT, 'Content-Type' => CONTENT_TYPE }.each { |k, v| req[k] = v } + headers.each { |k, v| req[k] = v } + req.body = body + @http.request(req) + end + + private + + def next_id + @next_id += 1 + end + + def post_json(payload, allow_empty: false) + req = Net::HTTP::Post.new(@uri.request_uri) + req['Accept'] = ACCEPT + req['Content-Type'] = CONTENT_TYPE + req['Authorization'] = "Bearer #{@token}" if @token + req['Mcp-Session-Id'] = @session_id if @session_id + req.body = JSON.generate(payload) + + debug_log_request(req) + http_response = @http.request(req) + debug_log_response(http_response) + RawResponse.new(http_response, allow_empty: allow_empty) + end + + def debug_log_request(req) + return unless @debug + + warn Color.cyan("--> #{req.method} #{@uri}") + req.each_header do |k, v| + # Redact the auth token but show that it was sent. + value = (k.downcase == 'authorization') ? v.sub(/Bearer .+/, 'Bearer ') : v + warn Color.cyan(" #{k}: #{value}") + end + warn Color.cyan(" body: #{pretty_json(req.body)}") if req.body && !req.body.empty? + end + + def debug_log_response(http_response) + return unless @debug + + warn Color.yellow("<-- HTTP #{http_response.code}") + http_response.each_header { |k, v| warn Color.yellow(" #{k}: #{v}") } + body = http_response.body.to_s + warn Color.yellow(" body: #{pretty_json(body)}") unless body.empty? + end + + def pretty_json(raw) + JSON.pretty_generate(JSON.parse(raw)) + rescue StandardError + raw + end +end + +# Wraps a Net::HTTPResponse and parses the body, transparently handling either +# `application/json` or `text/event-stream` (SSE) responses produced by the +# StreamableHTTPTransport. +class RawResponse + attr_reader :http_response + + def initialize(http_response, allow_empty: false) + @http_response = http_response + @allow_empty = allow_empty + end + + def status + @http_response.code.to_i + end + + def content_type + @http_response['Content-Type'].to_s.split(';').first&.strip + end + + def parsed_body + body = @http_response.body.to_s + if body.empty? + return nil if @allow_empty + + raise "Empty response body (HTTP #{status})" + end + + case content_type + when 'application/json' + JSON.parse(body) + when 'text/event-stream' + parse_sse(body) + else + JSON.parse(body) + end + rescue JSON::ParserError => e + raise "Could not parse response (HTTP #{status}, type=#{content_type}): #{e.message}\n#{body}" + end + + private + + # Extract the first `data:` payload from an SSE stream. The MCP transport + # only emits a single event per request/response, so this is sufficient. + def parse_sse(body) + body.each_line do |line| + line = line.chomp + next unless line.start_with?('data:') + + return JSON.parse(line.sub(/^data:\s*/, '')) + end + raise "No `data:` line in SSE response body:\n#{body}" + end +end + +# ---------------------------------------------------------------------------- +# Test framework +# ---------------------------------------------------------------------------- +module Color + def self.red(s) = "\e[31m#{s}\e[0m" + def self.green(s) = "\e[32m#{s}\e[0m" + def self.yellow(s) = "\e[33m#{s}\e[0m" + def self.cyan(s) = "\e[36m#{s}\e[0m" + def self.bold(s) = "\e[1m#{s}\e[0m" +end + +class TestRunner + Result = Struct.new(:name, :status, :message, :duration) do + def passed? = status == :pass + def failed? = status == :fail + def skipped? = status == :skip + end + + attr_reader :results + + def initialize(filter: nil, verbose: false, debug: false) + @filter = filter + @verbose = verbose + @debug = debug + @results = [] + @current_section = nil + @section_printed = false + end + + def run(name) + qualified = @current_section ? "#{@current_section} :: #{name}" : name + return if @filter && !qualified.match?(@filter) + + print_section_header + warn Color.bold(Color.cyan("\n-- #{name} --")) if @debug + started = Time.now + begin + yield + record(name, :pass, nil, Time.now - started) + rescue SkipTest => e + record(name, :skip, e.message, Time.now - started) + rescue AssertionFailed => e + record(name, :fail, e.message, Time.now - started) + rescue StandardError => e + record(name, :fail, "#{e.class}: #{e.message}", Time.now - started) + end + end + + def section(title) + @current_section = title + @section_printed = false + return if @filter # header prints lazily so filtered runs only show sections that have matches + + puts + puts Color.bold(Color.cyan("== #{title} ==")) + @section_printed = true + end + + def print_section_header + return if @section_printed || @current_section.nil? + + puts + puts Color.bold(Color.cyan("== #{@current_section} ==")) + @section_printed = true + end + + def summary + pass = @results.count(&:passed?) + fail = @results.count(&:failed?) + skip = @results.count(&:skipped?) + puts + puts Color.bold("Results: #{pass} passed, #{fail} failed, #{skip} skipped (#{@results.size} total)") + fail.zero? + end + + private + + def record(name, status, message, duration) + @results << Result.new(name, status, message, duration) + label = case status + when :pass then Color.green('PASS') + when :fail then Color.red('FAIL') + when :skip then Color.yellow('SKIP') + end + line = format(' [%s] %-60s %.3fs', label, name, duration) + line += "\n #{message}" if message + puts line + end +end + +class SkipTest < StandardError; end +class AssertionFailed < StandardError; end + +module Assertions + def assert(condition, message = 'assertion failed') + raise AssertionFailed, message unless condition + end + + def assert_equal(expected, actual, message = nil) + return if expected == actual + + raise AssertionFailed, message || "expected #{expected.inspect}, got #{actual.inspect}" + end + + def assert_includes(collection, value, message = nil) + return if collection.include?(value) + + raise AssertionFailed, message || "expected #{collection.inspect} to include #{value.inspect}" + end + + def assert_match(pattern, string, message = nil) + return if string.to_s.match?(pattern) + + raise AssertionFailed, message || "expected #{string.inspect} to match #{pattern.inspect}" + end + + # JSON-RPC level error (transport returned a JSON-RPC error envelope). + def assert_rpc_error(response, code: nil) + assert response.is_a?(Hash), "expected response Hash, got #{response.class}" + assert response['error'], "expected JSON-RPC error, got: #{response.inspect}" + if code + assert_equal code, response.dig('error', 'code'), + "expected error code #{code}, got #{response.dig('error', 'code').inspect}" + end + end + + # Successful tool response (no `isError`). + def assert_tool_success(response) + assert response.is_a?(Hash), "expected Hash, got #{response.class}" + assert response['result'], "expected result key, got: #{response.inspect}" + is_error = response.dig('result', 'isError') + assert !is_error, + "expected tool success, got isError=true: #{response.dig('result', 'content')&.first&.dig('text')}" + end + + # Tool returned a structured error (`isError: true`) -- the typical response + # for validation failures, gated dangerous tools, etc. + def assert_tool_error(response, contains: nil) + assert response.is_a?(Hash), "expected Hash, got #{response.class}" + assert response['result'], "expected result key, got: #{response.inspect}" + assert_equal true, response.dig('result', 'isError'), + "expected isError=true, got: #{response.inspect}" + return unless contains + + text = response.dig('result', 'content')&.first&.dig('text').to_s + assert text.include?(contains), "expected error text to include #{contains.inspect}, got #{text.inspect}" + end +end + +# ---------------------------------------------------------------------------- +# Test suites +# ---------------------------------------------------------------------------- +class IntegrationTests + include Assertions + + EXPECTED_TOOLS = %w[ + msf_search_modules + msf_module_info + msf_module_execute + msf_module_check + msf_module_results + msf_running_stats + msf_host_info + msf_service_info + msf_vulnerability_info + msf_note_info + msf_credential_info + msf_loot_info + msf_session_list + msf_session_stop + msf_session_read + msf_session_write + ].freeze + + DANGEROUS_TOOLS = %w[msf_module_execute msf_module_check msf_session_stop msf_session_write].freeze + + DEFAULT_CHECK_MODULE = 'exploit:windows/smb/ms17_010_eternalblue' + DEFAULT_EXECUTE_MODULE = 'auxiliary:scanner/portscan/tcp' + + def initialize(client:, runner:, options:) + @client = client + @runner = runner + @options = options + end + + def run_all + test_protocol + test_tools_list + test_read_only_happy_paths + test_input_validation_errors + test_dangerous_mode_gate + test_dangerous_mode_execution if @options[:dangerous] + test_exploit_lifecycle if @options[:dangerous] + test_protocol_violations + end + + # --- Protocol ----------------------------------------------------------- + + def test_protocol + @runner.section('Protocol') + + @runner.run('initialize negotiates protocol version') do + info = @client.server_info + assert_includes %w[2025-11-25 2025-06-18 2025-03-26 2024-11-05], + info['protocolVersion'] + assert_equal 'msfmcp', info.dig('serverInfo', 'name') + assert info.dig('serverInfo', 'version').is_a?(String), 'version should be a string' + end + end + + # --- tools/list --------------------------------------------------------- + + def test_tools_list + @runner.section('tools/list') + + @runner.run('tools/list returns all 16 registered tools') do + response = @client.list_tools + assert_tool_success(response) if response.dig('result', 'isError') + tools = response.dig('result', 'tools') || [] + names = tools.map { |t| t['name'] } + EXPECTED_TOOLS.each do |expected| + assert_includes names, expected + end + assert_equal EXPECTED_TOOLS.size, names.size, + "expected #{EXPECTED_TOOLS.size} tools, got #{names.size}: #{names.inspect}" + end + + @runner.run('tools/list flags dangerous tools via annotations') do + tools = @client.list_tools.dig('result', 'tools') || [] + DANGEROUS_TOOLS.each do |name| + tool = tools.find { |t| t['name'] == name } + assert tool, "missing dangerous tool: #{name}" + # MCP SDK serializes annotations under "annotations". Tools without + # destructive_hint=true would default to false. + assert_equal true, tool.dig('annotations', 'destructiveHint'), + "#{name} should have destructiveHint=true" + end + end + end + + # --- Read-only tool happy paths ----------------------------------------- + + def test_read_only_happy_paths + @runner.section('Read-only tools (happy path)') + + @runner.run('msf_search_modules returns results for a common query') do + r = @client.call_tool('msf_search_modules', { query: 'ms17_010', limit: 5 }) + assert_tool_success(r) + data = parse_tool_data(r) + assert data[:data].is_a?(Array), 'expected data array' + end + + @runner.run('msf_module_info returns metadata for a known module') do + r = @client.call_tool('msf_module_info', + { type: 'exploit', name: 'windows/smb/ms17_010_eternalblue' }) + assert_tool_success(r) + data = parse_tool_data(r) + assert data[:data][:fullname].to_s.include?('ms17_010'), + "unexpected fullname: #{data[:data][:fullname].inspect}" + end + + @runner.run('msf_running_stats returns counters') do + r = @client.call_tool('msf_running_stats') + assert_tool_success(r) + data = parse_tool_data(r) + %i[waiting running results].each do |k| + assert data[:data].key?(k), "missing key #{k}" + assert data[:data][k].is_a?(Array), "expected #{k} to be an Array" + end + end + + @runner.run('msf_session_list returns a hash keyed by session id') do + r = @client.call_tool('msf_session_list') + assert_tool_success(r) + data = parse_tool_data(r) + assert data[:data].is_a?(Hash), "expected data Hash, got #{data[:data].class}" + assert data[:metadata][:total_sessions].is_a?(Integer), 'expected total_sessions integer' + end + + # DB-backed tools: succeed regardless of whether records exist. + %w[msf_host_info msf_service_info msf_vulnerability_info + msf_note_info msf_credential_info msf_loot_info].each do |name| + @runner.run("#{name} returns a (possibly empty) listing") do + r = @client.call_tool(name, default_db_query_for(name)) + # If no DB is connected the tool returns an isError; tolerate that. + if r.dig('result', 'isError') + text = r.dig('result', 'content')&.first&.dig('text').to_s + if text.match?(/database|workspace/i) + raise SkipTest, "no database/workspace available: #{text}" + end + + raise AssertionFailed, "unexpected tool error: #{text}" + end + data = parse_tool_data(r) + assert data[:data].is_a?(Array), 'expected data array' + end + end + + @runner.run('msf_module_results returns not-found for a random UUID') do + r = @client.call_tool('msf_module_results', { uuid: SecureRandom.alphanumeric(24) }) + # Unknown UUID -> isError with a 'not found' message. + assert_tool_error(r) + end + end + + # --- Validation errors -------------------------------------------------- + + def test_input_validation_errors + @runner.section('Input validation') + + @runner.run('msf_search_modules rejects empty query') do + r = @client.call_tool('msf_search_modules', { query: '' }) + assert response_indicates_validation_error?(r), + "expected validation error, got: #{r.inspect}" + end + + @runner.run('msf_module_info rejects an invalid module type') do + r = @client.call_tool('msf_module_info', + { type: 'wat', name: 'windows/smb/ms17_010_eternalblue' }) + assert response_indicates_validation_error?(r), + "expected validation error, got: #{r.inspect}" + end + + @runner.run('msf_host_info rejects an invalid IP address') do + r = @client.call_tool('msf_host_info', { address: 'not.an.ip' }) + assert response_indicates_validation_error?(r), + "expected validation error, got: #{r.inspect}" + end + + @runner.run('msf_module_results rejects a malformed UUID') do + r = @client.call_tool('msf_module_results', { uuid: 'too-short' }) + assert response_indicates_validation_error?(r), + "expected validation error, got: #{r.inspect}" + end + + @runner.run('tools/call rejects unknown tool name') do + response = @client.call_tool('msf_does_not_exist', {}) + # SDK either returns an isError tool response or an RPC error envelope. + # The MCP Ruby SDK currently returns -32602 with the human-readable + # detail in `error.data` ("Tool not found: ..."), so check there too. + rpc_message = response.dig('error', 'message').to_s + rpc_data = response.dig('error', 'data').to_s + ok = response.dig('result', 'isError') || + rpc_message.match?(/tool|method|not found|invalid/i) || + rpc_data.match?(/tool|method|not found/i) + assert ok, "expected error for unknown tool, got: #{response.inspect}" + end + end + + # --- Dangerous mode gate ------------------------------------------------ + + def test_dangerous_mode_gate + return if @options[:dangerous] + + @runner.section('Dangerous tools (gate closed)') + + DANGEROUS_TOOLS.each do |name| + @runner.run("#{name} is gated when dangerous mode is disabled") do + r = @client.call_tool(name, minimal_args_for(name)) + assert_tool_error(r, contains: 'dangerous actions mode') + end + end + end + + # --- Dangerous mode execution ------------------------------------------- + + def test_dangerous_mode_execution + @runner.section('Dangerous tools (gate open)') + + @runner.run('msf_module_check executes against the configured target') do + type, name = split_module_spec(@options[:check_module] || DEFAULT_CHECK_MODULE) + defaults = {} + defaults['RHOSTS'] = @options[:rhost] if @options[:rhost] + tool_options = merge_options(defaults, @options[:check_option_overrides]) + if tool_options.empty? + raise SkipTest, 'provide --rhost or --check-option to set required datastore options' + end + + r = @client.call_tool('msf_module_check', + { type: type, name: name, options: tool_options }) + # We don't require Vulnerable/Safe, only that the gate let us through + # and the call did not produce a structural error. + assert_tool_success(r) + end + + @runner.run('msf_module_execute launches a module') do + type, name = split_module_spec(@options[:execute_module] || DEFAULT_EXECUTE_MODULE) + if type == 'exploit' + raise SkipTest, 'covered by full exploit lifecycle test (avoids handler contention)' + end + + # When the user keeps the bundled default module, apply its companion + # defaults so the test is runnable with just --rhost. + defaults = @options[:execute_module].nil? ? { 'PORTS' => '22' } : {} + defaults['RHOSTS'] = @options[:rhost] if @options[:rhost] + defaults['LHOST'] = @options[:lhost] if @options[:lhost] + defaults['LPORT'] = @options[:lport] if @options[:lport] + tool_options = merge_options(defaults, @options[:execute_option_overrides]) + if tool_options.empty? + raise SkipTest, 'provide --rhost, --lhost/--lport, or --execute-option to set required datastore options' + end + + r = @client.call_tool('msf_module_execute', + { type: type, name: name, options: tool_options }) + assert_tool_success(r) + data = parse_tool_data(r) + assert data[:data][:job_id], 'expected job_id' + assert data[:data][:uuid], 'expected uuid' + end + + @runner.run('msf_session_stop rejects a non-existent session id') do + r = @client.call_tool('msf_session_stop', { session_id: 99_999 }) + # 'Not found' from the RPC counts as a tool error response. + assert_tool_error(r) + end + + @runner.run('msf_session_write rejects a non-existent session id') do + r = @client.call_tool('msf_session_write', + { session_id: 99_999, data: 'whoami' }) + assert_tool_error(r) + end + end + + # --- Full exploit lifecycle --------------------------------------------- + # + # Sequential polling flow (fails fast on explicit error signals): + # + # 1. msf_module_execute -> capture uuid + # 2. msf_running_stats (poll until uuid in :results) -> run finished + # 3. msf_module_results (poll until 'session N opened') -> capture SID + # 4. msf_session_list -> confirm SID + # 5. msf_session_write (type-appropriate probe command) + # 6. msf_session_read (poll until probe output matches) + # 7. msf_session_stop + msf_session_list -> confirm cleanup + # + # Only runs when the module under test is an exploit -- other module types + # don't necessarily open a session. + def test_exploit_lifecycle + type, name = split_module_spec(@options[:execute_module] || DEFAULT_EXECUTE_MODULE) + + @runner.section('Full exploit lifecycle') + + unless type == 'exploit' + @runner.run("skipped for non-exploit module (#{type}:#{name})") do + raise SkipTest, "module type is #{type.inspect}, pass --execute-module exploit:..." + end + return + end + + tool_options = build_execute_options + if tool_options.empty? + @runner.run('exploit lifecycle') do + raise SkipTest, 'provide --rhost, --lhost/--lport, or --execute-option to set required datastore options' + end + return + end + + exploit_uuid = nil + session_id = nil + session_type = nil + probe = nil + + # Step 1: launch the exploit. + @runner.run("msf_module_execute launches exploit #{name}") do + r = @client.call_tool('msf_module_execute', + { type: type, name: name, options: tool_options }) + assert_tool_success(r) + data = parse_tool_data(r) + exploit_uuid = data[:data][:uuid] + assert exploit_uuid, 'expected uuid' + assert data[:data][:job_id], 'expected job_id' + end + + unless exploit_uuid + @runner.run('exploit lifecycle aborted') do + raise SkipTest, 'no uuid returned from msf_module_execute' + end + return + end + + # Step 2: wait for the run to complete (uuid appears in :results). + @runner.run("msf_running_stats reports run #{exploit_uuid} finished") do + done = poll_until( + timeout: @options[:exploit_timeout], + predicate: ->(data) { Array(data[:data][:results]).include?(exploit_uuid) } + ) { @client.call_tool('msf_running_stats') } + assert done, "uuid #{exploit_uuid} not in running_stats.results after #{@options[:exploit_timeout]}s" + end + + # Step 3: poll the run's result text for the 'session N opened' line. + # Fail fast if the result indicates the module errored or explicitly + # reports that no session was created. + @runner.run('msf_module_results reports a session-opened line') do + last_result_text = nil + outcome = poll_until( + timeout: 30, + predicate: lambda do |data| + status = data[:data][:status].to_s + result_text = data[:data][:result].to_s + last_result_text = result_text + + if status == 'errored' + raise AssertionFailed, "module run errored: #{data[:data][:error].inspect}" + end + if NO_SESSION_PATTERNS.any? { |pat| result_text.match?(pat) } + raise AssertionFailed, "module reported no session opened: #{result_text.inspect}" + end + + result_text.match?(SESSION_OPENED_PATTERN) + end + ) { @client.call_tool('msf_module_results', { uuid: exploit_uuid }) } + + unless outcome + raise AssertionFailed, + "no 'session opened' line detected within 30s; last result=#{last_result_text.inspect}" + end + + session_id = extract_reported_sid(outcome[:data][:result].to_s) + assert session_id, "could not parse SID from result: #{outcome[:data][:result].inspect}" + end + + unless session_id + @runner.run('session interaction skipped') do + raise SkipTest, 'no session opened -- cannot exercise interactive tools' + end + return + end + + # Step 4: confirm the session is in msf_session_list, and capture its + # type so the probe command can be chosen accordingly. + @runner.run("msf_session_list contains session #{session_id}") do + r = @client.call_tool('msf_session_list') + assert_tool_success(r) + data = parse_tool_data(r) + entry = data[:data][session_id.to_s.to_sym] + assert entry, "session #{session_id} not in list: #{session_ids_from(data).inspect}" + session_type = entry[:type].to_s + probe = probe_for(session_type) + end + + # Step 5: send the type-appropriate probe command. + @runner.run("msf_session_write sends #{probe[:description]} to #{session_type} session #{session_id}") do + r = @client.call_tool('msf_session_write', + { session_id: session_id, data: probe[:command] }) + assert_tool_success(r) + end + + # Step 6: poll session_read for the expected output (or, for DB/SMB/LDAP + # sessions where no universal probe response exists, verify only that + # the read call succeeds). + @runner.run("msf_session_read returns expected output from session #{session_id}") do + if probe[:verify] == :success_only + r = @client.call_tool('msf_session_read', { session_id: session_id }) + assert_tool_success(r) + else + matched = poll_until( + timeout: 15, + predicate: ->(data) { probe[:verify].call(data[:data][:data].to_s) } + ) { @client.call_tool('msf_session_read', { session_id: session_id }) } + assert matched, "probe output (#{probe[:description]}) not received within 15s for #{session_type} session" + end + end + + @runner.run("msf_session_stop terminates session #{session_id}") do + r = @client.call_tool('msf_session_stop', { session_id: session_id }) + assert_tool_success(r) + end + + @runner.run("msf_session_list no longer contains session #{session_id}") do + gone = poll_until( + timeout: 15, + predicate: ->(data) { !session_ids_from(data).include?(session_id) } + ) { @client.call_tool('msf_session_list') } + assert gone, "session #{session_id} still present after msf_session_stop" + end + end + + # --- Transport-level protocol violations -------------------------------- + + def test_protocol_violations + @runner.section('Transport protocol violations') + + @runner.run('POST without Accept header returns 406') do + response = @client.raw_post( + JSON.generate(jsonrpc: '2.0', id: 1, method: 'tools/list'), + headers: { 'Accept' => 'application/json', + 'Mcp-Session-Id' => @client.session_id } + ) + assert_equal 406, response.code.to_i + end + + @runner.run('POST with wrong Content-Type returns 415') do + response = @client.raw_post( + '{}', + headers: { 'Content-Type' => 'text/plain', + 'Mcp-Session-Id' => @client.session_id } + ) + assert_equal 415, response.code.to_i + end + + @runner.run('POST without session ID returns 400') do + stale_client = McpHttpClient.new(url: @options[:url], token: @options[:token], debug: @options[:debug]) + response = stale_client.raw_post( + JSON.generate(jsonrpc: '2.0', id: 1, method: 'tools/list') + ) + assert_equal 400, response.code.to_i + end + + @runner.run('POST with invalid bearer token returns 401') do + raise SkipTest, '--token not provided' unless @options[:token] + + bad_client = McpHttpClient.new(url: @options[:url], token: 'wrong-token', debug: @options[:debug]) + response = bad_client.raw_post( + JSON.generate(jsonrpc: '2.0', id: 1, method: 'tools/list') + ) + assert_equal 401, response.code.to_i + end + end + + private + + # Extract the structured payload from a tools/call success response. + def parse_tool_data(response) + structured = response.dig('result', 'structuredContent') + return symbolize_deeply(structured) if structured + + text = response.dig('result', 'content')&.first&.dig('text') + symbolize_deeply(JSON.parse(text)) + end + + def symbolize_deeply(obj) + case obj + when Hash then obj.each_with_object({}) { |(k, v), h| h[k.to_sym] = symbolize_deeply(v) } + when Array then obj.map { |v| symbolize_deeply(v) } + else obj + end + end + + def response_indicates_validation_error?(response) + # Validation errors are returned as tool responses with isError=true. + return true if response.dig('result', 'isError') + # SDK schema validation kicks in before our tool runs and returns a + # JSON-RPC error envelope (-32602 Invalid params). + return true if response['error'] + + false + end + + # `TYPE:NAME` -> [type, name]. Colon is unambiguous because module names + # contain forward slashes but never colons. + def split_module_spec(spec) + type, name = spec.to_s.split(':', 2) + if type.nil? || type.empty? || name.nil? || name.empty? + raise ArgumentError, "invalid module spec #{spec.inspect}, expected TYPE:NAME" + end + + [type, name] + end + + # Merge `defaults` with an array of `"KEY=VALUE"` overrides; overrides win. + def merge_options(defaults, overrides) + result = defaults.dup + Array(overrides).each do |entry| + key, raw_value = entry.to_s.split('=', 2) + if key.nil? || key.empty? || raw_value.nil? + raise ArgumentError, "invalid option #{entry.inspect}, expected KEY=VALUE" + end + + result[key] = coerce_option_value(raw_value) + end + result + end + + # Best-effort coercion so users can write `RPORT=8080` and not have to wrap + # numeric or boolean values. The MCP module-options schema accepts string, + # integer, number, boolean, and null, so this widens the accepted forms + # without breaking string-only options (which round-trip unchanged). + def coerce_option_value(value) + case value + when /\A-?\d+\z/ then value.to_i + when /\A-?\d+\.\d+\z/ then value.to_f + when /\Atrue\z/i then true + when /\Afalse\z/i then false + when /\Anull\z/i, '' then nil + else value + end + end + + # Assemble the datastore options passed to msf_module_execute. Kept in one + # place so the standalone execute test and the lifecycle test agree on the + # precedence rules (defaults < --rhost/--lhost/--lport < --execute-option). + def build_execute_options + defaults = @options[:execute_module].nil? ? { 'PORTS' => '22' } : {} + defaults['RHOSTS'] = @options[:rhost] if @options[:rhost] + defaults['LHOST'] = @options[:lhost] if @options[:lhost] + defaults['LPORT'] = @options[:lport] if @options[:lport] + merge_options(defaults, @options[:execute_option_overrides]) + end + + # Poll a tool call until the predicate returns truthy or `timeout` seconds + # elapse. Returns the parsed tool data on success, nil on timeout. + # + # poll_until(timeout: 30, predicate: ->(d) { d[:data][:status] == 'completed' }) do + # @client.call_tool('msf_module_results', { uuid: uuid }) + # end + # + def poll_until(timeout:, predicate:, interval: 1) + deadline = Time.now + timeout + last_data = nil + loop do + response = yield + assert_tool_success(response) + last_data = parse_tool_data(response) + return last_data if predicate.call(last_data) + + break if Time.now >= deadline + + sleep(interval) + end + nil + end + + # session.list returns a hash keyed by session id (as string in JSON). + # Return them as an Array so callers can compute set differences. + def session_ids_from(tool_data) + return [] unless tool_data.is_a?(Hash) + + hash = tool_data[:data] + return [] unless hash.is_a?(Hash) + + hash.keys.filter_map { |k| Integer(k.to_s, 10) rescue nil } + end + + # Pull the numeric SID out of ExploitDriver's completion message. Works + # for every session type because the driver formats it uniformly as: + # " session opened () at