CVE-2026-64784

Overview

Advisory: Apple Security Advisory

Impact:

Description: Processing maliciously crafted web content may lead to an unexpected Safari crash

Researchers: Janggoon Lee of Out of Bounds, OpenAI Codex Security - Amy Burnett

Attribute Value
CVE CVE-2026-64784
Bugzilla 317632
Component JSC
Bug Class OOB
Severity medium
Commit 97df94ead028cddc…
Advisory Apple Advisory

Root Cause Analysis

UnlinkedMetadataTable::finalize() computed metadata buffer offsets using unchecked unsigned arithmetic. When compiling JavaScript functions with tens of millions of bytecode instructions, the cumulative offset calculation (numberOfEntries * metadataSize) overflows the 32-bit unsigned integer, wrapping to a small value. This causes a heap allocation that is far too small for the actual metadata, and all subsequent metadata reads/writes go out of bounds. The fix replaces raw unsigned arithmetic with CheckedUint32, detects the overflow before allocating, and propagates a failure boolean up through UnlinkedCodeBlockGenerator::finalize() to BytecodeGenerator::generate(), which then returns an OutOfMemory parser error instead of crashing.

Attack Path

1. Craft oversized JS function

Attacker serves a web page that dynamically builds a JavaScript function with ~44 million repeated statements (e.g., ‘a();’.repeat(44739242)), causing the bytecode compiler to emit an extremely large instruction stream.

2. Trigger bytecode compilation

The engine invokes BytecodeGenerator::generate(), which iterates over the massive AST and emits bytecode for each statement, incrementing per-opcode metadata entry counts in UnlinkedMetadataTable.

3. Overflow in finalize()

During UnlinkedMetadataTable::finalize(), the engine computes total metadata size as offset = Σ(numberOfEntries * metadataSize). The 32-bit unsigned multiplication/addition overflows, wrapping offset to a small value.

4. Undersized allocation

The engine calls malloc(valueProfileSize + sizeof(LinkingData) + offsetTableSize + offset) with the wrapped (tiny) offset, allocating a buffer orders of magnitude too small for the real metadata.

5. OOB access on metadata use

When the engine later links or accesses metadata for any opcode, it writes or reads past the allocated buffer boundary, causing an out-of-bounds heap access and an unexpected Safari crash.

Changed Functions

Function File Change Note
UnlinkedMetadataTable::finalize Source/JavaScriptCore/bytecode/UnlinkedMetadataTable.cpp modified Replaced unchecked unsigned offset arithmetic with CheckedUint32; added overflow checks; returns bool instead of void.
UnlinkedMetadataTable::finalize Source/JavaScriptCore/bytecode/UnlinkedMetadataTable.h modified Changed signature from void finalize() to [[nodiscard]] bool finalize().
UnlinkedCodeBlockGenerator::finalize Source/JavaScriptCore/bytecode/UnlinkedCodeBlockGenerator.cpp modified Now returns bool; propagates metadataOK from m_metadata->finalize() to caller.
UnlinkedCodeBlockGenerator::finalize Source/JavaScriptCore/bytecode/UnlinkedCodeBlockGenerator.h modified Changed signature from void finalize() to [[nodiscard]] bool finalize().
BytecodeGenerator::generate Source/JavaScriptCore/bytecompiler/BytecodeGenerator.cpp modified Checks finalize() return value; returns ParserError::OutOfMemory if metadata overflowed.
unlinked-metadata-table-finalize-overflow.js JSTests/stress/unlinked-metadata-table-finalize-overflow.js added Regression test: 44-million-statement function must throw RangeError instead of crashing.

Files Changed

Source Files

  • 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

Test Files

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

Patch Preview

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;