You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Object.freeze/seal/preventExtensions write flag bits into (value-8) for values with no GcHeader: segfault on the namespace stub, silent wild write on registered symbols #10933
Object.freeze / Object.seal / Object.preventExtensions write flag bits into (value - 8) + 2 — where a real object's GcHeader._reserved sits — for any pointer-tagged value above the handle band, with no check that the value actually has a header. Several values perry hands to JS have no GcHeader, so the write lands in memory that belongs to something else. On one of them it writes into .rodata and segfaults.
Every earlier finding in this class (#10917, #10925, #10926) was a wild read. This is the write side of the same hole.
Reproduce (v0.5.1633, Linux x86_64)
Segfault, 3/3 deterministic — the unresolved-namespace stub is a .rodata static, so the write faults:
import*ascryptofrom"node:crypto";conststub: any=crypto.createHash("sha256").constructor;Object.freeze(stub);// node: returns the object perry: SIGSEGV
Silent wild write — a registered symbol is a Box::into_raw'd SymbolHeader with no header. Measured directly in a runtime probe: read the 8 bytes at sym - 8 before and after, over 32 registered symbols.
0x7 is OBJ_FLAG_FROZEN | OBJ_FLAG_SEALED | OBJ_FLAG_NO_EXTEND landing in _reserved (offset 2), i.e. six bytes in front of each symbol. Reached from plain JS as Object.freeze(Symbol.for("x")).
The write sites
crates/perry-runtime/src/object/object_ops_frozen.rs, all reached through extract_obj_ptr, which admits any POINTER_TAG value above the handle band (object_ops.rs:57):
op
line
guard before the write
Object.freeze
146
is_above_handle_band(obj)
Object.seal
256, 265
(obj as usize) > 0x10000 — a bare magnitude floor
Object.preventExtensions
368
(obj as usize) > 0x10000
None of them establishes that the value has a GcHeader. The band check keeps small registry ids out; it says nothing about whether value - 8 is a header.
registered / well-known symbols — Box::into_raw'd (Symbol.for(...), Symbol.iterator, …). Note a fresh Symbol("x") goes through gc_malloc and does have a header, so only the leaked ones are affected.
I did not find a user-visible symptom for the symbol and async cases: in the probe run the affected blocks were not adjacent to other symbols, so nothing symbol-shaped was hit, and where the bits do land on a symbol's id the OR preserves distinctness for small ids. That is luck about the allocator's layout, not a property of the code — the write goes into memory the runtime did not intend to modify, and a run where a live object sits there is silent corruption.
Fix direction
Two independent parts:
Gate the write on the value actually having a header. The band check is the wrong predicate; the question is ownership. try_read_tracked_gc_header answers it. A value that has no tracked header should take the no-op path that Object.freeze(handle) already takes (test_gap_handle_band_object_ops covers that shape for band ids).
Summary
Object.freeze/Object.seal/Object.preventExtensionswrite flag bits into(value - 8) + 2— where a real object'sGcHeader._reservedsits — for any pointer-tagged value above the handle band, with no check that the value actually has a header. Several values perry hands to JS have noGcHeader, so the write lands in memory that belongs to something else. On one of them it writes into.rodataand segfaults.Every earlier finding in this class (#10917, #10925, #10926) was a wild read. This is the write side of the same hole.
Reproduce (v0.5.1633, Linux x86_64)
Segfault, 3/3 deterministic — the unresolved-namespace stub is a
.rodatastatic, so the write faults:Silent wild write — a registered symbol is a
Box::into_raw'dSymbolHeaderwith no header. Measured directly in a runtime probe: read the 8 bytes atsym - 8before and after, over 32 registered symbols.0x7isOBJ_FLAG_FROZEN | OBJ_FLAG_SEALED | OBJ_FLAG_NO_EXTENDlanding in_reserved(offset 2), i.e. six bytes in front of each symbol. Reached from plain JS asObject.freeze(Symbol.for("x")).The write sites
crates/perry-runtime/src/object/object_ops_frozen.rs, all reached throughextract_obj_ptr, which admits anyPOINTER_TAGvalue above the handle band (object_ops.rs:57):Object.freezeis_above_handle_band(obj)Object.seal(obj as usize) > 0x10000— a bare magnitude floorObject.preventExtensions(obj as usize) > 0x10000None of them establishes that the value has a
GcHeader. The band check keeps small registry ids out; it says nothing about whethervalue - 8is a header.Affected populations (values with no
GcHeader).rodatastatic → the write faults. (Being fixed by fix(runtime): the unresolved-namespace stub is an ordinary object, not a header-less static (#10821 row 4, fixes #10917) #10924, which makes it a real object; this issue is why that PR is more urgent than a wrong-value fix.)Box::into_raw'd (Symbol.for(...),Symbol.iterator, …). Note a freshSymbol("x")goes throughgc_mallocand does have a header, so only the leaked ones are affected.AsyncHook/AsyncResourcehandles — theNR_FOREIGN_PTRBoxes of AsyncHook / AsyncResource handles have no GcHeader: JSON.stringify gives []/"" instead of {}, String() is build-dependent #10926.SharedArrayBuffer— fixed by fix(runtime): a SharedArrayBuffer carries a real GcHeader — user bytes decided another SAB's type, and a brand check segfaulted (fixes #10925) #10932, which gives it a real header.I did not find a user-visible symptom for the symbol and async cases: in the probe run the affected blocks were not adjacent to other symbols, so nothing symbol-shaped was hit, and where the bits do land on a symbol's
idthe OR preserves distinctness for small ids. That is luck about the allocator's layout, not a property of the code — the write goes into memory the runtime did not intend to modify, and a run where a live object sits there is silent corruption.Fix direction
Two independent parts:
try_read_tracked_gc_headeranswers it. A value that has no tracked header should take the no-op path thatObject.freeze(handle)already takes (test_gap_handle_band_object_opscovers that shape for band ids).typeof"symbol").Either alone narrows it; both together close it. (1) is small and does not wait on the migration.