The archive/CVE-2021-23369
handlebars-lang/handlebars.jsDisclosed 2021

CVE-2021-23369:handlebars.js: Code injection / template-compilation RCE

A reconstruction of the flaw, and the evidence path Lachesis follows to make it visible.

Historical recordv4.7.6 (affected: < 4.7.7)
WeaknessCWE-94 · Code injection / template-compilation RCE
ImpactA code injection / template-compilation rce in handlebars.js, reachable from attacker-controlled input.
Read the reconstruction
01What happened

A crafted property name in a compat:true template injects JavaScript into the compiled function.

That is the shape of the failure. The rest of this case file follows the evidence behind it.

Separate the record from the run

What was known.
What Lachesis established.

01Historical record

Known before Lachesis ran

The CVE and vulnerable release were selected from public history. We are not claiming novel discovery.

CVE
CVE-2021-23369
Vulnerable target
v4.7.6 (affected: < 4.7.7) · vulnerable
Prior knowledge
CVE-2021-23369 · v4.7.6 (affected: < 4.7.7) is vulnerable
then
02Lachesis run
Verified captured run

Independent re-detection

The experiment graph was built around the handlebars compiler tree (lib/, release v4.7.6) rather than starting from arbitrary handlebars source. We did not point the tool at the CVE. Enumeration ran over the whole candidate registry (31 constructors, every family) with the pass-3 ranker ordering leads; 481 candidates surfaced, 24 in the injection class, and the injection.exec.escaping family fired 15 times inside the compiler. The top-ranked injection lead anchored on a regex .exec call in the whitespace-control pass, and lower-ranked injection leads landed on the actual compile pipeline (compiler.compile, javascript-compiler prepend/decorator emission). Every field under sast_output is the enumerator's own emission (candidate capsule plus sources_of value-flow cone); everything under adjudication is my reading of the source and fix and is labelled as such.

Seeded inputs
None
Run timestamp
2026-09-02T13:32:48Z
Evidence artifact
handlebars_lib.kuzu

The commands and outputs below come from this recorded Lachesis run.

The reconstruction, in one frame

What Lachesis reconstructed.

One historical repository. Four captured queries. A complete source-to-sink argument.

Lachesis Casefiles · Unbound ComputeVerified runCVE-2021-23369
A crafted property name in a compat:true template injects JavaScript into the compiled function.
01Sourcelet body = program.body;
02Sinkcurrent.indent = /([ \t]+$)/.exec(body[i - 1].original)[1];
03Missing guardreturn ['lookup(depths, "', name, '")'];
$ lachesis sources_of → reaches → guard_dominance
handlebars-lang/handlebars.js · v4.7.6 (affected: < 4.7.7) · vulnerableunboundcompute.com
This was known history. Your code is not.

Lachesis follows the same evidence path through unfamiliar repositories.

The Lachesis reconstruction

Here is how the path becomes visible.

These are the recorded questions Lachesis asked of the historical vulnerable code, followed by the raw result and source location each query returned.

Repositoryhandlebars-lang/handlebars.js
Revisionv4.7.6 (affected: < 4.7.7) · vulnerable
EngineLachesis

Captured runThis is a real replay of Lachesis over the graph built for this case (lib/ of handlebars.js v4.7.6). Every command below was run against that graph and every result is the output captured on that run (elapsed 20.6s). We did not point the tool at a file or a family. The hunt listed the whole taxonomy first and the injection family came out of that ranking. No template or payload was seeded; the graph was built from source alone, so the mode is independent-redetection.

01 · load_graph01 / 05
Lachesis ran
load_graph --path handlebars_lib.kuzu --profile all && candidate_census

Load the graph and list every bug family

First we load the graph and ask the tool to list every bug family it knows, nothing chosen ahead of time. It reports all 8 domains and 31 constructors. Injection is one row among many here.

Captured resultCaptured · load_graph
raw captured outputload_graph
[lachesis-mcp] loaded the graph (published 6187 nodes and 20585 edges); overlay on demand.
taxonomy (8 domains): lifecycle, memory, injection, navigation, object-integrity, filesystem, crypto-config, resource
constructors (31): complete_for_observable_graph=True
lib/handlebars/compiler/whitespace-control.jsline 0
Code location returned for this evidence step.
The repair

A small line with a large consequence.

the property name is embedded into the generated JS string literal without escaping, so it can terminate the string and inject code.

Before
return ['lookup(depths, "', name, '")'];
After the patch
// escape the name with JSON.stringify before embedding it in the generated code (fixed in 4.7.7, commit f0589701).
Sources & further reading

The original record.

Read the historical advisory and vulnerability record behind this reconstruction.

Open the Lachesis quickstart