From dee38df282f3155c75e2bc9116df255ac4d071ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 6 Aug 2026 01:02:47 +0200 Subject: [PATCH 1/2] fix(json): validate the untagged key pointer in stringify_object_inner (#7472) JSON.stringify segfaulted on any object whose key slot held a NaN-boxed immediate. The member-key admission treated "not STRING_TAG and not POINTER_TAG" as "therefore a raw pointer": let key_ptr = if key_tag == STRING_TAG || key_tag == POINTER_TAG { (key_bits & POINTER_MASK) as *const StringHeader } else { key_bits as *const StringHeader // <- anything at all }; so key_bits = 0x7FFC_0000_0000_0010 was read as a StringHeader -- byte_len at +4, data at +0x14 -- and faulted at 0x7ffc000000000014. Same bug class as #7447 and the same fix: decide by GC allocation membership (ptr_is_tracked_heap_object, dereference-free page-map and registry lookups) instead of by bit pattern. A value that names no tracked allocation yields a null key, which str_from_header already handles. NOT a mask: with the tape on, the field_access benchmark's checksum is now 2552985550, which is exactly what node produces. Before this it crashed; PERRY_JSON_TAPE=0 yields 2552986400 and disagrees with node, i.e. the direct parser has its own pre-existing divergence -- filed separately, not the default path. The crash was NOT introduced by #7432. It reproduces on its parent, and on older commits, as soon as the scavenge cadence stops being degenerate: 0402a05af, PERRY_GC_SCAVENGE_NURSERY_MB=16 -> exit 0 (592 minors) 0402a05af, PERRY_GC_SCAVENGE_NURSERY_MB=32 -> exit 139 (8 minors) #7432 fixed the once-per-1MB-block cadence that had been masking it. Verified: default config 3/3 clean; caps 1/4/16/32 MB all clean and all producing node's checksum; 59 json unit tests; 12/12 json gap tests. --- crates/perry-runtime/src/json/stringify.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/perry-runtime/src/json/stringify.rs b/crates/perry-runtime/src/json/stringify.rs index 9267e08d3d..d1d05f89cc 100644 --- a/crates/perry-runtime/src/json/stringify.rs +++ b/crates/perry-runtime/src/json/stringify.rs @@ -1211,8 +1211,18 @@ pub(crate) unsafe fn stringify_object_inner(ptr: *const u8, buf: &mut String, de let key_tag = key_bits & 0xFFFF_0000_0000_0000; let key_ptr = if key_tag == STRING_TAG || key_tag == POINTER_TAG { (key_bits & POINTER_MASK) as *const StringHeader - } else { + } else if ptr_is_tracked_heap_object(key_bits as *const u8) { + // Untagged raw key pointer (#3576 module-slot shape). It must be + // VALIDATED, not assumed: this arm previously accepted anything + // that was not STRING_TAG/POINTER_TAG and dereferenced it, so a + // key slot holding a NaN-boxed immediate — observed as + // `0x7FFC_0000_0000_0010` — was read as a `StringHeader` + // (byte_len at +4, data at +0x14) and SIGSEGV'd. Same bug class as + // #7447, same predicate: decide by GC allocation membership, which + // is dereference-free, rather than by bit pattern. key_bits as *const StringHeader + } else { + std::ptr::null() }; // SerializeJSONProperty step 2 (#5909): apply a heap-valued member's From 6e8de836b0f093f1d09fa6791bc067c255aaa979 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 6 Aug 2026 01:03:16 +0200 Subject: [PATCH 2/2] docs: changelog fragment for 7473 --- changelog.d/7473-stringify-key-admission.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/7473-stringify-key-admission.md diff --git a/changelog.d/7473-stringify-key-admission.md b/changelog.d/7473-stringify-key-admission.md new file mode 100644 index 0000000000..63d6488bfb --- /dev/null +++ b/changelog.d/7473-stringify-key-admission.md @@ -0,0 +1 @@ +- **`JSON.stringify` no longer segfaults on a key slot holding a NaN-boxed immediate.** `stringify_object_inner` treated "not `STRING_TAG` and not `POINTER_TAG`" as "therefore a raw pointer", so `0x7FFC_0000_0000_0010` was dereferenced as a `StringHeader` and faulted — the same bug class as #7447, now fixed the same way, by deciding on GC allocation membership rather than bit pattern. This is not a mask: the default path's checksum on `json_polyglot/field_access` now matches node exactly (2552985550), where it previously crashed. The crash was **not** introduced by #7432 — it reproduces on older commits once the scavenge cadence stops being degenerate, and #7432 merely stopped masking it. (#7473)