Skip to content

Commit 3523355

Browse files
author
Ralph Küpper
committed
fix(runtime): arr[Symbol.iterator] read an array's capacity as a class_id (#7563)
ObjectHeader is { object_type: u32, class_id: u32, ... } and ArrayHeader is { length: u32, capacity: u32 }, so the two u32s at offset 4 alias: an array pointer read as an ObjectHeader reports its capacity as a class_id. arr[Symbol.iterator] resolves through js_class_method_bind(arr, "values"), and that builder's receiver->class step, class_id_from_method_receiver, read the field with a bare (*obj).class_id -- guarded against closures and the handle band, but never against the allocation's actual type. So whenever the class whose id equalled the array's capacity owned a method named `values`, the array's iterator resolved to THAT class's method. When it was the calling class, `values` re-entered `values` until the stack guard page: EXC_BAD_ACCESS at `str xzr, [sp], #-0x50`, ~26 000 frames deep. Reported as a `class X extends Map` values() override bug, but Map is incidental and so is the iteration -- the crash reproduces with no Map in the program and no for-of on the path: class Plain { values() { return [777][Symbol.iterator](); } } new Plain().values(); // SIGSEGV Use js_object_get_class_id, the guarded accessor that already existed for this read: it rejects the handle band, the std::alloc'd Map/Set/Regex headers (no GcHeader to probe), and any allocation whose GcHeader.obj_type is not GC_TYPE_OBJECT. The sibling symbol-method arm in native_call_method.rs already routed through it and was never affected -- verified, not assumed. Not #7561: rewrite_collection_view_for_of declines a subclass receiver exactly as documented, and the offending line predates it by hundreds of commits (traces through #5631's file split to #4630), matching the report that it reproduces at 969b447. Coverage: test-files/test_gap_7563_array_iterator_class_id_confusion.ts (byte-compared against node; SIGSEGVs at the parent commit) and object::tests::array_receiver_is_never_read_as_a_class_id (fails with Some(16), the array's capacity, before the fix).
1 parent b5a2954 commit 3523355

4 files changed

Lines changed: 338 additions & 2 deletions

File tree

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
### Bug fixes
2+
3+
**`arr[Symbol.iterator]` read an array's `capacity` as a `class_id`, so an
4+
array literal could resolve its iterator to an unrelated user class's `values`
5+
method — self-recursively, and fatally (#7563).**
6+
7+
Reported as "a `class X extends Map` that overrides `values()` SIGSEGVs when
8+
the override is iterated". `Map` turns out to be incidental, and so does the
9+
iteration: the same crash reproduces with **no `Map` anywhere in the program**
10+
and with **no `for-of` on the path**.
11+
12+
```ts
13+
class Plain {
14+
values(): IterableIterator<number> {
15+
return [777][Symbol.iterator](); // <-- SIGSEGV
16+
}
17+
}
18+
new Plain().values();
19+
```
20+
21+
#### Root cause
22+
23+
`ObjectHeader` is `{ object_type: u32, class_id: u32, … }`; `ArrayHeader` is
24+
`{ length: u32, capacity: u32 }`. The two `u32`s at offset 4 alias, so an array
25+
pointer read as an `ObjectHeader` reports its **capacity** as a `class_id`.
26+
27+
`arr[Symbol.iterator]` resolves through `js_class_method_bind(arr, "values")`
28+
(`symbol/get.rs`, the #321 arm that makes `typeof arr[Symbol.iterator] ===
29+
"function"` hold). That builder's receiver→class step,
30+
`class_id_from_method_receiver` in `object/native_module.rs`, read the field
31+
with a bare `(*obj).class_id` — guarded only against closures and against the
32+
handle band, never against the allocation's actual *type*. So whenever the class
33+
whose id equalled the array's capacity happened to own a method named `values`,
34+
`method_owner_class_id` found it and the canonical class method was returned as
35+
the array's iterator.
36+
37+
The default array capacity is `MIN_ARRAY_CAPACITY`-clamped, and class ids are
38+
handed out from 1 in declaration order, so the collision is not exotic — it is
39+
the common case for small programs. When the colliding class was the *calling*
40+
class, `values` re-entered `values` once per step until the stack guard page:
41+
42+
```
43+
perry_method_repro_ts__MyMap__values
44+
→ js_native_call_method_value → js_object_get_symbol_property [the array read]
45+
→ js_native_call_value → dispatch_bound_method → call_vtable_method
46+
→ perry_method_repro_ts__MyMap__values [× ~26 000]
47+
```
48+
49+
`EXC_BAD_ACCESS (code=2)` at `str xzr, [sp], #-0x50` — a stack-overflow guard
50+
fault, not a stale or null pointer.
51+
52+
#### Fix
53+
54+
`class_id_from_method_receiver` now uses `js_object_get_class_id`, the guarded
55+
accessor that already existed for exactly this read: it rejects the handle band,
56+
the `std::alloc`'d `Map`/`Set`/`Regex` headers (which have no `GcHeader` to
57+
probe), and any allocation whose `GcHeader.obj_type` is not `GC_TYPE_OBJECT`.
58+
The bare read bypassed all three. The sibling symbol-method arm in
59+
`object/native_call_method.rs` already routed through that accessor and was
60+
never affected — verified, not assumed.
61+
62+
One line of behaviour change; the guard is deliberately not narrower than the
63+
invariant it protects, so a genuine class instance still resolves to its id
64+
(asserted in the same test).
65+
66+
#### Not #7561
67+
68+
The #7561 `for (… of m.values())` rewrite is **not** implicated, and neither is
69+
any Map fast path. `rewrite_collection_view_for_of` declines a subclass receiver
70+
exactly as its doc comment claims, and the crash needs neither a `for-of` nor a
71+
`Map`: calling `m.values()` and discarding the result is enough, and a plain
72+
class reproduces it. The offending line predates #7561 by hundreds of commits
73+
(it traces back through #5631's file split to #4630), matching the issue's
74+
report that it reproduces at `969b447cc`.
75+
76+
#### Also fixed by the same line
77+
78+
The related shapes in the issue's table now match node as well — a `values` /
79+
`keys` / `entries` / `[Symbol.iterator]` override on a `Map`, `Set` or `Array`
80+
subclass, an indirect subclass, and a class-expression subclass, with
81+
`super.values()` from inside an override still reaching the native base.
82+
83+
Coverage: `test-files/test_gap_7563_array_iterator_class_id_confusion.ts`
84+
(byte-compared against node; SIGSEGVs at the parent commit) and
85+
`object::tests::array_receiver_is_never_read_as_a_class_id` (fails with
86+
`Some(16)` — the array's capacity — before the fix).

crates/perry-runtime/src/object/native_module.rs

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1301,7 +1301,12 @@ pub(crate) fn canonical_bound_method_receiver(captured: f64) -> f64 {
13011301
captured
13021302
}
13031303

1304-
fn class_id_from_method_receiver(instance: f64) -> Option<u32> {
1304+
/// The `class_id` of `instance`, when `instance` really is a class instance.
1305+
///
1306+
/// `pub(super)` so `object::tests` can assert the #7563 invariant directly: a
1307+
/// non-object allocation (an array, above all) must resolve to `None` rather
1308+
/// than to whatever its bytes happen to hold at the `class_id` offset.
1309+
pub(super) fn class_id_from_method_receiver(instance: f64) -> Option<u32> {
13051310
if let Some(cid) = class_ref_id(instance) {
13061311
return Some(cid);
13071312
}
@@ -1323,7 +1328,27 @@ fn class_id_from_method_receiver(instance: f64) -> Option<u32> {
13231328
if crate::closure::is_closure_ptr(obj as usize) {
13241329
return None;
13251330
}
1326-
let cid = unsafe { (*obj).class_id };
1331+
// #7563: the closure guard above fixed ONE instance of that type
1332+
// confusion; a bare `(*obj).class_id` read has it for every other
1333+
// non-object allocation too. `ObjectHeader` is `{ object_type: u32,
1334+
// class_id: u32, … }` while `ArrayHeader` is `{ length: u32,
1335+
// capacity: u32 }`, so the `class_id` slot of an ARRAY overlays its
1336+
// **capacity** — an N-capacity array literal was read back as
1337+
// "class id N". Reached from `arr[Symbol.iterator]`, which resolves via
1338+
// `js_class_method_bind(arr, "values")` (`symbol/get.rs`): whenever
1339+
// class id N happened to own a `values` method, the array's
1340+
// iterator resolved to THAT class's method. With `class Plain {
1341+
// values() { return [777][Symbol.iterator](); } }` the one-element
1342+
// literal read back as class id 1 — `Plain` itself — so `values`
1343+
// called `values` until the stack guard page: a SIGSEGV with no
1344+
// `Map` anywhere in the program.
1345+
//
1346+
// `js_object_get_class_id` is the guarded accessor for exactly this
1347+
// read: it rejects the handle band, the std::alloc'd Map/Set/Regex
1348+
// headers (which have no `GcHeader` to probe), and — the part that
1349+
// matters here — any allocation whose `GcHeader.obj_type` is not
1350+
// `GC_TYPE_OBJECT`. Reading the field directly bypassed all three.
1351+
let cid = crate::object::js_object_get_class_id(obj);
13271352
if cid != 0 {
13281353
return Some(cid);
13291354
}

crates/perry-runtime/src/object/tests.rs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1379,3 +1379,48 @@ fn stale_pre_grow_array_pointer_reads_the_real_length_in_object_ops() {
13791379
"the freeze walk must not run past the array's real length"
13801380
);
13811381
}
1382+
1383+
/// #7563: an ARRAY receiver must never be read back as a class instance.
1384+
///
1385+
/// `ObjectHeader` is `{ object_type: u32, class_id: u32, … }` and `ArrayHeader`
1386+
/// is `{ length: u32, capacity: u32 }`, so the two u32s at offset 4 alias — an
1387+
/// array read as an `ObjectHeader` reports its **capacity** as a `class_id`.
1388+
///
1389+
/// That mattered because `arr[Symbol.iterator]` resolves through
1390+
/// `js_class_method_bind(arr, "values")`, whose receiver→class step used a bare
1391+
/// `(*obj).class_id` read instead of the guarded `js_object_get_class_id`. Any
1392+
/// class whose id equalled the array's capacity and which owned a `values`
1393+
/// method therefore captured the array's iterator. When that class was the
1394+
/// *calling* class — `class C { values() { return [x][Symbol.iterator](); } }`
1395+
/// — `values` re-entered `values` until the stack guard page, i.e. a SIGSEGV
1396+
/// with no `Map` anywhere in the program.
1397+
#[test]
1398+
fn array_receiver_is_never_read_as_a_class_id() {
1399+
let arr = crate::array::js_array_alloc(3);
1400+
assert!(!arr.is_null());
1401+
// Impersonate exactly the class id this array's bytes would have yielded.
1402+
let impersonated = unsafe { (*arr).capacity };
1403+
assert_ne!(
1404+
impersonated, 0,
1405+
"the test is vacuous unless the capacity is a non-zero (i.e. lookup-able) class id"
1406+
);
1407+
1408+
let arr_value = crate::value::js_nanbox_pointer(arr as i64);
1409+
assert_eq!(
1410+
super::native_module::class_id_from_method_receiver(arr_value),
1411+
None,
1412+
"an array is not a class instance: its capacity must not be read as a class id"
1413+
);
1414+
1415+
// The guard must not over-narrow. A genuine class instance carrying the
1416+
// very same id still resolves, so the bound-method identity path (#446)
1417+
// keeps working.
1418+
let obj = js_object_alloc(impersonated, 0);
1419+
assert!(!obj.is_null());
1420+
let obj_value = crate::value::js_nanbox_pointer(obj as i64);
1421+
assert_eq!(
1422+
super::native_module::class_id_from_method_receiver(obj_value),
1423+
Some(impersonated),
1424+
"a real class instance must still resolve to its class id"
1425+
);
1426+
}
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
// #7563: `arr[Symbol.iterator]` read an ARRAY's `capacity` field as a `class_id`.
2+
//
3+
// `ObjectHeader` is `{ object_type: u32, class_id: u32, … }` and `ArrayHeader`
4+
// is `{ length: u32, capacity: u32 }`, so the two u32s at offset 4 alias: an
5+
// N-capacity array read as an `ObjectHeader` reports "class id N".
6+
//
7+
// `arr[Symbol.iterator]` resolves through `js_class_method_bind(arr, "values")`
8+
// (`symbol/get.rs`), and that bound-method builder read `class_id` off the
9+
// receiver with a BARE `(*obj).class_id` instead of the guarded
10+
// `js_object_get_class_id` accessor (which rejects any allocation whose
11+
// `GcHeader.obj_type` is not `GC_TYPE_OBJECT`). So whenever the class whose id
12+
// equalled the array's capacity happened to own a `values` method, the array's
13+
// iterator resolved to THAT class's method.
14+
//
15+
// The issue was reported as a `class X extends Map` bug, but Map is incidental:
16+
// the only thing that matters is a class owning a method named `values`. When
17+
// that class is also the one whose `values` body builds the array literal, the
18+
// method calls itself until the stack guard page — a SIGSEGV.
19+
20+
// ── the issue's exact reproducer ──
21+
class MyMap<K, V> extends Map<K, V> {
22+
values(): IterableIterator<V> {
23+
return [777 as unknown as V][Symbol.iterator]();
24+
}
25+
}
26+
27+
const m = new MyMap<string, number>();
28+
m.set("q", 5);
29+
console.log("size:", m.size);
30+
console.log("get:", m.get("q"));
31+
const out: number[] = [];
32+
for (const v of m.values()) out.push(v);
33+
console.log("values:", out.join(","));
34+
35+
// ── the same crash with NO `Map` anywhere: a plain class whose method is named
36+
// `values` and whose body iterates an array literal. Before the fix the
37+
// one-element literal read back as class id 1 — the class itself — so
38+
// `values` called `values` until the stack overflowed. ──
39+
class Plain {
40+
values(): IterableIterator<number> {
41+
return [777][Symbol.iterator]();
42+
}
43+
}
44+
console.log("plain:", [...new Plain().values()].join(","));
45+
46+
// ── the non-recursive form of the same mis-dispatch: the array's capacity
47+
// selects a DIFFERENT class that owns `values`. Pre-fix the 2-element
48+
// literal resolved to `B.values` (a number), and the spread threw
49+
// "value is not iterable". ──
50+
class A {
51+
one(): IterableIterator<number> {
52+
return [1][Symbol.iterator]();
53+
}
54+
two(): IterableIterator<number> {
55+
return [1, 2][Symbol.iterator]();
56+
}
57+
three(): IterableIterator<number> {
58+
return [1, 2, 3][Symbol.iterator]();
59+
}
60+
}
61+
class B {
62+
values(): number {
63+
return 42;
64+
}
65+
}
66+
const a = new A();
67+
console.log("cap1:", [...a.one()].join(","));
68+
console.log("cap2:", [...a.two()].join(","));
69+
console.log("cap3:", [...a.three()].join(","));
70+
console.log("B.values:", new B().values());
71+
72+
// ── a free function (no class in scope) always worked; keep it covered so a
73+
// future narrowing of the guard cannot silently break the ordinary path. ──
74+
function free(): IterableIterator<number> {
75+
return [888][Symbol.iterator]();
76+
}
77+
console.log("free:", [...free()].join(","));
78+
79+
// ── the array `values`/`keys`/`entries` surface itself must stay intact ──
80+
const plainArr = [10, 20, 30];
81+
console.log("arr values:", [...plainArr.values()].join(","));
82+
console.log("arr keys:", [...plainArr.keys()].join(","));
83+
console.log("arr entries:", JSON.stringify([...plainArr.entries()]));
84+
console.log("arr @@iterator:", [...plainArr[Symbol.iterator]()].join(","));
85+
86+
// ── native-base subclass overrides, the family the issue reported against ──
87+
class MapKeysOverride extends Map<string, number> {
88+
keys(): IterableIterator<string> {
89+
return ["kk"][Symbol.iterator]();
90+
}
91+
}
92+
class MapEntriesOverride extends Map<string, number> {
93+
entries(): IterableIterator<[string, number]> {
94+
return ([["ee", 1]] as [string, number][])[Symbol.iterator]();
95+
}
96+
}
97+
class MapIterOverride extends Map<string, number> {
98+
*[Symbol.iterator](): IterableIterator<[string, number]> {
99+
yield ["ii", 9];
100+
}
101+
}
102+
const mk = new MapKeysOverride();
103+
mk.set("q", 5);
104+
console.log("map keys override:", [...mk.keys()].join(","));
105+
const me = new MapEntriesOverride();
106+
me.set("q", 5);
107+
console.log("map entries override:", JSON.stringify([...me.entries()]));
108+
const mi = new MapIterOverride();
109+
mi.set("q", 5);
110+
console.log("map @@iterator override:", JSON.stringify([...mi]));
111+
112+
class SetValuesOverride extends Set<number> {
113+
values(): IterableIterator<number> {
114+
return [111][Symbol.iterator]();
115+
}
116+
}
117+
class SetIterOverride extends Set<number> {
118+
*[Symbol.iterator](): IterableIterator<number> {
119+
yield 444;
120+
}
121+
}
122+
const sv = new SetValuesOverride();
123+
sv.add(1);
124+
console.log("set values override:", [...sv.values()].join(","));
125+
const si = new SetIterOverride();
126+
si.add(1);
127+
console.log("set @@iterator override:", [...si].join(","));
128+
129+
class ArrValuesOverride extends Array<number> {
130+
values(): IterableIterator<number> {
131+
return [555][Symbol.iterator]();
132+
}
133+
}
134+
const av = new ArrValuesOverride();
135+
av.push(1);
136+
console.log("array values override:", [...av.values()].join(","));
137+
138+
// ── INDIRECT subclass and a class EXPRESSION: the two shapes CLAUDE.md's
139+
// "native base-class subclassing" note calls out as historically lossy. ──
140+
class MidMap extends Map<string, number> {}
141+
class LeafMap extends MidMap {
142+
values(): IterableIterator<number> {
143+
return [888][Symbol.iterator]();
144+
}
145+
}
146+
const lm = new LeafMap();
147+
lm.set("z", 3);
148+
console.log("indirect override:", [...lm.values()].join(","));
149+
150+
const ExprMap = class extends Map<string, number> {
151+
values(): IterableIterator<number> {
152+
return [999][Symbol.iterator]();
153+
}
154+
};
155+
const em = new ExprMap();
156+
em.set("z", 3);
157+
console.log("class-expression override:", [...em.values()].join(","));
158+
159+
// ── non-overriding subclasses keep the built-in surface ──
160+
class PlainMap extends Map<string, number> {}
161+
const pm = new PlainMap();
162+
pm.set("a", 1);
163+
pm.set("b", 2);
164+
console.log("no-override values:", [...pm.values()].join(","));
165+
console.log("no-override keys:", [...pm.keys()].join(","));
166+
console.log("no-override entries:", JSON.stringify([...pm.entries()]));
167+
console.log("no-override spread:", JSON.stringify([...pm]));
168+
169+
// ── `super.<m>()` from inside an override still reaches the native base ──
170+
class SuperMap extends Map<string, number> {
171+
values(): IterableIterator<number> {
172+
return super.values();
173+
}
174+
}
175+
const sm = new SuperMap();
176+
sm.set("a", 1);
177+
sm.set("b", 2);
178+
console.log("super.values():", [...sm.values()].join(","));
179+
180+
console.log("done");

0 commit comments

Comments
 (0)