From 46e25fab2535a6c198b1a8e7f2d13b2d3f0a98cf Mon Sep 17 00:00:00 2001 From: Nayeraneru Date: Sun, 23 Aug 2026 22:35:21 +0300 Subject: [PATCH 01/14] Local Simulation for CI --- ci_scripts/activemq_jolokia.rc | 8 ++++++++ plugins/test_env.rb | 4 ++-- 2 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 ci_scripts/activemq_jolokia.rc diff --git a/ci_scripts/activemq_jolokia.rc b/ci_scripts/activemq_jolokia.rc new file mode 100644 index 0000000000000..768bb7cd6d4f0 --- /dev/null +++ b/ci_scripts/activemq_jolokia.rc @@ -0,0 +1,8 @@ +load test_env +use exploit/multi/http/apache_activemq_jolokia_rce +test_env build +set ExitOnSession false +test_env exec 1 +test_env validate 1 +test_env remove 1 +exit -y diff --git a/plugins/test_env.rb b/plugins/test_env.rb index 83cf6989fd192..7143fb065ae80 100644 --- a/plugins/test_env.rb +++ b/plugins/test_env.rb @@ -1684,7 +1684,7 @@ def build_resolve_environment(mod, env, options) # 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.options.include?('PAYLOAD') && mod.datastore['PAYLOAD'] != recommended_payload + if recommended_payload && !mod.datastore['PAYLOAD'].nil? && mod.datastore['PAYLOAD'] != recommended_payload print_status("Setting recommended payload for this environment: #{recommended_payload}") mod.datastore['PAYLOAD'] = recommended_payload end @@ -2034,7 +2034,7 @@ def cmd_test_env_exec(args) 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'] && mod.options.include?('PAYLOAD') + if ci_exploit['payload'] && !mod.datastore['PAYLOAD'].nil? print_status("Setting recommended payload for this environment: #{ci_exploit['payload']}") driver.run_single("set PAYLOAD #{ci_exploit['payload']}") end From 023ed48044b746d2ec15429c40f19f7220ace81a Mon Sep 17 00:00:00 2001 From: Nayeraneru Date: Sun, 23 Aug 2026 22:53:40 +0300 Subject: [PATCH 02/14] Fix interactive session for the user side --- plugins/test_env.rb | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/plugins/test_env.rb b/plugins/test_env.rb index 7143fb065ae80..d77b4440e126d 100644 --- a/plugins/test_env.rb +++ b/plugins/test_env.rb @@ -1992,7 +1992,25 @@ def cmd_test_env_exec(args) # 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 + id = nil + background = false + + args.each do |arg| + case arg + when '-z', '--background' + background=true + when /^-/ + print_warning("Unknown option: #{arg}") + else + id = arg.to_i if id.nil? + end + end + + unless id + print_error("No environment ID specified.") + return + end + target = self.class.registry.get(id) unless target @@ -2078,7 +2096,14 @@ def cmd_test_env_exec(args) # 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. - action = mod.type == 'auxiliary' ? 'run' : 'exploit' + action = if mod.type == 'auxiliary' + 'run' + elsif background + 'exploit -z' + else + 'exploit' + end + opts = applied.map { |k, v| "#{k}=#{v}" }.join(' ') print_status("Executing: #{action} #{opts}") driver.run_single(action) From a1e016abd989dbbb380c96f533c58fdd134b3f4c Mon Sep 17 00:00:00 2001 From: Nayeraneru Date: Mon, 24 Aug 2026 01:21:11 +0300 Subject: [PATCH 03/14] Build the CI Wrapper Shell Script --- ci_scripts/activemq_jolokia.rc | 3 +- scripts/ci_runner.sh | 93 ++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 2 deletions(-) create mode 100755 scripts/ci_runner.sh diff --git a/ci_scripts/activemq_jolokia.rc b/ci_scripts/activemq_jolokia.rc index 768bb7cd6d4f0..0487508147ecc 100644 --- a/ci_scripts/activemq_jolokia.rc +++ b/ci_scripts/activemq_jolokia.rc @@ -1,8 +1,7 @@ load test_env use exploit/multi/http/apache_activemq_jolokia_rce test_env build -set ExitOnSession false -test_env exec 1 +test_env exec 1 -z test_env validate 1 test_env remove 1 exit -y diff --git a/scripts/ci_runner.sh b/scripts/ci_runner.sh new file mode 100755 index 0000000000000..b9983aa7c52f4 --- /dev/null +++ b/scripts/ci_runner.sh @@ -0,0 +1,93 @@ +#!/bin/bash +set -euo pipefail + +# ===================================================================== +# CI Runner for test_env — bridges msfconsole resource scripts to +# standard Unix exit codes for GitHub Actions / any CI system. +# ===================================================================== + +MODULE="${1:-}" +VARIANT="${2:-}" +PROFILE="${3:-default}" +EXIT_CODE=1 + +# --------------------------------------------------------------------- +# Validate arguments +# --------------------------------------------------------------------- +if [[ -z "$MODULE" || -z "$VARIANT" ]]; then + echo "Usage: $0 [profile]" + echo "" + echo "Examples:" + echo " $0 exploit/multi/http/apache_activemq_jolokia_rce 5.18.6 default" + echo " $0 exploit/multi/misc/apache_activemq_rce_cve_2023_46604 5.18.2 broker-only" + echo " $0 exploit/unix/webapp/wp_admin_shell_upload latest default" + exit 1 +fi + +# --------------------------------------------------------------------- +# Validate environment +# --------------------------------------------------------------------- +if [[ ! -x "./msfconsole" ]]; then + echo "Error: ./msfconsole not found or not executable." + echo "Run this script from the metasploit-framework root directory." + exit 1 +fi + +# --------------------------------------------------------------------- +# Generate unique temp files (supports parallel CI jobs) +# --------------------------------------------------------------------- +TIMESTAMP=$(date +%s)_$$ +RC_FILE="/tmp/test_env_${TIMESTAMP}.rc" +LOG_FILE="/tmp/test_env_${TIMESTAMP}.log" + +# --------------------------------------------------------------------- +# Build the Metasploit resource script dynamically +# --------------------------------------------------------------------- +cat > "$RC_FILE" <>> CI Runner Started" +echo ">>> Module: $MODULE" +echo ">>> Variant: $VARIANT" +echo ">>> Profile: $PROFILE" +echo ">>> RC Script: $RC_FILE" +echo ">>> Log File: $LOG_FILE" +echo "" + +# --------------------------------------------------------------------- +# Execute msfconsole headlessly +# --------------------------------------------------------------------- +echo ">>> Running msfconsole..." +./msfconsole -q -n -r "$RC_FILE" | tee "$LOG_FILE" + +# --------------------------------------------------------------------- +# Parse result from log (PASS / FAIL / unclear) +# --------------------------------------------------------------------- +echo "" +if grep -q "FAIL:" "$LOG_FILE"; then + echo ">>> RESULT: VALIDATION FAILED" + EXIT_CODE=1 +elif grep -q "PASS:" "$LOG_FILE"; then + echo ">>> RESULT: VALIDATION PASSED" + EXIT_CODE=0 +else + echo ">>> RESULT: UNCLEAR — no PASS or FAIL found in output" + echo ">>> This usually means the exploit or health check failed early." + EXIT_CODE=1 +fi + +# --------------------------------------------------------------------- +# Cleanup and report +# --------------------------------------------------------------------- +rm -f "$RC_FILE" +echo ">>> Full log preserved at: $LOG_FILE" +echo ">>> CI Runner Finished" + +exit "$EXIT_CODE" From beadd583e44fddfb80bf004919ab98aeec7267ab Mon Sep 17 00:00:00 2001 From: Nayeraneru Date: Mon, 24 Aug 2026 01:27:34 +0300 Subject: [PATCH 04/14] fix race condition for TCP-based profiles --- scripts/ci_runner.sh | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/scripts/ci_runner.sh b/scripts/ci_runner.sh index b9983aa7c52f4..75c6e2a1f638d 100755 --- a/scripts/ci_runner.sh +++ b/scripts/ci_runner.sh @@ -33,6 +33,15 @@ if [[ ! -x "./msfconsole" ]]; then exit 1 fi +# --------------------------------------------------------------------- +# Conditionally force exploit for modules with weak TCP health checks +# where AutoCheck can race against protocol initialization. +# --------------------------------------------------------------------- +FORCE_EXPLOIT="" +if [[ "$MODULE" == "exploit/multi/misc/apache_activemq_rce_cve_2023_46604" ]]; then + FORCE_EXPLOIT="set ForceExploit true" +fi + # --------------------------------------------------------------------- # Generate unique temp files (supports parallel CI jobs) # --------------------------------------------------------------------- @@ -46,6 +55,7 @@ LOG_FILE="/tmp/test_env_${TIMESTAMP}.log" cat > "$RC_FILE" <>> CI Runner Started" echo ">>> Module: $MODULE" echo ">>> Variant: $VARIANT" echo ">>> Profile: $PROFILE" +[[ -n "$FORCE_EXPLOIT" ]] && echo ">>> Override: ForceExploit enabled (TCP health-check race mitigation)" echo ">>> RC Script: $RC_FILE" echo ">>> Log File: $LOG_FILE" echo "" + # --------------------------------------------------------------------- # Execute msfconsole headlessly # --------------------------------------------------------------------- From 1af36b8009167774ff1e9581482a392848dab0e1 Mon Sep 17 00:00:00 2001 From: Nayeraneru Date: Mon, 24 Aug 2026 01:56:37 +0300 Subject: [PATCH 05/14] Better fix race condition for TCP-based profiles --- data/vuln_envs/activemq.yml | 5 ++++- plugins/test_env.rb | 6 +++++- scripts/ci_runner.sh | 10 ---------- 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/data/vuln_envs/activemq.yml b/data/vuln_envs/activemq.yml index 6ac9ce666eff1..b1f304d085a4d 100644 --- a/data/vuln_envs/activemq.yml +++ b/data/vuln_envs/activemq.yml @@ -58,4 +58,7 @@ profiles: broker-only: description: Web console not assumed reachable; only the broker port is health-checked health_check: - type: tcp \ No newline at end of file + type: tcp + ci: + exploit: + force_exploit: true \ No newline at end of file diff --git a/plugins/test_env.rb b/plugins/test_env.rb index d77b4440e126d..712e4668b693b 100644 --- a/plugins/test_env.rb +++ b/plugins/test_env.rb @@ -2052,7 +2052,7 @@ def cmd_test_env_exec(args) 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'] && !mod.datastore['PAYLOAD'].nil? + if ci_exploit['payload'] && mod.datastore['PAYLOAD'] != ci_exploit['payload'] print_status("Setting recommended payload for this environment: #{ci_exploit['payload']}") driver.run_single("set PAYLOAD #{ci_exploit['payload']}") end @@ -2060,6 +2060,10 @@ def cmd_test_env_exec(args) ci_exploit['options']&.each do |key, value| driver.run_single("set #{key} #{value}") end + + if ci_exploit['force_exploit'] + driver.run_single("set ForceExploit true") + end end # --- Step D: apply the suggested datastore automatically. This diff --git a/scripts/ci_runner.sh b/scripts/ci_runner.sh index 75c6e2a1f638d..2512debf316d4 100755 --- a/scripts/ci_runner.sh +++ b/scripts/ci_runner.sh @@ -33,14 +33,6 @@ if [[ ! -x "./msfconsole" ]]; then exit 1 fi -# --------------------------------------------------------------------- -# Conditionally force exploit for modules with weak TCP health checks -# where AutoCheck can race against protocol initialization. -# --------------------------------------------------------------------- -FORCE_EXPLOIT="" -if [[ "$MODULE" == "exploit/multi/misc/apache_activemq_rce_cve_2023_46604" ]]; then - FORCE_EXPLOIT="set ForceExploit true" -fi # --------------------------------------------------------------------- # Generate unique temp files (supports parallel CI jobs) @@ -55,7 +47,6 @@ LOG_FILE="/tmp/test_env_${TIMESTAMP}.log" cat > "$RC_FILE" <>> CI Runner Started" echo ">>> Module: $MODULE" echo ">>> Variant: $VARIANT" echo ">>> Profile: $PROFILE" -[[ -n "$FORCE_EXPLOIT" ]] && echo ">>> Override: ForceExploit enabled (TCP health-check race mitigation)" echo ">>> RC Script: $RC_FILE" echo ">>> Log File: $LOG_FILE" echo "" From 0f77f92be5ead608d3e577ce49406bb9f81ef7d3 Mon Sep 17 00:00:00 2001 From: Nayeraneru Date: Mon, 24 Aug 2026 02:58:27 +0300 Subject: [PATCH 06/14] ci: add test_env validation workflow --- .github/workflows/test_env.yml | 123 +++++++++++++++++++++++++++++++++ data/vuln_envs/openssh.yml | 1 + 2 files changed, 124 insertions(+) create mode 100644 .github/workflows/test_env.yml diff --git a/.github/workflows/test_env.yml b/.github/workflows/test_env.yml new file mode 100644 index 0000000000000..e972502044920 --- /dev/null +++ b/.github/workflows/test_env.yml @@ -0,0 +1,123 @@ +name: test_env CI Validation + +on: + push: + branches: [ master, vulnenv, vulnenv-week11, vulnenv-week12 ] + pull_request: + branches: [ master, vulnenv ] + +jobs: + validate: + runs-on: ubuntu-latest + timeout-minutes: 30 + + strategy: + fail-fast: false + matrix: + include: + # ============================================================= + # EXPLOIT MODULES + # ============================================================= + + # ActiveMQ shared definition — HTTP/Jolokia (authenticated) + - id: activemq-jolokia + module: exploit/multi/http/apache_activemq_jolokia_rce + definition: activemq + variant: "5.18.6" + profile: default + desc: "ActiveMQ Jolokia RCE — shared def, HTTP health, auth" + + # ActiveMQ shared definition — OpenWire (unauthenticated) + - id: activemq-openwire + module: exploit/multi/misc/apache_activemq_rce_cve_2023_46604 + definition: activemq + variant: "5.18.2" + profile: broker-only + desc: "ActiveMQ OpenWire RCE — shared def, TCP health, unauth" + + # WordPress — provision + verify pipeline + - id: wordpress + module: exploit/unix/webapp/wp_admin_shell_upload + definition: wordpress + variant: latest + profile: default + desc: "WordPress Admin Shell — provision+verify, custom payload" + + # ============================================================= + # AUXILIARY MODULES (shared definitions) + # ============================================================= + + # httpd shared definition — 3 independent scanner modules + - id: httpd-version + module: auxiliary/scanner/http/http_version + definition: httpd + variant: "2.4.57" + profile: default + desc: "HTTP Version Scanner — auxiliary, shared httpd def" + + - id: httpd-header + module: auxiliary/scanner/http/http_header + definition: httpd + variant: "2.4.57" + profile: default + desc: "HTTP Header Scanner — auxiliary, shared httpd def" + + - id: httpd-robots + module: auxiliary/scanner/http/robots_txt + definition: httpd + variant: "2.4.57" + profile: default + desc: "HTTP Robots.txt Scanner — auxiliary, shared httpd def" + + # openssh — SSH scanner with TCP health check + - id: openssh-version + module: auxiliary/scanner/ssh/ssh_version + definition: openssh + variant: "7.2" + profile: default + desc: "SSH Version Scanner — auxiliary, TCP health" + + name: ${{ matrix.desc }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install system build dependencies + run: | + sudo apt-get update + sudo apt-get install -y build-essential libpq-dev libsqlite3-dev + + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.3' + bundler-cache: true + + - name: Verify Docker runtime + run: | + docker --version + docker info + docker run --rm hello-world + + - name: Ensure CI runner is executable + run: chmod +x ./scripts/ci_runner.sh + + - name: Install framework dependencies + run: bundle install + + - name: Run test_env validation + run: | + ./scripts/ci_runner.sh \ + "${{ matrix.module }}" \ + "${{ matrix.variant }}" \ + "${{ matrix.profile }}" + timeout-minutes: 15 + + - name: Upload logs on completion + if: always() + uses: actions/upload-artifact@v4 + with: + name: msf-logs-${{ matrix.id }} + path: /tmp/test_env_*.log + if-no-files-found: warn diff --git a/data/vuln_envs/openssh.yml b/data/vuln_envs/openssh.yml index 31ed1fa0b3973..537831eb7def8 100644 --- a/data/vuln_envs/openssh.yml +++ b/data/vuln_envs/openssh.yml @@ -26,6 +26,7 @@ shared: ci: validation: expected_session: false + force_exploit: true profiles: default: From d9aeccb4f3c97d92ca477e352be73b640850b969 Mon Sep 17 00:00:00 2001 From: Nayeraneru Date: Mon, 24 Aug 2026 19:23:19 +0300 Subject: [PATCH 07/14] Fix system dependency --- .github/workflows/test_env.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test_env.yml b/.github/workflows/test_env.yml index e972502044920..b5f2cd88113a1 100644 --- a/.github/workflows/test_env.yml +++ b/.github/workflows/test_env.yml @@ -6,6 +6,7 @@ on: pull_request: branches: [ master, vulnenv ] + jobs: validate: runs-on: ubuntu-latest @@ -92,7 +93,8 @@ jobs: uses: ruby/setup-ruby@v1 with: ruby-version: '3.3' - bundler-cache: true + # bundler-cache: true removed — we run bundle install manually + # after system libraries are present - name: Verify Docker runtime run: | @@ -104,7 +106,9 @@ jobs: run: chmod +x ./scripts/ci_runner.sh - name: Install framework dependencies - run: bundle install + run: | + gem install bundler --no-document + bundle install --jobs 4 --retry 3 - name: Run test_env validation run: | @@ -120,4 +124,4 @@ jobs: with: name: msf-logs-${{ matrix.id }} path: /tmp/test_env_*.log - if-no-files-found: warn + if-no-files-found: warn \ No newline at end of file From c0f617cd19d4aa455bc1d4391f0b3030af9e4f67 Mon Sep 17 00:00:00 2001 From: Nayeraneru Date: Mon, 24 Aug 2026 19:45:11 +0300 Subject: [PATCH 08/14] Add dependency --- .github/workflows/test_env.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test_env.yml b/.github/workflows/test_env.yml index b5f2cd88113a1..61d31043f8124 100644 --- a/.github/workflows/test_env.yml +++ b/.github/workflows/test_env.yml @@ -87,7 +87,7 @@ jobs: - name: Install system build dependencies run: | sudo apt-get update - sudo apt-get install -y build-essential libpq-dev libsqlite3-dev + sudo apt-get install -y build-essential libpq-dev libsqlite3-dev libpcap-dev - name: Setup Ruby uses: ruby/setup-ruby@v1 From 276072fc9f7a16bfdd103a221cc738f253a40c15 Mon Sep 17 00:00:00 2001 From: Nayeraneru Date: Tue, 25 Aug 2026 18:15:50 +0300 Subject: [PATCH 09/14] Add a Protocol Grace Period for TCP Checks --- plugins/test_env.rb | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/plugins/test_env.rb b/plugins/test_env.rb index 712e4668b693b..ea6131f0bec28 100644 --- a/plugins/test_env.rb +++ b/plugins/test_env.rb @@ -1575,6 +1575,13 @@ def cmd_test_env_build(args) # Step 14: health check BEFORE registering return unless build_wait_for_health(runtime, container_id, config, port_mapping, allocated_ports) + # TCP health checks confirm liveness (port open) but not readiness + # (protocol fully initialized). Some services (e.g. ActiveMQ OpenWire) + # accept connections before the protocol handler is ready. + if config.dig('health_check', 'type') == 'tcp' + print_status("TCP port open; waiting 5 seconds for protocol initialization...") + sleep 5 + end # Step 15: build the datastore (RHOSTS/RPORT/credentials/etc.) datastore = build_construct_datastore(config, allocated_ports, port_mapping) From 13f43042389d84976a6cbb27b3b21894ee9cbaed Mon Sep 17 00:00:00 2001 From: Nayeraneru Date: Fri, 28 Aug 2026 22:22:10 +0300 Subject: [PATCH 10/14] Service probe for auxiliary modules --- data/vuln_envs/httpd.yml | 1 + data/vuln_envs/openssh.yml | 1 + plugins/test_env.rb | 81 ++++++++++++++++++++++++++++++++++++-- 3 files changed, 80 insertions(+), 3 deletions(-) diff --git a/data/vuln_envs/httpd.yml b/data/vuln_envs/httpd.yml index b4159b5d7c401..5014c6617a44a 100644 --- a/data/vuln_envs/httpd.yml +++ b/data/vuln_envs/httpd.yml @@ -22,6 +22,7 @@ shared: ci: validation: expected_session: false + expected_output: "Apache" profiles: default: diff --git a/data/vuln_envs/openssh.yml b/data/vuln_envs/openssh.yml index 537831eb7def8..79367874f7671 100644 --- a/data/vuln_envs/openssh.yml +++ b/data/vuln_envs/openssh.yml @@ -27,6 +27,7 @@ shared: validation: expected_session: false force_exploit: true + expected_output: "SSH" profiles: default: diff --git a/plugins/test_env.rb b/plugins/test_env.rb index ea6131f0bec28..e53b6e8eb7b27 100644 --- a/plugins/test_env.rb +++ b/plugins/test_env.rb @@ -11,6 +11,7 @@ require 'timeout' require 'net/http' require 'rex/text/table' +require 'delegate' module Msf class Plugin::TestEnv < Msf::Plugin @@ -587,6 +588,8 @@ def available?(port) false end end + + # ===================================================================== # VulnTarget — ActiveModel-based environment instance # ===================================================================== @@ -597,6 +600,7 @@ class VulnTarget attr_accessor :local_id, :container_id, :module_fullname, :env_version, :runtime, :image_ref, :status, :datastore, :allocated_ports, :created_at, :started_at, :stopped_at, :removed_at, :temp_dirs + validates :container_id, presence: true validates :module_fullname, presence: true @@ -669,7 +673,7 @@ def to_h 'created_at' => created_at&.iso8601, 'started_at' => started_at&.iso8601, 'stopped_at' => stopped_at&.iso8601, - 'removed_at' => removed_at&.iso8601 + 'removed_at' => removed_at&.iso8601, } end @@ -688,7 +692,7 @@ def self.from_h(hash) created_at: parse_time(hash['created_at']), started_at: parse_time(hash['started_at']), stopped_at: parse_time(hash['stopped_at']), - removed_at: parse_time(hash['removed_at']) + removed_at: parse_time(hash['removed_at']), ) end @@ -962,6 +966,10 @@ def remove_all @store.save(@vuln_env) end + def save_state + @store.save(@vuln_env) + end + def prune(runtime) return unless runtime @@ -2237,7 +2245,74 @@ def cmd_test_env_validate(args) print_status("Validating environment #{id} (#{target.module_fullname}) against #{env_meta.definition}'s ci.validation...") unless expected_session - print_good("PASS: ci.validation does not require a session.") + print_status("No session required; validating service behavior...") + + expected_text = validation['expected_output'] + probe_port = target.allocated_ports.values.first + + if expected_text && !expected_text.empty? + response = nil + + # Probe based on the environment's health check type + case config.dig('health_check', 'type') + when 'http' + begin + uri = URI("http://127.0.0.1:#{probe_port}/") + http = Net::HTTP.new(uri.host, uri.port) + http.open_timeout = 5 + http.read_timeout = 5 + response = http.request(Net::HTTP::Get.new(uri)) + response_body = response.body.to_s + response_headers = response.to_hash.map { |k, v| "#{k}: #{v.join}" }.join("\n") + response = "#{response_headers}\n\n#{response_body}" + rescue => e + print_error("FAIL: HTTP probe failed: #{e.message}") + return + end + + when 'tcp' + begin + socket = TCPSocket.new('127.0.0.1', probe_port) + response = socket.gets.to_s + socket.close + rescue => e + print_error("FAIL: TCP probe failed: #{e.message}") + return + end + end + + if response && response.include?(expected_text) + print_good("Service response contains expected text: '#{expected_text}'") + else + print_error("FAIL: service response did not contain '#{expected_text}'") + print_status("--- Response (first 400 chars) ---") + print_status(response.to_s[0..400]) + print_status("----------------------------------") + return + end + else + print_warning("No ci.validation.expected_output defined for this auxiliary module.") + print_warning("Falling back to smoke test (module executed without errors).") + end + + # Layer 2: verify service is still healthy after the scan + begin + if config && config['health_check'] + primary_port = target.allocated_ports[env_meta.port_mapping.key('RPORT')] + health_port = primary_port || target.allocated_ports.values.first + runtime = RuntimeAdapter.detect rescue nil + + if runtime && health_port + HealthManager.new(runtime, target.container_id, + config['health_check'], health_port, self).wait + end + end + rescue => e + print_error("FAIL: environment unhealthy after module execution: #{e.message}") + return + end + + print_good("PASS: environment validated successfully against #{env_meta.definition}'s ci.validation.") return end From ee1fd47ceb7b918be0b5e424506c3c47cbc5f001 Mon Sep 17 00:00:00 2001 From: Nayeraneru Date: Fri, 28 Aug 2026 22:36:04 +0300 Subject: [PATCH 11/14] minor fix --- plugins/test_env.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/plugins/test_env.rb b/plugins/test_env.rb index e53b6e8eb7b27..33eb66c19a058 100644 --- a/plugins/test_env.rb +++ b/plugins/test_env.rb @@ -11,7 +11,6 @@ require 'timeout' require 'net/http' require 'rex/text/table' -require 'delegate' module Msf class Plugin::TestEnv < Msf::Plugin From 4bcdaa0bdf9be352c80cc4563d70071050a504bb Mon Sep 17 00:00:00 2001 From: Nayeraneru Date: Fri, 28 Aug 2026 22:58:26 +0300 Subject: [PATCH 12/14] Removing the unnecessary --- ci_scripts/activemq_jolokia.rc | 7 ------- plugins/test_env.rb | 10 ++-------- 2 files changed, 2 insertions(+), 15 deletions(-) delete mode 100644 ci_scripts/activemq_jolokia.rc diff --git a/ci_scripts/activemq_jolokia.rc b/ci_scripts/activemq_jolokia.rc deleted file mode 100644 index 0487508147ecc..0000000000000 --- a/ci_scripts/activemq_jolokia.rc +++ /dev/null @@ -1,7 +0,0 @@ -load test_env -use exploit/multi/http/apache_activemq_jolokia_rce -test_env build -test_env exec 1 -z -test_env validate 1 -test_env remove 1 -exit -y diff --git a/plugins/test_env.rb b/plugins/test_env.rb index 33eb66c19a058..eb3d2124d077c 100644 --- a/plugins/test_env.rb +++ b/plugins/test_env.rb @@ -588,7 +588,6 @@ def available?(port) end end - # ===================================================================== # VulnTarget — ActiveModel-based environment instance # ===================================================================== @@ -600,7 +599,6 @@ class VulnTarget :runtime, :image_ref, :status, :datastore, :allocated_ports, :created_at, :started_at, :stopped_at, :removed_at, :temp_dirs - validates :container_id, presence: true validates :module_fullname, presence: true validates :local_id, presence: true, numericality: { only_integer: true } @@ -672,7 +670,7 @@ def to_h 'created_at' => created_at&.iso8601, 'started_at' => started_at&.iso8601, 'stopped_at' => stopped_at&.iso8601, - 'removed_at' => removed_at&.iso8601, + 'removed_at' => removed_at&.iso8601 } end @@ -691,7 +689,7 @@ def self.from_h(hash) created_at: parse_time(hash['created_at']), started_at: parse_time(hash['started_at']), stopped_at: parse_time(hash['stopped_at']), - removed_at: parse_time(hash['removed_at']), + removed_at: parse_time(hash['removed_at']) ) end @@ -965,10 +963,6 @@ def remove_all @store.save(@vuln_env) end - def save_state - @store.save(@vuln_env) - end - def prune(runtime) return unless runtime From 574a820f8df911e35b28b0cee05778193601a1f5 Mon Sep 17 00:00:00 2001 From: Nayeraneru Date: Wed, 2 Sep 2026 15:36:55 +0300 Subject: [PATCH 13/14] Fix Provision logic to run_once --- data/vuln_envs/openssh.yml | 3 ++- plugins/test_env.rb | 31 +++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/data/vuln_envs/openssh.yml b/data/vuln_envs/openssh.yml index 79367874f7671..f17068872a5bc 100644 --- a/data/vuln_envs/openssh.yml +++ b/data/vuln_envs/openssh.yml @@ -24,9 +24,10 @@ shared: retries: 10 ci: + exploit: + force_exploit: true validation: expected_session: false - force_exploit: true expected_output: "SSH" profiles: diff --git a/plugins/test_env.rb b/plugins/test_env.rb index eb3d2124d077c..e74c08632961d 100644 --- a/plugins/test_env.rb +++ b/plugins/test_env.rb @@ -1245,6 +1245,8 @@ def check_command # schema or admin account yet, so nothing is actually exploitable until # this runs. class Provisioner + PROVISION_MARKER = '/tmp/.msf_test_env_provisioned'.freeze + def initialize(runtime, container_id, provision_config, host_port, dispatcher = nil) @runtime = runtime @container_id = container_id @@ -1259,6 +1261,11 @@ def initialize(runtime, container_id, provision_config, host_port, dispatcher = def run(datastore = {}) return true if @config.empty? + if @config['run_once'] && already_provisioned? + print_status("Provisioning already completed (run_once). Skipping.") + return true + end + type = @config['type'] unless type == 'http_post' raise "Unknown provision type: #{type.inspect}" @@ -1270,6 +1277,8 @@ def run(datastore = {}) post_http(datastore) end + mark_provisioned! if @config['run_once'] + print_good("Provisioning request sent.") true rescue => e @@ -1290,6 +1299,28 @@ def print_error(msg) end private + + # Checks whether the provision marker file exists inside the + # container. This allows run_once provisioning to survive container + # stops/starts and state reconstruction. + def already_provisioned? + output, exit_code = @runtime.exec(@container_id, "test -f #{PROVISION_MARKER}") + exit_code == 0 + rescue => e + # If we can't check, assume not provisioned and proceed + false + end + + # Creates the provision marker file inside the container so that + # future start/restart operations know provisioning is complete. + def mark_provisioned! + @runtime.exec(@container_id, "touch #{PROVISION_MARKER}") + rescue => e + # Non-fatal: provisioning succeeded but marker could not be written. + # The worst case is that a future restart might re-run provisioning, + # which for idempotent operations (like form submissions) is harmless. + print_warning("Could not write provision marker: #{e.message}") + end def post_http(datastore) path = @config['path'] || '/' From 7a7b80c6cdd82afaa7085af32333527f7ac31b83 Mon Sep 17 00:00:00 2001 From: Nayeraneru Date: Wed, 2 Sep 2026 17:29:06 +0300 Subject: [PATCH 14/14] Fix restarted provisioned environments --- plugins/test_env.rb | 39 ++++++++++++++++++++++++++------------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/plugins/test_env.rb b/plugins/test_env.rb index e74c08632961d..87a3322431342 100644 --- a/plugins/test_env.rb +++ b/plugins/test_env.rb @@ -1299,7 +1299,7 @@ def print_error(msg) end private - + # Checks whether the provision marker file exists inside the # container. This allows run_once provisioning to survive container # stops/starts and state reconstruction. @@ -2443,7 +2443,7 @@ def cmd_test_env_start(args) return end - # start accepts single ID for safety (per your Week 1 spec) + # start accepts single ID id = args.first.to_i target = self.class.registry.get(id) @@ -2479,17 +2479,30 @@ def cmd_test_env_start(args) config = loader.resolve(env_meta.definition, target.env_version, env_meta.profile, env_meta.overrides) rescue nil - if config && config['health_check'] - # Determine which host port to health-check - primary_port = target.allocated_ports[env_meta.port_mapping.key('RPORT')] - health_port = primary_port || target.allocated_ports.values.first - - begin - HealthManager.new(runtime, target.container_id, - config['health_check'], health_port, self).wait - rescue => e - print_warning("Health check warning after start: #{e.message}") - # Container started; we still mark running but warn user + if config + # For environments that were provisioned during build, the + # service state after restart is post-provision (e.g. WordPress + # returns 200 at /, not 302 to the install wizard). Use verify + # instead of the base health_check to confirm the configured + # state is restored. + check_config = if config['provision'] && config['verify'] + config['verify'] + else + config['health_check'] + end + + if check_config + primary_port = target.allocated_ports[env_meta.port_mapping.key('RPORT')] + health_port = primary_port || target.allocated_ports.values.first + + begin + HealthManager.new(runtime, target.container_id, + check_config, health_port, self).wait + rescue => e + print_warning("Health check warning after start: #{e.message}") + # Container started; we still mark running but warn user + + end end end end