← All reports

Handle overflows in UnlinkedMetadataTable::finalize().

MediumJavaScriptCore bytecode metadata layoutIntegerOverflow

CVE: CVE-2026-64784 · Safari 26.6.1 · Released August 18, 2026 Impact: Processing maliciously crafted web content may lead to an unexpected Safari crash Apple's description: An out-of-bounds access issue was addressed with improved bounds checking. Credit: Janggoon Lee of Out of Bounds, OpenAI Codex Security - Amy Burnett

97df94e | Bugzilla 317632

Medium, and the gate is arithmetic rather than reachability. Any page can reach the wrapping accumulator, but making it wrap costs roughly 4 GB of metadata for one function — so the honest outcome is a fault, with a useful relative write conditional on shaping which opcodes land after the wrap.

JavaScriptCore does not store per-instruction mutable state — profiling counters, call-link slots, inline-cache scratch — inline in the bytecode stream. It lives in a separate contiguous block per code block, and a small offset table at the front of that block records where each opcode's metadata array begins. UnlinkedMetadataTable::finalize() is the one-shot step that converts per-opcode counts, collected during bytecode generation, into byte offsets and allocates the buffer those offsets address. The invariant that step must hold is unglamorous and total: the last entry of the offset table, the size reported by totalSize(), and the number handed to malloc() all have to describe the same byte extent.

The angle: A page whose script defines one enormous function can make the engine allocate a metadata buffer far smaller than the offsets baked into that function's bytecode, so executing it reads and writes profiling state outside the allocation.

Source/JavaScriptCore/bytecode/UnlinkedMetadataTable.cpp

