Medium CVSS 4.3 webkit Integer Overflow 🔧 Commit mapped

Overview

Medium
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
DescriptionProcessing maliciously crafted web content may lead to an unexpected Safari crash
ComponentJSC Bytecode
Bug ClassInteger Overflow
Tracker317632
Fix commit97df94ead028 (WebKit/WebKit) +74/-16
CWECWE-125, CWE-787 (Out-of-bounds read, Out-of-bounds write)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:L
CISA KEVNot listed
CreditedJanggoon Lee of Out of Bounds, OpenAI Codex Security - Amy Burnett
Disclosed2026-08-17

Background

Unlinked metadata table
JSC stores per-opcode metadata for a code block in a flat buffer whose per-opcode offsets are accumulated during finalize().
CheckedUint32
A 32-bit unsigned type that traps arithmetic overflow instead of wrapping — the fix’s tool for catching the bad size math.
Undersized allocation
When the total-size computation wraps 32 bits, malloc returns a buffer smaller than the offsets that index into it, so populating metadata writes out of bounds.

Root Cause Analysis

This fixes a 32-bit unsigned integer overflow in JavaScriptCore’s unlinked metadata table finalization that led to an undersized allocation and out-of-bounds writes. UnlinkedMetadataTable::finalize() computed per-opcode metadata offsets by accumulating unsigned arithmetic: offset = roundUpToMultipleOf(alignment, offset); offset += numberOfEntries * metadataSize(…); and then sized the final buffer as valueProfileSize + sizeof(LinkingData) + s_offset32TableSize + offset. For a function with an enormous amount of metadata (the test builds a function body with ~44.7 million calls), these unsigned computations overflow 32 bits, so the malloc’d buffer is far smaller than the offsets that index into it, and populating the metadata writes out of bounds — heap corruption behind the ‘unexpected Safari crash’ (OOB) impact.

The fix converts the accounting to CheckedUint32: it detects overflow in the offset accumulation, the alignment rounding, and the valueProfileSize and final size (including a 32-bit-only sizeof(LinkingData) padding guard), and on overflow frees the buffer, resets metadata state, and returns false. finalize() now returns bool; UnlinkedCodeBlockGenerator::finalize() propagates it ([[nodiscard]]), and BytecodeGenerator::generate() returns ParserError::OutOfMemory (surfaced as a RangeError) instead of proceeding with a corrupt table.

The restored invariant is that the metadata table’s total addressable size fits in the unsigned layout, or compilation is rejected. Fully established by the diff; the JSC component and the test filename (unlinked-metadata-table-finalize-overflow.js) confirm it.

Key insight
Size accounting for the metadata table used raw unsigned 32-bit arithmetic, so a function with tens of millions of metadata entries overflowed the total-size computation and under-allocated. The fix reworks the math with CheckedUint32 and rejects the compile (RangeError) on overflow rather than proceeding with a corrupt table.

Attack Path

  1. Generate an enormous function The page builds a function whose body contains a huge number of operations (e.g. tens of millions of calls), producing a very large metadata table.
  2. Trigger bytecode finalization Compiling the function calls UnlinkedMetadataTable::finalize(), which sums per-opcode metadata offsets and sizes as 32-bit unsigned.
  3. Overflow the size computation The accumulated offset/size wraps past 2^32, so the metadata buffer is malloc’d far too small.
  4. Out-of-bounds write Writing the metadata entries indexes beyond the undersized buffer, corrupting the heap and crashing (now rejected with a RangeError/OutOfMemory).

Impact Assessment

A heap out-of-bounds write in JavaScriptCore driven by a very large but legal-looking function. OOB writes in JSC’s own allocations are a classic exploitation primitive: the attacker controls the overflow magnitude and the adjacent heap layout, making this a strong step toward arbitrary write and code execution in the WebContent process.

Changed Functions

FunctionChangeNotes
UnlinkedMetadataTable::finalize
Source/JavaScriptCore/bytecode/UnlinkedMetadataTable.cpp
modified Reworks offset/size accounting with CheckedUint32 (alignment rounding, per-opcode sizes, valueProfileSize, and final buffer size with a 32-bit LinkingData padding guard); on overflow frees the buffer, resets state, and returns false instead of allocating an undersized table.
UnlinkedCodeBlockGenerator::finalize
Source/JavaScriptCore/bytecode/UnlinkedCodeBlockGenerator.cpp
modified Now returns bool and propagates the metadata finalize() success/failure to the caller.
BytecodeGenerator::generate
Source/JavaScriptCore/bytecompiler/BytecodeGenerator.cpp
modified Checks finalize()'s result and returns ParserError::OutOfMemory (surfaced as RangeError) on overflow instead of continuing with a corrupt metadata table.
UnlinkedMetadataTable::finalize / UnlinkedCodeBlockGenerator::finalize (declarations)
Source/JavaScriptCore/bytecode/UnlinkedMetadataTable.h
modified Signatures change to [[nodiscard]] bool finalize() so callers must handle the overflow-failure result.

