Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions data/vuln_envs/activemq.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
11 changes: 8 additions & 3 deletions data/vuln_envs/wordpress.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <container> 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

Expand Down
2 changes: 1 addition & 1 deletion docs/test_env/architecture/04-environment-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,14 +256,14 @@ shared:
exploit:
payload: java/meterpreter/reverse_tcp
options:
LHOST: 127.0.0.1
LPORT: 4444
validation:
expected_session: true
session_type: meterpreter
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

Expand Down
3 changes: 1 addition & 2 deletions docs/test_env/ci_workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
127 changes: 126 additions & 1 deletion plugins/test_env.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 <ID>")
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 <ID>")
Expand Down
Loading