diff --git a/data/vuln_envs/activemq.yml b/data/vuln_envs/activemq.yml index a0d78e2ee5fee..6ac9ce666eff1 100644 --- a/data/vuln_envs/activemq.yml +++ b/data/vuln_envs/activemq.yml @@ -2,11 +2,17 @@ name: activemq description: Apache ActiveMQ Classic with Jolokia API variants: + #Used by exploit/multi/http/apache_activemq_jolokia_rce - name: "5.18.6" version: "5.18.6" image: docker.io/apache/activemq-classic:5.18.6 default: true + #Used by exploit/multi/misc/apache_activemq_rce_cve_2023_46604 + - name: "5.18.2" + version: "5.18.2" + image: docker.io/dinifarb/activemq:5.18.2 + shared: ports: web: 8161 @@ -48,4 +54,8 @@ shared: profiles: default: - description: Standard Apache ActiveMQ with web console and broker \ No newline at end of file + description: Standard ActiveMQ; web console reachable via HTTP/Jolokia. + 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 diff --git a/data/vuln_envs/httpd.yml b/data/vuln_envs/httpd.yml new file mode 100644 index 0000000000000..b4159b5d7c401 --- /dev/null +++ b/data/vuln_envs/httpd.yml @@ -0,0 +1,28 @@ +name: httpd +description: Apache HTTP Server for scanner testing + +variants: + - name: "2.4.57" + version: "2.4.57" + image: docker.io/library/httpd:2.4.57 + default: true + +shared: + ports: + http: 80 + + health_check: + type: http + path: / + expected_status: 200 + interval: 2 + timeout: 2 + retries: 10 + + ci: + validation: + expected_session: false + +profiles: + default: + description: Standard Apache HTTP Server \ No newline at end of file diff --git a/data/vuln_envs/openssh.yml b/data/vuln_envs/openssh.yml new file mode 100644 index 0000000000000..31ed1fa0b3973 --- /dev/null +++ b/data/vuln_envs/openssh.yml @@ -0,0 +1,32 @@ +name: openssh +description: OpenSSH server for SSH scanner testing + +variants: + - name: "7.2" + version: "7.2p2" + image: docker.io/rastasheep/ubuntu-sshd:16.04 + default: true + +shared: + ports: + ssh: 22 + + credentials: + default: + username: root + password: root + + # TCP check is enough: if port 22 accepts a connection, SSH is ready + health_check: + type: tcp + interval: 2 + timeout: 2 + retries: 10 + + ci: + validation: + expected_session: false + +profiles: + default: + description: Standard OpenSSH server \ No newline at end of file diff --git a/docs/test_env/reference_modules.md b/docs/test_env/reference_modules.md index b72280be997c8..4071878e26f6b 100644 --- a/docs/test_env/reference_modules.md +++ b/docs/test_env/reference_modules.md @@ -12,7 +12,8 @@ ## Module 1: Apache ActiveMQ Jolokia RCE (Mentor Suggested) - **Path:** `exploit/multi/http/apache_activemq_jolokia_rce` - **Type:** Java web application (JMX-over-HTTP) -- **Ports:** 8161 (web console / Jolokia API), 61616 (OpenWire broker) +- **Ports:** 8161 (web console / Jolokia API) +- **Profile:** `default` - **Health Check:** HTTP GET `/api/jolokia/` expecting 200, or GET `/` expecting 200 - **Why:** h00die suggested PR #21497. Has a verified Docker one-liner. Real-world CVE-2026-34197. - **VulnerableEnvironment Definition:** `activemq` @@ -33,4 +34,19 @@ - **Docker Image:** `eystsen/vulnerablewordpress` - **Credentials:** admin / admin - **Exploit Context:** Authenticated admin access; uploads PHP shell via theme/plugin editor -- **Provisioning:** This image does **not** start ready-to-use. The Dockerfile configures `wp-config.php` to point at a `wordpress` database but never creates the schema or an admin account — WordPress boots straight into the install wizard (`/wp-admin/install.php`), and stays there indefinitely with no admin/admin login until the wizard is submitted. `wordpress.yml` now defines a `provision` step (`type: http_post`, submits `install.php?step=2` with the credentials from `credentials.default`) that runs once the base health check passes, followed by the `verify` check above before the environment is registered as ready. See `04-environment-schema.md` for the general `provision`/`verify` schema this relies on. \ No newline at end of file +- **Provisioning:** This image does **not** start ready-to-use. The Dockerfile configures `wp-config.php` to point at a `wordpress` database but never creates the schema or an admin account — WordPress boots straight into the install wizard (`/wp-admin/install.php`), and stays there indefinitely with no admin/admin login until the wizard is submitted. `wordpress.yml` now defines a `provision` step (`type: http_post`, submits `install.php?step=2` with the credentials from `credentials.default`) that runs once the base health check passes, followed by the `verify` check above before the environment is registered as ready. See `04-environment-schema.md` for the general `provision`/`verify` schema this relies on. + +--- + +## Module 3: Apache ActiveMQ OpenWire RCE (CVE-2023-46604) +- **Path:** `exploit/multi/misc/apache_activemq_rce_cve_2023_46604` +- **Type:** Java message broker (raw OpenWire protocol, not HTTP) +- **Port:** 61616 (broker) +- **Profile:** `broker-only` +- **Health Check:** Uses activemq.yml's broker-only profile (tcp on 61616). The default HTTP check fails against this image due to AMQ-8018 (web console binds 127.0.0.1 since 5.16.0). Since the module only needs OpenWire, a TCP profile is accurate and matches the schema guidance to create a new profile when the health check type differs. +- **Why this module specifically:** it's the first real proof that `activemq.yml` works as a genuine shared definition across independent modules, not just independent files that happen to use the same schema. Two unrelated CVEs, two different attack surfaces (HTTP/Jolokia vs. raw OpenWire), one definition file. +- **VulnerableEnvironment Definition:** `activemq` (same file as Module 1 — nothing duplicated: image family, credentials, health check, and `ci.exploit`'s recommended payload are all inherited unchanged) +- **Docker Image:** `dinifarb/activemq:5.18.2` +- **Credentials:** none required — this CVE is unauthenticated +- **Exploit Context:** Unauthenticated; sends crafted OpenWire packet that loads attacker-hosted Spring XML config. Requires TARGET => 1 (Linux) override — default target is Windows. + diff --git a/modules/auxiliary/scanner/http/http_header.rb b/modules/auxiliary/scanner/http/http_header.rb index 6633cac2c928c..dd873aa17b902 100644 --- a/modules/auxiliary/scanner/http/http_header.rb +++ b/modules/auxiliary/scanner/http/http_header.rb @@ -26,6 +26,11 @@ def initialize(info = {}) 'Reliability' => UNKNOWN_RELIABILITY, 'Stability' => UNKNOWN_STABILITY, 'SideEffects' => UNKNOWN_SIDE_EFFECTS + }, + 'VulnerableEnvironment' => { + 'definition' => 'httpd', + 'default_variant' => '2.4.57', + 'port_mapping' => { 80 => 'RPORT' } } ) ) @@ -63,9 +68,8 @@ def run_host(ip) end # Header Names are case insensitive so convert them to upcase - headers_uppercase = headers.inject({}) do |hash, keys| + headers_uppercase = headers.each_with_object({}) do |keys, hash| hash[keys[0].upcase] = keys[1] - hash end ignored_headers.each do |h| @@ -76,18 +80,18 @@ def run_host(ip) end headers_uppercase.to_a.compact.sort - counter = 0; + counter = 0 headers_uppercase.each do |h| header_string = "#{h[0]}: #{h[1]}" print_good "#{peer}: #{header_string}" report_note( - :type => "http.header.#{rport}.#{counter}", - :data => { :header_string => header_string }, - :host => ip, - :port => rport + type: "http.header.#{rport}.#{counter}", + data: { header_string: header_string }, + host: ip, + port: rport ) - counter = counter + 1 + counter += 1 end if counter == 0 print_warning "#{peer}: all detected headers are defined in IGN_HEADER and were ignored " diff --git a/modules/auxiliary/scanner/http/http_version.rb b/modules/auxiliary/scanner/http/http_version.rb index 0c6ebf79a9e8b..f97b7f70d14ec 100644 --- a/modules/auxiliary/scanner/http/http_version.rb +++ b/modules/auxiliary/scanner/http/http_version.rb @@ -16,7 +16,12 @@ def initialize 'Name' => 'HTTP Version Detection', 'Description' => 'Display version information about each system.', 'Author' => 'hdm', - 'License' => MSF_LICENSE + 'License' => MSF_LICENSE, + 'VulnerableEnvironment' => { + 'definition' => 'httpd', + 'default_variant' => '2.4.57', + 'port_mapping' => {80 => 'RPORT'} + } ) register_wmap_options({ diff --git a/modules/auxiliary/scanner/http/robots_txt.rb b/modules/auxiliary/scanner/http/robots_txt.rb index 2f354ab2f77ab..699258e6ddc32 100644 --- a/modules/auxiliary/scanner/http/robots_txt.rb +++ b/modules/auxiliary/scanner/http/robots_txt.rb @@ -17,12 +17,17 @@ def initialize 'Name' => 'HTTP Robots.txt Content Scanner', 'Description' => 'Detect robots.txt files and analyze its content', 'Author' => ['et'], - 'License' => MSF_LICENSE + 'License' => MSF_LICENSE, + 'VulnerableEnvironment' => { + 'definition' => 'httpd', + 'default_variant' => '2.4.57', + 'port_mapping' => { 80 => 'RPORT' } + } ) register_options( [ - OptString.new('PATH', [ true, "The test path to find robots.txt file", '/']), + OptString.new('PATH', [ true, 'The test path to find robots.txt file', '/']), ] ) @@ -40,15 +45,15 @@ def run_host(target_host) res = send_request_raw({ 'uri' => turl, 'method' => 'GET', - 'version' => '1.0', + 'version' => '1.0' }, 10) - if not res + if !res print_error("[#{target_host}] #{tpath}robots.txt - No response") return end - if not res.body.include?("llow:") + if !res.body.include?('llow:') vprint_status("[#{target_host}] #{tpath}robots.txt - Doesn't contain \"llow:\"") return end @@ -57,20 +62,20 @@ def run_host(target_host) print_good("Contents of Robots.txt:\n#{res.body}") # short url regex - aregex = /llow:[ ]{0,2}(.*?)$/i + aregex = /llow: {0,2}(.*?)$/i result = res.body.scan(aregex).flatten.map { |s| s.strip }.uniq vprint_status("[#{target_host}] #{tpath}robots.txt - #{result.join(', ')}") result.each do |u| report_note( - :host => target_host, - :port => rport, - :proto => 'tcp', - :sname => (ssl ? 'https' : 'http'), - :type => 'ROBOTS_TXT', - :data => { :file => u }, - :update => :unique_data + host: target_host, + port: rport, + proto: 'tcp', + sname: (ssl ? 'https' : 'http'), + type: 'ROBOTS_TXT', + data: { file: u }, + update: :unique_data ) end rescue ::Rex::ConnectionRefused, ::Rex::HostUnreachable, ::Rex::ConnectionTimeout diff --git a/modules/auxiliary/scanner/ssh/ssh_version.rb b/modules/auxiliary/scanner/ssh/ssh_version.rb index 3129572b4d25c..a92b2afa54a45 100644 --- a/modules/auxiliary/scanner/ssh/ssh_version.rb +++ b/modules/auxiliary/scanner/ssh/ssh_version.rb @@ -26,7 +26,12 @@ def initialize 'Daniel van Eeden ', # original author 'h00die' # algorithms enhancements ], - 'License' => MSF_LICENSE + 'License' => MSF_LICENSE, + 'VulnerableEnvironment' => { + 'definition' => 'openssh', + 'default_variant' => '7.2', + 'port_mapping' => { 22 => 'RPORT' } + } ) register_options( diff --git a/modules/exploits/multi/misc/apache_activemq_rce_cve_2023_46604.rb b/modules/exploits/multi/misc/apache_activemq_rce_cve_2023_46604.rb index cb37b0ba5b154..4151956810144 100644 --- a/modules/exploits/multi/misc/apache_activemq_rce_cve_2023_46604.rb +++ b/modules/exploits/multi/misc/apache_activemq_rce_cve_2023_46604.rb @@ -67,6 +67,21 @@ def initialize(info = {}) # The maximum time in seconds to wait for a session. 'WfsDelay' => 30 }, + 'VulnerableEnvironment' => { + 'definition' => 'activemq', + 'default_variant' => '5.18.2', + 'profile' => 'broker-only', + 'port_mapping' => { 61616 => 'RPORT' }, + 'overrides' => { + 'ci' => { + 'exploit' => { + 'options' => { + 'TARGET' => 1 + } + } + } + } + }, 'Notes' => { 'Stability' => [CRASH_SAFE], 'Reliability' => [REPEATABLE_SESSION], diff --git a/plugins/test_env.rb b/plugins/test_env.rb index 7def551916f68..83cf6989fd192 100644 --- a/plugins/test_env.rb +++ b/plugins/test_env.rb @@ -1595,11 +1595,13 @@ def cmd_test_env_build(args) # Step 17: apply datastore to the active module datastore.each do |key, value| - mod.datastore[key] = value + if mod.options.include?(key) + mod.datastore[key] = value + end end # Step 18: display results to user - build_display_results(env_id, config, datastore) + build_display_results(env_id, config, datastore, mod) rescue PortAllocator::NoPortsAvailable => e print_error("No available ports: #{e.message}") @@ -1682,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.datastore['PAYLOAD'] != recommended_payload + if recommended_payload && mod.options.include?('PAYLOAD') && mod.datastore['PAYLOAD'] != recommended_payload print_status("Setting recommended payload for this environment: #{recommended_payload}") mod.datastore['PAYLOAD'] = recommended_payload end @@ -1870,16 +1872,18 @@ def build_register_environment(runtime, container_id, mod, variant, config, allo end # Step 18: Display build results and suggested exploit command. -def build_display_results(env_id, config, datastore) +def build_display_results(env_id, config, datastore, mod) print_good("Environment ready.") print_status("Environment ID: #{env_id}") - datastore.each do |key, value| + + applicable = datastore.select { |k, _v| mod.options.include?(k) } + applicable.each do |key, value| print_status(" #{key.ljust(12)} => #{value}") end - - env = self.class.registry.get(env_id) - print_status("Suggested: #{env.exploit_command}") + action = mod.type == 'auxiliary' ? 'run' : 'exploit' + opts = applicable.map { |k, v| "#{k}=#{v}" }.join(' ') + print_status("Suggested: #{action} #{opts}") end def cmd_test_env_help print_line("Usage: test_env ") @@ -2030,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'] + if ci_exploit['payload'] && mod.options.include?('PAYLOAD') print_status("Setting recommended payload for this environment: #{ci_exploit['payload']}") driver.run_single("set PAYLOAD #{ci_exploit['payload']}") end @@ -2045,8 +2049,12 @@ def cmd_test_env_exec(args) # 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. + applied = {} target.datastore.each do |key, value| - driver.run_single("set #{key} #{value}") + if mod.options.include?(key) + driver.run_single("set #{key} #{value}") + applied[key] = value + end end # --- Step D.5: avoid Rex::BindFailed from stale listeners on @@ -2058,17 +2066,22 @@ def cmd_test_env_exec(args) # 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}") + if mod.options.include?(opt) + free_port = free_local_port + driver.run_single("set #{opt} #{free_port}") + applied[opt] = free_port + end 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") + action = mod.type == 'auxiliary' ? 'run' : 'exploit' + opts = applied.map { |k, v| "#{k}=#{v}" }.join(' ') + print_status("Executing: #{action} #{opts}") + driver.run_single(action) rescue => e print_error("test_env exec failed: #{e.class} - #{e.message}") elog("test_env exec error: #{e.class} - #{e.message}")