CVE-2026-73649: a critical RCE we found in velocity.js

We reported a critical remote code execution bug in velocity.js, CVSS 9.8, now tracked as CVE-2026-73649, 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.

Updated August 13, 2026: GitHub, the CNA here, assigned CVE-2026-73649 and published the record to the CVE List.

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 · where the cve is tracked · 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
CVECVE-2026-73649
AdvisoryGHSA-7gfh-x38p-prh3
SeverityCritical, CVSS 9.8
VectorAV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
WeaknessCWE-94, code injection
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, 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, and the only question left is how far one of them can 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 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 constructor off the empty object and returns Object. No key is blocked: 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, the canonical sandbox-escape primitive: Function('...body...') compiles a string into a callable function with 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 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 a problem. It hardened the write path. But constructor is equally dangerous as a thing you read, because reading it hands you a live reference you can keep walking. Guarding one direction left the door open under a lock that looked closed.

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.

What the real fix looks like

That is exactly what the maintainer shipped in pull request #192. Instead of adding a second denylist next to the first, it introduces one module, src/compile/prototype-guard.ts, and calls it from both sides: references.ts on the read path (property, index, and method access) and set.ts on the assignment path, which drops its own duplicated checks. One guard, every position, plus tests for inherited constructor exposure. Copy that shape.

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 whole chain is that an attacker can write the template text, so if template source is trusted code and user input only ever arrives through the context object, the attacker never gets to write constructor. Upgrade anyway: defense in depth is cheaper than a reachability argument you re-verify on every refactor.

Where CVE-2026-73649 is tracked

One bug gets several identifiers, which is why scanners disagree about whether you are patched. All of these describe the same flaw:

RecordWhere to read itStatus
CVE-2026-73649CVE List recordPublished August 13, 2026, CNA GitHub
GHSA-7gfh-x38p-prh3GitHub Advisory DatabasePublished July 24, 2026
OSV entryosv.devnpm ecosystem, fixed 2.1.7
NVD entrynvd.nist.govingesting from the CVE List
Fixed releasevelocityjs 2.1.7 on npmJuly 19, 2026

The disclosure ran the coordinated path: reported privately, patch merged in PR #192 on July 15, release 2.1.7 on July 19, advisory published July 24, CVE issued August 13 after GitHub reviewed the record for CVE-rule compliance. Nothing public until the fix existed. If your scanner keys on the CVE rather than the GHSA, it stayed quiet for three weeks on a 9.8 you were already exposed to, which is the argument for alerting on the advisory database your ecosystem publishes to instead of waiting for NVD enrichment.

What a library RCE has to do with buying a pentest

The hardest thing to verify when you buy security testing is whether the tester can find something a scanner cannot. A vendor can claim depth; a credited, fixed, publicly numbered CVE is proof you can check yourself.

It is also why our price works, 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 under 10 people and $499 at 10 or more, against the $5,000 to $30,000 a single traditional boutique test costs. Both include a pentest every month, unlimited if you bring your own Anthropic key, and a human-validated engagement is $2,999 per engagement on top.

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 half a billion downloads a month), a 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 proof of concept, and files the coordinated disclosure. That division of labor, machine coverage no human team can afford monthly plus a hacker validating exploitability, is the model behind our white-box testing and why 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 what PCI DSS 11.4 exists to catch and what a passing quarterly scan misses. 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, now CVE-2026-73649 (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

What is CVE-2026-73649, and is my application affected?

CVE-2026-73649 is a critical remote code execution flaw in the velocityjs template engine, CVSS 9.8, tracked by GitHub as GHSA-7gfh-x38p-prh3 and classed CWE-94 code injection. 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. Static templates you ship and control are far lower risk, but upgrade anyway: a 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 it privately through GitHub's advisory process, and the fix shipped before any public detail. 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 thing that matters: the testing stack found a reachable, exploitable path in code thousands of teams depend on, and a human validated and reported it. Scanners flag patterns; they do not chain a property read into the Function constructor and confirm shell execution. A public, credited, fixed CVE 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, exploitation tooling, and reporting are 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 carry SOC 2 controls, with an independent AICPA-member CPA attesting them and billing you directly. Companies under 10 people pay $299 a month, 10 or more pay $499, both include a pentest every month, unlimited if you bring your own Anthropic key. A human-validated engagement is $2,999 per engagement on top.