-void UnlinkedMetadataTable::finalize()
+bool UnlinkedMetadataTable::finalize()
{
- unsigned offset = s_offset16TableSize;
+ unsigned offset;
+ unsigned valueProfileSize;
{
+ CheckedUint32 checkedOffset = s_offset16TableSize;
Offset32* buffer = preprocessBuffer();
- for (unsigned i = 0; i < s_offsetTableEntries - 1; i++) {
+ for (unsigned i = 0; i < s_offsetTableEntries - 1 && !checkedOffset.hasOverflowed(); i++) {
unsigned numberOfEntries = buffer[i];
...
- offset = roundUpToMultipleOf(alignment, offset);
- offset += numberOfEntries * metadataSize(static_cast<OpcodeID>(i));
+ unsigned alignedOffset = roundUpToMultipleOf(alignment, checkedOffset.value());
+ if (alignedOffset < checkedOffset.value()) {
+ checkedOffset.overflowed();
+ break;
+ }
+ checkedOffset = alignedOffset;
+ checkedOffset += CheckedUint32(numberOfEntries) * metadataSize(static_cast<OpcodeID>(i));
}
+
+ CheckedUint32 checkedValueProfileSize = m_numValueProfiles;
+ checkedValueProfileSize *= static_cast<unsigned>(sizeof(ValueProfile));
+
+ unsigned paddingFor32Bit = 0;
+ if constexpr (sizeof(size_t) == sizeof(unsigned))
+ paddingFor32Bit = sizeof(LinkingData);
+
+ if ((checkedOffset + s_offset32TableSize + checkedValueProfileSize + paddingFor32Bit).hasOverflowed()) [[unlikely]] {
+ MetadataTableMalloc::free(m_rawBuffer);
+ m_rawBuffer = nullptr;
+ m_hasMetadata = false;
+ m_is32Bit = false;
+ m_numValueProfiles = 0;
+ return false; // Failure.
+ }
+ offset = checkedOffset.value();
+ valueProfileSize = checkedValueProfileSize.value();
buffer[s_offsetTableEntries - 1] = offset;
m_is32Bit = offset > UINT16_MAX;
}
 
- unsigned valueProfileSize = m_numValueProfiles * sizeof(ValueProfile);
if (m_is32Bit) {
uint8_t* newBuffer = reinterpret_cast_ptr<uint8_t*>(MetadataTableMalloc::malloc(valueProfileSize + sizeof(LinkingData) + s_offset32TableSize + offset));
...
}
+ return true;
}

Source/JavaScriptCore/bytecompiler/BytecodeGenerator.cpp

- m_codeBlock->finalize(m_writer.finalize());
+ if (!m_codeBlock->finalize(m_writer.finalize())) [[unlikely]]
+ return ParserError(ParserError::OutOfMemory);

JSTests/stress/unlinked-metadata-table-finalize-overflow.js

+//@ skip if $buildType == "debug" or $memoryLimited or $addressBits <= 32
+let n = 44739242;
+let s = 'a();'.repeat(n);
+let f = new Function('a', s);
+try {
+ f(function() { });
+} catch (e) {
+ caught = true;
+ if (!(e instanceof RangeError))
+ throw new Error("Expected RangeError but got: " + e);
+}

The change is three edits that together install a failure channel where there was none.

Inside UnlinkedMetadataTable::finalize(), the plain unsigned offset accumulator becomes a CheckedUint32 (<wtf/CheckedArithmetic.h> is newly included). The per-opcode loop gains && !checkedOffset.hasOverflowed() in its condition so it stops accumulating the moment the sticky flag trips, and the per-opcode size step becomes checkedOffset += CheckedUint32(numberOfEntries) * metadataSize(...) so the count-times-size product is itself checked rather than computed raw and then added.

The alignment step gets special handling, because roundUpToMultipleOf is ordinary unchecked arithmetic that a checked type cannot see through: the patch computes alignedOffset explicitly, and treats alignedOffset < checkedOffset.value() — rounding produced a smaller number — as the overflow signal, calling checkedOffset.overflowed() and breaking out. valueProfileSize is hoisted up from its old declaration site below the loop and recomputed as a checked product of m_numValueProfiles and sizeof(ValueProfile).

One combined test gates everything downstream:

  (checkedOffset + s_offset32TableSize + checkedValueProfileSize + paddingFor32Bit)
      .hasOverflowed()

paddingFor32Bit is sizeof(LinkingData) only when sizeof(size_t) == sizeof(unsigned) — on 32-bit targets the malloc() argument addition happens at 32 bits and can itself wrap, whereas on 64-bit the operands promote to size_t and the addition is safe. On failure the table is torn down to a coherent empty state (m_rawBuffer freed and nulled, m_hasMetadata, m_is32Bit, m_numValueProfiles cleared) and false returned, so nothing downstream can act on a half-built layout.

The signal then has to travel. UnlinkedMetadataTable::finalize() and UnlinkedCodeBlockGenerator::finalize() both become [[nodiscard]] bool; the latter captures metadataOK inside the cellLock() scope and returns it after the write barrier and reportExtraMemoryAllocated(). BytecodeGenerator::generate() converts a false result into return ParserError(ParserError::OutOfMemory). The stress test builds a function body of 44,739,242 call expressions and asserts a RangeError — skipped on debug, memory-limited, and 32-bit-address configurations, since it needs the address space and RAM to get there.

Unlinked code blocks and metadata. JSC compiles each JS function to an UnlinkedCodeBlock, a source-derived and cacheable bytecode form. Many bytecodes carry per-instruction mutable state — profiling counters, call link info, inline-cache slots — collectively called metadata. That state is not stored inline in the instruction stream; it lives in one contiguous metadata block per code block, which the LLInt and baseline JIT read and write during execution.

The offset table. The metadata block opens with a table of s_offsetTableEntries entries, where s_offsetTableEntries is NUMBER_OF_BYTECODE_WITH_METADATA + 1. Entry i gives the byte offset at which opcode i's metadata array starts; the extra trailing entry holds the end offset — that is, the total. During bytecode generation the very same buffer holds per-opcode counts instead of offsets, and finalize() rewrites counts into offsets in place. preprocessBuffer() returns that buffer for the conversion.

Two layout tiers. To save memory, the offset table is stored as Offset16 (uint16_t) entries when every offset fits in 16 bits, and as Offset32 (uint32_t) entries otherwise. s_offset16TableSize and s_offset32TableSize are the byte sizes of those two forms; the 32-bit layout retains both tables, so its offsets carry an s_offset32TableSize bias. m_is32Bit records which tier was picked.

What else shares the buffer. Ahead of the offset table, the raw buffer reserves sizeof(LinkingData) — a Ref<UnlinkedMetadataTable> plus an atomic refcount — and m_numValueProfiles * sizeof(ValueProfile) bytes of value-profiling slots. The buffer() and offsetTable16()/offsetTable32() accessors in UnlinkedMetadataTable.h skip past both when computing addresses.

CheckedUint32. WTF's checked-integer wrapper. Arithmetic through its operators sets a sticky overflow flag readable via hasOverflowed(); overflowed() sets that flag by hand; value() reads the underlying number, which is meaningless once the flag is set. roundUpToMultipleOf(alignment, x) is a plain arithmetic helper and knows nothing about checked types.

Error propagation and lazy generation. BytecodeGenerator::generate() returns a ParserError, and ParserError::OutOfMemory surfaces to script as a RangeError. A function built with new Function(args, body) is syntax-checked at construction but its bytecode is generated lazily on first call — so a generation-time error is observed at call time, not at construction. [[nodiscard]] is the C++ attribute that makes the compiler warn when a return value is dropped.

This is a size-computation overflow whose distinguishing feature is that the wrapped value feeds three consumers, not one.

  count-times-size loop  ──►  offset (unsigned, wraps at 2^32)
                                │
        ┌───────────────────────┼───────────────────────┐
        ▼                       ▼                       ▼
  buffer[last] = offset   m_is32Bit =            malloc(valueProfileSize
   (totalSize() reads      offset > UINT16_MAX     + LinkingData
    this back)             — layout tier from      + s_offset32TableSize
                           a wrapped number         + offset)

  meanwhile: per-opcode start offsets already written into the table,
  and metadata indices baked into the bytecode, still describe the
  UN-wrapped multi-gigabyte layout.

Before the fix the loop body was two lines of raw 32-bit arithmetic — offset = roundUpToMultipleOf(alignment, offset); then offset += numberOfEntries * metadataSize(opcodeID); — with nothing asserting that either the product or the running sum stayed under UINT32_MAX. A single JS function can contain an essentially unbounded number of metadata-carrying opcodes, so numberOfEntries * metadataSize() accumulated past 2^32 reduces offset modulo 2^32 to a small value.

Follow the three arrows in the diagram. buffer[s_offsetTableEntries - 1] = offset records a wrapped end-offset, and totalSize() — visible in UnlinkedMetadataTable.h as valueProfileSize + offsetTable32()[s_offsetTableEntries - 1], again all unsigned — reports that as the table's size. m_is32Bit = offset > UINT16_MAX selects the layout tier from the same wrapped number, so a table whose true extent exceeds 4 GB can be classified as fitting the compact 16-bit form. And the MetadataTableMalloc::malloc(valueProfileSize + sizeof(LinkingData) + s_offset32TableSize + offset) call sizes the actual buffer from that wrapped offset plus a valueProfileSize that was itself truncated — the old unsigned valueProfileSize = m_numValueProfiles * sizeof(ValueProfile); evaluated the product at size_t width and then threw the high bits away on assignment.

The per-opcode start offsets already written into the table, and the metadata indices baked into each instruction of the bytecode stream, are unaffected by any of this: they still point into the un-wrapped multi-gigabyte layout. So the moment that bytecode executes, metadata accesses index far outside the allocation that was actually made. That is the out-of-bounds access Apple's description refers to, and it is why the realistic outcome is an immediate fault — the displacements involved are enormous. An attacker who could arrange for the wrap to occur partway through the offset-table build would put the post-wrap opcodes' metadata at small offsets instead, which would turn the same bug into a relative heap write whose contents are partly attacker-influenced (profiling values, call-link pointers). That shaping remains a projection, not something the diff establishes. A secondary consequence follows from the shared source of truth: because m_is32Bit and totalSize() both derive from the wrapped value, a mis-sized linked copy can follow from the same wrap through link().

The fix restores the agreement between offsets, reported size, and allocation size by refusing to build a table it cannot describe. Every arithmetic step now runs through CheckedUint32, the loop aborts as soon as the sticky flag trips, and the single combined test covers the end-offset, the bias, the value-profile bytes, and — on 32-bit targets only — the LinkingData term of the malloc argument. Rejection tears the table down to a coherent empty state rather than leaving a partially converted count/offset buffer behind, and the new [[nodiscard]] bool return chain carries the failure from finalize() through UnlinkedCodeBlockGenerator to BytecodeGenerator::generate(), where it becomes ParserError::OutOfMemory.

The alignment guard deserves its own beat, because it is the piece a reader will otherwise skim past:

unsigned alignedOffset = roundUpToMultipleOf(alignment, checkedOffset.value());
if (alignedOffset < checkedOffset.value()) {
    checkedOffset.overflowed();
    break;
}
checkedOffset = alignedOffset;

Rounding an offset near UINT32_MAX up to an 8-byte boundary wraps to a smaller number. CheckedUint32 never sees that happen — the value left the wrapper via .value(), the arithmetic occurred in a helper that owns no checking, and the result came back in through plain assignment. The explicit "did rounding make it smaller?" comparison is the only thing that catches it.

Because bytecode for a new Function(body) function is generated lazily on first invocation, the test's RangeError arrives at the call site rather than at construction — hence the f(function() { }) call inside the try block. The whole path lives in the WebContent process, so this is renderer-side memory safety only; any real exploitation would still need a separate sandbox escape. The ~4 GB-of-metadata precondition also makes memory-constrained and 32-bit-address targets materially harder to reach, which is exactly what the test's skip directives encode.

A single JS function large enough to wrap the 32-bit metadata offset accumulator gets a buffer sized from the wrapped total while its bytecode still addresses the un-wrapped multi-gigabyte layout.

The sharper structural lesson is not the size overflow itself but the tier selector: m_is32Bit = offset > UINT16_MAX decides the storage encoding from a value that can wrap. A plain size overflow under-allocates a buffer; a wrapped discriminant additionally picks the wrong on-disk or in-memory format for it, so the readers and the writer disagree about the shape of the data before anyone even reaches a bounds question. Any threshold comparison whose left operand is the output of an accumulation loop rather than a validated input belongs in the same audit bucket — a discriminant derived from arithmetic must be computed on a value already proven not to have wrapped.