GitHub Advisoryadvisory · Packagist
GHSA-r7cg-qjjm-xhqq webonyx/graphql-php has unbounded recursion in parser that causes stack overflow on crafted nested input
HIGH
Official page ↗
- Ecosystem
- Packagist
- Packages
- webonyx/graphql-php
- Severity
- High
- CVSS vector
- CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:H
- Weaknesses
- CWE-674
- Reviewed
- 2026-05-05
Affected packages
• Packagist webonyx/graphql-php: < 15.32.3 → fixed in 15.32.3
Description
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
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 is delivered by the kernel after the C stack pointer crosses the guard page; the PHP runtime never gets a chance to raise a userland exception. The process exits with status 139 and the `catch` block is never entered.
Impact
• Process termination: a single 74 KB POST kills the entire PHP process that handles it. In php-fpm, the worker is killed and respawned by the master; every other in-flight request on that worker is dropped. In long-running PHP runtimes (Swoole, RoadRunner, ReactPHP), the entire daemon dies.
• Pre-validation: `Parser::parse` is invoked before any validation rule. Field count caps, complexity analyzers, persisted query allow-lists, and all custom validators run after parsing and therefore cannot intercept the crash.
• No catchable error: unlike a slow query, a memory_limit exceeded, or a parse error, SIGSEGV cannot be intercepted by PHP. There is no error log entry from the PHP application; only the FPM maste…
Related
Weakness · CWE (1)
- CWE-674 Uncontrolled Recursion weakness