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.

Diagram of the velocity.js exploit chain: an attacker-controlled template reads the constructor property, reaches the Function constructor, builds a call to child_process execSync, and executes an arbitrary shell command on the server

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.

FieldValue
AdvisoryGHSA-7gfh-x38p-prh3
SeverityCritical, CVSS 9.8
VectorAV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
Affectedvelocityjs 2.1.6 and earlier
Patched2.1.7 (July 19, 2026)
ClassRemote code execution via property read
Reported bycruzryan

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.

Diagram showing the earlier velocity.js patch guarded the write path of set assignments while the read path that resolves property expressions stayed unfiltered and exploitable until version 2.1.7

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.

ActionCommand or check
Upgradenpm install velocityjs@latest, then confirm 2.1.7 resolved in your lockfile
Find transitive copiesnpm ls velocityjs to catch an old version pinned by a dependency
Interim, if pinnednever 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.constructor to 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.