We reported this July 30. It was fixed in graphql-c_parser 1.1.4 and published as GHSA-52mm-32rv-3rpg. Upgrade first and read after.
The short version
If your Ruby app has the optional graphql-c_parser gem, then until last week anyone who could reach your /graphql endpoint could send one ordinary-looking query and corrupt your server's memory. They needed no account, no password, and not even a valid query, because the damage happened before anything got around to checking.
The advisory rates it CVSS 9.8, critical, and the fix is one line.
What makes it interesting isn't the score but the fact that nobody really wrote this bug. Three people, in three different projects, each wrote a correct line of code, and the vulnerability lives in the space between them. That's why it survived the gem's entire 1.x life, because you can't see it from inside any one of the three files.
Are you affected?
graphql-c_parser is not the default. GraphQL-Ruby ships a pure-Ruby parser that never had this bug. The C extension is an opt-in speed upgrade, so most GraphQL-Ruby apps are fine.
$ bundle list | grep c_parser
* graphql-c_parser (1.1.3) # <- affected. Anything below 1.1.4.
If you do have it, know that the gem installs itself as the process-wide default parser the moment it's required. There is no per-schema opt-in and no config to review. If it's in your bundle, every parse in the process goes through it, including the ones on your public endpoint.
The main graphql gem has about 198 million downloads, while graphql-c_parser has 3.1 million. That's a minority, but a self-selecting one, because you reach for a faster parser when you're parsing a lot of queries.
How we found it
This didn't come out of fuzzing. It came from a question we ask about any C extension living inside a Ruby app, because that seam is where this ecosystem's memory bugs hide:
Where do this extension's Ruby object references physically live, and can the garbage collector see that place?
Half the answer sat in the first three lines of the gem's grammar file:
#include <ruby.h>
#define YYSTYPE VALUE
graphql-c_parser is built on Bison, a parser generator. That second line tells Bison that every slot on its working stack holds a Ruby object. As the parser chews through your query, each field and argument gets built as a real Ruby object and parked in a slot. That's idiomatic, and exactly how you write a Ruby parser in C.
Those slots start on the machine stack, which the collector scans, so everything is safe so far.
The other half of the question is the one that finds bugs. Can those references move? Here they can, not because the gem moves them but because Bison does, in generated code the gem's authors never wrote and never read.
That's the whole finding, because the extension put its references somewhere safe and Bison moved them.
Three correct decisions, one critical bug
One. The parser holds your half-built query as live Ruby objects on Bison's working stack, which is correct and unavoidable if you want a Ruby syntax tree at the end.
Two. Ruby's garbage collector finds live objects by searching a specific, finite set of places, mainly the machine stack. Think of a cleanup crew that sweeps a fixed set of rooms and throws out whatever nobody has claimed. It is very good at this without being omniscient, because it has a map and the map has edges.
Three. Bison's stack starts with room for 200 entries, and when a parse needs more it grows the stack by allocating fresh heap memory and copying everything across. That's routine bookkeeping, and Bison has no idea it's relocating garbage-collected objects and no reason to, since it predates Ruby's collector entirely.
Put them together and, on a deep enough query, the parser carries the whole box of object references out of the rooms the crew sweeps and into a storage unit across the street. The crew comes through, finds nothing claiming those objects, and throws them out exactly as designed.
The parser is still working out of the storage unit. It keeps appending children, reading nodes back, calling methods, all on objects that no longer exist. The window opens the moment the stack moves and stays open for the rest of the parse.
Forty-nine
The trigger is a capacity threshold, not a syntax trick. One level of nesting costs about four stack entries, so 200 entries runs out at GraphQL nesting depth 49. That's where corruption started in testing.
Forty-nine is what makes this a vulnerability instead of a curiosity, since it's deep enough that no real query reaches it by accident and shallow enough that anyone who can POST a string reaches it on purpose.
Moving the stack only opens the window. Something still has to trigger a collection while it's open, so the payload has two halves: a deep nest to force the move, and a wide selection set that burns allocations until the collector shows up.
DEPTH = 100 # past 49, forces the parser's stack into unscanned memory
CHURN = 3000 # wide selection set, enough allocation to make GC land mid-parse
query = ("{" + "a{" * DEPTH + "b" + "}" * (DEPTH + 1)
+ " {" + " ".join("f%d" % i for i in range(CHURN)) + "}")
That comes to about 17 KB of text. GC timing is probabilistic, so the proof-of-concept loops, and against a fresh worker it lands within a handful of requests.
One detail is worth pausing on. That document contains two anonymous operations, which is not valid GraphQL, and every GraphQL server on earth would reject it. None of them get the chance, because it parses fine, and parsing is the vulnerable step.
Why none of your defenses fire
Walk the request path for POST /graphql:
- Read the body.
- Parse the document into a syntax tree. ← the bug lives here
- Validate that tree against your schema.
- Run analyzers:
max_depth,max_complexity. - Resolve fields, running your authorization logic.
Every control a GraphQL team reaches for lives at step 3 or later, and all of them take a parsed tree as input. By construction, they are downstream of the thing that builds it.
max_depth measures the depth of a parsed document, so the document has to be parsed first. max_complexity is the same, one step further along. Authorization runs during execution, two phases later.
A GraphQL server's security model assumes it already has a query to reason about. This bug happens while the query is still being built.
Persisted queries are the one real mitigation, because they reject an unknown document as a string before it ever reaches the parser. The near-miss is max_query_string_tokens, which defaults to 5,000, and our payload of 3,300 fits under it comfortably.
What it looks like when it fires
Sometimes the worker just dies. Freed memory gets reused for something incompatible, the process segfaults, the connection drops mid-request. On a multi-worker deployment that's a cheap, repeatable, unauthenticated way to kill workers.
The more interesting outcome is that Ruby catches it first:
NotImplementedError: method 'line' called on terminated object (0x7f2c48119a30)
That's CRuby noticing a method call against an object slot it already reclaimed. It is not a GraphQL error or a validation failure, but the interpreter reporting in its own words that the parser called a method on a freed object.
Default error handling surfaces the exception message, so that address comes back in the HTTP response body. An unauthenticated stranger gets a live heap address out of your process on request, which defeats address-space randomization.
That is why "it's just a crash" is the wrong read. The attacker also influences what refills the freed slot, because the rest of the parse is spent allocating objects whose number, size and contents come from the query they just sent. Freeing an object and then letting the attacker pick what lands in its place is the textbook setup for something worse than a crash. In testing it reached memory corruption at an attacker-influenced address.
Whether a full code-execution chain is practical depends on allocator state, Ruby version and workload, and we didn't build one. But an unauthenticated stranger who controls a use-after-free, influences what reoccupies the memory, and gets a heap address back is the impact class this belongs in, and the maintainer agreed.
The fix is one line
#include <ruby.h>
#define YYSTYPE VALUE
+ #define YYSTACK_USE_ALLOCA 1
That macro tells Bison to grow its stack on the machine stack instead of the heap, which is to say inside the rooms the cleanup crew already sweeps. References stay visible for the whole parse, so the window never opens.
It changes no grammar and no behavior, and growth stays bounded at roughly 100 KB against a default 8 MB stack. Past that the parser returns an ordinary "memory exhausted" error, which is the same ceiling the heap path had without the vulnerability.
It was committed as f794520c on July 31 and shipped in 1.1.4 on August 3.
What to do
Upgrade.
bundle update graphql-c_parser # -> 1.1.4
If you can't, drop the gem. GraphQL-Ruby falls back to the pure-Ruby parser, which never had this bug because it never had a stack the collector couldn't see. You lose throughput and nothing else. Then confirm it took, since a stray require anywhere puts the C parser back:
GraphQL.default_parser
# => GraphQL::Language::Parser # pure Ruby, unaffected
# => GraphQL::CParser # still on the C parser
Don't count on max_depth. It's good practice for other reasons but useless here, because it runs on a document that has already been parsed.
Grep your logs for terminated object (0x. It should never appear in a healthy Ruby process. If it's there next to GraphQL stack frames, someone has already been by.
The lesson
Strip out the GraphQL and you have a pattern that shows up wherever native code meets a managed runtime:
An object is only alive if the collector can find it. Any memory it doesn't search is where references go to die.
Ruby's collector scans the machine stack, the object heap, registered globals, and whatever your mark functions declare. That is the complete list. Every other byte in the address space is a blind spot, and this remains a live class of bug. In June the oj gem patched CVE-2026-54901, a use-after-free where a mark function forgot to declare two references it held.
So if you maintain or audit a C extension, the question that finds these isn't "is this code correct." Every line here was correct. It's can these references move at runtime, and if they do, does the collector still know where they went? The answer is usually "they can't" at every allocation site you wrote yourself, and "constantly" in the code you generated.
Timeline
| Date | Event |
|---|---|
| 2026-07-30 | Reported privately to the GraphQL-Ruby maintainer with a proof-of-concept |
| 2026-07-31 | Fix committed (f794520c) |
| 2026-08-03 | 1.1.4 released, GHSA-52mm-32rv-3rpg published |
| 2026-08-04 | This post |
The maintainer went from report to patch in about a day on a critical memory-safety bug, with a clean minimal fix and a published advisory. That deserves credit, and it's worth saying given how often it goes the other way.
We do vulnerability research on the software platforms are built on. If you'd like that attention pointed at yours, get in touch.
Written by
Senior Security Engineer at PlatformSecurity. Leads the firm's AI research initiatives and bridges secure application development with emerging AI security techniques. Works across Ruby on Rails, application security, and AI research.