Files Changed

  • JSTests/stress/unlinked-metadata-table-finalize-overflow.js
  • Source/JavaScriptCore/bytecode/UnlinkedCodeBlockGenerator.cpp
  • Source/JavaScriptCore/bytecode/UnlinkedCodeBlockGenerator.h
  • Source/JavaScriptCore/bytecode/UnlinkedMetadataTable.cpp
  • Source/JavaScriptCore/bytecode/UnlinkedMetadataTable.h
  • Source/JavaScriptCore/bytecompiler/BytecodeGenerator.cpp

Audit Directions

  • Unsigned size arithmetic in bytecode/JIT
    Audit other buffer-size and offset accumulations in JSC’s bytecode and JIT layers for raw unsigned (non-Checked) arithmetic that a large program could overflow.
  • Allocation-size vs. index derivation
    Wherever an allocation size and the indices into it are computed separately, confirm both use overflow-checked math so they cannot diverge.
diff --git a/JSTests/stress/unlinked-metadata-table-finalize-overflow.js b/JSTests/stress/unlinked-metadata-table-finalize-overflow.js
new file mode 100644
index 000000000000..4cd8dc4bd73d
--- /dev/null
+++ b/JSTests/stress/unlinked-metadata-table-finalize-overflow.js
@@ -0,0 +1,18 @@
+//@ skip if $buildType == "debug" or $memoryLimited or $addressBits <= 32
+//@ slow!
+//@ runDefault
+
+let n = 44739242;
+let s = 'a();'.repeat(n);
+let f = new Function('a', s);
+
+let caught = false;
+try {
+    f(function() { });
+} catch (e) {
+    caught = true;
+    if (!(e instanceof RangeError))
+        throw new Error("Expected RangeError but got: " + e);
+}
+if (!caught)
+    throw new Error("Expected RangeError to be thrown");
diff --git a/Source/JavaScriptCore/bytecode/UnlinkedCodeBlockGenerator.cpp b/Source/JavaScriptCore/bytecode/UnlinkedCodeBlockGenerator.cpp
index d3149828198e..9e9d552623e2 100644
--- a/Source/JavaScriptCore/bytecode/UnlinkedCodeBlockGenerator.cpp
+++ b/Source/JavaScriptCore/bytecode/UnlinkedCodeBlockGenerator.cpp
@@ -1,5 +1,5 @@
 /*
- * Copyright (C) 2019-2024 Apple Inc. All rights reserved.
+ * Copyright (C) 2019-2024, 2026 Apple Inc. All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions
@@ -57,14 +57,15 @@ void UnlinkedCodeBlockGenerator::addTypeProfilerExpressionInfo(unsigned instruct
     m_typeProfilerInfoMap.set(instructionOffset, range);
 }
 
-void UnlinkedCodeBlockGenerator::finalize(std::unique_ptr<JSInstructionStream> instructions)
+bool UnlinkedCodeBlockGenerator::finalize(std::unique_ptr<JSInstructionStream> instructions)
 {
     ASSERT(instructions);
+    bool metadataOK = true;
     {
         Locker locker { m_codeBlock->cellLock() };
         m_codeBlock->m_instructions = WTF::move(instructions);
         m_codeBlock->allocateSharedProfiles(m_numBinaryArithProfiles, m_numUnaryArithProfiles);
-        m_codeBlock->m_metadata->finalize();
+        metadataOK = m_codeBlock->m_metadata->finalize();
 
         m_codeBlock->m_identifiers = WTF::move(m_identifiers);
         m_codeBlock->m_constantRegisters = WTF::move(m_constantRegisters);
@@ -100,6 +101,7 @@ void UnlinkedCodeBlockGenerator::finalize(std::unique_ptr<JSInstructionStream> i
     }
     m_vm.writeBarrier(m_codeBlock.get());
     m_vm.heap.reportExtraMemoryAllocated(m_codeBlock.get(), m_codeBlock->m_instructions->sizeInBytes() + m_codeBlock->metadataSizeInBytes());
+    return metadataOK;
 }
 
 UnlinkedHandlerInfo* UnlinkedCodeBlockGenerator::handlerForBytecodeIndex(BytecodeIndex bytecodeIndex, RequiredHandler requiredHandler)
diff --git a/Source/JavaScriptCore/bytecode/UnlinkedCodeBlockGenerator.h b/Source/JavaScriptCore/bytecode/UnlinkedCodeBlockGenerator.h
index b2de7a9a77c3..6096a6da0526 100644
--- a/Source/JavaScriptCore/bytecode/UnlinkedCodeBlockGenerator.h
+++ b/Source/JavaScriptCore/bytecode/UnlinkedCodeBlockGenerator.h
@@ -1,5 +1,5 @@
 /*
- * Copyright (C) 2019-2024 Apple Inc. All rights reserved.
+ * Copyright (C) 2019-2024, 2026 Apple Inc. All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions
@@ -185,7 +185,7 @@ class UnlinkedCodeBlockGenerator {
 
     void applyModification(BytecodeRewriter&);
 
-    void finalize(std::unique_ptr<JSInstructionStream>);
+    [[nodiscard]] bool finalize(std::unique_ptr<JSInstructionStream>);
 
     void NODELETE dump(PrintStream&) const;
 
diff --git a/Source/JavaScriptCore/bytecode/UnlinkedMetadataTable.cpp b/Source/JavaScriptCore/bytecode/UnlinkedMetadataTable.cpp
index 9796701f7d64..290af4d71e45 100644
--- a/Source/JavaScriptCore/bytecode/UnlinkedMetadataTable.cpp
+++ b/Source/JavaScriptCore/bytecode/UnlinkedMetadataTable.cpp
@@ -1,5 +1,5 @@
 /*
- * Copyright (C) 2019-2023 Apple Inc. All rights reserved.
+ * Copyright (C) 2019-2023, 2026 Apple Inc. All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions
@@ -27,6 +27,7 @@
 #include "UnlinkedMetadataTable.h"
 
 #include "BytecodeStructs.h"
+#include <wtf/CheckedArithmetic.h>
 
 WTF_ALLOW_UNSAFE_BUFFER_USAGE_BEGIN
 
@@ -98,41 +99,79 @@ void MetadataStatistics::reportMetadataStatistics()
 }
 #endif
 
-void UnlinkedMetadataTable::finalize()
+bool UnlinkedMetadataTable::finalize()
 {
     ASSERT(!m_isFinalized);
     m_isFinalized = true;
     if (!m_hasMetadata) {
         MetadataTableMalloc::free(m_rawBuffer);
         m_rawBuffer = nullptr;
-        return;
+        return true;
     }
 
-    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];
             if (!numberOfEntries) {
-                buffer[i] = offset;
+                buffer[i] = checkedOffset.value();
                 continue;
             }
-            buffer[i] = offset; // We align when we access this.
+            buffer[i] = checkedOffset.value(); // We align when we access this.
             unsigned alignment = metadataAlignment(static_cast<OpcodeID>(i));
             ASSERT(alignment <= s_maxMetadataAlignment);
 
 #if CPU(ADDRESS64)
             // This is only necessary for the first metadata entry, if the buffer
             // is 4-byte aligned and the entry has an alignment requirement of 8
-            ASSERT(offset == roundUpToMultipleOf(alignment, offset) || offset == s_offset16TableSize);
+            ASSERT(checkedOffset.value() == roundUpToMultipleOf(alignment, checkedOffset.value()) || checkedOffset.value() == s_offset16TableSize);
 #endif
-            offset = roundUpToMultipleOf(alignment, offset);
+            unsigned alignedOffset = roundUpToMultipleOf(alignment, checkedOffset.value());
+            if (alignedOffset < checkedOffset.value()) {
+                checkedOffset.overflowed();
+                break;
+            }
+            checkedOffset = alignedOffset;
 
-            offset += numberOfEntries * metadataSize(static_cast<OpcodeID>(i));
+            checkedOffset += CheckedUint32(numberOfEntries) * metadataSize(static_cast<OpcodeID>(i));
 #if ENABLE(METADATA_STATISTICS)
+            // In the unlikely event of an overflow, MetadataStatistics::perOpcodeCount
+            // will not be accurate. But this is OK because these stats are only used for
+            // development time analysis where overflow is not expected.
             MetadataStatistics::perOpcodeCount[i] += numberOfEntries;
 #endif
         }
+
+        // Each computed offset is stored as Offset32 (with an additional s_offset32TableSize bias in the
+        // 32-bit layout) and totalSize() sums valueProfileSize with that biased offset as unsigned.
+        // Reject any function whose metadata cannot be addressed within those limits.
+        CheckedUint32 checkedValueProfileSize = m_numValueProfiles;
+        checkedValueProfileSize *= static_cast<unsigned>(sizeof(ValueProfile));
+
+        // On 32-bits, also guard against potential malloc size overflow in the newBuffer
+        // allocation below, where we add sizeof(LinkingData). On 64-bit, sizes are
+        // auto-casted to a 64-bit size_t that can handle the addition, and hence, does
+        // not need this padding to reserve space for the size of sizeof(LinkingData).
+        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.
+        }
+
+        ASSERT(!checkedOffset.hasOverflowed());
+        ASSERT(!checkedValueProfileSize.hasOverflowed());
+        offset = checkedOffset.value();
+        valueProfileSize = checkedValueProfileSize.value();
         buffer[s_offsetTableEntries - 1] = offset;
         m_is32Bit = offset > UINT16_MAX;
     }
@@ -148,7 +187,6 @@ void UnlinkedMetadataTable::finalize()
     });
 #endif
 
-    unsigned valueProfileSize = m_numValueProfiles * sizeof(ValueProfile);
     if (m_is32Bit) {
         // offset already accounts for s_offset16TableSize
         uint8_t* newBuffer = reinterpret_cast_ptr<uint8_t*>(MetadataTableMalloc::malloc(valueProfileSize + sizeof(LinkingData) + s_offset32TableSize + offset));
@@ -170,6 +208,7 @@ void UnlinkedMetadataTable::finalize()
         MetadataTableMalloc::free(m_rawBuffer);
         m_rawBuffer = newBuffer;
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker.