From 86abdea6af9d99c8e2e7f0fedc7c3562789676cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 4 Aug 2026 16:41:10 +0200 Subject: [PATCH] fix(gc): Object.assign type-confused a RegExp source as a plain object js_object_assign_one skips exotic sources whose header layout is not an ObjectHeader -- Map, Set, Promise, Date, WeakMap -- and it classifies them by GC type. A RegExp is allocated as gc_malloc(GC_TYPE_OBJECT), so it passes the very test that excludes the others and falls into the plain-object key walk, where (*src).keys_array reads a RegExpHeader at ObjectHeader's field offset. js_array_length then reads a GcHeader at garbage - 8. Unprotected that silently walks unrelated memory and still prints the right answer, which is why it survived; under #7341's from-space quarantine the address is a retired protected page and the process takes SIGBUS. Per CopyDataProperties a RegExp exposes no own enumerable string keys through this path, so skipping it matches Node: Object.assign({}, /x/g) is {}. is_regex_pointer is the bounds-checked magic probe, safe on the arbitrary payloads this site sees. test_gap_object_assign_collection is now byte-identical to Node under quarantine. 40 regex/assign/spread/object gap tests and 43 regex unit tests unchanged. --- changelog.d/7379-assign-regexp-source.md | 23 +++++++++++++++++++++++ crates/perry-runtime/src/object/alloc.rs | 23 +++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 changelog.d/7379-assign-regexp-source.md diff --git a/changelog.d/7379-assign-regexp-source.md b/changelog.d/7379-assign-regexp-source.md new file mode 100644 index 0000000000..778b9fd0e0 --- /dev/null +++ b/changelog.d/7379-assign-regexp-source.md @@ -0,0 +1,23 @@ +**Fixed** `Object.assign({}, /re/)` and `{ ...\/re\/ }` type-confused the RegExp +source as a plain object, reading `ObjectHeader.keys_array` out of a +`RegExpHeader`'s fields and calling `js_array_length` on the resulting garbage +pointer. + +`js_object_assign_one` skips exotic sources (Map/Set/Promise/Date/WeakMap) whose +header layout is not an `ObjectHeader`, and classifies them by GC type. A RegExp +is allocated as `gc_malloc(GC_TYPE_OBJECT)`, so it passes the `== GC_TYPE_OBJECT` +test that excludes the others and falls into the plain-object key walk. The slot +that lands at `ObjectHeader.keys_array`'s offset in a `RegExpHeader` is not a +keys array, so `js_array_length` reads a `GcHeader` at `garbage - 8`. + +Unprotected this silently walks unrelated memory and usually still prints the +right answer; under `#7341`'s from-space quarantine the address is a retired +protected page and the process takes SIGBUS. This is 1 of the 8 catches that +remained open after #7373-#7376, and the only one whose root cause was a type +confusion rather than a rooting-order defect. + +Per CopyDataProperties a RegExp exposes no own enumerable string keys through +this path, so skipping it matches Node: `Object.assign({}, /x/g)` is `{}`. +Verified with `test_gap_object_assign_collection`, which is now byte-identical +to Node **under quarantine**; 40 regex/assign/spread/object gap tests and 43 +regex unit tests unchanged. diff --git a/crates/perry-runtime/src/object/alloc.rs b/crates/perry-runtime/src/object/alloc.rs index 8a5cca6483..7e34f35522 100644 --- a/crates/perry-runtime/src/object/alloc.rs +++ b/crates/perry-runtime/src/object/alloc.rs @@ -1468,6 +1468,29 @@ pub unsafe extern "C" fn js_object_assign_one(target_f64: f64, source_f64: f64) }; let source_is_array = source_obj_type == crate::gc::GC_TYPE_ARRAY; + // #7341: a RegExp source must be skipped here, and the exotic guard above + // cannot do it. That guard classifies by GC type, and a RegExp is literally + // `gc_malloc(GC_TYPE_OBJECT)` (see `regex.rs`) — so unlike Map/Set/Date it + // passes `== GC_TYPE_OBJECT` and falls into the plain-object arm, where + // `(*src).keys_array` reads a `RegExpHeader` at `ObjectHeader`'s field + // offset. That is a type confusion: the slot it lands on is not a keys + // array, and `js_array_length` then reads a GcHeader at `garbage - 8`. + // Under from-space quarantine that address is a retired protected page and + // the process dies; unprotected it silently walks unrelated memory. + // + // Per CopyDataProperties a RegExp exposes no own enumerable string keys + // through this path (`source`/`flags`/`lastIndex` are prototype accessors + // or non-enumerable), so skipping contributes nothing and matches Node: + // `Object.assign({}, /x/g)` is `{}`. Any own expandos a user attached live + // in the exotic-expando side table, which this raw walk never read anyway. + // + // `is_regex_pointer` is the bounds-checked magic probe, safe on arbitrary + // payloads. Repro: `Object.assign({}, /x/g)` under + // PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_HEAP_LIMIT=8. + if crate::regex::is_regex_pointer(src_raw as *const u8) { + return target_f64; + } + // #7200: EVERYTHING BELOW RUNS WITH USER CODE IN THE WINDOW. // // Both copy loops reach a `[[Get]]` that short-circuits into