Medium webkit Integer Overflow 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactProcessing maliciously crafted web content may lead to an unexpected Safari crash
DescriptionAn out-of-bounds access issue was addressed with improved bounds checking.
ComponentJSC
Bug ClassInteger Overflow
Tracker317632
Fix commit9343a9521f58 (webkit) +80/-20
CISA KEVNot listed
CreditedJanggoon Lee of Out of Bounds, OpenAI Codex Security - Amy Burnett
Disclosed2026-09-14

Background

UnlinkedMetadataTable
JavaScriptCore structure (Source/JavaScriptCore/bytecode/UnlinkedMetadataTable.cpp) holding per-opcode metadata for a code block. finalize() lays out each opcode class’s metadata into one contiguous buffer, computing per-entry offsets and the total size, then allocating that buffer.
Offset table layout
Offsets start at s_offset16TableSize; for each opcode class the code aligns the running offset and advances it by numberOfEntries * metadataSize(opcode). The final size also adds s_offset32TableSize, valueProfileSize (m_numValueProfiles * sizeof(ValueProfile)), and sizeof(LinkingData).
`CheckedUint32`
WTF checked-arithmetic type (<wtf/CheckedArithmetic.h>) that tracks overflow via hasOverflowed() and overflowed().
`UnlinkedCodeBlockGenerator::finalize` / `BytecodeGenerator::generate`
Callers that drive metadata finalization during bytecode generation; generate can surface a ParserError.

Root Cause Analysis

UnlinkedMetadataTable::finalize() accumulated the metadata buffer layout in a plain unsigned offset, computing offset = roundUpToMultipleOf(alignment, offset) and offset += numberOfEntries * metadataSize(...) across all opcode classes, then valueProfileSize = m_numValueProfiles * sizeof(ValueProfile) separately, and finally allocated the buffer with MetadataTableMalloc::malloc(valueProfileSize + sizeof(LinkingData) + s_offset32TableSize + offset). All of this was 32-bit unsigned arithmetic with no overflow detection. For a sufficiently large function (enormous metadata counts), the running offset, the alignment round-up, the numberOfEntries * metadataSize product, or the final sum could wrap around UINT32_MAX. A wrapped total produced a buffer far smaller than the metadata actually requires, while the stored per-opcode offsets still indexed at their true (large) positions — a classic size-computation integer overflow leading to a heap buffer that is then written and read out of bounds.

The patch converts the layout math to CheckedUint32. The per-opcode loop runs only while ... && !checkedOffset.hasOverflowed(), the alignment round-up is validated (if (alignedOffset < checkedOffset.value()) { checkedOffset.overflowed(); break; }), and the advance uses checkedOffset += CheckedUint32(numberOfEntries) * metadataSize(...). valueProfileSize becomes a CheckedUint32, and a 32-bit-only paddingFor32Bit = sizeof(LinkingData) is folded in. If (checkedOffset + s_offset32TableSize + checkedValueProfileSize + paddingFor32Bit).hasOverflowed(), finalize() frees the buffer, resets state, and returns false. The signature changes to [[nodiscard]] bool finalize(); UnlinkedCodeBlockGenerator::finalize propagates the result, and BytecodeGenerator::generate turns a false into ParserError(ParserError::OutOfMemory) (surfaced to script as a RangeError). The regression test builds a Function with ~44.7M statements to drive the metadata size past 32 bits and expects a RangeError.

Key insight
UnlinkedMetadataTable::finalize() computed the metadata buffer’s size and per-opcode offsets with unchecked 32-bit arithmetic, so a huge function could wrap the total, allocate an undersized buffer, and leave the real offsets pointing out of bounds — fixed by doing the layout in CheckedUint32 and failing finalization on overflow.

Attack Path

  1. Delivery Attacker runs script on a victim page (web-reachable) that can construct a very large function body.
  2. Inflate metadata Generate a function with an enormous number of metadata-bearing opcodes (e.g. new Function with tens of millions of calls), driving the per-opcode offset accumulation toward and past UINT32_MAX.
  3. Overflow the size During finalize(), the running offset/final sum wraps, so the allocation size is computed far smaller than required.
  4. Undersized allocation MetadataTableMalloc::malloc returns a buffer too small for the metadata whose stored offsets still address the true large positions.
  5. Primitive Subsequent metadata writes/reads use the true (unwrapped) offsets against the undersized buffer, giving a heap out-of-bounds access (the advisory’s “out-of-bounds access”); controlled corruption beyond the crash is inferred, not shown by the diff.

Impact Assessment

