CVE-2026-33287
LiquidJS has Exponential Memory Amplification through its replace_first Filter $& Pattern
LiquidJS is a Shopify / GitHub Pages compatible template engine in pure JavaScript. Prior to version 10.25.1, the replace_first filter in LiquidJS uses JavaScript's String.prototype.replace() which interprets $& as a back reference 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. Version 10.25.1 patches the issue.
Attack profile
How easy it is to exploit
- Attack vector
- Network
- Complexity
- Low
- Privileges needed
- None
- User interaction
- None
What an attacker can affect
- Confidentiality
- None
- Integrity
- None
- Availability
- High
Exploit likelihood
What each source reports
CVE List Score used
7.5
LiquidJS has Exponential Memory Amplification through its replace_first Filter $& Pattern
LiquidJS is a Shopify / GitHub Pages compatible template engine in pure JavaScript. Prior to version 10.25.1, the replace_first filter in LiquidJS uses JavaScript's String.prototype.replace() which interprets $& as a back reference 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. Version 10.25.1 patches the issue.
- Record
CVE-2026-33287- CVSS 3.1
- 7.5 High
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H - Weakness
- CWE-20, CWE-400
- Updated
- 2026-11-02
EUVDEUVD-2026-16064
7.5
LiquidJS is a Shopify / GitHub Pages compatible template engine in pure JavaScript. Prior to version 10.25.1, the replace_first filter in LiquidJS uses JavaScript's String.prototype.replace() which interprets $& as a back reference 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. Version 10.25.1 patches the issue.
- Record
EUVD-2026-16064- CVSS 3.1
- 7.5 High
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H - Updated
- 2026-11-02
GitHub AdvisoriesGHSA-6q5m-63h6-5x4v
7.5
LiquidJS has Exponential Memory Amplification through its replace_first Filter $& Pattern
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.
// 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:
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 |…
- Record
GHSA-6q5m-63h6-5x4v- CVSS 3.1
- 7.5 High
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H - Weakness
- CWE-20, CWE-400
- Updated
- 2026-11-02
NVD
7.5
LiquidJS is a Shopify / GitHub Pages compatible template engine in pure JavaScript. Prior to version 10.25.1, the replace_first filter in LiquidJS uses JavaScript's String.prototype.replace() which interprets $& as a back reference 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. Version 10.25.1 patches the issue.
- Record
CVE-2026-33287- CVSS 3.1
- 7.5 High
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H - Weakness
- CWE-20, CWE-400
- Updated
- 2026-11-02
OSV
7.5
LiquidJS has Exponential Memory Amplification through its replace_first Filter $& Pattern
LiquidJS is a Shopify / GitHub Pages compatible template engine in pure JavaScript. Prior to version 10.25.1, the replace_first filter in LiquidJS uses JavaScript's String.prototype.replace() which interprets $& as a back reference 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. Version 10.25.1 patches the issue.
- Record
CVE-2026-33287- CVSS 3.1
- 7.5 High
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H - Weakness
- CWE-20, CWE-400
- Updated
- 2026-11-02
OSVGHSA-6q5m-63h6-5x4v
7.5
LiquidJS has Exponential Memory Amplification through its replace_first Filter $& Pattern
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.
// 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:
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 |…
- Record
GHSA-6q5m-63h6-5x4v- CVSS 3.1
- 7.5 High
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H - Weakness
- CWE-20, CWE-400
- Updated
- 2026-11-02
Affected products
Affected packages
| Package | Affected | Fixed |
|---|---|---|
liquidjsnpm |
before 10.25.1 | 10.25.1 |
History
| Recorded | Changed | Sources |
|---|---|---|
| first indexed | CVE List, EUVD, GitHub Advisories, NVD, OSV |
Dates are when Every CVE recorded each change. Upstream dates may differ.
In short
CVE-2026-33287 is a high vulnerability (CVSS 7.5) in Harttle Liquidjs, published March 25, 2026. No source lists public exploit code. EPSS puts the chance of exploitation in the next 30 days at 0.48%, the 41st percentile. Fixed in liquidjs 10.25.1.
References
github.com/harttle/liquidjs/security/advisories/GHSA-6q5m-63h6-5x4v
github.com/harttle/liquidjs/commit/35d523026345d80458df24c72e653db78b5d061d
nvd.nist.gov/vuln/detail/CVE-2026-33287
github.com/harttle/liquidjs/blob/v10.25.1/CHANGELOG.md
github.com/harttle/liquidjs/releases/tag/v10.25.1
github.com/advisories/GHSA-6q5m-63h6-5x4v
github.com/CVEProject/cvelistV5/tree/main/cves/2026/33xxx/CVE-2026-33287.json
github.com/harttle/liquidjs
Left out here: the rest of 2 source descriptions. The JSON record has them.