GHSA-9r5m-9576-7f6x LiquidJS: memoryLimit Bypass through Negative Range Values Leads to Process Crash
SummaryLiquidJS's `memoryLimit` security mechanism can be completely bypassed by using reverse range expressions (e.g., `(100000000..1)`), allowing an attacker to allocate unlimited memory. Combined with a string flattening operation (e.g., `replace` filter), this causes a **V8 Fatal error that crashes the Node.js process**, resulting in complete denial of service from a single HTTP request.DetailsWhen LiquidJS evaluates a range token `(low..high)`, it calls `ctx.memoryLimit.use(high - low + 1)` in `src/render/expression.ts:70` to account for memory usage. However, for reverse ranges where `low > high` (e.g., `(100000000..1)`), this computation yields a negative value (`1 - 100000000 + 1 = -99999998`).The `Limiter.use()` method in `src/util/limiter.ts:11-14` does not validate that the `count` parameter is non-negative. It simply adds `count` to `this.base`, causing the internal counter to go negative. Once the counter is sufficiently negative, subsequent legitimate memory allocations that would normally exceed the configured `memoryLimit` pass the `base + count <= limit` assertion.Escalation to Process Crash via Cons-String FlatteningV8 optimizes string concatenation (`append` filter) by creating a cons-string (a linked tree of string fragments) rather than copying data. This means `{% assign s = s | append: s %}` repeated 27 times creates a 134MB logical string that consumes only kilobytes of actual memory.However, when a filter that requires the full string buffer is applied — such as `replace` — V8 must "flatten" the cons-string into a contiguous memory buffer. For a 134MB cons-string, this requires allocating ~268MB (UTF-16) in a single operation. This triggers a **V8 C++ level Fatal error** (`Fatal JavaScript invalid size error 134217729`) that:**Cannot be caught** by JavaScript `try-catch` or `process.on('uncaughtException')`**Immediately terminates** the Node.js process (exit code 133 / SIGTRAP)**Crashes the entire service**, not just the attacking connectionThe complete attack chain:Insert 5 reverse ranges `{% for x in (100000000..1) %}{% endfor %}` → memory budget becomes -500MBuild a 134MB cons-string via 27 iterations of `{% assign s = s | append: s %}`  → negligible actual memoryApply `{% assign flat = s | replace: 'A', 'B' %}` → V8 attempts to flatten → **Fatal error → process crash**The attacker payload is ~400 bytes. The server process dies instantly. Express error handlers, domain handlers, and uncaughtException handlers are all bypassed.PoCLiquidJS <= 10.24.x with `memoryLimit` option enabledAttacker can control Liquid template source codeSave the following as `poc_memorylimit_bypass.js` and run with `node poc_memorylimit_bypass.js`:**Expected output:**The process terminates at Step 3 with exit code 133 (SIGTRAP). The V8 Fatal error occurs at the C++ level and **cannot be caught** by `try-catch`, `process.on('uncaughtException')`, or any JavaScript error handler.HTTP Reproduction (for applications that accept user templates)If the application exposes an endpoint that renders user-supplied Liquid templates with `memoryLimit` configured (e.g., CMS preview, newsletter editor, etc.):Replace `http://<app>/render` with the actual template rendering endpoint. The payload is pure Liquid syntax and works regardless of the HTTP framework or endpoint structure.ImpactAn attacker who can control template content (common in CMS, email template editors, and SaaS platforms using LiquidJS) can bypass the `memoryLimit` protection entirely and crash the Node.js process:**Complete bypass of the `memoryLimit` security mechanism**: The explicitly configured memory limit becomes ineffective.**Process crash from a single HTTP request**: V8 Fatal error terminates the entire Node.js process, not just the attacking request. This is not a catchable JavaScript exception.**Service-wide denial of service**: All in-flight requests are terminated. Manual restart or container restart policy is required to recover.**False sense of security**: Administrators who configured `memoryLimit` believe their service is protected when it is not.**Container restart policy does not mitigate**: Even with Docker `restart: always` or Kubernetes liveness probes, repeated crash payloads can keep the service in a perpetual restart loop. Each restart takes several seconds, during which all in-flight requests are lost and the service is unavailable.
CVSS v3.1 score: 7.5