Module: Msf::Exploit::Remote::Mongodb

Includes:
Tcp
Defined in:
lib/msf/core/exploit/remote/mongodb.rb

Overview

This mixin provides the MongoDB functionality shared by the auxiliary scanner modules: the low-level wire protocol helpers (OP_QUERY and OP_MSG packet construction, reply framing, BSON reply parsing, and cursor draining via getMore) with per-connection protocol negotiation, the shared #get_version buildInfo probe and #mongodb_auth_state auth probe, and the single shared authentication entry point, #authenticate, which tries SCRAM-SHA-1 (MongoDB 3.0+) and falls back to legacy MONGODB-CR (MongoDB < 3.0). Modules build their flows on these primitives and must not reimplement the version probe or auth handshakes; only the module-specific parts (credential reporting, brute-force loops) stay in the modules themselves.

Constant Summary collapse

AUTH_ERROR_CODES =

MongoDB error codes that explicitly indicate an authentication or authorization failure. 13 = Unauthorized, 18 = AuthenticationFailed. https://www.mongodb.com/docs/manual/reference/error-codes/

[13, 18].freeze
AUTH_ERROR_CODENAMES =

The codeName counterparts of AUTH_ERROR_CODES.

%w[Unauthorized AuthenticationFailed].freeze
AUTH_ERROR_MESSAGES =

Substrings of an errmsg (or of a raw reply body that will not parse as BSON) that indicate a command failed for authentication reasons rather than some unrelated error.

[
  'unauthorized',
  'not authorized',
  'authentication failed',
  'auth failed',
  'requires authentication',
  'need to login'
].freeze
MAX_MESSAGE_LENGTH =

Upper bound on the length of a single wire protocol message accepted from the server. mongod caps maxMessageSizeBytes far below this, so a declared length over it is corrupt and must not be trusted for framing.

64 * 1024 * 1024
OP_MSG =

MongoDB wire protocol opcode for OP_MSG, the modern command transport. Introduced in 3.6; from 6.0 on it is the only way to run anything beyond the handshake/buildInfo/SASL allowlist that OP_QUERY answers.

2013
MAX_GETMORE_BATCHES =

Safety cap on getMore rounds so a server that keeps reporting an open cursor cannot loop forever; far past any realistic system.users or listCollections result set.

1000

Instance Attribute Summary

Attributes included from Tcp

#sock

Instance Method Summary collapse

Methods included from Tcp

#chost, #cleanup, #connect, #connect_timeout, #cport, #handler, #lhost, #lport, #peer, #print_prefix, #proxies, #replicant, #rhost, #rport, #set_tcp_evasions, #shutdown, #ssl, #ssl_cipher, #ssl_verify_mode, #ssl_version, #sslkeylogfile

Instance Method Details

#authenticate(user: , pass: , db: ) ⇒ String?

Authenticates against the server: SCRAM-SHA-1 first (MongoDB 3.0+), falling back to legacy MONGODB-CR (MongoDB < 3.0) when SCRAM is not accepted. This is the single authentication entry point shared by all MongoDB modules; do not reimplement the handshakes per module.

Parameters:

  • user (String) (defaults to: ) —

    username; defaults to the USERNAME option

  • pass (String) (defaults to: ) —

    password; defaults to the PASSWORD option

  • db (String) (defaults to: ) —

    authentication database; defaults to the AUTH_DB option

Returns:

  • (String, nil) —

    the mechanism that succeeded ('SCRAM-SHA-1' or 'MONGODB-CR'), or nil on failure



667
668
669
670
671
672
673
674
675
676
# File 'lib/msf/core/exploit/remote/mongodb.rb', line 667

def authenticate(user: datastore['USERNAME'], pass: datastore['PASSWORD'], db: datastore['AUTH_DB'])
  mechanism = mongodb_auth_scram_sha1(user, pass, db)
  return mechanism if mechanism

  vprint_status('SCRAM-SHA-1 not accepted or failed; trying MONGODB-CR fallback...')
  nonce = mongodb_get_nonce(db)
  return nil if nonce.blank?

  mongodb_auth_cr(user, pass, db, nonce)
