A Function-constructor escape we fixed in JSONPath-Plus
We reported and fixed a remote code execution escape in JSONPath-Plus, a
library pulled 12 million times a week: its “safe” eval still handed back the
Function constructor through a property read, so obj.constructor reached
Function and ran arbitrary code. The fix, publicly credited to Ryan Cruz
in PR #266, refuses to return Function.
On this page: the bug · what safe eval is · the escape · the fix · the CVE chain · disclosure · what it means for testing
The bug in one sentence
JSONPath-Plus lets callers evaluate path expressions through an eval mode
that is meant to be sandboxed. The sandbox blocked the Function constructor
in one code path and not another, so a member expression like
obj.constructor returned Function itself, and everything downstream of
the Function constructor is arbitrary code.
| Field | Value |
|---|---|
| Package | jsonpath-plus (npm) |
| Reach | ~12.4 million downloads a week |
| Class | Sandbox escape to the Function constructor (RCE) |
| Root cause | member-expression path returned Function unchecked |
| Fix | PR #266, one added clause, merged July 2, 2026 |
| Credit | Publicly “Reported by: Ryan Cruz” |
What safe eval was supposed to do
JSONPath-Plus is a widely used implementation of JSONPath, the query language for JSON, at roughly 12.4 million downloads a week. Some expressions need to evaluate JavaScript-like sub-expressions, and for that the library ships a guarded evaluator that is supposed to allow the useful parts of expression evaluation while denying the dangerous ones.
The dangerous one, always, is the Function constructor. In JavaScript,
Function("...body...") compiles a string into a callable with access to the
global scope. Any sandbox that lets an attacker reach Function is not a
sandbox. So the library kept a denylist of blocked properties that included
constructor, and checked for the Function constructor before calling a
resolved function.
The check existed, in one place
The call-expression handler did the right thing. As the fixed code shows, it
knew Function was the thing to stop:
if (func === Function) { // unreachable since BLOCKED_PROTO_PROPERTIES includes ‘constructor’
The comment even asserts the case is unreachable. It was not, because the guard lived on the calling path, not on the path that produced the value.
The escape: a property read that returns Function
Here is the gap. When the evaluator resolved a member expression, it returned any value that happened to be a function, and bound it, with no check for which function:
// vulnerable: returns ANY function, including Function itself
if (typeof result === 'function') {
return result.bind(obj);
}
From a property read to a shell
Reading obj.constructor resolves to Function. The member-expression
handler saw a function and handed it back. Now the attacker holds a reference
to the Function constructor through a path the call-expression guard never
saw, and the “unreachable” branch was reachable after all. From that
reference, Function(payload)() is arbitrary code execution.
This is the same primitive we found in a different library: reaching the Function constructor through a property read that a write-path guard did not cover. Two libraries, same class of bug, and it is worth internalizing why.
The fix is one clause
The patch states the missing invariant directly: return the function only when it is not the Function constructor.
// before
if (typeof result === 'function') {
return result.bind(obj);
}
// after: never hand back Function itself
if (typeof result === 'function' && result !== Function) {
return result.bind(obj);
}
Merged in PR #266 with new tests, moving the suite from 278 to 280 cases. The one-clause shape is typical of these bugs: the logic was almost right, and the gap was a value that one path checked and another did not.
The same escape hatch, patched three times
The most useful part of this finding is the history, because it shows how a sandbox erodes. JSONPath-Plus has closed a route to the Function constructor more than once.
| Stage | What it was |
|---|---|
| CVE-2024-21534 | the original eval RCE |
| CVE-2025-1302 | an incomplete fix, RCE again, patched in 10.3.0, rated 9.8 |
| PR #266 | the member-expression path still returned Function |
CVE-2025-1302 was rated 9.8 on the CVSS scale and described by its advisory as caused by an incomplete fix for the one before it. Our finding is the next in that line: not the same bug, the same escape hatch through a door the previous fixes did not close.
How we disclosed it
Through GitHub, on the project’s own terms. We reported the escape and wrote the fix in a pull request, which is public and credited to Ryan Cruz, merged by the maintainer on July 2, 2026. Coordinated disclosure on open source is straightforward: propose the fix, let the maintainer review and merge, and the credit trail is on the record. That record is the point.
The pattern to take away
A denylist that blocks a dangerous value in one code path but not in every code path is not a fix. It is a smaller target. The Function constructor was blocked where functions were called and not where functions were read, and the sandbox held right up until someone read instead of called. Finding that kind of gap is a reading problem, which is what a real penetration test does that a scanner, matching patterns, does not.
What a library RCE says about testing
We publish findings like this, and the critical RCE in velocity.js alongside it, because depth is the one thing a security vendor cannot fake. Anyone can say their testing is deep. A fixed, credited RCE in a library with millions of weekly installs is a claim you can click on. That same white-box reading, pointed at your application every month, is what our subscription buys, and our benchmark runs are public for the parts a disclosure cannot show.
Hackers stay in the loop
None of this is the model alone. AI surfaces the suspicious property walk; a
human confirms it reaches Function, writes the working proof, and files the
pull request the maintainer can review and merge. That division of labor is
the whole point: coverage no human team can afford monthly, plus a person who
verifies the finding is real before it goes anywhere.
The pricing is public too: $299 a month for early-stage startups, $2,999 for everyone else, against the $5,000 to $30,000 a single traditional test costs. A startup’s year of continuous testing plus an independent SOC 2 attestation lands near $6,088, versus the $30,000-plus a three-vendor stack runs.
The short version
- We reported and fixed a Function-constructor escape in JSONPath-Plus, a library pulled about 12 million times a week.
- The safe eval blocked
Functionon the call path but not the member path, soobj.constructorreturnedFunctionand gave RCE. - The fix, in PR #266
credited to Ryan Cruz, returns a function only when it is not
Function. - It is the newest link in the library’s safe-eval chain, after CVE-2024-21534 and CVE-2025-1302.
- Public, credited findings are the check you can run on any tester’s claim of depth. Ours is a subscription, and the engine points at your stack every month.
Frequently asked questions
Is my application affected?
You are at risk if you evaluate JSONPath expressions that an attacker can influence, on a version of jsonpath-plus whose safe eval still returns the Function constructor through a member expression. The safest posture is to upgrade to the latest release once it carries the PR #266 fix, and to never pass attacker-controlled JSONPath strings to the eval-backed paths. If your expressions are all static and developer-authored, the exposure is far lower, but the eval surface is worth removing regardless.
What actually goes wrong here?
The library's safe eval blocked the Function constructor in its call-expression handling but not in its member-expression handling. Reading a property that resolves to a function returned that function directly, and obj.constructor resolves to Function. From the Function constructor, an attacker compiles and runs arbitrary code, which is full remote code execution. The fix refuses to return a function when it is the Function constructor itself.
How is this different from the earlier jsonpath-plus CVEs?
It is the same escape hatch, narrowed again. CVE-2024-21534 was the original eval RCE, and CVE-2025-1302 was an incomplete fix that reintroduced it, patched in 10.3.0. Each fix closed one route to the Function constructor while another remained. The pattern is the lesson: a denylist that blocks a dangerous value in one code path but not every code path is not a fix, it is a smaller target.
How was this disclosed?
Through the project's own process on GitHub. We reported the escape and authored the fix in pull request 266, which is publicly credited Reported by: Ryan Cruz and was merged on July 2, 2026. Coordinated disclosure on open-source means the maintainer gets the fix and the credit trail is public, which is exactly why a finding like this is a claim you can verify rather than take on faith.
How does HackZero find bugs like this and still charge $299 a month?
We own the whole testing stack, so the marginal cost of a run is compute plus senior review, not tester-weeks. The attack agents, the exploitation tooling, and the reporting are all built in-house: AI does the continuous coverage no human team can afford monthly, and hackers stay in the loop to confirm a finding is real, reduce it, and report it responsibly. Owning the stack is also why one subscription can fold in SOC 2 controls and connect you with an independent AICPA-member CPA who attests them. Startups pay $299 a month, every other company $2,999.