Skip to content

JSON.parse segfaults on deeply-nested input instead of throwing (node parses it; scriptc raises RangeError) #7792

Closed

Description

@proggeramlug

Summary

JSON.parse on a deeply-nested document segfaults (SIGSEGV, exit 139) instead of throwing. Node parses the same document successfully; scriptc raises a catchable RangeError. Perry is the only one of the three that dies.

node --experimental-strip-types nested_json.ts   → 300000
scriptc 0.0.23                                   → Uncaught RangeError: Maximum call stack size exceeded  (exit 1)
perry  0.5.1455 (0a2bf15bd)                      → SIGSEGV, no output                    ← CRASH

Threshold

Bisected by nesting depth (the 300000 literal below):

depth 1 000 5 000 10 000 50 000 100 000 300 000
perry 0 0 0 139 139 139
node 0 0 0 0 0 0

So the cliff is between 10k and 50k nesting levels. Node handles 300k. Perry produces no output at all — the crash is during the parse, not at teardown.

Repro

// nested_json.ts
function main(): void {
  let s = "0";
  for (let i = 0; i < 300000; i++) s = "[" + s + "]";
  const v = JSON.parse(s);
  let d = 0; let cur: any = v;
  while (Array.isArray(cur)) { d++; cur = cur[0]; }
  console.log(d);
}
main();
export PERRY_RUNTIME_DIR=<build>/target/release
PERRY_NO_AUTO_OPTIMIZE=1 <build>/target/release/perry nested_json.ts -o p_nested_json
./p_nested_json; echo "exit=$?"        # exit=139, no output
node --experimental-strip-types nested_json.ts   # 300000

Committed at gc-handoff/m0810/deep/nested_json.ts.

Why this matters beyond the synthetic case

Deeply-nested JSON is a well-known untrusted-input shape — it is the classic "billion laughs"-adjacent parser DoS. A segfault on attacker-controlled input is a materially worse outcome than a thrown error: it cannot be caught by a try/catch around JSON.parse, so a server handling a hostile request body goes down rather than returning 400.

The expected behaviour is a catchable RangeError (what scriptc does), or successful parsing (what Node does). Either is fine; crashing is not.

Likely mechanism

A recursive-descent parser recursing once per nesting level and overflowing the 8 MB stack, with no depth limit and no stack-guard check. Note this repo has fixed one instance of this class before — #5065, "try/catch nesting deeper than 128 panics the process instead of throwing" — so a depth guard that converts overflow into a throw is an established pattern here.

Worth checking whether the same shape exists in the other recursive consumers of untrusted input (JSON.stringify on a deep object, structuredClone, the regex engine, deep util.inspect).

Found by

The realistic-program corpus sweep of 2026-08-10 (perry 0.5.1455 @ 0a2bf15bd), while probing whether scriptc's recursive-refcount-drop depth cliff generalizes. It does — scriptc segfaults on deep linked lists, deep unbalanced trees, and deep closure chains, where Perry is clean. This is the one deep-structure shape where Perry is the one that crashes.

Activity

  1. proggeramlug commented on Aug 11, 2026

    @proggeramlug
    ContributorAuthor

    Heads-up for anyone picking this up: the crash described here is already fixed on main, and there is an open PR that supersedes the fix. Posting the pointers so the work is not done twice.

    Landed: 196c8111b — fix(json): throw instead of crashing on deeply nested JSON.parse input (#7792) (#7816), in the 2026-08-11 batch. JSON.parse now measures nesting depth with a single non-recursive scan before either parser descends, and throws a catchable RangeError past 1,000 levels. Notes worth knowing from that change:

    • Both parsers recurse — the syntax-validation pass and the value parser — so the guard had to run ahead of validation, which means the depth scan sees malformed input and copes with it (brackets inside strings do not count; a stray closer clamps instead of underflowing).
    • The limit is 1,000 because it must be safe on the smallest stack in the process, not the main thread's. A first attempt at 10,000 came from a main-thread measurement and crashed the harness at 9,999 on a 2 MiB worker stack.
    • All three entry points share the one guard; js_json_parse and js_json_parse_result are separate copies of the same flow, and guarding only the latter covered nothing a compiled program reaches.

    That is outcome 2 from the issue (scriptc's behaviour: catchable RangeError), not outcome 1 (Node's: parse it).

    Open and unmerged: PR #7832, branch fix/7817-iterative-json-parser — fix(json): parse deep nesting with a heap-backed stack. That is the upgrade from outcome 2 to outcome 1.

    So the remaining question on this issue is only whether Perry should match Node and parse 300k levels rather than throw at 1,000 — which is what #7832 is for. The segfault itself is gone from main.

    Found while working #7838 (unrelated); I am not taking this issue.

  2. proggeramlug commented on Aug 11, 2026

    @proggeramlug
    ContributorAuthor

    Verified fixed on current main at 48c5e9a by merged PR #7816.

    Fresh verification:

    • Focused runtime boundary test: 1 passed, 0 failed.

    • Built perry, perry-runtime-static, and perry-stdlib-static with a fresh optimized target: exit 0.

    • Ran the issue's 300,000-level shape through the freshly linked binary: exit 0.

    • Output confirmed the RangeError was catchable and execution continued:

      caught true RangeError: JSON.parse: input nested deeper than 1000 levels
      alive

    That meets this issue's stated acceptance condition (catchable RangeError or successful parsing). Full iterative parsing/parity is tracked separately by #7817 / PR #7832.

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