end

#disconnect ⇒ Object



100
101
102
103
# File 'lib/msf/core/exploit/remote/mongodb.rb', line 100

def disconnect
  @mongodb_use_op_msg = nil
  super
end

#get_version ⇒ String?

Retrieves the server version with the buildInfo command. buildInfo answers without authentication through MongoDB 8.0; 8.1+ gates it behind authentication. With a username configured the mixin authenticates on the connection and asks again for the real version string (the password may legitimately be empty); without credentials the auth error itself dates the server, which is reported as '8.1+'. Note that a successful retry leaves the connection authenticated, so anything that probes for enforced authentication (like #require_auth?) must run before calling this. When a version is identified the service is reported to the database (when the host module supports reporting).

Returns:

  • (String, nil) —

    the server version ('8.1+' when buildInfo is auth-gated and no username is configured), or nil when no version can be determined



619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
# File 'lib/msf/core/exploit/remote/mongodb.rb', line 619

def get_version
  buildinfo_cmd = BSON::Document.new({ 'buildInfo' => BSON::Int32.new(1) })
  doc, auth_error = mongodb_run_command('admin', buildinfo_cmd)
  return nil unless doc

  version_str = doc['version']

  if version_str.nil? && auth_error
    if datastore['USERNAME'].to_s.empty?
      version_str = '8.1+'
    else
      vprint_status('buildInfo requires authentication; retrying with configured credentials')
      if authenticate
        doc, = mongodb_run_command('admin', buildinfo_cmd)
        version_str = doc['version'] if doc
      end
      version_str ||= '8.1+'
    end
  end

  return nil if version_str.nil?

  if respond_to?(:report_service)
    report_service(
      host: rhost,
      port: rport,
      name: 'mongodb',
      proto: 'tcp',
      info: "MongoDB #{version_str}"
    )
  end

  version_str
rescue StandardError => e
  vprint_error("Failed to parse version from buildInfo: #{e.message}")
  nil
end

#initialize(info = {}) ⇒ Object



53
54
55
56
57
58
59
60
61
62
# File 'lib/msf/core/exploit/remote/mongodb.rb', line 53

def initialize(info = {})
  super

  register_options([
    Opt::RPORT(27017),
    OptString.new('AUTH_DB', [ false, 'Database to authenticate against', 'admin' ]),
    OptString.new('USERNAME', [ false, 'Username for authentication', '' ]),
    OptString.new('PASSWORD', [ false, 'Password for authentication', '' ]),
  ])
end

#mongodb_auth_state ⇒ Symbol

Determines whether the server enforces authentication, as far as an unauthenticated probe allows:

  • Over OP_MSG (3.6+) listDatabases answers with an authentication error on servers that enforce auth and succeeds otherwise, which separates the two states on every version.
  • Over legacy OP_QUERY (pre-3.6) listDatabases separates the states the same way. Should it fail for another reason, the state falls back to buildInfo inference: 8.1+ gates it behind authentication (auth error = required, success = disabled), while 6.0-8.0 servers reject most OP_QUERY commands identically with auth on or off and report :unknown rather than a guess.

Returns:

  • (Symbol) —

    :required, :absent, or :unknown



575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
# File 'lib/msf/core/exploit/remote/mongodb.rb', line 575

def mongodb_auth_state
  doc, auth_error = mongodb_run_command('admin', BSON::Document.new({ 'listDatabases' => BSON::Int32.new(1) }))

  return :required if auth_error
  return :absent if doc && doc['ok'].to_i == 1
  return :unknown if mongodb_use_op_msg?

  doc, auth_error = mongodb_run_command('admin', BSON::Document.new({ 'buildInfo' => BSON::Int32.new(1) }))
  return :required if auth_error

  version = doc.is_a?(Hash) ? doc['version'] : nil
  return :unknown if version.to_s.empty?

  begin
    Rex::Version.new(version) >= Rex::Version.new('8.1') ? :absent : :unknown
  rescue StandardError
    :unknown
  end
end

#mongodb_build_msg_packet(db, bson_payload) ⇒ String

Builds a MongoDB wire protocol OP_MSG message (opcode 2013), the modern command transport supported from 3.6 on. Unlike OP_QUERY the database is not part of a namespace: the $db document field selects it, so it is injected into the (re-serialized) command document when not already present.

Parameters:

  • db (String) —

    database the command runs against, stored as $db

  • bson_payload (String) —

    the BSON-encoded command document

Returns:

  • (String) —

    the raw wire protocol message bytes



157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
# File 'lib/msf/core/exploit/remote/mongodb.rb', line 157

def mongodb_build_msg_packet(db, bson_payload)
  doc = BSON::Document.from_bson(BSON::ByteBuffer.new(bson_payload))
  doc['$db'] = db unless doc.key?('$db')

  payload = doc.to_bson.to_s
  msg_len = 16 + 4 + 1 + payload.length

  packet = [msg_len].pack('V')
  packet << Rex::Text.rand_text(4)              # requestID
  packet << "\x00\x00\x00\x00"                  # responseTo: 0
  packet << [OP_MSG].pack('V')                  # opCode: 2013
  packet << "\x00\x00\x00\x00"                  # flagBits
  packet << "\x00"                              # section kind: body
  packet << payload
  packet
end

#mongodb_build_packet(coll_name, bson_payload, number_to_return: 1) ⇒ String

Builds a MongoDB wire protocol OP_QUERY message.

Parameters:

  • coll_name (String) —

    fully qualified namespace, e.g. "admin.$cmd"

  • bson_payload (String) —

    the BSON-encoded command/query document

  • number_to_return (Integer) (defaults to: 1) —

    OP_QUERY numberToReturn field

Returns:

  • (String) —

    the raw wire protocol message bytes



131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
# File 'lib/msf/core/exploit/remote/mongodb.rb', line 131

def mongodb_build_packet(coll_name, bson_payload, number_to_return: 1)
  coll_str = "#{coll_name}\x00"
  req_id = Rex::Text.rand_text(4)
  msg_len = 16 + 4 + coll_str.length + 4 + 4 + bson_payload.length

  packet = [msg_len].pack('V')
  packet << req_id                          # requestID
  packet << "\x00\x00\x00\x00"              # responseTo: 0
  packet << "\xd4\x07\x00\x00"              # opCode: 2004 (OP_QUERY)
  packet << "\x00\x00\x00\x00"              # flags
  packet << coll_str                        # fullCollectionName
  packet << "\x00\x00\x00\x00"              # numberToSkip: 0
  packet << [number_to_return].pack('V')    # numberToReturn
  packet << bson_payload
  packet
end

#mongodb_doc_auth_error?(doc) ⇒ Boolean

Returns true only when the document explicitly indicates an authentication or authorization failure: error codes 13/18, their codeNames, or an errmsg matching a known auth phrase. A generic ok:0 failure (unknown command, bad syntax, ...) is not treated as an auth error, since that would misclassify servers and trigger pointless brute forcing.

Parameters:

  • doc (BSON::Document, nil) —

    a parsed reply document

Returns:

  • (Boolean) —

    true only when the document explicitly indicates an authentication or authorization failure: error codes 13/18, their codeNames, or an errmsg matching a known auth phrase. A generic ok:0 failure (unknown command, bad syntax, ...) is not treated as an auth error, since that would misclassify servers and trigger pointless brute forcing.



535
536
537
538
539
540
541
542
543
# File 'lib/msf/core/exploit/remote/mongodb.rb', line 535

def mongodb_doc_auth_error?(doc)
  return false unless doc.is_a?(Hash) # BSON::Document subclasses Hash

  return true if AUTH_ERROR_CODES.include?(doc['code'].to_i)
  return true if doc['codeName'] && AUTH_ERROR_CODENAMES.include?(doc['codeName'])

  errmsg = doc['errmsg'].to_s.downcase
  AUTH_ERROR_MESSAGES.any? { |msg| errmsg.include?(msg) }
end

#mongodb_encode_scram_username(user) ⇒ String?

Encodes a username for use in a SCRAM client-first message per RFC 5802 section 5.1: '=' becomes '=3D' and ',' becomes '=2C' so they cannot be mistaken for payload delimiters. Usernames containing a nul byte cannot be represented at all and are rejected.

Parameters:

  • user (String)

Returns:

  • (String, nil) —

    the encoded username, or nil when it cannot be encoded (nul byte)



287
288
289
290
291
# File 'lib/msf/core/exploit/remote/mongodb.rb', line 287

def mongodb_encode_scram_username(user)
  return nil if user.nil? || user.include?("\x00")

  user.gsub('=', '=3D').gsub(',', '=2C')
end

#mongodb_getmore_collection(cursor_doc, fallback) ⇒ String?

Derives the collection argument for a getMore command from a cursor's namespace ("db.system.users" -> "system.users", "db.$cmd.listCollections" -> "$cmd.listCollections"), falling back to the collection the original query ran against when the cursor carries no namespace.

Parameters:

  • cursor_doc (BSON::Document, nil) —

    the reply's cursor document

  • fallback (String) —

    collection name from the queried namespace

Returns:

  • (String, nil)


504
505
506
507
508
509
# File 'lib/msf/core/exploit/remote/mongodb.rb', line 504

def mongodb_getmore_collection(cursor_doc, fallback)
  ns = cursor_doc ? cursor_doc['ns'].to_s : ''
  coll = ns.empty? ? nil : ns.split('.', 2)[1]

  coll.nil? || coll.empty? ? fallback : coll
end

#mongodb_have_auth_error?(response) ⇒ Boolean

Returns true only when the response explicitly indicates an authentication or authorization failure; see #mongodb_doc_auth_error? for what counts.

Parameters:

  • response (String, nil) —

    a raw OP_REPLY response

Returns:

  • (Boolean) —

    true only when the response explicitly indicates an authentication or authorization failure; see #mongodb_doc_auth_error? for what counts.



549
550
551
552
553
554
555
556
557
558
559
# File 'lib/msf/core/exploit/remote/mongodb.rb', line 549

def mongodb_have_auth_error?(response)
  return true if response.nil? || response.length <= 36

  doc = mongodb_parse_doc(response)
  return mongodb_doc_auth_error?(doc) if doc

  # Unparseable body: search the raw bytes for the same auth phrases
  # rather than treating any error text as an auth failure.
  raw = response[36..].to_s.downcase
  AUTH_ERROR_MESSAGES.any? { |msg| raw.include?(msg) }
end

#mongodb_msg_query_all(sock, db, coll, bson_payload, timeout: 5, number_to_return: 0) ⇒ Array<BSON::Document>

OP_MSG counterpart of #mongodb_query_all: runs a find against a collection (or an arbitrary command when coll is '$cmd') and drains the reply cursor with getMore commands. OP_MSG has no plain-query opcode, so collection reads become find commands, and batches may arrive either inside the cursor document or as a kind 1 'documents' sequence.

Parameters:

  • db (String) —

    database to query

  • coll (String) —

    collection name, or '$cmd' to run a command

  • bson_payload (String) —

    BSON-encoded filter/command document

  • timeout (Integer) (defaults to: 5) —

    seconds to wait for each read

  • number_to_return (Integer) (defaults to: 0) —

    batchSize hint; 0 lets the server use its default and keeps the cursor open

  • sock (Rex::Socket::Tcp) —

    connected socket to read from

Returns:

  • (Array<BSON::Document>) —

    result documents, or the error reply document alone on failure (see #mongodb_query_error)



445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
# File 'lib/msf/core/exploit/remote/mongodb.rb', line 445

def mongodb_msg_query_all(sock, db, coll, bson_payload, timeout: 5, number_to_return: 0)
  payload_doc = begin
    BSON::Document.from_bson(BSON::ByteBuffer.new(bson_payload.to_s))
  rescue StandardError
    BSON::Document.new
  end

  if coll == '$cmd'
    cmd = payload_doc
  else
    cmd = BSON::Document.new({ 'find' => coll, 'filter' => payload_doc })
    cmd['batchSize'] = BSON::Int32.new(number_to_return) if number_to_return > 0
  end

  body, sequences = mongodb_msg_send(sock, db, cmd, timeout)
  return [] if body.nil?
  return [body] if body['ok'] && body['ok'].to_i != 1

  cursor_doc = body['cursor']
  return [body] unless cursor_doc # e.g. listDatabases: not a cursor command

  docs = mongodb_msg_batch(sequences, cursor_doc, 'firstBatch')
  cursor_id = cursor_doc['id'].is_a?(Integer) ? cursor_doc['id'] : 0
  getmore_collection = mongodb_getmore_collection(cursor_doc, coll == '$cmd' ? nil : coll)

  batches = 0
  while cursor_id != 0
    break if getmore_collection.nil? || getmore_collection.empty?
    break if (batches += 1) > MAX_GETMORE_BATCHES

    getmore_cmd = BSON::Document.new({
      'getMore' => BSON::Int64.new(cursor_id),
      'collection' => getmore_collection
    })
    getmore_cmd['batchSize'] = BSON::Int32.new(number_to_return) if number_to_return > 0

    vprint_status("Following cursor #{cursor_id} on #{db}.#{coll} (#{docs.length} documents so far)")
    body, sequences = mongodb_msg_send(sock, db, getmore_cmd, timeout)
    break if body.nil? || body['ok'].to_i != 1

    cursor_doc = body['cursor']
    break unless cursor_doc

    docs.concat(mongodb_msg_batch(sequences, cursor_doc, 'nextBatch'))
    cursor_id = cursor_doc['id'].is_a?(Integer) ? cursor_doc['id'] : 0
    getmore_collection = mongodb_getmore_collection(cursor_doc, getmore_collection)
  end

  docs
end

#mongodb_parse_cursor_id(response) ⇒ Integer

Extracts the cursor id from the OP_REPLY header of a raw response.

Parameters:

  • response (String, nil)

Returns:

  • (Integer) —

    the 64-bit cursor id, or 0 when the reply carries no cursor (or is too short to hold one)



342
343
344
345
346
# File 'lib/msf/core/exploit/remote/mongodb.rb', line 342

def mongodb_parse_cursor_id(response)
  return 0 if response.nil? || response.length < 28

  response[20, 8].unpack1('Q<')
end

#mongodb_parse_doc(response) ⇒ BSON::Document?

Parses the single BSON reply document from a raw OP_REPLY response.

Parameters:

  • response (String, nil) —

    raw bytes read from the socket

Returns:

  • (BSON::Document, nil)


178
179
180
181
182
183
184
185
# File 'lib/msf/core/exploit/remote/mongodb.rb', line 178

def mongodb_parse_doc(response)
  return nil if response.nil? || response.length <= 36

  buffer = BSON::ByteBuffer.new(response[36..])
  BSON::Document.from_bson(buffer)
rescue StandardError
  nil
end

#mongodb_parse_docs(response) ⇒ Array<BSON::Document>

Parses every BSON document present in a raw OP_REPLY response body.

Parameters:

  • response (String, nil) —

    raw bytes read from the socket

Returns:

  • (Array<BSON::Document>)


191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
# File 'lib/msf/core/exploit/remote/mongodb.rb', line 191

def mongodb_parse_docs(response)
  return [] if response.nil? || response.length <= 36

  data = response[36..]
  return [] if data.nil? || data.empty?

  docs = []
  offset = 0

  while offset < data.length
    break if offset + 4 > data.length

    doc_len = data[offset, 4].unpack1('V')
    break if doc_len.nil? || doc_len <= 0 || (offset + doc_len) > data.length

    buffer = BSON::ByteBuffer.new(data[offset, doc_len])
    docs << BSON::Document.from_bson(buffer)
    offset += doc_len
  end

  docs
rescue StandardError => e
  vprint_error("BSON parse error: #{e.message}")
  []
end

#mongodb_parse_msg(response) ⇒ Array<BSON::Document, Hash>

Parses an OP_MSG reply into its body document and any document sequences. A reply carries the 16 byte standard header, 4 bytes of flag bits, then one or more sections: a kind 0 section holds the single body document, and kind 1 sections hold a cstring identifier plus a stream of documents (find/getMore replies may deliver their batch as a 'documents' sequence instead of inside the cursor document).

Parameters:

  • response (String, nil) —

    raw bytes read from the socket

Returns:

  • (Array<BSON::Document, Hash>) —

    the body document (nil on parse failure) and the document sequences keyed by identifier



228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
# File 'lib/msf/core/exploit/remote/mongodb.rb', line 228

def mongodb_parse_msg(response)
  return [nil, {}] if response.nil? || response.length <= 21

  flags = response[16, 4].unpack1('V')
  data = response[20..]
  data = data[0...-4] if (flags & 1) == 1 # checksumPresent strips the CRC

  body = nil
  sequences = {}
  offset = 0

  while offset < data.length
    kind = data.getbyte(offset)
    if kind.zero?
      doc_len = data[offset + 1, 4].unpack1('V')
      break if doc_len.nil? || doc_len < 5 || (offset + 1 + doc_len) > data.length

      body = BSON::Document.from_bson(BSON::ByteBuffer.new(data[offset + 1, doc_len]))
      offset += 1 + doc_len
    elsif kind == 1
      # The section size counts from the size field itself (the kind
      # byte before it is not included), so the section spans
      # offset + 1 through offset + 1 + size.
      size = data[offset + 1, 4].unpack1('V')
      break if size.nil? || size < 5 || (offset + 1 + size) > data.length

      section = data[(offset + 1)...(offset + 1 + size)]
      terminator = section.index("\x00", 4)
      sequences[section[4...terminator]] = mongodb_parse_seq_docs(section[(terminator + 1)..]) if terminator
      offset += 1 + size
    else
      break
    end
  end

  [body, sequences]
rescue StandardError
  [nil, {}]
end

#mongodb_parse_scram_payload(payload) ⇒ Hash<String, String>

Parses a SCRAM key=value,key=value wire payload into a Hash.

Parameters:

  • payload (String)

Returns:

  • (Hash<String, String>)


272
273
274
275
276
277
# File 'lib/msf/core/exploit/remote/mongodb.rb', line 272

def mongodb_parse_scram_payload(payload)
  payload.split(',').each_with_object({}) do |var, hash|
    k, v = var.split('=', 2)
    hash[k] = v if k && v
  end
end

#mongodb_query_all(sock, namespace, bson_payload, timeout: 5, number_to_return: 0) ⇒ Array<BSON::Document>

Sends an OP_QUERY and drains the reply cursor with getMore commands until it is exhausted, so a namespace holding more documents than the first OP_REPLY batch (101 documents by default) is fully retrieved. Both cursor styles are followed: plain collection queries expose the cursor id in the OP_REPLY header, while command cursors (listCollections and friends) embed a cursor document carrying firstBatch/nextBatch in the reply document.

Parameters:

  • namespace (String) —

    fully qualified namespace, e.g. "admin.system.users"

  • bson_payload (String) —

    BSON-encoded query/command document

  • timeout (Integer) (defaults to: 5) —

    seconds to wait for each read

  • number_to_return (Integer) (defaults to: 0) —

    OP_QUERY numberToReturn field; 0 lets the server use its default batch size and keeps the cursor open

  • sock (Rex::Socket::Tcp) —

    connected socket to read from

Returns:

  • (Array<BSON::Document>)


363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
# File 'lib/msf/core/exploit/remote/mongodb.rb', line 363

def mongodb_query_all(sock, namespace, bson_payload, timeout: 5, number_to_return: 0)
  db, coll = namespace.split('.', 2)
  if mongodb_use_op_msg?
    return mongodb_msg_query_all(sock, db, coll, bson_payload, timeout: timeout, number_to_return: number_to_return)
  end

  # Commands answer with a single reply document and mongod enforces that
  # on the wire: an OP_QUERY against a $cmd namespace must set
  # numberToReturn to exactly 1 or -1 (0 draws "Bad numberToReturn").
  # Cursor batching for commands happens inside that document, so 1 is
  # always the right envelope no matter what the caller asked for.
  number_to_return = 1 if namespace.end_with?('$cmd')

  sock.put(mongodb_build_packet(namespace, bson_payload, number_to_return: number_to_return))
  response = mongodb_read_message(sock, timeout)
  return [] if response.nil?

  docs = mongodb_parse_docs(response)
  reply = docs.first

  cursor_doc = reply.is_a?(BSON::Document) ? reply['cursor'] : nil
  if cursor_doc
    # Command cursor: the batches live inside the reply's cursor
    # document, so the wrapper itself is not a result document.
    first_batch = cursor_doc['firstBatch']
    docs = first_batch.is_a?(Array) ? first_batch.dup : []
    cursor_id = cursor_doc['id'].is_a?(Integer) ? cursor_doc['id'] : 0
    getmore_collection = mongodb_getmore_collection(cursor_doc, coll)
  else
    cursor_id = mongodb_parse_cursor_id(response)
    getmore_collection = coll
  end

  batches = 0
  while cursor_id != 0
    break if getmore_collection.nil? || getmore_collection.empty?
    break if (batches += 1) > MAX_GETMORE_BATCHES

    getmore_cmd = BSON::Document.new({
      'getMore' => BSON::Int64.new(cursor_id),
      'collection' => getmore_collection
    })
    vprint_status("Following cursor #{cursor_id} on #{namespace} (#{docs.length} documents so far)")
    sock.put(mongodb_build_packet("#{db}.$cmd", getmore_cmd.to_bson.to_s))
    response = mongodb_read_message(sock, timeout)
    break if response.nil?

    reply = mongodb_parse_doc(response)
    break if reply.nil? || reply['ok'].to_i != 1

    cursor_doc = reply['cursor']
    batch = cursor_doc ? cursor_doc['nextBatch'] : mongodb_parse_docs(response)
    break if batch.nil? || batch.empty?

    docs.concat(batch)
    if cursor_doc
      cursor_id = cursor_doc['id'].is_a?(Integer) ? cursor_doc['id'] : 0
      getmore_collection = mongodb_getmore_collection(cursor_doc, getmore_collection)
    else
      cursor_id = mongodb_parse_cursor_id(response)
    end
  end

  docs
end

#mongodb_query_error(docs) ⇒ String?

Detects a legacy OP_QUERY error reply hiding among the result documents: failed collection queries are answered with an ordinary document carrying $err (or errmsg) plus ok: 0 instead of data, so a dump that only checks for empty results mistakes them for records.

Parameters:

  • docs (Array<BSON::Document>) —

    documents returned by a query

Returns:

  • (String, nil) —

    the server's error message, nil for real data



518
519
520
521
522
523
524
525
526
# File 'lib/msf/core/exploit/remote/mongodb.rb', line 518

def mongodb_query_error(docs)
  doc = docs.first
  return nil unless doc.is_a?(Hash) # BSON::Document subclasses Hash

  return doc['$err'] || doc['errmsg'] if doc['$err'] || doc['errmsg']
  return "command failed (code #{doc['code']})" if doc.key?('ok') && doc['ok'].to_i == 0

  nil
end

#mongodb_read_exact(sock, length, timeout) ⇒ String?

Reads exactly length bytes from the socket. Rex get_once returns as soon as any data is available, so a reply split across TCP segments arrives in pieces that must be accumulated.

Parameters:

  • sock (Rex::Socket::Tcp) —

    connected socket to read from

  • length (Integer) —

    number of bytes to read

  • timeout (Integer) —

    seconds to wait per read

Returns:

  • (String, nil) —

    nil when the socket times out or closes early



301
302
303
304
305
306
307
308
309
310
311
312
313
# File 'lib/msf/core/exploit/remote/mongodb.rb', line 301

def mongodb_read_exact(sock, length, timeout)
  data = ''
  while data.length < length
    chunk = sock.get_once(length - data.length, timeout)
    return nil if chunk.nil? || chunk.empty?

    data << chunk
  end

  data
rescue IOError # covers EOFError, its subclass
  nil
end

#mongodb_read_message(sock, timeout = 5) ⇒ String?

Reads one complete MongoDB wire protocol message: the 16 byte standard header first, then messageLength minus 16 body bytes, so a large reply split across TCP segments is fully reassembled before it is parsed.

Parameters:

  • timeout (Integer) (defaults to: 5) —

    seconds to wait per read

  • sock (Rex::Socket::Tcp) —

    connected socket to read from

Returns:

  • (String, nil) —

    the complete message including its header



322
323
324
325
326
327
328
329
330
331
332
333
334
335
# File 'lib/msf/core/exploit/remote/mongodb.rb', line 322

def mongodb_read_message(sock, timeout = 5)
  header = mongodb_read_exact(sock, 16, timeout)
  return nil if header.nil?

  msg_len = header.unpack1('V')
  return nil if msg_len.nil? || msg_len < 16 || msg_len > MAX_MESSAGE_LENGTH

  return header if msg_len == 16

  body = mongodb_read_exact(sock, msg_len - 16, timeout)
  return nil if body.nil?

  header + body
end

#mongodb_run_command(db, cmd, timeout: 5) ⇒ Array<BSON::Document, Boolean>

Runs a single command document over whichever wire protocol the server negotiated, normalizing the reply.

Parameters:

  • db (String) —

    database the command runs against

  • cmd (BSON::Document) —

    command document

  • timeout (Integer) (defaults to: 5) —

    seconds to wait per read

Returns:

  • (Array<BSON::Document, Boolean>) —

    the reply document (nil on no or unparseable reply) and whether it explicitly failed for authentication reasons



114
115
116
117
118
119
120
121
122
123
# File 'lib/msf/core/exploit/remote/mongodb.rb', line 114

def mongodb_run_command(db, cmd, timeout: 5)
  if mongodb_use_op_msg?
    doc, = mongodb_msg_send(sock, db, cmd, timeout)
    [doc, mongodb_doc_auth_error?(doc)]
  else
    sock.put(mongodb_build_packet("#{db}.$cmd", cmd.to_bson.to_s))
    resp = mongodb_read_message(sock, timeout)
    [mongodb_parse_doc(resp), mongodb_have_auth_error?(resp)]
  end
end

#mongodb_use_op_msg? ⇒ Boolean

Negotiates the wire protocol for this connection, memoized: MongoDB 3.6+ speak OP_MSG, which from 6.0 on is the only transport able to run anything beyond the handshake/buildInfo/SASL allowlist that OP_QUERY still answers, while older servers only understand OP_QUERY. The probe itself rides an OP_QUERY buildInfo because every version answers it: a parsed version dates the server, and the auth error reply (no version) marks 8.1+ where buildInfo is gated behind authentication.

Returns:

  • (Boolean) —

    true when OP_MSG should be used



74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
# File 'lib/msf/core/exploit/remote/mongodb.rb', line 74

def mongodb_use_op_msg?
  return @mongodb_use_op_msg unless @mongodb_use_op_msg.nil?

  begin
    doc, _resp = mongodb_opquery_command('admin', BSON::Document.new({ 'buildInfo' => BSON::Int32.new(1) }))
  rescue StandardError
    # A probe that cannot even be sent (no connection yet) defaults to
    # the legacy protocol so callers fail the way they always have.
    doc = nil
  end
  version = doc.is_a?(Hash) ? doc['version'] : nil

  @mongodb_use_op_msg = if version
                          begin
                            Rex::Version.new(version) >= Rex::Version.new('3.6')
                          rescue StandardError
                            false
                          end
                        else
                          # 8.1+ gates buildInfo behind authentication
                          mongodb_doc_auth_error?(doc)
                        end
  vprint_status("Negotiated wire protocol: #{@mongodb_use_op_msg ? 'OP_MSG' : 'OP_QUERY'}")
  @mongodb_use_op_msg
end

#require_auth? ⇒ Boolean

Backward-compatible boolean view of #mongodb_auth_state: true only when the server verifiably enforces authentication; :unknown does not count as required.

Returns:

  • (Boolean)


600
601
602
# File 'lib/msf/core/exploit/remote/mongodb.rb', line 600

def require_auth?
  mongodb_auth_state == :required
end