A critical RCE we found in velocity.js
We reported a critical remote code execution bug in velocity.js, CVSS 9.8
(GHSA-7gfh-x38p-prh3), fixed in 2.1.7. A template can read
$x.constructor.constructor to reach JavaScript’s Function constructor and
run shell commands on the server. The earlier patch blocked the write path;
the read path stayed open. velocityjs ships 2.8 million downloads a month.
On this page: the bug · what velocity.js is · the exploit chain · why the last patch missed it · am i affected · what this has to do with pentests
The bug in one sentence
velocity.js evaluated property-read expressions inside a template without
filtering dangerous keys, so constructor walked from an empty object all
the way to Function, and Function(body)() executed attacker-chosen code.
The advisory
rates it 9.8 on the CVSS v3.1 scale,
one of the few scores reserved for unauthenticated, network-reachable,
full-compromise bugs.
| Field | Value |
|---|---|
| Advisory | GHSA-7gfh-x38p-prh3 |
| Severity | Critical, CVSS 9.8 |
| Vector | AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| Affected | velocityjs 2.1.6 and earlier |
| Patched | 2.1.7 (July 19, 2026) |
| Class | Remote code execution via property read |
| Reported by | cruzryan |
Background: a template engine that runs expressions
velocity.js is a JavaScript port of Apache Velocity, a template language. You give it a template string and a context object, and it substitutes values. It is a real dependency, not a toy: the velocityjs package on npm records around 700,000 downloads a week, and it sits inside tools that render mail, documents, and dashboards.
Template engines are a classic server-side template injection target, because a template is code, not data. The moment attacker input reaches the template source rather than the context, the attacker is writing expressions the engine will evaluate. The interesting question is always the same: how far can one of those expressions reach.
The prior fix set the stage
In May 2026, velocity.js patched a separate issue,
GHSA-j658-c2gf-x6pq
(CVE-2026-44966, CVSS 8.3), a prototype-pollution bug in #set path
assignment. That fix taught the engine to reject dangerous keys like
__proto__, constructor, and prototype when they appeared as the
target of an assignment. It was the right fix for the write path. It did
nothing for reads.
The exploit chain, step by step
The chain is short, which is what makes it dangerous. Here is the proof of concept from the advisory, verbatim:
const velocity = require('velocityjs');
const template = "#set($f=$x.constructor.constructor('return process.mainModule.require(\"child_process\").execSync(\"whoami\").toString()'))#set($r=$f())$r";
console.log(velocity.render(template, { x: {} }));
The context passes in the most innocuous value imaginable, an empty object. Everything else is done by walking properties the engine happily resolves.
Step one: a property read that is never filtered
When velocity evaluates the value expression in that #set, it resolves the
reference through getReferences(), which calls getAttributes() to walk
each .property in turn. As the advisory puts it:
The property access at line 88-89 has no filtering.
So $x.constructor reads the constructor property of the empty object and
returns Object. No key is blocked, because the earlier fix only guarded
assignment targets, and this is a read.
Step two: constructor of a constructor is Function
Read .constructor a second time, on Object, and you get JavaScript’s
Function constructor. This is the canonical sandbox-escape primitive:
Function('...body...') compiles a string into a callable function with
access to the global scope. From there, require('child_process') and
execSync are one call away.
Step three: build the function and call it
The first #set stores the compiled function in $f; the second calls it
with $f(); $r renders the output. The advisory is blunt about the
consequence:
Any application that renders attacker-controlled Velocity templates using velocityjs is vulnerable to full server compromise. The attacker can execute arbitrary shell commands, read environment variables, access cloud credentials, and pivot to internal network resources.
Why the previous patch did not stop it
This is the part worth internalizing, because it is a pattern, not a
one-off. The May fix reasoned about where dangerous keys are dangerous and
concluded, correctly, that assigning to __proto__ or constructor was the
problem. It hardened the write path. But constructor is equally dangerous
as a thing you read, because reading it hands you a live reference to a
constructor you can keep walking. Guarding one direction and not the other
left the door open under a lock that looked closed. Version 2.1.7 filters
the read path too, which is why the fix is a real fix and not another
partial one.
The lesson generalizes past this library: a mitigation that enumerates dangerous operations on a dangerous value usually misses an operation. The durable fix is to treat the value as forbidden in every position.
Am I affected, and how to fix it
Affected: any service on velocityjs 2.1.6 or earlier that renders a template whose text an attacker can influence, directly or through stored data. Fixed: 2.1.7.
| Action | Command or check |
|---|---|
| Upgrade | npm install velocityjs@latest, then confirm 2.1.7 resolved in your lockfile |
| Find transitive copies | npm ls velocityjs to catch an old version pinned by a dependency |
| Interim, if pinned | never render attacker-controlled template source; pass user input only through the context object |
The interim mitigation matters because upgrading a transitive dependency is
not always instant. The precondition for the entire chain is that an
attacker can write the template text. If your template source is trusted
code and user input only ever arrives through the context object velocity
substitutes, the attacker never gets to write constructor in the first
place. Upgrade anyway: defense in depth is cheaper than a reachability
argument you have to re-verify on every refactor.
What a library RCE has to do with buying a pentest
We publish this for a reason beyond the disclosure itself. The single hardest thing to verify when you buy security testing is whether the tester can actually find something a scanner cannot. A vendor can claim depth; a credited, fixed, public CVE-class finding is proof you can check yourself.
This is also why our lower price works, which is the question founders actually ask. When you own the whole testing stack, the attack agents, the exploitation tooling, and the reporting, the marginal cost of a run is compute plus senior review, not tester-weeks. The full cost breakdown shows the day-rate math the market runs on; ours is public at $299 a month for early-stage startups and $2,999 for everyone else, against $5,000 to $30,000 for a single traditional boutique test. A startup’s continuous testing plus an independent SOC 2 attestation lands near $6,088 for the year. The same stack that found this velocity.js RCE has also produced a Function-constructor RCE in JSONPath-Plus, a library pulled 12 million times a week, a cookie-leak in tough-cookie (about 480 million downloads a month), plus a reported and fixed security-check bug in Tor’s arti and privately confirmed findings in NASA. Its runs on the 104-challenge XBOW benchmark are public.
Hackers stay in the loop
None of this is the AI alone. A model can surface a suspicious property
walk; a human confirms it reaches Function, writes the working proof of
concept, and files the coordinated disclosure. That division of labor, the
machine doing coverage no human team can afford monthly and the hacker
validating exploitability, is the whole model behind our
white-box testing and the reason a finding like this
lands as a real report rather than a scanner alert.
Where else this matters
If you handle cardholder data, an exploitable RCE in a rendering path is exactly the kind of finding PCI DSS 11.4 exists to catch, and exactly the kind a passing quarterly scan will miss. The difference between a scan and a test is whether anyone chains the primitive to a shell.
The short version
- We reported a critical RCE in velocity.js, CVSS 9.8, GHSA-7gfh-x38p-prh3, fixed in 2.1.7.
- A template reads
$x.constructor.constructorto reach the Function constructor and run shell commands. - The prior patch guarded assignment (write) keys; the property-read path was still unfiltered.
- Affected: velocityjs 2.1.6 and earlier. Fix: upgrade to 2.1.7, or never render attacker-controlled template source.
- Public, credited findings like this 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 by GHSA-7gfh-x38p-prh3?
You are affected if you run velocityjs 2.1.6 or earlier and render any template whose text an attacker can influence. That includes user-supplied templates, email or document templates edited through your product, and templates loaded from a database an attacker can write to. If every template is a static file you ship and control, the risk is far lower, but you should still upgrade, because a future refactor can turn a static template into a dynamic one.
What versions of velocityjs are affected and which one fixes it?
Everything up to and including 2.1.6 is vulnerable. Version 2.1.7, published on July 19, 2026, filters the property-read path and closes it. Upgrade with npm install velocityjs@latest and confirm the resolved version in your lockfile, because a transitive dependency can pin an old copy even after you bump your direct dependency.
How do I fix it if I cannot upgrade immediately?
Upgrading to 2.1.7 is the real fix. If you are pinned for a release window, the interim mitigation is to never render attacker-controlled template text: treat template source as trusted code, not as data, and pass user input only through the context object that velocity substitutes into a fixed template. That removes the attacker's ability to write the constructor expression in the first place, which is the precondition for the whole chain.
Did you have authorization to test velocity.js?
This was coordinated disclosure on open-source software, not an engagement against a private target. We tested a public library on our own systems, reported the finding privately to the maintainer through GitHub's security advisory process, and the fix shipped before any public detail. That is the standard responsible-disclosure path, and it is the same conservative posture we hold on client work, where we test only what is in a signed scope.
Does finding this prove an AI can run a real penetration test?
It proves the specific thing that matters: the testing stack found a reachable, exploitable path in code that thousands of teams depend on, and a human validated and reported it. Automated scanners flag patterns; they do not chain a property read into the Function constructor and confirm shell execution. The finding is public, credited, and fixed, which is a stronger claim than a benchmark score, though our benchmark runs are public too.
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 actually exploits and to write the report your auditor reads. 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, instead of a three-vendor stack. Startups pay $299 a month, every other company $2,999.