Skip to content

Incomplete fix for CVE-2026-0994: Struct/Value/ListValue mutual recursion bypasses max_recursion_depth #26432

@geoo115

Description

@geoo115

protobuf-recursion-poc.zip

Incomplete fix for CVE-2026-0994: Struct/Value/ListValue mutual recursion bypasses max_recursion_depth in Python JSON parser

Summary

The fix for CVE-2026-0994 (GHSA-7gcm-g887-7qv7) patched _ConvertAnyMessage by routing it through ConvertMessage(), which increments self.recursion_depth. However, the identical pattern remains unfixed in three other methods: _ConvertValueMessage, _ConvertStructMessage, and _ConvertListOrTupleValueMessage. These methods call each other in a mutual recursion cycle without ever passing through ConvertMessage(), so self.recursion_depth never advances past 1. The max_recursion_depth parameter is completely inert for these well-known types.

Confirmed vulnerable: 6.33.5 (the CVE-2026-0994 patched release) and 7.34.0 (latest as of this report).

Root cause

File: python/google/protobuf/json_format.py

ConvertMessage() (line 531) is the sole location that increments self.recursion_depth. The three WKT conversion methods call each other directly, bypassing this increment:

_ConvertValueMessage      → _ConvertStructMessage            (line 778)
_ConvertStructMessage     → _ConvertValueMessage             (line 818)
_ConvertValueMessage      → _ConvertListOrTupleValueMessage  (line 780)
_ConvertListOrTupleValueMessage → _ConvertValueMessage       (line 804)

The result: a caller that sets max_recursion_depth=10 and parses a google.protobuf.Struct from untrusted JSON receives no protection. Parsing succeeds at depth 100, 200, 500, … until Python's own default recursion limit (~1 000) is hit, at which point the process raises an unhandled RecursionError.

Reproduction

from google.protobuf import json_format, struct_pb2

~2 KB payload, 500 levels deep

nested = {"leaf": "data"}
for _ in range(500):
nested = {"level": nested}

msg = struct_pb2.Struct()

max_recursion_depth=10 should reject this immediately.

Instead it crashes with RecursionError.

json_format.ParseDict(nested, msg, max_recursion_depth=10)

Expected: json_format.ParseError raised at depth 10.
Actual: RecursionError (Python stack exhausted, worker process crashes).

All four type variants are affected (see attached PoC for full test suite):

Type Depth 100, limit 10 Depth 500, limit 10
Struct parses — limit bypassed RecursionError
Value parses — limit bypassed RecursionError
ListValue parses — limit bypassed RecursionError

Proposed fix

Add the same depth guard used by ConvertMessage() to each of the three offending methods. The try/finally is required so that a mid-parse exception does not leave the counter permanently incremented.

def _ConvertStructMessage(self, value, message):
    if self.recursion_depth >= self.max_recursion_depth:
        raise ParseError(
            "Message too nested: the value specified for message type "
            f"{message.DESCRIPTOR.full_name} is too deeply nested."
        )
    self.recursion_depth += 1
    try:
        # ... existing body unchanged ...
    finally:
        self.recursion_depth -= 1

def _ConvertValueMessage(self, value, message):
if self.recursion_depth >= self.max_recursion_depth:
raise ParseError(
"Message too nested: the value specified for message type "
f"{message.DESCRIPTOR.full_name} is too deeply nested."
)
self.recursion_depth += 1
try:
# ... existing body unchanged ...
finally:
self.recursion_depth -= 1

def _ConvertListOrTupleValueMessage(self, value, message):
if self.recursion_depth >= self.max_recursion_depth:
raise ParseError(
"Message too nested: the value specified for message type "
f"{message.DESCRIPTOR.full_name} is too deeply nested."
)
self.recursion_depth += 1
try:
# ... existing body unchanged ...
finally:
self.recursion_depth -= 1

A ready-to-apply patch is attached to this issue.

Impact

google.protobuf.Struct is the standard representation for arbitrary JSON in gRPC APIs. Google Cloud services (Dialogflow intents, Firestore documents, Cloud Functions event payloads) and countless third-party gRPC services accept Struct fields from untrusted callers.

Any service that:

  1. accepts a Struct, Value, or ListValue field from untrusted input, and
  2. calls ParseDict (or Parse) with an explicit max_recursion_depth

…believes it is protected against deeply-nested payloads. It is not. A single ~2 KB HTTP/gRPC request crashes one Python worker. Sending the same request in a loop achieves full service outage.

References

Workaround

Until a patch is released, services can enforce a depth limit at the transport layer (e.g. validate JSON depth before calling ParseDict) or wrap the call and catch RecursionError explicitly:

import sys

old_limit = sys.getrecursionlimit()
sys.setrecursionlimit(old_limit // 2) # tighten the safety net
try:
json_format.ParseDict(data, msg, max_recursion_depth=10)
except RecursionError:
raise ValueError("Input too deeply nested")
finally:
sys.setrecursionlimit(old_limit)

Note: this is not a proper fix—it merely converts the crash into a catchable exception. Apply the patch above for a correct solution.

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions