Skip to content

fix(json): JSON.stringify segfaulted on a non-ObjectHeader pointer#6334

Open
proggeramlug wants to merge 2 commits into
PerryTS:mainfrom
proggeramlug:fix/json-stringify-nonobject-deref
Open

fix(json): JSON.stringify segfaulted on a non-ObjectHeader pointer#6334
proggeramlug wants to merge 2 commits into
PerryTS:mainfrom
proggeramlug:fix/json-stringify-nonobject-deref

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

JSON.stringify could SIGSEGV. is_object_pointer checked that the value's pointer was not a handle-band id, then read (*obj).keys_array and validated that slot with only an alignment/magnitude heuristic:

top16 in {0,1} && p > 0x10000 && (p & 7) == 0

A garbage word like 0x223af100 satisfies all three, so the following (*keys_arr).length load dereferenced unmapped memory and faulted.

The deeper problem is that being GC-tracked only proves the allocation is real — not that it has an ObjectHeader layout. A Promise / WeakMap / ArrayBuffer reaches the object walkers anyway (e.g. through a static TYPE_OBJECT hint from codegen), and then that slot is some unrelated internal field being read as a pointer. The same held for object_has_no_own_keys and for the pretty printer's walkers, which loaded field_count / keys_array with no gate at all.

  • ptr_is_tracked_heap_object (dereference-free: page-map + malloc registry) now gates the object pointer itself, and — new — the keys array it points to.
  • object_keys_array_checked is the single accessor every object walker uses; a slot that isn't a tracked allocation yields {} instead of a fault.

Value-level divergences this exposed

All in the pretty (space-argument) path, which had drifted from the plain one:

  • a function inside an array serialized as its own pointer bits reinterpreted as a string ([function(){}][""], and for most closures a fault). Node emits null; every other array path already did.
  • JSON.stringify(new Date(), null, 2) walked the DateCell as a plain object and produced "" instead of the ISO string — silently corrupting any indented JSON file containing a date. The plain path has always had the toJSON arm.
  • a RegExp emitted {"field0":null} (its RegExpHeader slots read as fields); Node emits {}.
  • a Promise fell through to the StringHeader fallback and emitted ""; Node emits {}. Detected by GC type alongside Map/Set/Error.

Validation

A node-vs-perry differential over a value matrix — functions (bare, in arrays, as properties), dates, regexps, Map/Set/Error, Promise, WeakMap/WeakSet/WeakRef, sync + async generators, buffers, typed arrays, boxed primitives, sparse arrays, replacer functions, key whitelists, deep nesting, circular detection — is byte-identical to Node in both the plain and pretty forms, where 8 cases previously diverged and several faulted.

perry-runtime: 1264 passed / 0 failed (also with --test-threads=1).

On a large esbuild-bundled CLI app this removes a reproducible SIGSEGV: 42/42 runs clean, from 5/25 crashing.

Known gaps left for follow-ups

Pre-existing, and none of them faults: ArrayBuffer/DataView serialize as a Buffer rather than {}; a Proxy yields null rather than its target's properties; new URL(...) throws "Converting circular structure to JSON" instead of emitting href; Headers/TextEncoder yield null, and AbortController/AbortSignal expose internals, where Node emits {}.

Summary by CodeRabbit

  • Bug Fixes
    • Hardened JSON stringification against mis-tagged and untracked pointers, preventing unsafe dereferencing and possible crashes.
    • Improved object key traversal by validating object and keys_array layouts before reading headers.
    • Fixed JSON.stringify behavior for edge GC types: Promise and RegExp now serialize as {}; Date uses toJSON() semantics.
    • Updated pretty-printing so untracked garbage pointers return "null" and array elements that are sparse/undefined/functions serialize as null.

`JSON.stringify` could SIGSEGV. `is_object_pointer` checked that the value's
pointer was not a handle-band id, then read `(*obj).keys_array` and validated
*that* slot with only an alignment/magnitude heuristic:

    top16 in {0,1} && p > 0x10000 && (p & 7) == 0

A garbage word like `0x223af100` satisfies all three, so the following
`(*keys_arr).length` load dereferenced unmapped memory and faulted.

The deeper problem is that being GC-tracked only proves the *allocation* is real —
not that it has an `ObjectHeader` layout. A Promise / WeakMap / ArrayBuffer reaches
the object walkers anyway (e.g. through a static `TYPE_OBJECT` hint from codegen),
and then that slot is some unrelated internal field being read as a pointer. The
same held for `object_has_no_own_keys` and for the pretty printer's walkers, which
loaded `field_count` / `keys_array` with no gate at all.

- `ptr_is_tracked_heap_object` (dereference-free: page-map + malloc registry) now
  gates the object pointer itself, and — new — the keys array it points to.
- `object_keys_array_checked` is the single accessor every object walker uses; a
  slot that isn't a tracked allocation yields `{}` instead of a fault.

Fixing the walk exposed four value-level divergences from Node on the way, all in
the pretty (`space`-argument) path, which had drifted from the plain one:

- a function inside an array serialized as its own pointer bits reinterpreted as a
  string (`[function(){}]` → `[""]`, and for most closures a fault). Node emits
  `null`; every other array path already did.
- `JSON.stringify(new Date(), null, 2)` walked the `DateCell` as a plain object and
  produced `""` instead of the ISO string — silently corrupting any indented JSON
  file containing a date. The plain path has always had the `toJSON` arm.
- a `RegExp` emitted `{"field0":null}` (its `RegExpHeader` slots read as fields);
  Node emits `{}`.
- a `Promise` fell through to the `StringHeader` fallback and emitted `""`; Node
  emits `{}`. Detected by GC type alongside Map/Set/Error.

Validation: a node-vs-perry differential over a value matrix — functions (bare, in
arrays, as properties), dates, regexps, Map/Set/Error, Promise, WeakMap/WeakSet/
WeakRef, sync + async generators, buffers, typed arrays, boxed primitives, sparse
arrays, replacer functions, key whitelists, deep nesting, circular detection — is
byte-identical to Node in both the plain and pretty forms, where 8 cases previously
diverged and several faulted. perry-runtime: 1264 passed / 0 failed.

On a large esbuild-bundled CLI app this removes a reproducible SIGSEGV: 42/42 runs
clean, from 5/25 crashing.

Known gaps left for follow-ups (pre-existing, none of them faults): `ArrayBuffer`/
`DataView` serialize as a Buffer rather than `{}`; a `Proxy` yields `null` rather
than its target's properties; `new URL(...)` throws "Converting circular structure
to JSON" instead of emitting `href`; `Headers`/`TextEncoder` yield `null`, and
`AbortController`/`AbortSignal` expose internals, where Node emits `{}`.
@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 331329e6-83c3-4942-9aa2-74897fa490dd

📥 Commits

Reviewing files that changed from the base of the PR and between 12ebae6 and 48d1d58.

📒 Files selected for processing (1)
  • crates/perry-runtime/src/json/stringify.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/perry-runtime/src/json/stringify.rs

📝 Walkthrough

Walkthrough

JSON serialization now validates tracked pointers and object key arrays before dereferencing, handles Promise, RegExp, and Date values explicitly, and serializes invalid or non-serializable array elements safely.

Changes

JSON stringify safety

Layer / File(s) Summary
Tracked pointer and keys validation
crates/perry-runtime/src/json/stringify.rs
GC-tracked pointer checks and validated keys_array access prevent unsafe object traversal and return empty objects for invalid layouts.
Built-in value dispatch
crates/perry-runtime/src/json/stringify.rs
Promise and RegExp values serialize as {} through explicit dispatch paths.
Pretty and replacer serialization
crates/perry-runtime/src/json/replacer.rs
Pretty and replacer paths validate object and array pointers, serialize Date values using toJSON() semantics, handle RegExp and Promise values, and emit null for closures, undefined values, and holes.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • PerryTS/perry#5723: Updates related JSON stringify pointer classification and dereference safety.
  • PerryTS/perry#5876: Changes JSON array serialization for function and closure-like values.
  • PerryTS/perry#6055: Hardens replacer traversal against corrupted or mis-tagged pointers.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description covers the bug and validation, but it omits the template sections like Summary, Related issue, Test plan, and Checklist. Add the required template headings and fill in Summary, Changes, Related issue, Test plan, Screenshots/output, and Checklist items.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main fix: preventing JSON.stringify crashes on invalid object pointers.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-runtime/src/json/stringify.rs`:
- Around line 1983-1989: Update the array-element match in stringify_value_depth
to check regex_header_has_magic before dispatching on gc_obj_type, emitting "{}"
for RegExp elements such as /re/. Align the GC_TYPE_PROMISE arm with the
surrounding match arms so cargo fmt --check passes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 28002b57-86e0-4ccf-be7c-a0dadbd4d473

📥 Commits

Reviewing files that changed from the base of the PR and between 2c32585 and 12ebae6.

📒 Files selected for processing (2)
  • crates/perry-runtime/src/json/replacer.rs
  • crates/perry-runtime/src/json/stringify.rs

Comment on lines +1983 to +1989
// A Promise has no enumerable own properties — Node emits "{}". Its
// `PromiseHeader` is not the JSObject keys/values layout, so falling
// through to the structural heuristics below read its slots as a
// StringHeader and emitted `""`.
crate::gc::GC_TYPE_PROMISE => {
buf.push_str("{}");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Missing RegExp arm (parity gap with stringify_value_depth) plus indentation drift.

Two issues in this array-element match:

  1. Like the top-level stringify_value gap above, array elements here dispatch on gc_obj_type(elem_ptr) without a prior regex_header_has_magic check, so a RegExp element (e.g. JSON.stringify([/re/])) falls into the GC_TYPE_OBJECT arm at line 1968 and risks misreading RegExpHeader as ObjectHeader instead of emitting {}.
  2. The new GC_TYPE_PROMISE arm is indented 12 spaces while the surrounding GC_TYPE_MAP | GC_TYPE_SET arm and the _ => catch-all use 16 spaces. Based on learnings, CI only runs cargo fmt --check (no clippy) for this repo, so this indentation mismatch would fail that check.
🐛 Proposed fix — add RegExp arm + fix indentation
+            if crate::regex::regex_header_has_magic(elem_ptr as *const crate::regex::RegExpHeader) {
+                buf.push_str("{}");
+                continue;
+            }
             match gc_obj_type(elem_ptr) {
                 crate::gc::GC_TYPE_OBJECT => stringify_object_inner(elem_ptr, buf, depth),
                 crate::gc::GC_TYPE_ARRAY => stringify_array_depth(elem_ptr, buf, depth),
@@
                 crate::gc::GC_TYPE_MAP | crate::gc::GC_TYPE_SET => {
                     buf.push_str("{}");
                 }
-            // A Promise has no enumerable own properties — Node emits "{}". Its
-            // `PromiseHeader` is not the JSObject keys/values layout, so falling
-            // through to the structural heuristics below read its slots as a
-            // StringHeader and emitted `""`.
-            crate::gc::GC_TYPE_PROMISE => {
-                buf.push_str("{}");
-            }
+                crate::gc::GC_TYPE_PROMISE => {
+                    // A Promise has no enumerable own properties — Node emits "{}".
+                    buf.push_str("{}");
+                }
                 _ => {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/json/stringify.rs` around lines 1983 - 1989, Update
the array-element match in stringify_value_depth to check regex_header_has_magic
before dispatching on gc_obj_type, emitting "{}" for RegExp elements such as
/re/. Align the GC_TYPE_PROMISE arm with the surrounding match arms so cargo fmt
--check passes.

Source: Learnings

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant