Handle overflows in UnlinkedMetadataTable::finalize().
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
Medium 등급이며, 진입 장벽은 도달 가능성이 아니라 산술 조건 쪽에 있습니다. 문제의 누산기 자체는 어떤 페이지에서도 도달 가능하지만, 실제로 wrap을 일으키려면 함수 하나에 대해 약 4GB 규모의 metadata가 필요합니다. 그래서 현실적인 결과는 fault이고, 의미 있는 상대 write까지 가려면 wrap 이후에 어떤 opcode가 배치되는지를 조작할 수 있어야 합니다.
JavaScriptCore는 명령어마다 달라지는 가변 상태를 bytecode stream 안에 직접 두지 않습니다. profiling counter, call-link slot, inline-cache scratch 같은 값들이 여기에 해당합니다. 이런 상태는 code block마다 별도의 연속된 블록에 모아 두고, 그 블록 앞쪽의 작은 offset table이 각 opcode의 metadata 배열이 시작되는 위치를 기록합니다. UnlinkedMetadataTable::finalize()는 bytecode 생성 과정에서 수집한 opcode별 개수를 byte offset으로 바꾸고, 그 offset들이 가리킬 버퍼를 할당하는 단발성 단계입니다. 이때 지켜야 할 invariant는 화려하지 않지만 절대적입니다. offset table의 마지막 항목, totalSize()가 보고하는 크기, 그리고 malloc()에 넘겨지는 값이 모두 동일한 byte 범위를 가리켜야 합니다.
관전 포인트: 스크립트가 거대한 함수 하나를 정의하는 페이지라면, 해당 함수의 bytecode에 박힌 offset보다 훨씬 작은 metadata 버퍼를 엔진이 할당하도록 유도할 수 있습니다. 그 상태에서 함수를 실행하면 allocation 바깥의 profiling 상태를 읽고 쓰게 됩니다.
Source/JavaScriptCore/bytecode/UnlinkedMetadataTable.cpp
Source/JavaScriptCore/bytecompiler/BytecodeGenerator.cpp
JSTests/stress/unlinked-metadata-table-finalize-overflow.js
Patch Details
이번 변경은 세 군데의 수정으로 구성됩니다. 원래는 존재하지 않던 실패 전달 경로가 이 세 수정을 통해 마련되었습니다.
먼저 UnlinkedMetadataTable::finalize() 내부에서 누산기로 쓰이던 unsigned offset이 CheckedUint32로 바뀌었습니다. 이를 위해 <wtf/CheckedArithmetic.h>가 새로 include되었습니다. opcode 단위 루프의 조건에는 && !checkedOffset.hasOverflowed()가 추가되어, sticky flag가 서는 순간 누적이 중단됩니다. 크기를 더하는 단계 역시 checkedOffset += CheckedUint32(numberOfEntries) * metadataSize(...) 형태로 변경되었습니다. 개수와 크기의 곱 자체가 검사 대상이 되며, raw로 계산한 뒤 더하던 기존 방식은 사라졌습니다.
alignment 단계는 별도로 다뤄집니다. roundUpToMultipleOf가 checked type이 들여다볼 수 없는 일반 산술 helper이기 때문입니다. 패치는 alignedOffset을 명시적으로 계산합니다. 그리고 alignedOffset < checkedOffset.value(), 즉 반올림 결과가 오히려 작아진 경우를 overflow 신호로 간주합니다. 이때 checkedOffset.overflowed()를 호출하고 루프를 빠져나갑니다. valueProfileSize는 루프 아래에 있던 기존 선언 위치에서 위로 끌어올려졌고, 값도 m_numValueProfiles와 sizeof(ValueProfile)의 checked 곱으로 다시 계산됩니다.
이후 단계 전체는 하나로 합쳐진 검사가 통제합니다:
(checkedOffset + s_offset32TableSize + checkedValueProfileSize + paddingFor32Bit)
.hasOverflowed()
paddingFor32Bit은 sizeof(size_t) == sizeof(unsigned)인 경우에만 sizeof(LinkingData) 값을 갖습니다. 32-bit 타깃에서는 malloc() 인자 덧셈이 32비트 폭에서 수행되므로 그 덧셈 자체가 wrap될 수 있습니다. 반대로 64-bit에서는 피연산자가 size_t로 승격되기 때문에 덧셈이 안전합니다. 검사에 실패하면 테이블은 일관된 빈 상태로 정리됩니다. 구체적으로는 m_rawBuffer를 해제한 뒤 null로 만들고, m_hasMetadata와 m_is32Bit, m_numValueProfiles를 모두 초기화한 다음 false를 반환합니다. 절반만 만들어진 layout을 이후 코드가 건드릴 여지가 없어지는 셈입니다.
그 다음으로 실패 신호가 위로 전달되어야 합니다. UnlinkedMetadataTable::finalize()와 UnlinkedCodeBlockGenerator::finalize()가 모두 [[nodiscard]] bool로 바뀌었습니다. 후자는 cellLock() scope 안에서 metadataOK를 확보해 두었다가, write barrier와 reportExtraMemoryAllocated() 처리를 마친 뒤 반환합니다. BytecodeGenerator::generate()는 false를 받으면 return ParserError(ParserError::OutOfMemory)로 변환합니다. stress test는 호출식 44,739,242개로 이루어진 함수 본문을 만들고 RangeError 발생을 확인합니다. 다만 debug 빌드와 메모리가 제한된 환경, 32비트 주소 공간 구성에서는 실행되지 않습니다. 여기까지 도달하려면 주소 공간과 RAM이 모두 필요하기 때문입니다.
Background
Unlinked code block과 metadata. JSC는 각 JS 함수를 UnlinkedCodeBlock으로 컴파일합니다. 소스에서 파생되며 캐시 가능한 bytecode 형태입니다. 상당수의 bytecode는 명령어 단위의 가변 상태를 함께 갖는데, profiling counter나 call link info, inline-cache slot 등이 여기에 해당하며 이를 통틀어 metadata라고 부릅니다. 이 상태는 명령어 스트림 안에 inline으로 들어가지 않습니다. 대신 code block마다 하나씩 존재하는 연속된 metadata block에 자리하며, 실행 중 LLInt와 baseline JIT가 이 영역을 읽고 씁니다.
Offset table. metadata block의 앞부분에는 s_offsetTableEntries개의 엔트리로 이루어진 테이블이 놓입니다. s_offsetTableEntries는 NUMBER_OF_BYTECODE_WITH_METADATA + 1입니다. i번째 엔트리는 opcode i의 metadata 배열이 시작되는 바이트 offset을 나타내고, 마지막에 하나 더 붙는 엔트리는 끝 offset, 즉 전체 크기를 담습니다. bytecode 생성 중에는 같은 buffer가 offset 대신 opcode별 개수를 보관합니다. finalize()가 이 개수를 제자리에서 offset으로 다시 기록하며, 이 변환을 위해 preprocessBuffer()가 해당 buffer를 반환합니다.
두 가지 layout tier. 메모리를 아끼기 위해 offset table은 두 가지 형태로 저장됩니다. 모든 offset이 16비트에 들어가면 Offset16(uint16_t) 엔트리로, 그렇지 않으면 Offset32(uint32_t) 엔트리로 저장됩니다. s_offset16TableSize와 s_offset32TableSize가 각 형태의 바이트 크기입니다. 32비트 layout은 두 테이블을 모두 유지하므로, 이 경우 offset에는 s_offset32TableSize만큼의 bias가 붙습니다. 어느 쪽이 선택되었는지는 m_is32Bit에 기록됩니다.
같은 buffer를 공유하는 나머지 요소. offset table 앞쪽에는 raw buffer가 두 영역을 미리 확보해 둡니다. 하나는 sizeof(LinkingData)로, Ref<UnlinkedMetadataTable>과 atomic refcount로 구성됩니다. 다른 하나는 m_numValueProfiles * sizeof(ValueProfile) 바이트의 value profiling slot입니다. UnlinkedMetadataTable.h의 buffer()와 offsetTable16()/offsetTable32() 접근자는 주소를 계산할 때 이 두 영역을 건너뜁니다.
CheckedUint32. WTF의 checked integer wrapper입니다. 연산자를 통한 산술은 sticky overflow flag를 세우며, 이 값은 hasOverflowed()로 읽습니다. overflowed()는 이 flag를 직접 세우는 함수이고, value()는 내부 값을 읽습니다. 다만 flag가 세워진 뒤의 내부 값은 의미가 없습니다. 한편 roundUpToMultipleOf(alignment, x)는 평범한 산술 helper일 뿐이라 checked type에 대해 아무것도 알지 못합니다.
에러 전파와 lazy generation. BytecodeGenerator::generate()는 ParserError를 반환하며, ParserError::OutOfMemory는 스크립트 레벨에서 RangeError로 드러납니다. new Function(args, body)로 만든 함수는 생성 시점에 문법 검사만 거치고, bytecode는 첫 호출 때 지연 생성됩니다. 그래서 생성 단계의 에러는 construction이 아니라 호출 시점에 관찰됩니다. [[nodiscard]]는 반환값을 버릴 때 컴파일러가 경고하도록 만드는 C++ attribute입니다.
Analysis
크기 계산 과정에서 발생하는 overflow입니다. 다만 이 버그의 특징은 wrap된 값이 한 곳이 아니라 세 곳에서 사용된다는 점에 있습니다.
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.
패치 이전의 루프 본문은 raw 32비트 산술 두 줄이 전부였습니다. offset = roundUpToMultipleOf(alignment, offset); 다음에 offset += numberOfEntries * metadataSize(opcodeID);가 이어졌을 뿐입니다. 곱셈 결과든 누적 합이든 UINT32_MAX 아래에 머무는지 확인하는 코드는 없었습니다. JS 함수 하나가 담을 수 있는 metadata 보유 opcode 수에는 사실상 상한이 없습니다. 따라서 numberOfEntries * metadataSize()의 누적이 2^32를 넘어가면, offset은 modulo 2^32 연산으로 작은 값이 됩니다.
다이어그램의 화살표 세 개를 따라가 보겠습니다. 먼저 buffer[s_offsetTableEntries - 1] = offset은 wrap된 끝 offset을 그대로 기록합니다. totalSize()는 UnlinkedMetadataTable.h에서 valueProfileSize + offsetTable32()[s_offsetTableEntries - 1]로 정의되어 있고 역시 전부 unsigned이므로, 이 값을 테이블 크기로 보고합니다. 다음으로 m_is32Bit = offset > UINT16_MAX는 같은 wrap된 값에서 layout tier를 결정합니다. 실제 크기가 4 GB를 넘는 테이블이 compact한 16비트 형태에 들어맞는다고 분류될 수 있는 셈입니다. 마지막으로 MetadataTableMalloc::malloc(valueProfileSize + sizeof(LinkingData) + s_offset32TableSize + offset) 호출이 실제 buffer 크기를 정합니다. 여기 들어가는 offset은 wrap된 값이고, valueProfileSize 역시 이미 잘려 있습니다. 기존 코드의 unsigned valueProfileSize = m_numValueProfiles * sizeof(ValueProfile);는 곱셈을 size_t 폭에서 수행한 뒤, 대입 과정에서 상위 비트를 버렸기 때문입니다.
반면 테이블에 이미 기록된 opcode별 시작 offset과 bytecode 스트림의 각 명령어에 박혀 있는 metadata 인덱스는 아무런 영향을 받지 않습니다. 여전히 wrap되지 않은 수 기가바이트 규모의 layout을 가리키고 있습니다. 그래서 해당 bytecode가 실행되는 순간, metadata 접근은 실제로 할당된 영역을 한참 벗어난 곳을 인덱싱하게 됩니다. Apple의 설명이 지목하는 out-of-bounds 접근이 바로 이 지점이며, 현실적으로 즉시 fault가 나는 이유이기도 합니다. 관여하는 변위가 워낙 크기 때문입니다. 공격자가 offset table을 만드는 도중에 wrap이 일어나도록 배치할 수 있는 경우를 가정할 수 있습니다. 이 경우 wrap 이후 opcode들의 metadata는 오히려 작은 offset에 놓이게 됩니다. 그러면 같은 버그가 relative heap write로 성격이 바뀔 가능성이 있고, 기록되는 내용도 profiling 값이나 call-link pointer처럼 공격자 영향이 일부 미치는 데이터가 됩니다. 다만 이런 형태의 heap 배치는 예상되는 방향에 머물 뿐, 이번 변경 자체가 뒷받침하는 내용은 아닙니다. 부차적인 결과도 하나 따라옵니다. m_is32Bit과 totalSize()가 모두 같은 wrap된 값에서 파생되므로, link()를 거치면서 크기가 잘못 잡힌 linked 사본으로 이어질 가능성이 있습니다.
패치는 설명할 수 없는 테이블은 아예 만들지 않는 방식으로, offset과 보고된 크기, 할당 크기 사이의 일치를 회복합니다. 모든 산술 단계가 CheckedUint32를 거치고, sticky flag가 서는 즉시 루프가 중단됩니다. 하나로 합쳐진 검사는 끝 offset과 bias, value profile 바이트를 모두 포함하며, 32비트 타깃에 한해 malloc 인자의 LinkingData 항까지 함께 확인합니다. 거부 시에는 테이블이 일관된 빈 상태로 정리되므로, 개수와 offset이 뒤섞여 절반만 변환된 buffer가 남지 않습니다. 새로 추가된 [[nodiscard]] bool 반환 체인은 finalize()에서 시작해 UnlinkedCodeBlockGenerator를 거쳐 BytecodeGenerator::generate()까지 실패를 전달하고, 그곳에서 ParserError::OutOfMemory가 됩니다.
alignment 가드는 따로 짚어둘 만합니다. 그러지 않으면 대충 읽고 넘어가기 쉬운 부분이기 때문입니다:
unsigned alignedOffset = roundUpToMultipleOf(alignment, checkedOffset.value());
if (alignedOffset < checkedOffset.value()) {
checkedOffset.overflowed();
break;
}
checkedOffset = alignedOffset;
UINT32_MAX에 가까운 offset을 8바이트 경계로 올림하면 결과가 오히려 작은 수로 wrap됩니다. CheckedUint32는 이 과정을 전혀 알지 못합니다. 값은 .value()로 wrapper를 빠져나갔고, 산술은 검사 기능이 없는 helper 안에서 수행되었으며, 결과는 평범한 대입으로 되돌아왔기 때문입니다. "올림 결과가 더 작아졌는가?"를 명시적으로 비교하는 코드만이 이 상황을 잡아냅니다.
new Function(body)로 만든 함수의 bytecode는 첫 호출 시점에 생성되므로, 테스트의 RangeError도 construction이 아니라 호출 지점에서 도착합니다. try 블록 안에 f(function() { }) 호출이 들어 있는 이유입니다. 이 경로 전체는 WebContent process 안에 있어, 문제의 범위는 renderer 쪽 메모리 안전성에 한정됩니다. 실제 exploit으로 이어지려면 별도의 sandbox escape가 여전히 필요합니다. 또한 metadata가 ~4 GB 규모여야 한다는 전제 때문에, 메모리가 제한된 환경이나 32비트 주소 타깃에서는 도달 난이도가 실질적으로 크게 올라갑니다. 테스트의 skip 지시문이 인코딩하고 있는 조건이 정확히 이 부분입니다.
32비트 metadata offset 누산기를 wrap시킬 만큼 거대한 JS 함수 하나가 있으면, buffer 크기는 wrap된 총합에서 결정되는 반면 bytecode는 여전히 wrap되지 않은 수 기가바이트 layout을 주소로 사용합니다.
Insight
구조적으로 더 날카로운 교훈은 크기 overflow 그 자체가 아닙니다. tier selector 쪽입니다. m_is32Bit = offset > UINT16_MAX는 wrap될 수 있는 값에서 저장 인코딩을 결정합니다. 단순한 크기 overflow는 buffer를 작게 할당하는 데서 끝납니다. 그러나 wrap된 discriminant는 그 buffer의 in-memory 혹은 on-disk 포맷까지 잘못 고르게 만듭니다. 그러면 bounds 문제를 따지기도 전에 읽는 쪽과 쓰는 쪽이 데이터의 형태부터 다르게 인식하는 상태가 됩니다. 임계값 비교의 좌변이 검증된 입력이 아니라 누적 루프의 결과물인 경우라면, 모두 같은 점검 범주에 들어갑니다. 산술에서 파생된 discriminant는 wrap이 없었다는 점이 이미 보장된 값 위에서 계산되어야 합니다.
Audit directions
-
wrap이 일어난 뒤에야
size_t로 넓혀지는 고정 폭 누산기. Narrow:Source/JavaScriptCore/bytecode와bytecompiler에서unsigned/uint32_t지역 변수나 멤버가sizeof(...)와 곱해진 뒤malloc/fastMalloc/Vector::grow로 넘겨지는 지점을 검색합니다. 바로 이 클래스의 형제 함수들부터 시작하는 편이 좋습니다.UnlinkedMetadataTable::totalSize(),sizeInBytesForGC(), 그리고UnlinkedMetadataTableInlines.h의link()가 여기에 해당합니다.JSInstructionStream과ExpressionInfo의 크기 계산도 같은 범주인데, 모두 상한 없는 opcode 개수에 비례해 커지기 때문입니다. Wider: 개수 타입이 크기 타입보다 좁은 모든 곳에서 같은 유형이 나타납니다. struct-of-arrays layout builder,uint32_t섹션 offset을 기록하는 serialization writer, 그리고unsigned n = someSizeT * sizeof(T)같은 절단 대입이 대표적입니다. Widest: "크기를 인덱스 폭에서 계산하고 포인터 폭에서 사용한다"는 일반 유형에 해당하며, 4 GB를 넘길 수 있는 구조를 32비트 offset으로 기술하는 코드베이스라면 어디서든 성립합니다. V8의 bytecode 및 Zone offset 산술, SpiderMonkey의jsbytecodeoffset, 이후as usize할당으로 이어지는 Rust의as u32캐스트가 그런 예입니다. 모든 층위에서 공통으로 확인할 신호는 하나입니다. 표현식 안에서 가장 좁은 타입이 개수나 offset이고 가장 넓은 타입이 할당 인자인데, 그 사이에Checked<>나 saturating helper가 없는 경우입니다. -
인코딩 tier까지 선택하는 크기 값. Narrow: 여기서의
m_is32Bit = offset > UINT16_MAX판정을 점검하고,finalize()의 16비트 분기를UnlinkedMetadataTable.h의offsetTable16()/offsetTable32()와 함께 살펴봅니다. 이어서 JSC 안에서 임계값이 좁은 저장 형태와 넓은 저장 형태를 가르는 다른 지점도 찾아봅니다. compact jump table과 full jump table의 구분, 다른Offset16/Offset32쌍, small index 기반 inline cache 등이 대상입니다. Wider:if (n > SOME_MAX) use wide layout형태의 분기라면 무엇이든 해당합니다. property storage tiering, 문자열의 8비트/16비트 선택, varint와 고정 폭 인코더 사이의 선택이 그렇습니다. 점검해야 할 질문은 비교가 이루어지는 시점에n이 정확한 값임을 증명할 수 있는가입니다. Widest: "산술에서 파생된 discriminant는 wrap이 없었다는 점이 이미 보장된 값 위에서 계산되어야 한다"는 불변식은 언어를 가리지 않고 모든 바이너리 포맷 writer와 tier 기반 컨테이너에 적용됩니다. 확인할 신호는*_MAX상수와의 비교에서 좌변이 검증된 입력이 아니라 누적 루프의 결과인 경우입니다. -
실패를 표현할 경로가 없는 할당·상태 확정 루틴. Narrow:
Source/JavaScriptCore/bytecode와bytecompiler에서void를 반환하는finalize/allocate/reserve/build멤버를 검색합니다. 이 중malloc/fastMalloc을 호출하거나 크기 계산을 수행하는 것들을 추려냅니다. 그다음 그 안에서 발생한 OOM이나 overflow가ParserError까지 전달될 경로를 갖는지 확인합니다. 이번 commit 이전의UnlinkedCodeBlockGenerator::finalize()가 정확히 그런 호출 지점이었습니다. Wider: 범위를 넓히면 two-phase-init API 전반이 대상입니다. 검증보다 먼저 상태를 확정해 버리는 생성자나init()메서드가 여기 해당하고, 실패를 crash로만 표현하는 builder의commit()/seal()단계도 같은 부류입니다. Widest: "실패할 수 있는 모든 단계는 표현 가능한 실패 결과를 가져야 하고, 호출자는 그 결과를 반드시 확인하도록 강제되어야 한다" — 언어를 가리지 않고 통용되는[[nodiscard]]/#[must_use]/ checked exception의 규율이 같은 이야기입니다. narrow 단계의 판별 단서는 할당과m_isFinalized형태의 확정 플래그 설정을 한 함수 안에서 함께 수행하는void멤버입니다. 더 넓은 단계에서는 오류 처리가RELEASE_ASSERT하나뿐이거나 조용히 early return으로 빠져나가는 commit 메서드를 찾으면 됩니다. -
Checked 값이 검사 없는 helper를 거치며 보증을 잃는 패턴. Narrow: JSC와 WTF에서
.value()호출을 검색하되, 그 결과가roundUpToMultipleOf나WTF::roundUpToPowerOfTwo, 혹은 임의의 산술 free function으로 전달되는 지점만 추립니다. 이때 계산 결과가 checked 변수로 다시 대입되기 전에 재검증을 거치는지 확인합니다. unwrap → 계산 → rewrap으로 이어지는 이 3단 구조가 바로 이번 패치에alignedOffset < checkedOffset.value()라는 명시적 비교가 필요했던 이유입니다. Wider: 기존 코드에 안전성 wrapper를 나중에 덧씌운 자리라면 어디서나 같은 형태가 반복됩니다. pointer 연산 직전에.data()로 풀려버리는 bounds-checked span, 호출 경계를 넘으면서.get()으로 벗겨지는CheckedPtr/RefPtr, raw pointer로 환원되는 iterator wrapper가 모두 그렇습니다. Widest: "타입 수준의 안전성 보장은 첫 unwrap에서 끝나며, wrapper 밖으로 나간 값은 다시 들어오기 전에 재검증되어야 한다"는 원칙입니다. Rust에서Wrapping/checked_*를 raw 연산과 섞어 쓰는 경우에도, Java에서Math.addExact대신+연산자를 그대로 사용하는 경우에도 동일하게 성립합니다. checked integer 타입으로 이전하는 중인 코드베이스라면 어디든 마찬가지입니다. 판별 단서는x.value()→ helper →x = result로 이어지면서 helper 전후 값을 한 번도 비교하지 않는 3단 구조입니다.