Skip to content

fix(runtime): Object.freeze/seal/preventExtensions check ownership before writing header flags — a wild write that segfaults on one receiver (fixes #10933) - #10935

Closed
proggeramlug wants to merge 2 commits into
mainfrom
fix/10933-freeze-header-gate
Closed

proggeramlug wants to merge 2 commits into
mainfrom
fix/10933-freeze-header-gate

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Fixes #10933. Off upstream/main v0.5.1633, independent of the honest-tag PRs.

The write side of the same hole

Object.freeze / Object.seal / Object.preventExtensions wrote OBJ_FLAG_FROZEN | SEALED | NO_EXTEND into (value - 8) + 2 — a real object's GcHeader._reserved — for any pointer-tagged value above the handle band, with nothing establishing that the value has a header. extract_obj_ptr admits every such value, and several perry hands to JS have no header, so the write landed in memory belonging to something else.

#10917, #10925 and #10926 were all wild reads. This is the write side, and on one value it is fatal:

import * as crypto from "node:crypto";
Object.freeze(crypto.createHash("sha256").constructor);   // node: fine   perry: SIGSEGV, 3/3

That receiver is the unresolved-namespace stub, a .rodata static, so the store faults. On a registered symbol — a Box::into_raw'd SymbolHeader — it doesn't fault, it corrupts. Measured over 32 of them, reading the word at sym - 8 before and after all three ops:

pre[0] 0x8000000000000000 -> 0x8000000000070000
pre[2] 0x0000000000000004 -> 0x0000000000070004
pre_header_words_changed=30 of 32

0x7 is the three flags landing in _reserved, six bytes in front of each symbol. In the sabotage run one reads 0x0000583129dbb9f0 -> 0x0000583129dfb9f0the write went into a pointer-shaped value in an unrelated live allocation.

The guards were asking the wrong question

op old guard
Object.freeze is_above_handle_band(obj)
Object.seal (×2) (obj as usize) > 0x10000
Object.preventExtensions (obj as usize) > 0x10000

Both keep small registry ids out — which is why they were written — and neither can tell whether value - 8 is a header. The question is ownership, and try_read_tracked_gc_header is the funnel that answers it: it proves the allocator owns this address on this thread (arena membership or the gc_malloc registry) instead of trusting addr - 8. All four write sites now go through one integrity_flags_are_writable helper.

Behaviour for a rejected receiver is unchanged — a no-op returning the value, exactly as Object.freeze(handle) already was (test_gap_handle_band_object_ops). Object.isFrozen(stub) still answers true, matching node.

Why this is separate from the honest-tag migration

The migration removes the header-less populations one at a time (#10924 stub, #10932 SAB, row 13 async, symbols later). This removes the ability to write through any of them, including ones not yet found, and it doesn't wait on that work. The two are complementary; I'd take both.

Worth noting for the migration: a fresh Symbol("x") goes through gc_malloc and does carry a header. Only the leaked registered / well-known symbols are header-less, which narrows that row.

Tests — must-fail committed BEFORE the gate (63e44afb6)

test without the gate here
integrity_ops_do_not_write_in_front_of_a_header_less_value 30–31 of 32 symbols' preceding word corrupted unchanged
integrity_ops_still_apply_to_a_real_object passes passes — the gate must not become a blanket no-op
compiled Object.freeze(stub) SIGSEGV 3/3 returns normally, isFrozen true like node

Sabotage: restoring the old band predicate behind an env var reddens the first test with an integrity op wrote in front of a header-less value (31 of 32) and the byte-level before/after. Restored; no sabotage code remains.

The stub program is fixed by this gate alone, without #10924 — verified on a build of this branch only.

Verified locally (CI runners are unreliable)

  • cargo test --release -p perry-runtime --lib -- --test-threads=1, both arms (the mode matters — see below): baseline upstream/main 0fa391529 4215 passed, 0 failed; this branch 4217 passed, 0 failed. The +2 are this PR's two header_gate_tests. No pre-existing failure, no new failure.
  • In the default parallel mode the same two arms are noisy and not attributable: this branch reported 4202/15. Lane 16 has since quantified why — -p perry-runtime cannot attribute a regression in parallel mode, because memo-counter assertions share process-global state in one binary: pristine main fails 13, a change fails 14, and the failing sets differ in BOTH directions. Three lanes got 0, 11 and 13 failures on comparable trees the same night. Quote the single-threaded numbers above; the parallel counts mean nothing either way.
  • The existing integrity behaviour is covered by tests that still pass: typed_feedback_class_field_set_guard_fails_for_frozen_object, typed_feedback_array_set_guards_reject_frozen_arrays, numeric_range_add_rejects_frozen_arrays_without_writing.
  • clippy --all-targets has ~12 pre-existing errors in unrelated files.

Summary by CodeRabbit

  • Bug Fixes
    • Fixed crashes and memory corruption that could occur when freezing, sealing, or preventing extensions on header-less values.
    • These operations now safely leave unsupported values unchanged while continuing to work correctly for regular objects.
    • Added regression coverage for symbols and valid objects.

Ralph Kuepper added 2 commits September 21, 2026 22:25
…ront of header-less values

Committed BEFORE the gate. On v0.5.1633, 30 of 32 registered symbols have the
word at sym - 8 change under freeze/seal/preventExtensions:
0x...0000 -> 0x...00070000, i.e. FROZEN|SEALED|NO_EXTEND landing in _reserved
six bytes in front of the symbol. The second test pins that the ops still mark
a REAL object, so the gate cannot pass by becoming a blanket no-op.
…iting header flags (#10933)

`Object.freeze` / `Object.seal` / `Object.preventExtensions` wrote
`OBJ_FLAG_FROZEN | SEALED | NO_EXTEND` into `(value - 8) + 2` -- a real
object's `GcHeader._reserved` -- for ANY pointer-tagged value above the handle
band, with nothing establishing that the value HAS a header. `extract_obj_ptr`
admits every such value, and several that perry hands to JS have no header at
all, so the write landed in memory belonging to something else.

Every earlier finding in this class (#10917, #10925, #10926) was a wild READ.
This is the write side of the same hole, and on one value it is fatal:

    import * as crypto from "node:crypto";
    Object.freeze(crypto.createHash("sha256").constructor);   // SIGSEGV, 3/3

That receiver is the unresolved-namespace stub, a `.rodata` static, so the
store faults. On a registered symbol -- a `Box::into_raw`'d `SymbolHeader` --
it does not fault, it just corrupts. Measured over 32 of them, reading the
word at `sym - 8` before and after:

    pre[0] 0x8000000000000000 -> 0x8000000000070000
    pre[2] 0x0000000000000004 -> 0x0000000000070004
    pre_header_words_changed=30 of 32

`0x7` is the three flags landing in `_reserved`, six bytes in front of each
symbol. Under the sabotage run below one of them reads
`0x0000583129dbb9f0 -> 0x0000583129dfb9f0`: the write went into a
POINTER-shaped value in an unrelated live allocation.

THE GUARDS WERE THE WRONG QUESTION. `freeze` tested
`is_above_handle_band(obj)`; `seal` (twice) and `preventExtensions` tested a
bare `(obj as usize) > 0x10000`. Both keep small registry ids out -- which is
why they were written -- and neither can tell whether `value - 8` is a header.
The question is OWNERSHIP, and `try_read_tracked_gc_header` is the funnel that
answers it: it proves the allocator owns this address on THIS thread (arena
membership or the gc_malloc registry) instead of trusting `addr - 8`. All four
write sites now go through one `integrity_flags_are_writable` helper.

Behaviour for a rejected receiver is unchanged: the op is a no-op that returns
the value, exactly as `Object.freeze(handle)` already was
(`test_gap_handle_band_object_ops`). `Object.isFrozen` on the stub still
answers `true`, matching node.

This is narrower than the honest-tag migration and does not wait on it. The
migration removes the header-less populations (#10924 stub, #10932 SAB, row 13
async, symbols later); this removes the ability to write through ANY of them,
including ones not yet found.

Tests, must-fail committed BEFORE the gate (63e44af):
  * `integrity_ops_do_not_write_in_front_of_a_header_less_value` -- 32
    registered symbols, word at `sym - 8` before and after all three ops.
    Sabotaged by restoring the old band predicate: 31 of 32 corrupted.
  * `integrity_ops_still_apply_to_a_real_object` -- the gate must not pass by
    becoming a blanket no-op.
  * The compiled `Object.freeze(stub)` program segfaults on v0.5.1633 and
    returns normally here, WITHOUT #10924 -- the gate alone is sufficient.

Note a fresh `Symbol("x")` goes through `gc_malloc` and DOES carry a header;
only the leaked registered / well-known symbols are header-less.
@coderabbitai

coderabbitai Bot commented Sep 21, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

Understand this PR’s impact

Explore downstream dependencies and potential security impact with Blast Radius.

View blast radius →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: cf07c787-36ac-4420-9b53-265dc8104d30

📥 Commits

Reviewing files that changed from the base of the PR and between 0fa3915 and a429aa8.

📒 Files selected for processing (2)
  • changelog.d/10933-integrity-op-ownership-gate.md
  • crates/perry-runtime/src/object/object_ops_frozen.rs

Included review availability: Your plan provides up to 8 included reviews per hour; 6 remain after this review.


📝 Walkthrough

Walkthrough

The integrity operations now verify tracked GC ownership before writing object flags. Header-less values remain unchanged. Regression tests cover symbols without GC headers and valid allocated objects.

Changes

Integrity operation ownership gate

Layer / File(s) Summary
Tracked GC ownership validation
crates/perry-runtime/src/object/object_ops_frozen.rs
Adds a shared check that permits flag writes only for non-null pointers with tracked GC headers.
Integrity operation integration and regression coverage
crates/perry-runtime/src/object/object_ops_frozen.rs, changelog.d/10933-integrity-op-ownership-gate.md
Updates freeze, seal, and prevent-extensions to use tracked ownership checks. Tests verify that header-less symbols remain unchanged and that real objects still freeze. The changelog records the fix.

Priority: ⬆️ High

Estimated code review effort: 2 (Simple) | ~15 minutes

Change: Bug fix · Severity of issue fixed: High

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes satisfy the coding requirements in [#10933]. integrity_flags_are_writable replaces address-magnitude checks with try_read_tracked_gc_header. Object.freeze, Object.seal, and `Object…
Out of Scope Changes check ✅ Passed The changed runtime code, regression tests, and changelog entry directly support [#10933]. No unrelated behavior or files are shown in the reviewed pull-request changes.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 8 functions across 1 files. (1 skipped: 1 …
Title check ✅ Passed The title clearly identifies the runtime integrity-operation fix and the ownership check that prevents wild header writes. It is somewhat long, but it remains specific and directly related to the main…
Description check ✅ Passed The description provides the issue reference, change rationale, detailed implementation summary, behavioral expectations, regression tests, and local verification results. It does not use every templa…
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

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.

proggeramlug pushed a commit that referenced this pull request Sep 22, 2026
Three new bare GcHeader casts failed `scripts/addr_class_inventory.py`, a
required `lint` gate. One is expressible through the canonical predicate and is
converted; two are not, and carry written justifications rather than a blanket
silence:

* shared_sab.rs (test read) -> `try_read_gc_header`, exactly as
  object::tombstone_tests reads a keys array's flags.
* shared_sab.rs (header INITIALISATION) -> allowlisted. Every addr_class
  predicate is a read-side check on an address of unknown provenance; none can
  express writing a header onto a block this function just alloc_zeroed'd.
* keys_front_offset_tests.rs (test flag WRITE) -> allowlisted.
  `try_read_gc_header` returns a shared reference and cannot express the write;
  same discipline as the box/release_tests.rs entry.

Also lowers the ratchet baseline by 10 sites across 8 entries, which the audit
asked for: #10935's ownership gate, #10948's keys-array fix, this train's
null_stub reconciliation and the earlier binding removals all deleted sites.
Verified mechanically that no entry rose and none was added -- the ratchet only
tightened.
proggeramlug pushed a commit that referenced this pull request Sep 22, 2026
Three new bare GcHeader casts failed `scripts/addr_class_inventory.py`, a
required `lint` gate. One is expressible through the canonical predicate and is
converted; two are not, and carry written justifications rather than a blanket
silence:

* shared_sab.rs (test read) -> `try_read_gc_header`, exactly as
  object::tombstone_tests reads a keys array's flags.
* shared_sab.rs (header INITIALISATION) -> allowlisted. Every addr_class
  predicate is a read-side check on an address of unknown provenance; none can
  express writing a header onto a block this function just alloc_zeroed'd.
* keys_front_offset_tests.rs (test flag WRITE) -> allowlisted.
  `try_read_gc_header` returns a shared reference and cannot express the write;
  same discipline as the box/release_tests.rs entry.

Also lowers the ratchet baseline by 10 sites across 8 entries, which the audit
asked for: #10935's ownership gate, #10948's keys-array fix, this train's
null_stub reconciliation and the earlier binding removals all deleted sites.
Verified mechanically that no entry rose and none was added -- the ratchet only
tightened.
proggeramlug pushed a commit that referenced this pull request Sep 22, 2026
Three new bare GcHeader casts failed `scripts/addr_class_inventory.py`, a
required `lint` gate. One is expressible through the canonical predicate and is
converted; two are not, and carry written justifications rather than a blanket
silence:

* shared_sab.rs (test read) -> `try_read_gc_header`, exactly as
  object::tombstone_tests reads a keys array's flags.
* shared_sab.rs (header INITIALISATION) -> allowlisted. Every addr_class
  predicate is a read-side check on an address of unknown provenance; none can
  express writing a header onto a block this function just alloc_zeroed'd.
* keys_front_offset_tests.rs (test flag WRITE) -> allowlisted.
  `try_read_gc_header` returns a shared reference and cannot express the write;
  same discipline as the box/release_tests.rs entry.

Also lowers the ratchet baseline by 10 sites across 8 entries, which the audit
asked for: #10935's ownership gate, #10948's keys-array fix, this train's
null_stub reconciliation and the earlier binding removals all deleted sites.
Verified mechanically that no entry rose and none was added -- the ratchet only
tightened.
proggeramlug pushed a commit that referenced this pull request Sep 22, 2026
Three new bare GcHeader casts failed `scripts/addr_class_inventory.py`, a
required `lint` gate. One is expressible through the canonical predicate and is
converted; two are not, and carry written justifications rather than a blanket
silence:

* shared_sab.rs (test read) -> `try_read_gc_header`, exactly as
  object::tombstone_tests reads a keys array's flags.
* shared_sab.rs (header INITIALISATION) -> allowlisted. Every addr_class
  predicate is a read-side check on an address of unknown provenance; none can
  express writing a header onto a block this function just alloc_zeroed'd.
* keys_front_offset_tests.rs (test flag WRITE) -> allowlisted.
  `try_read_gc_header` returns a shared reference and cannot express the write;
  same discipline as the box/release_tests.rs entry.

Also lowers the ratchet baseline by 10 sites across 8 entries, which the audit
asked for: #10935's ownership gate, #10948's keys-array fix, this train's
null_stub reconciliation and the earlier binding removals all deleted sites.
Verified mechanically that no entry rose and none was added -- the ratchet only
tightened.
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Landed on main in merge train 255 (#10950, v0.5.1636), main c7cbc3c73b.

The train carried this PR at head a429aa8dff. The landed tree is byte-identical to the validated train tree (d43bd23008), and CI on the train head passed every job except the known public-baseline lint step. Trains rebase-merge, which gives new commit SHAs, so GitHub can't mark this PR merged. It's closed as landed.

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

Labels

None yet

Projects

None yet

1 participant