Runs in the renderer (Safari web content) process at ordinary web privilege via bytecode generation. The diff substantiates an integer-overflow-to-OOB: the metadata buffer size and offsets were computed with unchecked 32-bit math, so an oversized function wraps the total and yields an undersized allocation addressed by true offsets — matching the advisory’s out-of-bounds-access label, with integer overflow as the mechanism. Confirmed effect is a crash/OOB; escalation to a controlled write primitive is inferred. Gating: requires generating a function large enough to exceed 32-bit layout limits (the test is marked slow and skipped on addressBits <= 32/memory-limited configs), so it is resource-intensive but web-triggerable.

Changed Functions

FunctionChangeNotes
if
Source/JavaScriptCore/bytecode/UnlinkedMetadataTable.cpp
modified
for
Source/JavaScriptCore/bytecode/UnlinkedMetadataTable.cpp
modified
f
JSTests/stress/unlinked-metadata-table-finalize-overflow.js
modified

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

  • Other metadata/offset layouts
    Review sibling size computations in bytecode/metadata (value-profile sizing, MetadataTable::link, instruction-stream sizing) for unchecked 32-bit accumulation.
  • Allocation-size math
    Audit other *Malloc::malloc(a + b + c) sites where the operands derive from script-controlled counts without CheckedUint32.
  • `[[nodiscard]]` adoption
    Look for other finalize/allocate helpers whose failure was previously discarded, where a wrapped size silently produced an undersized buffer.
diff --git a/Source/JavaScriptCore/bytecode/UnlinkedCodeBlockGenerator.cpp b/Source/JavaScriptCore/bytecode/UnlinkedCodeBlockGenerator.cpp
index e4f5af853969..22f6b8bbaaac 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
@@ -51,14 +51,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_jumpTargets = WTF::move(m_jumpTargets);
         m_codeBlock->m_identifiers = WTF::move(m_identifiers);
@@ -95,6 +96,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 174c2ca40646..5f624ea106dd 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
@@ -193,7 +193,7 @@ public:
 
     void applyModification(BytecodeRewriter&, JSInstructionStreamWriter&);
 
-    void finalize(std::unique_ptr<JSInstructionStream>);
+    [[nodiscard]] bool finalize(std::unique_ptr<JSInstructionStream>);
 
     void 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;
     }
+    return true;
 }
 
 UnlinkedMetadataTable::~UnlinkedMetadataTable()
diff --git a/Source/JavaScriptCore/bytecode/UnlinkedMetadataTable.h b/Source/JavaScriptCore/bytecode/UnlinkedMetadataTable.h
index b5ea1b7b9583..d2ae301643a7 100644
--- a/Source/JavaScriptCore/bytecode/UnlinkedMetadataTable.h
+++ b/Source/JavaScriptCore/bytecode/UnlinkedMetadataTable.h
@@ -1,5 +1,5 @@
 /*
- * Copyright (C) 2018-2023 Apple Inc. All rights reserved.
+ * Copyright (C) 2018-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
@@ -81,7 +81,7 @@ public:
 
     size_t sizeInBytesForGC();
 
-    void finalize();
+    [[nodiscard]] bool finalize();
 
     RefPtr<MetadataTable> link();
 
diff --git a/Source/JavaScriptCore/bytecompiler/BytecodeGenerator.cpp b/Source/JavaScriptCore/bytecompiler/BytecodeGenerator.cpp
index 4848f4df2c32..ecaebac0fd5a 100644
--- a/Source/JavaScriptCore/bytecompiler/BytecodeGenerator.cpp
+++ b/Source/JavaScriptCore/bytecompiler/BytecodeGenerator.cpp
@@ -1,5 +1,5 @@
 /*
- * Copyright (C) 2008-2023 Apple Inc. All rights reserved.
+ * Copyright (C) 2008-2023, 2026 Apple Inc. All rights reserved.
  * Copyright (C) 2008 Cameron Zwarich <[email protected]>
  * Copyright (C) 2012 Igalia, S.L.
  *
@@ -360,7 +360,8 @@ ParserError BytecodeGenerator::generate(unsigned& size)
 
     RELEASE_ASSERT(m_codeBlock->numCalleeLocals() < static_cast<unsigned>(FirstConstantRegisterIndex));
     size = instructions().size();
-    m_codeBlock->finalize(m_writer.finalize());
+    if (!m_codeBlock->finalize(m_writer.finalize())) [[unlikely]]
+        return ParserError(ParserError::OutOfMemory);
 
     // We limit total bytecode sequence size to int32_t so that we can use int32_t jump offsets.
     // Also, this allows us to use one bit of bytecode for some flag, including "ignore-result-flag".
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");
Loading diff…

Regression Test / PoC

shipped with the fix
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");
Loading diff…