← WebKit Silent-Fix Report — 2026-W34

97df94ead028cddcfc21e48ec6f2f72d25ec1662  Handle overflows in UnlinkedMetadataTable::finalize().

severity high class IntegerOverflow confidence 0.90 JSC Bytecode exploitable-grade
Mark Lam Sun Aug 23 19:25:02 2026 -0700 full: 97df94ead028cddcfc21e48ec6f2f72d25ec1662 bug report ↗ view on GitHub ↗
Primitive: Unchecked metadata size overflow in UnlinkedMetadataTable::finalize()
Triage note: finalize() previously returned void and ignored metadata allocation overflow; the fix makes it return bool ([[nodiscard]]) so a huge Function body (44M call statements) that overflows the metadata table size now propagates failure/RangeError instead of under-allocating. An unchecked size overflow would yield an undersized metadata buffer and subsequent OOB writes.
Contents

The bug at a glance

The bug is reachable from unprivileged web content: any script can hand JavaScriptCore an enormous function body (via new Function or a large source), and bytecode metadata layout runs during ordinary parsing/compilation in the content process. An unchecked 32-bit offset overflow in UnlinkedMetadataTable::finalize() under-allocates the metadata table, so subsequent metadata writes land out of bounds on a heap-allocated buffer sized smaller than the opcodes require, an RCE-capable linear heap corruption primitive. The high attack surface and memory-corruption impact justify CVSS 8.1; it is not a 9+ only because triggering the overflow requires an extremely large program (tens of millions of metadata-bearing opcodes) that is memory-intensive to construct and is gated behind 64-bit address space.

This is the classic “the size computation is 32-bit but the thing it sizes can be bigger” bug hiding inside JSC’s bytecode metadata layout, a place almost nobody audits because the offsets are computed once, deep in finalize(), and were assumed to be small. The tell is the API-shape change: finalize() went from returning void to a [[nodiscard]] bool, an admission that a routine everyone treated as infallible could in fact fail. The overflow needs ~44 million call opcodes to hit, so it looks academic until you remember that a single new Function(‘a();’.repeat(n)) manufactures exactly that, and the payoff is an undersized metadata buffer that every later opcode happily writes past. What makes it elegant is the fix’s honesty about the layout’s real ceiling: it does not just guard one addition but folds in the s_offset32TableSize bias, the value-profile array, and even a 32-bit-only sizeof(LinkingData) malloc pad, because totalSize() sums all of them as unsigned.

Root cause

UnlinkedMetadataTable::finalize() walks the per-opcode metadata table and assigns each opcode class a byte offset into one flat metadata buffer. The running cursor offset is a plain 32-bit unsigned, accumulated as offset = roundUpToMultipleOf(alignment, offset); offset += numberOfEntries * metadataSize(opcode). Both the alignment round-up and the numberOfEntries * metadataSize product are unchecked 32-bit arithmetic. When a single function contains a very large number of opcodes that carry substantive metadata (the test uses 44,739,242 call statements), the accumulated offset exceeds UINT32_MAX and silently wraps.

The wrapped, too-small offset is then used directly as the size input to the metadata buffer allocation: MetadataTableMalloc::malloc(valueProfileSize + sizeof(LinkingData) + s_offset32TableSize + offset) (the 32-bit branch) or the 16-bit branch. Because offset wrapped low, the buffer is allocated far smaller than the sum of all per-opcode metadata regions actually requires. The per-opcode offsets stored back into the table, however, were computed from the same wrapping arithmetic and point wherever the wrap left them, so later metadata accesses index past the end of the undersized allocation, a heap out-of-bounds write/read driven entirely by attacker-controlled program size.

The unsafety is a size/allocation mismatch: the layout pass and the allocation both trust 32-bit sums that can overflow, and nothing downstream re-validates that the computed offsets fit the buffer. finalize() previously returned void, so BytecodeGenerator::generate() had no way to learn the layout had failed and unconditionally proceeded to a corrupted CodeBlock.

The fix converts the cursor to CheckedUint32 (checkedOffset), so the loop stops accumulating once checkedOffset.hasOverflowed(), and it explicitly detects an alignment round-up that decreases the value (if (alignedOffset < checkedOffset.value()) { checkedOffset.overflowed(); break; }). After the loop it computes the full addressable total the way totalSize()/the allocator will: (checkedOffset + s_offset32TableSize + checkedValueProfileSize + paddingFor32Bit), where checkedValueProfileSize = m_numValueProfiles * sizeof(ValueProfile) and paddingFor32Bit = sizeof(LinkingData) only on 32-bit. If that sum has overflowed, finalize() frees m_rawBuffer, resets m_hasMetadata/m_is32Bit/m_numValueProfiles, and returns false. UnlinkedCodeBlockGenerator::finalize() propagates that bool up, and BytecodeGenerator::generate() turns a false into ParserError(ParserError::OutOfMemory), surfacing to script as a RangeError instead of allocating an undersized buffer.

Key code

UnlinkedMetadataTable::finalize(): the offset accumulator becomes checked, and the full addressable size is validated before allocation.

// before: unchecked 32-bit accumulation
// unsigned offset = s_offset16TableSize;
// offset = roundUpToMultipleOf(alignment, offset);
// offset += numberOfEntries * metadataSize(static_cast<OpcodeID>(i));
// ...
// unsigned valueProfileSize = m_numValueProfiles * sizeof(ValueProfile);

// after:
CheckedUint32 checkedOffset = s_offset16TableSize;
for (unsigned i = 0; i < s_offsetTableEntries - 1 && !checkedOffset.hasOverflowed(); 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.
}

Patch walkthrough

  • Source/JavaScriptCore/bytecode/UnlinkedMetadataTable.cpp — The core fix. The offset accumulator becomes a CheckedUint32 named checkedOffset; the layout loop now bails as soon as it overflows and additionally treats an alignment round-up that produces a smaller value as an overflow. A new post-loop guard sums checkedOffset with s_offset32TableSize, the checked value-profile size (m_numValueProfiles * sizeof(ValueProfile)), and a 32-bit-only sizeof(LinkingData) pad; on overflow it frees m_rawBuffer, clears m_hasMetadata/m_is32Bit/m_numValueProfiles and returns false. The old unchecked valueProfileSize = m_numValueProfiles * sizeof(ValueProfile) line is removed and the value is now the validated checkedValueProfileSize.
  • Source/JavaScriptCore/bytecode/UnlinkedMetadataTable.h — Changes the signature of finalize() from void finalize() to [[nodiscard]] bool finalize(), forcing every caller to observe and handle the failure return.
  • Source/JavaScriptCore/bytecode/UnlinkedCodeBlockGenerator.cpp — UnlinkedCodeBlockGenerator::finalize() now returns bool. It captures the result of m_codeBlock->m_metadata->finalize() into metadataOK and returns it after the extra-memory accounting, threading the metadata-layout failure up to its own caller.
  • Source/JavaScriptCore/bytecode/UnlinkedCodeBlockGenerator.h — Matching declaration change: finalize() becomes [[nodiscard]] bool finalize(std::unique_ptr<JSInstructionStream>).
  • Source/JavaScriptCore/bytecompiler/BytecodeGenerator.cpp — BytecodeGenerator::generate() now checks the return of m_codeBlock->finalize() and, on failure, returns ParserError(ParserError::OutOfMemory) instead of continuing with a CodeBlock whose metadata buffer was under-allocated. This is what converts the internal overflow into a script-visible RangeError.
  • JSTests/stress/unlinked-metadata-table-finalize-overflow.js — Regression test. Builds a Function body of 44,739,242 a(); call statements, invokes it, and asserts that a RangeError (not a crash or other error) is thrown. Skipped on debug/memory-limited/32-bit configs and marked slow because it needs a large 64-bit allocation.

Background

UnlinkedMetadataTable / bytecode metadata — In JSC, each bytecode opcode can carry a fixed-size metadata record (inline caches, profiling slots, etc.). finalize() lays out one contiguous buffer holding all opcodes’ metadata, assigning each opcode class a byte offset; the layout is computed once when a CodeBlock is generated.

Offset32 / s_offset16TableSize / s_offset32TableSize — The table has a 16-bit-offset layout and a promotes-to-32-bit layout (m_is32Bit) chosen by whether the total offset exceeds UINT16_MAX. The stored offsets carry a fixed table-size bias, and totalSize() sums the value-profile array plus the biased final offset as unsigned, which is why the fix must check that entire sum, not just the loop cursor.

CheckedArithmetic / CheckedUint32 — WTF’s CheckedUint32 is an integer wrapper that records overflow across arithmetic; hasOverflowed() reports whether any operation exceeded the type. It is the standard WebKit idiom for making size computations fail-closed instead of wrapping silently.

ParserError::OutOfMemory / RangeError — JSC funnels resource-exhaustion failures during parsing/compilation through ParserError; an OutOfMemory ParserError is surfaced to JavaScript as a RangeError, which is why the test expects a RangeError rather than a crash.

Vulnerability window

  1. Trigger — Web content constructs a single enormous function, e.g. new Function(‘a’, ‘a();’.repeat(44739242)), so one CodeBlock contains tens of millions of metadata-bearing call opcodes.
  2. Compilation — BytecodeGenerator::generate() finishes emitting bytecode and calls m_codeBlock->finalize(), which calls UnlinkedMetadataTable::finalize() to lay out the metadata buffer.
  3. Overflow (pre-patch) — The 32-bit offset accumulator wraps as offset += numberOfEntries * metadataSize(...) exceeds UINT32_MAX; the wrapped small value is used as the allocation size.
  4. Under-allocation (pre-patch) — MetadataTableMalloc::malloc receives the wrapped size and returns a buffer far smaller than the metadata actually needs.
  5. Corruption (pre-patch) — Per-opcode metadata offsets computed from the same wrapping arithmetic index past the undersized buffer, yielding heap out-of-bounds accesses during subsequent execution/linking.
  6. Fixed behavior — finalize() detects the overflow via CheckedUint32, returns false, and generate() returns ParserError::OutOfMemory, so script sees a RangeError and no buffer is ever under-allocated.

Proof of concept

The test manufactures a function whose body is ~44.7M call statements, forcing the per-opcode metadata offset accumulation in UnlinkedMetadataTable::finalize() past UINT32_MAX. On a patched build the checked arithmetic converts this to ParserError::OutOfMemory / RangeError; on a vulnerable build the wrapped size under-allocates the metadata buffer. This is a crash/corruption trigger, not a weaponized exploit; controlling exactly which opcodes land out of bounds would require far more layout shaping.

// Reconstructed from JSTests/stress/unlinked-metadata-table-finalize-overflow.js
// Requires a 64-bit build with ample memory; pre-patch this under-allocates the
// metadata buffer instead of throwing.
let n = 44739242;
let s = 'a();'.repeat(n);
let f = new Function('a', s);
try {
    f(function() { });
} catch (e) {
    if (!(e instanceof RangeError))
        throw new Error("Expected RangeError but got: " + e);
}

Exploitation

  1. Reach the overflow — Generate one function whose metadata layout sum exceeds 2^32. This is the easy part from web content, but it costs a very large source string and a correspondingly large allocation, so it is loud and memory-hungry rather than stealthy.
  2. Shape the mismatch — To move from a mere crash toward a controlled OOB, an attacker would need to tune the opcode mix so the wrapped offset produces a buffer of a chosen small size while specific opcodes’ stored offsets point to attacker-useful distances past the end, all within a single monolithic function. The layout is deterministic but coarse, making precise control difficult.
  3. Turn OOB metadata writes into a primitive — Metadata records are written during execution/linking; overlapping the undersized buffer with an adjacent controllable heap object could give a write primitive, but the enormous compile-time footprint and lack of fine-grained offset control make reliable exploitation hard. Realistically this is a strong DoS/corruption bug whose practical weaponization is expensive.

Detection & hunting

For defenders and SOC / detection engineers:

  • Heap OOB in MetadataTableMalloc region — ASan heap-buffer-overflow reports with the allocation stack in UnlinkedMetadataTable::finalize() (MetadataTableMalloc::malloc) and the faulting write during CodeBlock metadata access are the signature; on non-ASan builds, malloc-zone crashes writing just past a JSC metadata buffer.
  • Pathologically large single functions — Fuzzers and content filters can flag scripts that build multi-hundred-megabyte function bodies or use new Function/eval with source repeated into the tens of millions of statements, especially with a single dominant opcode.
  • RangeError-on-compile telemetry — Post-patch, a spike in ParserError::OutOfMemory / RangeError thrown at compile time for very large functions indicates code that was previously hitting the overflow path.

Audit directions

  • Other 32-bit size accumulators in JSC layout code — Audit sibling routines that compute buffer sizes as plain unsigned sums scaled by an entry count, e.g. instruction-stream sizing, constant/identifier tables, and other *::finalize()/link() paths, for the same unchecked multiply-then-add pattern.
  • Callers of newly [[nodiscard]] APIs — Grep for other finalize()/allocate() style functions still returning void whose result feeds a malloc size; the void return is the smell that overflow cannot be propagated.
  • totalSize()/link() consumers of these offsets — Verify MetadataTable::link() and totalSize() consistently use the same biased sum the fix now validates, so no consumer re-derives an unchecked size that could still overflow.
  • Alignment round-up helpers — roundUpToMultipleOf on 32-bit values can wrap below its input; hunt other uses where the rounded result is trusted without the aligned < original overflow check this patch added.

Before / after

Loading diff…