diff --git a/.github/workflows/test_env.yml b/.github/workflows/test_env.yml new file mode 100644 index 0000000000000..61d31043f8124 --- /dev/null +++ b/.github/workflows/test_env.yml @@ -0,0 +1,127 @@ +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 libpcap-dev + + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.3' + # bundler-cache: true removed — we run bundle install manually + # after system libraries are present + + - 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: | + gem install bundler --no-document + bundle install --jobs 4 --retry 3 + + - 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 \ No newline at end of file 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/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 31ed1fa0b3973..f17068872a5bc 100644 --- a/data/vuln_envs/openssh.yml +++ b/data/vuln_envs/openssh.yml @@ -24,8 +24,11 @@ shared: retries: 10 ci: + exploit: + force_exploit: true validation: expected_session: false + expected_output: "SSH" profiles: default: diff --git a/plugins/test_env.rb b/plugins/test_env.rb index 83cf6989fd192..87a3322431342 100644 --- a/plugins/test_env.rb +++ b/plugins/test_env.rb @@ -587,6 +587,7 @@ def available?(port) false end end + # ===================================================================== # VulnTarget — ActiveModel-based environment instance # ===================================================================== @@ -597,7 +598,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 validates :local_id, presence: true, numericality: { only_integer: true } @@ -1244,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 @@ -1258,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}" @@ -1269,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 +1300,28 @@ def print_error(msg) 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'] || '/' uri = URI("http://127.0.0.1:#{@host_port}#{path}") @@ -1575,6 +1607,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) @@ -1684,7 +1723,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 @@ -1992,7 +2031,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 @@ -2034,7 +2091,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'] != ci_exploit['payload'] print_status("Setting recommended payload for this environment: #{ci_exploit['payload']}") driver.run_single("set PAYLOAD #{ci_exploit['payload']}") end @@ -2042,6 +2099,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 @@ -2078,7 +2139,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) @@ -2201,7 +2269,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 @@ -2308,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) @@ -2344,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 diff --git a/scripts/ci_runner.sh b/scripts/ci_runner.sh new file mode 100755 index 0000000000000..2512debf316d4 --- /dev/null +++ b/scripts/ci_runner.sh @@ -0,0 +1,95 @@ +#!/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"