diff --git a/data/vuln_envs/activemq.yml b/data/vuln_envs/activemq.yml index 479818be21544..a0d78e2ee5fee 100644 --- a/data/vuln_envs/activemq.yml +++ b/data/vuln_envs/activemq.yml @@ -33,9 +33,12 @@ 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 validation: expected_session: true @@ -45,4 +48,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..42e9cf3a508e1 100644 --- a/data/vuln_envs/wordpress.yml +++ b/data/vuln_envs/wordpress.yml @@ -63,13 +63,18 @@ shared: ci: exploit: - payload: php/meterpreter/reverse_tcp + # 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 LPORT: 4444 validation: expected_session: true - session_type: meterpreter + session_type: shell expected_output: "uid=" timeout: 120 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 diff --git a/plugins/test_env.rb b/plugins/test_env.rb index 53883191fcf08..a1fdec684b3c5 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' @@ -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 @@ -1958,6 +1968,121 @@ 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 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. + 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 + + # 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 ")