recursion
recursion
Table of Contents 1
Introduction 2
Background: Recursion 101 3
The Dangers of Recursion 4
The limit of the bug 4
An Overview of the Different Solutions 5
Our favorite: Don’t use recursion 5
Second-best choice: Use a depth counter 5
Not a solution: Catch StackOverflow errors 5
Exploring the Vulnerabilities 7
Protobuf Java Lite 7
Elasticsearch 9
Jenkins 13
Jackson-core 14
The Broader Impact of Recursions 16
Background: How We Developed the Query 17
Why we used CodeQL 17
First query: Learning from mistakes 17
Subsequent tentatives: Speed, precision, and depth 18
The final tentative: Exploring path queries 21
Query limitations and further improvements 23
Key Takeaways and Next Steps 26
About the Authors 27
About Trail of Bits 28
For readers in a hurry, this white paper could be summarized in a single sentence:
We disclosed these vulnerabilities to the respective project owners, who have fixed them:
This white paper presents several case studies that demonstrate the harms of recursive
functions and reviews the effectiveness of available solutions for addressing them. Our
takeaway: don’t rely on StackOverflowErrors to catch recursive attacks; instead, avoid
using recursion altogether, and rewrite recursive functions as iterative functions.
Recursion is one of the first “Eureka!” moments that new programmers experience. The
humble Fibonacci is a classic introduction to the concept. A recursive function calls itself
to solve a problem by breaking it down into smaller, similar subproblems.
Recursion can be elegant, simple, and, most importantly, practical. Once understood, it is
the go-to method for dealing with nested structures, whether traversing a tree, visiting
nodes in a graph, or parsing JSON.
If you remember CS101 (actually CS106B), the three “musts” of recursions are:
Although elegant, most recursive functions can be flawed if they process untrusted data.
Computers have finite resources, including a finite number of stack frames (corresponding
to function call depth) available during program execution. Large amounts of recursion can,
therefore, exhaust the stack and generate a StackOverflowError (or similar ones in
different languages). For example, the fibonacci example from Stack Overflow1 will crash
on a large input (like 0xabcdabcd):
Violating any of the three “musts” in the previous section can result in a stack exhaustion
problem, which can be a security concern, as the case studies in this white paper highlight.
However, stack overflows result in little or no real-world harm in most contexts. Imagine
compiling a Rust program that includes “1+” repeated ten thousand times. As expected,
rustc overflows the stack and crashes. The only harm is to our ego, and we can fix the
issue by rerunning the compiler, this time on a less audacious program.
1
Yet another unintended pun
if(n == 0) return 0;
else if(n == 1) return 1;
else
return _fibonacci(n-1, depth+1) + _fibonacci(n-2, depth+1);
}
Manually adding depth to each call is both tedious and error-prone. The extra boilerplate
reduces readability and removes some of the elegance that makes recursion an attractive
choice. As such, few recursive functions include a depth limit.
A system under heavy load or resource pressure rarely fails predictably. While the ideal
outcome may be that the system simply refuses to handle new requests, the more likely
outcome is a cascade of other problems. It may be a failed database connection, timeout,
logging failure, failed background task, or any number of seemingly unrelated issues.
Unless the system was designed from the ground up with resource constraints in mind and
built with graceful degradation and failure handling at every level, resource pressure will
likely cause unpredictable failures throughout the entire system
While the theory is nice, we wanted to see if real-world projects supported it. We wrote a
CodeQL query for recursion cases on potentially untrusted user input in popular Java
projects to do this. Indeed, we found this problematic pattern alarmingly common; below,
we highlight some examples.
Parsing untrusted data is notoriously tricky, and security researchers have targeted parsers
for every format. Protocol buffers are a solution developed by Google to provide a
serialized exchange format with automatically generated parsers in various languages.
They are used extensively both within Google and in the greater ecosystem.
For instance, one could crash a Java application parsing an external message using the
protobuf-lite library by simply sending this one message.
It will throw a StackOverflowError. The problem lies in how Protobuf parses Unknown
Fields.
Unknown fields are well-formed protocol buffer serialized data representing fields
that the parser does not recognize. For example, when an old binary parses data
sent by a new binary with new fields, those new fields become unknown fields in the
old binary. (source)
When this issue is combined with Groups—a deprecated feature that is still parsed because
of backward compatibility—you get an explosive mix:
2. The new group is parsed as an unknown field if the attacked schema does
not contain a group.
4. Goto 2
The exciting thing about this vulnerability is that it has one precondition on the attacked
target: it must use the Java lite version of the Protocol Buffer library. There are no
requirements for the scheme used by the targeted application.
While C++ API’s official documentation advises discarding Unknown Fields for security
reasons, it advises doing it after parsing the message. At this point, it is already too late.
Elasticsearch
Elasticsearch is an open-source search and analytics engine built on Apache Lucene. It
enables users to store, search, and analyze large volumes of data quickly and in near-real
time. However, several of its features are also vulnerable to recursion attacks.
First, Elasticsearch supports a feature called Glob Patterns. The documentation states the
following:
Match a String against the given pattern, supporting the following simple pattern
styles: "xxx*", "*xxx", "*xxx*" and "xxx*yyy" matches (with an arbitrary number of
pattern parts), as well as direct equality.
The issue arises when handling patterns with repeated wildcard symbols (*). Logically,
multiple consecutive wildcards are equivalent to a single wildcard. This implementation
handles consecutive wildcards by removing the first wildcard and making a recursive call to
match against the remaining pattern. The algorithm works beautifully for a pattern like
**foo. It removes the first wildcard and performs a recursive call to allow the matching to
continue. On the other hand, this function does not handle many consecutive wildcards as
beautifully—a pattern with thousands of consecutive wildcards results in thousands of
recursive calls.
A malicious user who can submit an arbitrary glob pattern could cause a stack overflow.
Because Elastic’s developers extensively use the recursion pattern in their codebase, the
globMatch issue was not an isolated case. For example, Elasticsearch has a custom regular
expression dialect called Grok that supports named patterns:
Each line contains a name followed by the pattern associated with that name. Patterns can
be regular expressions, references to other patterns, or a mix of both. This design leads to
the potential for patterns to create unresolvable cycles.
SELF %{SELF}
A %{B}
B %{A}
Because Grok patterns are user-defined and cannot be trusted to be cycle-free, the Grok
parser includes a check for cycles, implemented with recursion. An abbreviated version is
included below, with comments added by us.
A %{B}
B %{C}
C %{A}
The call stack at the innermost call chain to check “A” will look like this:
Unfortunately, the recursion depth depends on the number of nested patterns. A malicious
user could create many deeply nested patterns, causing the circular reference check to
overflow.
A0 %{A1}
A1 %{A2}
A2 %{A3}
…
An %{An+1}
The previous example underlines another unfortunate side effect of recursive processing. If
each recursive call allocates a new stack frame, it also performs allocations for the newly
created stack frame. In this case, it will allocate a new string at each iteration. Replacing this
string allocation with a vast array can lead to an Out-of-memory error, even before
exhausting the stack size. The key takeaway is that catching StackOverflowErrors
gracefully is not enough to catch recursive attacks.
Jenkins
Jenkins is a popular CI/CD server designed to be highly extensible through its plugin
architecture. As each plugin can have dependencies, Jenkins implants a
CyclicGraphDetector to detect cycles between them.
The visit method presented below is responsible for cycle detection. As you have
probably figured out, the function uses a recursive pattern.
Creating hundreds of plugins to trigger the recursion is not a realistic attack vector because
you already have code execution on the targeted system. Nonetheless, the
CyclicGraphDetector is also used to detect cycles in Environment variables. Adding
multiple environment variables, as defined below, also triggers the same recursion.
VAR1=${VAR0}
VAR2=${VAR1}
VAR3=${VAR2}
…
VAR10000=${VAR9999}
Jackson-core
Jackson-core is the foundational library for Jackson, providing base low-level parser and
generator abstractions for various data formats, such as JSON, XML, CSV, CBOR, and
others.
The JsonPointer class provides a head function, which constructs a new JsonPointer
with the last segment dropped. In our previous example, this would construct the /a/b/c
pointer.
The head method implementation is challenging because a JSON pointer object contains a
_nextSegment attribute, which holds the remainder of the segment (in our example,
This method calls the _constructHead function (displayed below), which recursively calls
itself to construct a new JsonPointer object without the dropped segment.
If head() is called on a user-controlled pointer, a malicious user could create a pointer with
many sections and cause a stack overflow. The test case below shows how to trigger the
overflow.
@Test
void maliciousPath() throws Exception
{
final String INPUT = "/a".repeat(10000);
JsonPointer ptr = JsonPointer.compile(INPUT);
assertEquals("/a".repeat(9999), ptr.head().toString());
}
However, it should be noted that Jackson’s maintainer considers that attackers should not
be able to use methods invoking head(), preventing this issue from being a security risk.
They nonetheless fixed the issue in a recent commit, following our report.
If denial of service is a part of your threat model, we recommend adding this additional
item to your Secure Coding Practices Checklist:
With relatively little effort, we skimmed through the results from a straightforward CodeQL
query and found a handful of denial-of-services vulnerabilities. Although we highlighted
specific projects here, the patterns are common. With more time, we will likely continue to
find issues in other projects.
Our approach limited itself to recursive chains of length 3 (A calls B calls C calls A), but
longer chains are even more dangerous because they exhaust the stack more quickly.
We only looked at a handful of applications, and only in Java. Similar issues exist in other
languages, as highlighted by the issues affecting several language-specific implementations
in Protobuf and safety-first languages like Rust. This suggests an extensive landscape of
potential threats waiting to be discovered and mitigated.
This section describes our iterative process of writing increasingly sophisticated CodeQL
queries—from our first basic attempts to our final optimized version that detected real
vulnerabilities.
You can find recursive functions without CodeQL—one approach is to use Tree Sitter to
build an Abstract Syntax Tree of your code and search for cycles in the call graph. We tried
this with Rust, where CodeQL support is not available yet. But this approach quickly breaks
down with real codebases; you’d need complex logic to handle language features like
polymorphism and macro expansions. These challenges led us to focus on Java instead,
where we could leverage CodeQL’s robust analysis capabilities.
import java
This query worked fine on our test cases but hit a wall with real codebases. The problem? It
performs a cartesian product between every method call in the codebase. With 1,000
method calls, you’re looking at 1,000,000 comparisons. For example, this request does not
finish in a reasonable time on Elasticsearch codebase.
import java
from MethodCall ma
where
// Check for self-recursive functions
ma.getMethod() = ma.getEnclosingCallable()
// Or check for second-order recursion
or exists(
Method m |
m = ma.getMethod() and m.getACallee() = ma.getEnclosingCallable()
)
select ma, "Recursion starting in $@", ma.getMethod(), ma.getMethod().getName()
The performance difference when we ran both queries against the Elasticsearch codebase
was dramatic: whereas the first query didn’t finish after 10 minutes, the second found 825
results in just five seconds.
Still, we saw opportunities to improve it. Although our query was finding hundreds of
recursive functions, many were in test and benchmark files—code that would never run in
production. Here’s how we filtered them out:
from MethodCall ma
where
(
ma.getMethod() = ma.getEnclosingCallable()
or exists(
Method m |
m = ma.getMethod() and m.getACallee() = ma.getEnclosingCallable()
)
)
and not isTestPackage(ma.getMethod().getDeclaringType())
Figure 19: Improved CodeQL query filtering out results in test files
This filter reduced our results from 825 to 683 functions—all in production code. While the
CodeQL Java library offers a classify predicate for similar filtering, we found that our
custom solution worked better for our needs.
At this point, we were catching functions that call themselves (order 1) and functions that
call back to themselves through one intermediary (order 2). But what about longer chains?
Recursion chains of order 3 or higher can be even more dangerous—they consume more
stack frames relative to the input size needed to trigger them.
Method m1;
Method m2;
Method m3;
Method m4;
string toString(Method m) {
result = m.getName() + "(" + m.getLocation().getStartLine() + ")" + " -> "
}
}
...
RecursiveCallOrder2() {
m1 = this.getMethod()
and m2 = m1.getACallee()
and m2.getACallee() = m1
and isBefore(m1, m2)
}
...
1. Creating an abstract base class that handles common recursion chain logic
First, we created an abstract RecursiveCall class to handle the common logic all
recursion chains share. This base class manages the methods in the chain and provides a
framework for visualizing the call sequence.
We then built specialized classes for each recursion order, from direct self-recursion
through complex four-function chains. Each class implements its logic for detecting specific
recursion patterns.
Since recursion chains are loops, they could be detected starting from any function in the
chain. For example, if A calls B calls C calls A, we could report it starting from A, B, or C. To
avoid this redundancy, we implemented deduplication by always using the function with
the lowest line number as the starting point2.
Finally, we added visualization logic that transforms these chains into human-readable
output. Instead of just listing function names, our output shows the complete path of
recursive calls with line numbers, making it much easier to understand how functions call
each other in complex scenarios.
2
This is obviously not perfect, as chains can span multiple files, but a good enough approximation
for our use case.
import java
import semmle.code.java.dataflow.DataFlow
import RecursiveFlow::PathGraph
While writing this query, the main problem was to find a solution to keep track of the nodes
already seen in the current path. This is elegantly solved using a State variable to keep
track of the initial source of the current recursion chain.
To keep the figure lighter, we removed any refinements (such as deduplication logic) from
the query in the excerpt, but the full query is available in the repository. Unlike the other
Filtering out false positives: Our query flags all recursive functions, including in some safe
cases where it shouldn’t. Consider this function from Elasticsearch:
While technically recursive, this function is safe—its recursion depth is always limited to 2
calls because the else branch inverts k from positive to negative, triggering the base case
on the next call.
At first glance, this looks dangerous: the function recursively calls itself while decrementing
the level parameter. With a sufficiently high initial level (say 10,000), this could cause a stack
overflow. However, Elasticsearch’s codebase limits this level parameter to a maximum of
12.
Our query can’t easily detect these kinds of safety checks. One solution would be to add
heuristics that recognize when recursive functions use strictly decreasing parameters and
then verify their bounds through manual review.
Identifying attacker control: Our query’s biggest limitation to our query is its inability to
determine whether attackers can control the recursive input. This is crucial for
distinguishing between genuine security vulnerabilities and harmless recursive functions.
The code above depicts a standard recursive parser implementation—it handles nested
data structures by calling itself on each nested element. In theory, deeply nested input
could crash the parser:
[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[...[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[
But context matters. In Elasticsearch, this parser (XContentParser) only processes data
from trusted sources within the cluster. An attacker can’t provide malicious input, so the
recursion isn’t a security risk.
Support for other languages: While this post focuses on Java, CodeQL supports Python,
C/C++, Go, and other languages. The concepts we’ve covered—detecting recursive
functions, filtering test code, and handling complex call chains—can be adapted to find
similar vulnerabilities across these languages. The main differences will be in the syntax
and language-specific features you’ll need to account for.
These CodeQL queries dramatically improved our ability to find recursive patterns in large
codebases. What previously required extensive manual review can now be
automated—though you still need expertise to evaluate which findings are security issues.
Most importantly, remember that recursive functions are dangerous when they process
untrusted input. While our queries can find recursive patterns, determining whether an
attacker can control the input requires manual analysis. Always validate and limit recursive
depth when handling data that could come from untrusted sources.
We’re grateful to GitHub for making CodeQL accessible through excellent documentation
and example queries from their Security Labs team. These resources helped us develop
our approach, and we hope sharing our experience here helps others in the security
community leverage CodeQL for their own analysis needs.
Want help securing your codebase with custom CodeQL queries? Our team can adapt
these techniques to find recursion vulnerabilities and other security patterns specific to
your code.
Founded in 2012 and headquartered in New York, Trail of Bits provides technical security
assessment and advisory services to some of the world’s most targeted organizations. We
combine high-end security research with a real-world attacker mentality to reduce risk and
fortify code. With 100+ employees around the globe, we’ve helped secure critical software
elements that support billions of end users, including Kubernetes and the Linux kernel.
In recent years, Trail of Bits consultants have showcased cutting-edge research through
presentations at CanSecWest, HCSS, Devcon, Empire Hacking, GrrCon, LangSec, NorthSec,
the O’Reilly Security Conference, PyCon, REcon, Security BSides, and SummerCon.
We specialize in software testing and code review projects, supporting client organizations
in the technology, defense, and finance industries, as well as government entities. Notable
clients include HashiCorp, Google, Microsoft, Western Digital, and Zoom.
Trail of Bits also operates a center of excellence with regard to blockchain security. Notable
projects include audits of Algorand, Bitcoin SV, Chainlink, Compound, Ethereum 2.0,
MakerDAO, Matic, Uniswap, Web3, and Zcash.
To keep up to date with our latest news and announcements, please follow @trailofbits on
Twitter and explore our public repositories at https://github.com/trailofbits. To engage us
directly, visit our “Contact” page at https://www.trailofbits.com/contact, or email us at
[email protected].