GHSA-6q5m-63h6-5x4v: LiquidJS has Exponential Memory Amplification through its replace_first Filter $& Pattern
SummaryThe `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.DetailsThe `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.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`:**Expected output:**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:**`$&` 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.**Empirical results** (Node.js v20.20.1, LiquidJS 10.24.0):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)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.
CVSS v3.1: 7.5