From a66875a641c01aa57c4ae5525134d09d82fc4a24 Mon Sep 17 00:00:00 2001 From: Nayeraneru Date: Sat, 1 Aug 2026 21:19:18 +0300 Subject: [PATCH 1/5] Add auto-apply recommended CI payload --- data/vuln_envs/activemq.yml | 8 ++++++-- data/vuln_envs/wordpress.yml | 2 +- plugins/test_env.rb | 10 ++++++++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/data/vuln_envs/activemq.yml b/data/vuln_envs/activemq.yml index 479818be21544..c9105c5cc5c5e 100644 --- a/data/vuln_envs/activemq.yml +++ b/data/vuln_envs/activemq.yml @@ -33,7 +33,11 @@ shared: ci: exploit: - payload: java/meterpreter/reverse_tcp + # The module's own auto-default (cmd/linux/ftp/x64/meterpreter/reverse_tcp) + # does not work: FTP isn't one of Metasploit's supported fetch-payload + # server protocols (only HTTP, HTTPS, SMB, TFTP), so it fails with + # "bad-config: Unsupported Binary Selected" every time. http works. + payload: cmd/linux/http/x64/meterpreter/reverse_tcp options: LHOST: 127.0.0.1 LPORT: 4444 @@ -45,4 +49,4 @@ shared: profiles: default: - description: Standard Apache ActiveMQ with web console and broker + description: Standard Apache ActiveMQ with web console and broker \ No newline at end of file diff --git a/data/vuln_envs/wordpress.yml b/data/vuln_envs/wordpress.yml index e2e5e2d9727bf..1b2c2a56bfc69 100644 --- a/data/vuln_envs/wordpress.yml +++ b/data/vuln_envs/wordpress.yml @@ -63,7 +63,7 @@ shared: ci: exploit: - payload: php/meterpreter/reverse_tcp + payload: php/reverse_php options: LHOST: 127.0.0.1 LPORT: 4444 diff --git a/plugins/test_env.rb b/plugins/test_env.rb index 53883191fcf08..be1953ee0bb2b 100644 --- a/plugins/test_env.rb +++ b/plugins/test_env.rb @@ -1675,6 +1675,16 @@ def build_resolve_environment(mod, env, options) print_status("Definition: #{definition_name} | Variant: #{variant} | Profile: #{profile}") print_status("Image: #{config['image']}") + # If the environment definition names a recommended payload (e.g. because + # the module's own auto-selected default is known not to work against + # this specific image/variant), apply it now, before the container is + # even started, so it's reflected if the user runs 'show options'. + recommended_payload = config.dig('ci', 'exploit', 'payload') + if recommended_payload && mod.datastore['PAYLOAD'] != recommended_payload + print_status("Setting recommended payload for this environment: #{recommended_payload}") + mod.datastore['PAYLOAD'] = recommended_payload + end + [definition_name, variant, profile, config, port_mapping] end From bdb98bf618d9c0ac34db003a2c2b9e464aeb90f5 Mon Sep 17 00:00:00 2001 From: Nayeraneru Date: Sat, 1 Aug 2026 23:03:17 +0300 Subject: [PATCH 2/5] Automated Exploit Execution --- data/vuln_envs/activemq.yml | 2 +- data/vuln_envs/wordpress.yml | 10 +++- plugins/test_env.rb | 93 +++++++++++++++++++++++++++++++++++- 3 files changed, 101 insertions(+), 4 deletions(-) diff --git a/data/vuln_envs/activemq.yml b/data/vuln_envs/activemq.yml index c9105c5cc5c5e..b31803cf381a6 100644 --- a/data/vuln_envs/activemq.yml +++ b/data/vuln_envs/activemq.yml @@ -39,7 +39,7 @@ shared: # "bad-config: Unsupported Binary Selected" every time. http works. payload: cmd/linux/http/x64/meterpreter/reverse_tcp options: - LHOST: 127.0.0.1 + #LHOST: 127.0.0.1 LPORT: 4444 validation: expected_session: true diff --git a/data/vuln_envs/wordpress.yml b/data/vuln_envs/wordpress.yml index 1b2c2a56bfc69..12aa0dd36e807 100644 --- a/data/vuln_envs/wordpress.yml +++ b/data/vuln_envs/wordpress.yml @@ -63,13 +63,19 @@ shared: ci: exploit: + # php/meterpreter/reverse_tcp does not work against this image: + # it has no PHP openssl extension (confirmed via + # `docker exec php -m | grep -i openssl` returning + # nothing), so Meterpreter can never negotiate TLV encryption + # and the session gets closed as invalid immediately after + # opening. Plain shell sidesteps encryption negotiation entirely. payload: php/reverse_php options: - LHOST: 127.0.0.1 + #LHOST: 127.0.0.1 LPORT: 4444 validation: expected_session: true - session_type: meterpreter + session_type: shell expected_output: "uid=" timeout: 120 diff --git a/plugins/test_env.rb b/plugins/test_env.rb index be1953ee0bb2b..a6e832c188f19 100644 --- a/plugins/test_env.rb +++ b/plugins/test_env.rb @@ -1505,7 +1505,7 @@ def cmd_test_env(*args) when 'remove-all' cmd_test_env_remove_all(args) when 'exec' - print_status("TODO: test_env exec") + cmd_test_env_exec(args) when 'status' cmd_test_env_status(args) when 'help' @@ -1968,6 +1968,97 @@ def cmd_test_env_list(_args = []) print_status("#{targets.length} environment(s) tracked.") end + def cmd_test_env_exec(args) + if args.empty? || args.first == '-h' || args.first == '--help' + print_line("Usage: test_env exec ") + print_line + print_line("Loads the module this environment was built for, applies its") + print_line("stored datastore and recommended payload (if any), and runs it.") + print_line("Equivalent to manually running the 'Suggested:' command printed") + print_line("by 'test_env build', but works from anywhere in the console -") + print_line("you do not need to 'use' the module first.") + return + end + + # --- Step A: look up the stored environment. Fail fast with a + # specific, actionable message rather than letting a nil target + # propagate into a confusing NoMethodError three lines down. This + # is the "improve error handling" deliverable in practice: every + # precondition gets its own guard and its own message. + id = args.shift.to_i + target = self.class.registry.get(id) + + unless target + print_error("Environment #{id} not found. Run 'test_env list' to see tracked environments.") + return + end + + unless target.running? + print_error("Environment #{id} is #{target.status}, not running.") + print_status("Run 'test_env start #{id}' first, then retry.") + return + end + + # --- Step B: load the module by fullname. Deliberately not using + # driver.active_module here - exec should work standalone, even if + # the console is currently pointed at a completely different module + # (or nothing at all). This makes 'exec' safe to call repeatedly in + # automation without tracking console state externally. + print_status("Using #{target.module_fullname}...") + driver.run_single("use #{target.module_fullname}") + + mod = driver.active_module + unless mod && mod.fullname == target.module_fullname + print_error("Could not load module '#{target.module_fullname}'. It may have been removed or renamed since this environment was built.") + return + end + + # --- Step C: re-resolve the environment definition to recover any + # ci.exploit recommendation (payload + options). This is NOT part + # of target.datastore - build_construct_datastore only stores + # RHOSTS/RPORT/TARGETURI/credentials, not PAYLOAD/LHOST/LPORT - so + # without this step 'exec' would silently fall back to whatever + # payload the module auto-selects, reintroducing the exact + # incompatible-default-payload problem 'build' already solves. + raw = mod.send(:module_info)['VulnerableEnvironment'] rescue nil + if raw + env_meta = VulnerableEnvironment.new(raw) + loader = EnvironmentDefinitionLoader.new(Msf::Config.data_directory) + config = loader.resolve(env_meta.definition, target.env_version, env_meta.profile, env_meta.overrides) rescue nil + ci_exploit = config&.dig('ci', 'exploit') || {} + + if ci_exploit['payload'] + print_status("Setting recommended payload for this environment: #{ci_exploit['payload']}") + driver.run_single("set PAYLOAD #{ci_exploit['payload']}") + end + + ci_exploit['options']&.each do |key, value| + driver.run_single("set #{key} #{value}") + end + end + + # --- Step D: apply the suggested datastore automatically. This + # reuses target.datastore directly rather than re-deriving RHOSTS/ + # RPORT/credentials by hand - single source of truth, and it's the + # exact same hash 'test_env build' already showed the user under + # "Suggested:", so what runs here always matches what was printed. + target.datastore.each do |key, value| + driver.run_single("set #{key} #{value}") + end + + # --- Step E: run it. driver.run_single("exploit") reuses the + # console's own exploit-execution path - AutoCheck, payload + # generation, session creation, and all success/failure messaging + # come from that well-tested path rather than being reimplemented + # here. See the design note above for why this matters. + print_status("Executing: #{target.exploit_command}") + driver.run_single("exploit") + rescue => e + print_error("test_env exec failed: #{e.class} - #{e.message}") + elog("test_env exec error: #{e.class} - #{e.message}") + elog(e.backtrace.join("\n")) + end + def cmd_test_env_start(args) if args.empty? print_error("Usage: test_env start ") From 96415e4270f925a6697f8b2c05e64ff4fa0a499c Mon Sep 17 00:00:00 2001 From: Nayeraneru Date: Sun, 2 Aug 2026 00:43:44 +0300 Subject: [PATCH 3/5] minor change --- data/vuln_envs/activemq.yml | 1 - data/vuln_envs/wordpress.yml | 1 - plugins/test_env.rb | 24 ++++++++++++------------ 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/data/vuln_envs/activemq.yml b/data/vuln_envs/activemq.yml index b31803cf381a6..a0d78e2ee5fee 100644 --- a/data/vuln_envs/activemq.yml +++ b/data/vuln_envs/activemq.yml @@ -39,7 +39,6 @@ shared: # "bad-config: Unsupported Binary Selected" every time. http works. payload: cmd/linux/http/x64/meterpreter/reverse_tcp options: - #LHOST: 127.0.0.1 LPORT: 4444 validation: expected_session: true diff --git a/data/vuln_envs/wordpress.yml b/data/vuln_envs/wordpress.yml index 12aa0dd36e807..42e9cf3a508e1 100644 --- a/data/vuln_envs/wordpress.yml +++ b/data/vuln_envs/wordpress.yml @@ -71,7 +71,6 @@ shared: # opening. Plain shell sidesteps encryption negotiation entirely. payload: php/reverse_php options: - #LHOST: 127.0.0.1 LPORT: 4444 validation: expected_session: true diff --git a/plugins/test_env.rb b/plugins/test_env.rb index a6e832c188f19..c943c4de6c18f 100644 --- a/plugins/test_env.rb +++ b/plugins/test_env.rb @@ -1980,11 +1980,11 @@ def cmd_test_env_exec(args) return end - # --- Step A: look up the stored environment. Fail fast with a + # Step A: look up the stored environment. Fail fast with a # specific, actionable message rather than letting a nil target - # propagate into a confusing NoMethodError three lines down. This + # propagate into a confusing NoMethodError three lines down. T=this # is the "improve error handling" deliverable in practice: every - # precondition gets its own guard and its own message. + # precondition gets its own guard and its own message id = args.shift.to_i target = self.class.registry.get(id) @@ -1999,11 +1999,11 @@ def cmd_test_env_exec(args) return end - # --- Step B: load the module by fullname. Deliberately not using + # Step B: load the module by fullname. Deliberately not using # driver.active_module here - exec should work standalone, even if # the console is currently pointed at a completely different module - # (or nothing at all). This makes 'exec' safe to call repeatedly in - # automation without tracking console state externally. + # (or nothing at all). this makes 'exec' safe to call repeatedly in + # automation without tracking console state externally print_status("Using #{target.module_fullname}...") driver.run_single("use #{target.module_fullname}") @@ -2013,13 +2013,13 @@ def cmd_test_env_exec(args) return end - # --- Step C: re-resolve the environment definition to recover any + # Step C: re-resolve the environment definition to recover any # ci.exploit recommendation (payload + options). This is NOT part # of target.datastore - build_construct_datastore only stores # RHOSTS/RPORT/TARGETURI/credentials, not PAYLOAD/LHOST/LPORT - so # without this step 'exec' would silently fall back to whatever # payload the module auto-selects, reintroducing the exact - # incompatible-default-payload problem 'build' already solves. + # incompatible-default-payload problem 'build' already solves raw = mod.send(:module_info)['VulnerableEnvironment'] rescue nil if raw env_meta = VulnerableEnvironment.new(raw) @@ -2037,20 +2037,20 @@ def cmd_test_env_exec(args) end end - # --- Step D: apply the suggested datastore automatically. This + # Step D: apply the suggested datastore automatically. This # reuses target.datastore directly rather than re-deriving RHOSTS/ # RPORT/credentials by hand - single source of truth, and it's the # exact same hash 'test_env build' already showed the user under - # "Suggested:", so what runs here always matches what was printed. + # "Suggested:", so what runs here always matches what was printed target.datastore.each do |key, value| driver.run_single("set #{key} #{value}") end - # --- Step E: run it. driver.run_single("exploit") reuses the + # Step E: run it. driver.run_single("exploit") reuses the # console's own exploit-execution path - AutoCheck, payload # generation, session creation, and all success/failure messaging # come from that well-tested path rather than being reimplemented - # here. See the design note above for why this matters. + # here. See the design note above for why this matters print_status("Executing: #{target.exploit_command}") driver.run_single("exploit") rescue => e From 0ce99400217f2b4577208a6bad837c520690057b Mon Sep 17 00:00:00 2001 From: Nayeraneru Date: Sun, 2 Aug 2026 01:07:05 +0300 Subject: [PATCH 4/5] Docs update --- docs/test_env/architecture/04-environment-schema.md | 2 +- docs/test_env/ci_workflow.md | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/test_env/architecture/04-environment-schema.md b/docs/test_env/architecture/04-environment-schema.md index ca0bd05126b32..0c06b29436e82 100644 --- a/docs/test_env/architecture/04-environment-schema.md +++ b/docs/test_env/architecture/04-environment-schema.md @@ -256,7 +256,6 @@ shared: exploit: payload: java/meterpreter/reverse_tcp options: - LHOST: 127.0.0.1 LPORT: 4444 validation: expected_session: true @@ -264,6 +263,7 @@ shared: expected_output: "uid=" timeout: 120 ``` +**NOTE:** LHOST is deliberately NOT set here. The target runs inside a container network namespace, so a hardcoded 127.0.0.1 resolves to the container itself, not the host - the payload can never call back. Metasploit's own outbound-interface auto-detection (used when LHOST is left unset) correctly picks the host's real reachable IP, which is what actually works. ### profiles Section diff --git a/docs/test_env/ci_workflow.md b/docs/test_env/ci_workflow.md index d5afe15f8c877..c172cd4821865 100644 --- a/docs/test_env/ci_workflow.md +++ b/docs/test_env/ci_workflow.md @@ -226,9 +226,8 @@ Environment definitions include a `ci` section so the automation knows what payl # data/vuln_envs/activemq.yml ci: exploit: - payload: java/meterpreter/reverse_tcp + payload: cmd/linux/http/x64/meterpreter/reverse_tcp options: - LHOST: 127.0.0.1 LPORT: 4444 validation: expected_session: true From 0b9b21fb3a6cb9df14fab1ab86ee0e0377d60ead Mon Sep 17 00:00:00 2001 From: Nayeraneru Date: Fri, 7 Aug 2026 18:50:56 +0300 Subject: [PATCH 5/5] fix --- plugins/test_env.rb | 48 +++++++++++++++++++++++++++++++++------------ 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/plugins/test_env.rb b/plugins/test_env.rb index c943c4de6c18f..a1fdec684b3c5 100644 --- a/plugins/test_env.rb +++ b/plugins/test_env.rb @@ -1980,11 +1980,11 @@ def cmd_test_env_exec(args) return end - # Step A: look up the stored environment. Fail fast with a + # --- Step A: look up the stored environment. Fail fast with a # specific, actionable message rather than letting a nil target - # propagate into a confusing NoMethodError three lines down. T=this + # propagate into a confusing NoMethodError three lines down. This # is the "improve error handling" deliverable in practice: every - # precondition gets its own guard and its own message + # precondition gets its own guard and its own message. id = args.shift.to_i target = self.class.registry.get(id) @@ -1999,11 +1999,11 @@ def cmd_test_env_exec(args) return end - # Step B: load the module by fullname. Deliberately not using + # --- Step B: load the module by fullname. Deliberately not using # driver.active_module here - exec should work standalone, even if # the console is currently pointed at a completely different module - # (or nothing at all). this makes 'exec' safe to call repeatedly in - # automation without tracking console state externally + # (or nothing at all). This makes 'exec' safe to call repeatedly in + # automation without tracking console state externally. print_status("Using #{target.module_fullname}...") driver.run_single("use #{target.module_fullname}") @@ -2013,13 +2013,13 @@ def cmd_test_env_exec(args) return end - # Step C: re-resolve the environment definition to recover any + # --- Step C: re-resolve the environment definition to recover any # ci.exploit recommendation (payload + options). This is NOT part # of target.datastore - build_construct_datastore only stores # RHOSTS/RPORT/TARGETURI/credentials, not PAYLOAD/LHOST/LPORT - so # without this step 'exec' would silently fall back to whatever # payload the module auto-selects, reintroducing the exact - # incompatible-default-payload problem 'build' already solves + # incompatible-default-payload problem 'build' already solves. raw = mod.send(:module_info)['VulnerableEnvironment'] rescue nil if raw env_meta = VulnerableEnvironment.new(raw) @@ -2037,20 +2037,33 @@ def cmd_test_env_exec(args) end end - # Step D: apply the suggested datastore automatically. This + # --- Step D: apply the suggested datastore automatically. This # reuses target.datastore directly rather than re-deriving RHOSTS/ # RPORT/credentials by hand - single source of truth, and it's the # exact same hash 'test_env build' already showed the user under - # "Suggested:", so what runs here always matches what was printed + # "Suggested:", so what runs here always matches what was printed. target.datastore.each do |key, value| driver.run_single("set #{key} #{value}") end - # Step E: run it. driver.run_single("exploit") reuses the + # --- Step D.5: avoid Rex::BindFailed from stale listeners on + # repeated runs. Some payloads (e.g. cmd/linux/http/.../reverse_tcp) + # bind a local server on this host to serve the stage/fetch content + # - by default on a fixed port (often 8080). If a previous 'exec' + # run's server didn't get torn down cleanly, that port stays bound + # and every subsequent run fails until someone manually finds and + # kills the stale process. Picking a fresh free port each time + # removes the collision entirely rather than requiring cleanup. + %w[SRVPORT FETCH_SRVPORT].each do |opt| + free_port = free_local_port + driver.run_single("set #{opt} #{free_port}") + end + + # --- Step E: run it. driver.run_single("exploit") reuses the # console's own exploit-execution path - AutoCheck, payload # generation, session creation, and all success/failure messaging # come from that well-tested path rather than being reimplemented - # here. See the design note above for why this matters + # here. See the design note above for why this matters. print_status("Executing: #{target.exploit_command}") driver.run_single("exploit") rescue => e @@ -2059,6 +2072,17 @@ def cmd_test_env_exec(args) elog(e.backtrace.join("\n")) end + # Asks the OS for an unused ephemeral port by binding to port 0 and + # reading back what it was assigned. Simpler and more reliable than + # maintaining our own "is this port free" bookkeeping for host-side + # (non-container) ports - the OS already tracks this correctly. + def free_local_port + server = TCPServer.new('0.0.0.0', 0) + port = server.addr[1] + server.close + port + end + def cmd_test_env_start(args) if args.empty? print_error("Usage: test_env start ")