2026-06-06·1분 읽기·
d8 패치 한 줄이 TurboFan에게 "Math.exp는 절대 NaN을 만들지 않는다"고 거짓말을 시킨다. 하지만 Math.exp(NaN)은 NaN이다. 타입혼동 트리거까지는 깔끔하게 된다. 문제는 그 어긋남을 OOB로 키우는 단계 — NaN은 인덱스로 가는 모든 길에서 정화되고, Object.is 불리언은 폴딩되거나 Boolean으로 남고, 자연 티어업에선 어긋남이 인덱스까지 살아 도달하는데 거기서 11.2가 무조건 박아둔 kAbortOnOutOfBounds 하드닝이 트랩으로 끊어버린다. 이 글(1편)은 버그·트리거와, 공개된 typer-OOB 기법이 V8 11.2 하드닝에 어떻게 막히는지를 실측으로 끝까지 추적한다.
이 글이 도움이 됐나요?
문제: DreamHack — EXP-NaN (Dreamhack CTF Season 4 Round 5, Division 1 출제작) 분류: Pwnable (Browser / V8 JIT) 난이도: 💎 Diamond 1 이 글: 시리즈 1편 — 버그·트리거와 11.2 하드닝의 벽까지. (우회 RE는 2편)
Math.exp의 반환 타입에서 NaN 하나를 빼는 패치 한 줄. 그게 버그의 전부다. 컴파일러(TurboFan)는 이제 "Math.exp는 절대 NaN을 돌려주지 않는다"고 굳게 믿지만, 실제로 Math.exp(NaN)은 NaN이다.
타입혼동을 트리거하는 것까지는 깔끔하게 됐다. 그런데 그걸 OOB(out-of-bounds)로 키우는 단계에서 끝까지 막혔다. NaN은 인덱스로 쓰면 0이 되고, 비교에 넣으면 컴파일 타임에 폴딩되고, min/max에 넣으면 클램프된다. Object.is로 우회해도 폴딩되거나 Boolean으로 남는다. 자연 티어업으로 옮기면 어긋남이 드디어 인덱스까지 살아 도달하는데 — 거기서 V8 11.2가 CheckBounds에 무조건 박아둔 kAbortOnOutOfBounds 하드닝이 int3 트랩으로 끊어버린다.
이 1편은 그 과정을, 막혔던 측정을 전부 그대로 담아 적는다. 공개된 typer-OOB 기법(abiondo · doar-e · Quick-Maffs)이 왜 이 빌드에서 하나도 안 통하는지가 핵심이다. 우회 가젯을 찾는 소스 심층 분석은 2편으로 넘긴다.
솔직히 고백하면, 이 문제를 붙잡고 가장 오래 한 일은 "익스를 짜는 것"이 아니라 "왜 안 되는지"를 납득하는 것이었다. 타입혼동은 30분 만에 띄웠는데, 그 뒤로는 될 듯 될 듯 안 되는 게 반복됐다. 그 며칠치 삽질을 미화 없이 그대로 적는다 — 막힌 기록도 누군가에겐 지도가 되니까.
브라우저 익스플로잇이 처음이면 용어부터 벽이다. 이 글에 계속 나올 것만 사람 말로 추려둔다. (이미 아는 분은 건너뛰어도 된다.)
d8이 쓰는 자바스크립트 엔진. 그 안의 최적화 JIT 컴파일러 이름이 TurboFan이다.a[i]에서 i가 범위 안인지 검사하는 게 CheckBounds. 이게 없거나 속으면 배열 밖 메모리를 읽고 쓰는 **OOB(out-of-bounds)**가 되고, 거기서부터 임의 코드 실행(셸)까지 간다.0/0, Math.sqrt(-1) 같은 결과. 성질이 유별나서(자기 자신과도 같지 않다) 익스에서 다루기가 까다롭다 — 이 글의 주인공이다.| 항목 | 내용 |
|---|---|
| 문제명 | EXP-NaN |
| 난이도 | 💎 Diamond 1 |
| 분류 | Pwnable — Browser / V8 JIT exploitation |
| 대상 | V8 d8 11.2.214.14 (x64, Chrome M112 계열, commit 6538a20a…) |
| 빌드 | v8_enable_sandbox = true · pointer compression · is_debug = false |
| 제공 | 패치된 d8 + snapshot_blob.bin · runner.py(stdin → ./out/d8 file) |
| 핵심 | typer.cc 패치로 Math.exp 반환타입에서 NaN 제거 → 타입혼동 |
목표는 단순하다. runner.py가 받아주는 JS 한 덩어리(2000바이트 이내)를 던져서, 같은 서버에 있는 setuid 바이너리 flag_reader-<md5>를 실행시키는 것. d8에는 read·load·os·Realm·d8.file 같은 입출력 수단이 전부 제거돼 있어서, 순수 메모리 손상만으로 코드 실행까지 가야 한다.
1편에서 다루는 범위는 이렇다.
Math.exp의 타입혼동을 실제로 트리거한다 (speculation을 꺼야 한다).NaN을 직접 인덱스로 쓰려는 시도가 왜 전부 실패하는지 측정한다 (삽질).Object.is 불리언 가젯이 이 빌드에서 폴딩/Boolean 양쪽으로 죽는 걸 확인한다.kAbortOnOutOfBounds 하드닝이 트랩으로 끊는 걸 디스어셈블리로 확인한다.V8은 자바스크립트를 이렇게 굴린다. 처음엔 바이트코드 인터프리터(Ignition)로 실행하다가, 어떤 함수가 자주 불리면(hot) TurboFan이라는 최적화 컴파일러가 그 함수를 기계어로 다시 컴파일한다. 이걸 JIT(Just-In-Time)라고 부른다.

위 그림처럼 "느리지만 바로 도는 인터프리터 → 자주 쓰는 함수만 골라 최적화 컴파일"이 핵심 아이디어다. 브라우저 익스의 무대는 거의 항상 저 최적화 컴파일러다 — 빠르려고 검사를 생략하는 곳이라, 그 생략의 근거(타입 추론)가 틀리면 곧 메모리 안전성 구멍이 되기 때문이다.
TurboFan이 빠른 코드를 뽑는 비결은 **추측(speculation)과 타입 추론(typing)**이다. 인터프리터가 모아둔 피드백("이 변수는 지금까지 항상 정수였어")을 믿고, 느린 일반 경로 대신 빠른 특수 경로를 깐다. 이 추론을 담당하는 게 src/compiler/typer.cc다.
typer는 그래프의 모든 노드에 타입(가능한 값의 집합) 을 붙인다. 숫자 타입은 격자(lattice) 구조로 쪼개져 있다.
Number = PlainNumber ∪ MinusZero(-0) ∪ NaN
└─ 정수 범위, 유한 실수, ±Infinity (NaN·-0 제외)여기서 PlainNumber는 NaN도 -0도 포함하지 않는다. 이 구분이 왜 중요하냐면, typer가 어떤 값을 "절대 NaN이 아닌, 좁은 정수 범위"라고 증명하면 TurboFan은 그 값에 대한 안전 검사를 들어낸다. 대표적인 게 배열 접근 직전의 CheckBounds(인덱스가 [0, length) 안인지 검사)다. typer가 "이 인덱스는 항상 [0, 4)"라고 증명하면 CheckBounds는 사라진다.
즉 typer가 타입을 실제보다 좁게 믿게 만들 수만 있으면, 런타임에 그 범위를 벗어나는 값으로 검사 없는 메모리 접근을 만들 수 있다 — 이게 모든 "typer confusion" 버그의 골격이다. 다만 뒤에서 보겠지만, 이 골격을 막으려는 별도의 하드닝이 11.2엔 켜져 있다.
TurboFan은 함수를 노드 그래프로 표현한다 — 값·연산·제어흐름을 노드로 두고 데이터/효과 의존성을 간선으로 잇는 "sea-of-nodes"라는 IR(중간표현)이다. 우리가 뒤에서 --trace-turbo로 들여다볼 SameValue·NumberMultiply·CheckBounds 같은 게 전부 이 그래프의 노드다.

이 그래프 위에서 TurboFan은 여러 패스를 순서대로 돌린다. 이 글에서 계속 등장할 단계만 추리면 이렇다.
| 단계 | 하는 일 | 우리 버그와의 관계 |
|---|---|---|
Typer | 모든 노드에 타입 부여 | Math.exp가 여기서 PlainNumber로 (잘못) 타입됨 |
TypedLowering | 타입 기반 단순화 + ConstantFoldingReducer | singleton 타입 노드를 상수로 치환 → 리터럴 NaN 센티넬이 여기서 폴딩 |
LoadElimination / EscapeAnalysis | 중복 로드 제거, 도망 안 가는 객체 분해 | 객체 필드 센티넬의 NaN이 여기서 드러남 |
SimplifiedLowering | 표현(int/float/tagged) 선택 + 노드 재타입 | abiondo가 기대한 SameValue→false 재타입이 여기서 (안) 일어남 / CheckBounds가 CheckedUint32Bounds로 |
EffectControlLinearization | 검사 노드를 실제 분기로 | CheckedUint32Bounds → Uint32LessThan → LoadElement 또는 |
폴딩이 언제 일어나느냐(이른 TypedLowering vs 늦은 SimplifiedLowering)가 이 문제의 사활을 가른다 — 너무 일찍 폴딩되면 self-consistent해서 어긋남이 죽고, 늦게 타입이 좁혀져야 검사만 빠지고 노드는 살아 어긋남이 런타임까지 간다. abiondo·doar-e는 이 "늦은 좁힘" 창을 노렸다.

제공된 deploy/patches/v8.patch의 본체는 정확히 이 한 조각이다.
--- a/src/compiler/typer.cc
+++ b/src/compiler/typer.cc
@@ JSCallTyper @@
case Builtin::kMathAbs:
- case Builtin::kMathExp:
return Type::Union(Type::PlainNumber(), Type::NaN(), t->zone());
+ case Builtin::kMathExp:
+ return Type::PlainNumber();원래 V8은 Math.exp의 반환 타입을 Union(PlainNumber, NaN)으로 둔다 — "유한수일 수도, NaN일 수도 있다"는 정직한 타입이다. 패치는 Math.exp만 빼내서 PlainNumber(NaN 배제)로 바꿨다. Math.abs는 그대로 NaN을 포함한 채 남겨뒀다. 이제 TurboFan은 Math.exp(x)의 결과가 절대 NaN이 아니라고 확신한다.
그런데 IEEE-754에서 Math.exp(NaN) = NaN이다. 입력이 NaN(또는 숫자가 아닌 값)이면 출력도 NaN. 컴파일러의 믿음과 런타임의 진실이 어긋나는 지점이 여기다.
패치의 나머지는 전부 d8의 입출력 차단이다 — read/readline/load/writeFile/os.system/Realm/d8.file.*을 주석 처리해서 없앴다. 그래서 익스플로잇은 파일이나 셸 호출에 기댈 수 없고, 메모리 손상만으로 끝을 봐야 한다.
서버 쪽은 단순하다.
# runner.py (요약)
filename = f"/tmp/{uuid4()}"
# stdin 에서 '<EOF>' 까지, 최대 2000바이트 받아 파일로 저장
subprocess.run(["./out/d8", filename])여기서 중요한 제약 하나 — subprocess.run(["./out/d8", filename])에 --allow-natives-syntax가 없다. 즉 서버에선 %OptimizeFunctionOnNextCall 같은 네이티브 신택스를 못 쓰고, 최적화는 자연 티어업(hot loop) 으로만 일어난다. 이 한 줄이 뒤에서 결정적인 의미를 갖는다. 로컬 분석용 d8은 --allow-natives-syntax·%DebugPrint·--trace-turbo·--print-opt-code가 다 열려 있으니(빌드에 v8_enable_object_print·disassembler가 켜져 있다) 개발은 로컬에서 하되, 동작은 항상 자연 티어업 기준으로 확인해야 한다.
패치를 봤으니 타입혼동을 실제로 만들어보자. 먼저 순진하게 짜면 안 된다. 평범한 Math.exp(x)는 typer가 보기도 전에 JSCallReducer::ReduceMathUnary가 NumberExp라는 저수준 노드로 낮춰버린다. NumberExp의 타입은 OperationTyper가 매기는데, 거긴 패치가 안 됐으니 정직하게 NaN을 포함한다. 결국 패치된 JSCallTyper는 타지도 않는다.
핵심은 콜사이트의 speculation을 꺼는 것이다. ReduceMathUnary는 speculation이 비활성(kDisallowSpeculation)이면 그냥 포기(bail)한다. 그러면 Math.exp는 일반 JSCall 노드로 남고, 그제서야 패치된 JSCallTyper(→ PlainNumber)를 탄다.
speculation은 한 번 deopt가 나면 그 콜사이트에서 꺼진다. 그래서 절차는 달군다 → 최적화 → 나쁜 타입 인자로 deopt → 다시 최적화다. (서버용 자연 티어업 버전은 인자를 문자열로 줘서 같은 효과를 낸다 — Math.exp("x") = NaN이라 콜사이트가 숫자 speculation을 안 깐다.)
function f(x){
let t = Math.exp(x);
if (t === t) return 111; // typer: t는 PlainNumber(=NaN 아님) → t===t 는 항상 true 로 폴딩
return 222; // 런타임에 t가 진짜 NaN이면 여기(222)가 맞다
}
%PrepareFunctionForOptimization(f);
f(1.1); f(2.2);
%OptimizeFunctionOnNextCall(f);
f(1.1); // speculation ON 으로 최적화 → Math.exp 가 NumberExp 로 낮춰짐
f("x"); // 나쁜 타입 인자로 DEOPT → 콜사이트 speculation 이 꺼진다
%PrepareFunctionForOptimization(f);
f(1.1
이걸 로컬 d8로 돌리면:
$ ./out/d8 --allow-natives-syntax trig.js
opt status f(1.1) = 111 optStatus= 81 # 81 = 0x51 = 최적화 + TurboFanned
CONFUSION f(NaN) = 111 (111 = 타입혼동 LIVE, 222 = 정상)f(NaN)이 111을 돌려준다. 정상이라면 NaN === NaN은 false니 222가 나와야 한다. 즉 최적화된 코드 안에서 t === t가 (런타임이 NaN인데도) true로 폴딩됐다. 타입혼동은 살아 있다.

이 f가 TurboFan에서 실제로 어떻게 컴파일되는지 turbolizer로 열어 보자. --trace-turbo로 뽑은 turbo-f-1.json을 turbolizer에 올리면, 왼쪽 sea-of-nodes에 JSLoadGlobal(Math) → JSLoadNamed(exp) → JSCall → t === t의 Branch가 그대로 보인다. 결정적인 건 오른쪽 최적화 디스어셈블리에 call MathExp가 남아 있다는 것 — speculation을 꺼서 Math.exp가 NumberExp로 인라인되지 않고 일반 호출로 남았다는 증거다(그래서 패치된 JSCallTyper를 타고 PlainNumber가 붙는다). 앞서 본 일반 sea-of-nodes 예시(ref)가 아니라, 우리 트리거 함수의 진짜 그래프다.

자바스크립트의 숫자는 전부 64비트 부동소수점(IEEE-754 double)이다. 64비트는 부호 1 + 지수 11 + 소수부 52로 쪼개진다.

NaN(Not a Number)은 이 중 지수가 전부 1이고 소수부가 0이 아닌 비트패턴이다(소수부가 0이면 ±Infinity). 0/0, Math.sqrt(-1), Number("abc"), 그리고 이 문제의 Math.exp(NaN)이 NaN을 만든다. NaN이 익스에서 유별나게 까다로운 건 이런 성질들 때문이다.
NaN === NaN 은 false다. IEEE-754 규칙상 NaN은 자기 자신과도 같지 않다. 그래서 자바스크립트에서 t !== t는 사실상 "t가 NaN인가?"를 묻는 관용구다 — 1편 트리거의 if (t === t)가 바로 이걸 노린다.NaN + 1 = NaN, NaN * 0 = NaN. 그리고 정수로 떨구면 ToInt32(NaN) = 0. 즉 NaN은 "큰 정수"로 자라지 않고 0이나 NaN으로 미끄러진다.이 성질들이 모여, "타입혼동으로 만든 NaN을 큰 배열 인덱스로 키우자"는 자연스러운 시도를 전부 좌초시킨다. 아래가 그 좌초의 기록이다.
타입혼동은 됐는데, 그 어긋남을 메모리 침범으로 바꾸는 게 쉽지 않다. 보통의 typer 버그(잘못된 정수 범위)는 큰 정수 인덱스를 바로 만들 수 있는데, 여기 어긋난 값은 하필 NaN이다. NaN은 이상하리만치 얌전하다.
t===t, t<t)는 컴파일러가 상수로 폴딩해버린다 → 폴딩된 뒤엔 typer가 본 값 = 런타임 값이라 어긋남이 사라진다(self-consistent).t|0, ~~t, t&3 같은 정수 변환은 자바스크립트의 ToInt32 규칙을 따라 NaN → 0으로 정화한다.arr[t]는 t가 정수가 아니거나 NaN이면 이름 프로퍼티 조회가 돼서 undefined가 나온다.Math.min/max(t, k)는 NaN을 경계값으로 클램프한다.정리하면, NaN은 비교에 넣으면 컴파일 타임에 사라지고, 산술/변환에 넣으면 런타임에 0이나 경계값으로 정화된다. 큰 OOB 인덱스로 자라지를 않는다.

돌파구를 찾기 전에, "NaN을 어떻게든 인덱스로 만들자"는 방향으로 한참 헤맸다. 로컬 d8로 각 경로의 런타임 값을 직접 측정한 기록이다. 결론부터 말하면 전부 실패했고, 그 실패가 올바른 방향을 가리켰다.
확정된 트리거 패턴을 함수마다 적용하고, 정상 입력과 NaN 입력의 최적화된 결과를 찍어봤다(a는 길이 4짜리 PACKED_DOUBLE 배열).
// probe.js — 각 게이트의 런타임 거동 측정
// -> 1.1 : 인덱스가 0이 됨 (NaN이 0으로 정화)
// -> undefined : CheckBounds 유지 / NaN이 tagged 인덱스 → 이름 조회
mk("arr[t]", x=>{let t=Math.exp(x);let a=[1.1,2.2,3.3,4.4];return a[t];});
mk("arr[t%4]", x=>{let t=Math.exp(x);let a=[1.1,2.2
t===t가 true로 폴딩되는 걸 봤으니, 이걸로 분기를 지우거나 인덱스를 만들려 했다.
let i = (t === t) ? 0 : 8; // typer: true 로 폴딩 → i = 0 (상수)
let n = (t === t) | 0; // typer: true|0 = 1 (상수)문제는 폴딩이 곧 self-consistency라는 점이다. typer가 t===t를 true로 증명하면 ConstantFoldingReducer가 그 노드를 상수 true로 치환해버린다. 그 순간 최적화된 코드에는 비교 자체가 없어진다 — 런타임 NaN이 다시 평가될 일이 없다. 그래서 typer가 본 값과 런타임 값이 똑같아진다(둘 다 폴딩된 상수). 함수 하나 안에서 데이터 흐름으로는 어긋남을 만들 수 없다.
if (t >= N) ... else if (t < 0) ... else arr[t] 식으로 분기를 통해 인덱스 범위를 좁혀 CheckBounds를 빼려 했다. || 버전, 중첩 단일비교 버전 다 시도.
결과: V8 typer는 이 버전에서 </>= 분기를 타고 피연산자(인덱스)의 타입을 좁히지 않았다. 인덱스는 PlainNumber 그대로 남아 CheckBounds가 살아 있고, arr[NaN]은 여전히 undefined. 타입 좁힘(narrowing) 과 NaN이 인덱스까지 살아 도달하는 것이 충돌한다 — min/max는 좁히지만 클램프하고, 분기는 안 좁힌다.
세 삽질의 공통 교훈은 분명했다 — NaN을 직접 인덱스로 쓰려는 시도 자체가 틀렸다. 방향을 바꿔야 했다.
돌파구는 관점을 뒤집는 데 있다. NaN을 인덱스로 쓰지 않는다. 대신, NaN을 재료로 "컴파일러는 항상 false라고 증명하지만, 런타임엔 true가 되는 불리언" 을 만든다. 불리언은 NaN처럼 정화될 일이 없다.
자연스러운 후보는 Object.is(t, NaN)이다. Object.is(NaN, NaN)은 (===와 달리) true다. 그리고 typer는 t를 PlainNumber(NaN 아님)로 믿으니, Object.is(PlainNumber, NaN)은 "두 값이 같을 리 없다 → 항상 false"로 증명한다. 그러면 b = Object.is(t, NaN)은 typer엔 false(=0), 런타임엔 true(=1). a[b * K]는 typer엔 a[0](→ CheckBounds 제거), 런타임엔 a[K](→ OOB). 이게 abiondo의 Math.expm1(-0) 익스, Quick-Maffs가 쓴 정석이다.
문제는 또 폴딩이다. 비교 상대가 NaN 리터럴이면 typer가 즉시 상수 false로 폴딩해버린다 — 삽질 2와 똑같은 self-consistency 함정. 교과서대로라면 비교 상수를 객체 필드 뒤에 숨겨 이스케이프 분석이 그 상수를 나중에 드러내게 만들면, SameValue 노드가 폴딩 패스를 살아 통과한 뒤 나중 단계에서 false로 재타입돼 CheckBounds가 빠진다.
function foo(x) {
let a = [1.1, 2.2, 3.3, 4.4]; // 길이 4 PACKED_DOUBLE
let o = { s: NaN }; // 센티넬 NaN 을 "필드"에 숨김
let b = Object.is(Math.exp(x), o.s); // SameValue 노드
return a[b * 0x1337]; // 의도: typer a[0] / 런타임 a[0x1337]
}그런데 이 빌드(11.2.214.14)에서 직접 측정해보니 그 정석이 그대로는 안 통했다. b만 떼어 b * 7로 어긋남을 재본다. 정상이라면 Object.is(NaN,NaN)=true니 결과는 7이어야 한다.
| 패턴 | 인터프리터 | 최적화 | 무슨 일이 |
|---|---|---|---|
let s=NaN; Object.is(t,s)*7 (로컬) | 7 | 0 | SameValue가 즉시 상수 false로 폴딩 |
let o={s:NaN}; Object.is(t,o.s)*7 (필드) | 7 | 7 | SameValue가 살아남되 Boolean 타입 그대로 |
비교식 10종을 한꺼번에 돌려 인터프리터(=스펙) 값과 최적화 값을 맞춰보면, ===/!==/Number.isNaN/Object.is(로컬)은 어긋나지만(OPT가 스펙과 다름) Object.is(필드)·Object.is(t,t)는 일치한다 — 즉 필드 센티넬은 어긋남을 못 만든다.
../extracted/deploy/out/d8 --allow-natives-syntax soundness_3_variants.js
왜 안 좁혀지는지 정확히 보려고 --trace-turbo로 SameValue 노드 하나를 모든 phase에 걸쳐 추적했다(객체 필드 센티넬, a[b*1337]). turbolizer JSON의 각 graph phase에서 해당 노드 타입만 뽑으면 이렇다.
# --trace-turbo, foo 의 SameValue(#88) 노드 타입을 phase별로
V8.TFTyper SameValue type = Boolean
V8.TFTypedLowering SameValue type = Boolean
V8.TFLoopPeeling SameValue type = Boolean
V8.TFLoadElimination SameValue type = Boolean
V8.TFEscapeAnalysis SameValue type = Boolean # ← 이스케이프 분석 후에도 그대로
V8.TFSimplifiedLowering NumberSameValue type = Boolean # ← 여기서 false 로 재타입 '되어야' 하는데 안 됨
# 같은 그래프의 다른 노드들
JSCall(Math.exp) type = PlainNumber # ← 버그는 살아 있다
SpeculativeNumberMultiply (b*1337) type = Range(0, 1337)
CheckBounds → CheckedUint32Bounds type = Range(0, 1337) # ← 인덱스가 작다고 증명 안 됨 → 검사 생존JSCall이 PlainNumber로 타입되는 건 버그가 멀쩡히 작동한다는 뜻이다. 그런데 그걸 받아 Object.is를 도는 SameValue는 Typer부터 SimplifiedLowering까지 한 번도 false로 좁혀지지 않고 Boolean으로 남는다. 그래서 b * 1337이 Range(0, 1337), CheckBounds도 Range(0, 1337) — 인덱스가 작다는 증명이 없으니 검사가 살아 있다.
OperationTyper::SameValue는 두 입력 타입이 서로소(disjoint) 임을 보일 수 있을 때만 false(singleton)를 돌려준다. SameValue(PlainNumber, NaN-상수)는 서로소라 false여야 하는데, 객체 필드의 NaN은 typer에게 Number로 보일 뿐(정확히 NaN 상수로 노출되지 않음) SameValue(PlainNumber, Number)가 되어 겹친다 → Boolean. abiondo의 V8에선 이스케이프 분석이 필드 상수를 드러낸 뒤 SimplifiedLowering이 SameValue를 다시 타입해 false로 좁혔는데(그래서 mem2019의 글이 "simplified-lowering.cc:1559 조건을 만족해 CheckBounds가 제거된다"고 적었다), 이 11.2 빌드는 그 늦은 재타입을 하지 않는다.
정리하면 — 로컬(리터럴) 센티넬은 SameValue를 컴파일 타임에 상수 false로 굳혀버려서 런타임도 false(→ a[b*K]가 그냥 a[0], 어긋남 소멸). 객체 필드 센티넬은 폴딩은 피하지만 false로 증명되지 않아 b가 Boolean으로 남고, 그러면 b*K가 Range(0,K)라 CheckBounds가 안 빠진다. abiondo·Quick-Maffs가 기댄 "필드에 숨기면 늦게 false로 재타입된다"가 이 버전에선 성립하지 않는다 — 이게 첫 번째 벽이다.

불리언을 안 거치는 길도 봤다. t * 0.
let idx = t * 0; // typer: 0 (NaN 아님이라 믿음) ; 런타임: NaN * 0 = NaN
return f64arr[idx];유한수는 finite * 0 = 0이라 typer는 idx를 작은 정수로 믿는다. 하지만 런타임 NaN * 0 = NaN이다. typer가 "NaN 아닌 정수"라고 확신하면 RepresentationChanger는 float→int 변환에서 NaN 보정을 생략한 bare cvttsd2si 를 고른다. x86의 cvttsd2si는 NaN을 만나면 자바스크립트의 ToInt32(NaN)=0이 아니라 0x8000000000000000(INT64_MIN) 을 돌려준다. 삽질 1에서 t|0이 0으로 죽은 건 이 보정 때문이었고, t*0 + unchecked 경로는 그 보정이 없어 INT_MIN이 살아남는다.
--print-opt-code로 보면 인덱스 변환에 보정 없는 vcvttsd2si가 그대로 박혀 있다.
../extracted/deploy/out/d8 --allow-natives-syntax --print-opt-code repsel.js 2>&1 | grep -iE 'cvttsd2si|cvtsd2si'
--trace-turbo-reduction도 본다. t*0은 상수로 폴딩되지 않고 NumberMultiply로 살아남고(런타임에 NaN*0=NaN 유지), 변환은 CheckedFloat64ToInt64[dont-check-for-minus-zero] → 위의 vcvttsd2si로 내려간다. 그런데 같은 트레이스에 CheckedUint64Bounds가 그대로 남아 있다.
../extracted/deploy/out/d8 --allow-natives-syntax --trace-turbo-reduction repsel.js 2>&1 | grep -iE 'Float64ToInt32|CheckBounds|NumberMultiply|Checked|TruncateFloat64' | head -40
그래서 arr[t*0]를 실제로 돌리면 OOB가 아니라 undefined 가 나온다. 런타임에 cvttsd2siq(NaN)=INT64_MIN(거대한 음수)이 인덱스가 되는데, CheckedUint64Bounds가 unsigned로 검사해 범위 밖으로 걸러내 안전한 undefined로 처리한다.
$ ./out/d8 --allow-natives-syntax repsel_oob.js
status before = 81
g(NaN) = undefined # OOB 아님 — 경계검사가 INT_MIN 인덱스를 걸러냄
status after = 81 # deopt도 아님 (81 = TurboFan 최적화 유지)변환(cvttsd2si)은 뚫었지만 경계검사(CheckedUint64Bounds)가 막는다. 이 "경계검사가 안 빠진다"가 두 번째이자 진짜 벽으로 이어진다.
여기서 중요한 전환. 지금까지는 %OptimizeFunctionOnNextCall + deopt 하니스로 측정했는데, 서버는 natives가 없어 자연 hot-loop로 티어업한다. 그 경로로 옮기니 거동이 달라졌다.
자연 티어업 하니스는 이렇게 생겼다. 문자열 "0"으로 달구면 Math.exp("0")=1이라 b=false(안전, a[0])이고, 동시에 문자열 인자라 콜사이트가 숫자 speculation을 안 깔아 패치 typer가 적용된다. 트리거는 "z"(→ Math.exp("z")=NaN). 그리고 globalThis.ob = tb처럼 비교와 배열 접근 사이에 부수효과(store) 를 끼워 b가 상수로 폴딩되는 걸 막는다 — 이렇게 해야 어긋남이 인덱스까지 살아 도달한다(이걸 안 끼우면 %Optimize 때처럼 b가 0으로 폴딩돼 a[0]로 죽는다).
배열 길이만 8과 4로 바꿔 같은 가젯을 돌린 게 이 문제의 핵심을 한 화면에 보여준다.
function make(len){
let e = Array.from({length:len}, (_,i)=>(i+1.1)).join(',');
return new Function('x', `
let a=[${e}];
let tb=[9.9,8.8,7.7];
let o={mz:NaN};
let b=Object.is(Math.exp(x), o.mz); // typer: false / 런타임: true
globalThis.ob=tb; // store 로 b 를 live 유지(폴딩 방지)
return a[b*7];`); // typer: a[0] / 런타임: a[7]
}
let f8 = make(8); for(let i=0;i<
$ ./out/d8 --allow-natives-syntax trap_poc.js
len 8 : a[0](b=false)=1.1 trigger a[b*7] = 8.1 (런타임 인덱스 7 → a[7]=8.1)
len 4 : a[0](b=false)=1.1 trigger →
$ echo $?
133 # 133 = 128 + 5 = SIGTRAP이 두 줄이 전부를 말한다.
b*7을 Range(0,0)(=a[0])이라 믿었지만, 런타임은 b=true → 7이라 a[7]=8.1 을 돌려준다. 어긋남이 인덱스까지 살아 도달했다는 직접 증거다(8은 길이 8 안이라 무사).< 4" 증명을 깨는 순간, wild read가 아니라 SIGTRAP으로 즉사한다.배열 길이 하나만 바꿨는데 한쪽은 OOB 값을 멀쩡히 반환하고 한쪽은 트랩이다 — 트랩의 원인이 경계검사라는 걸 그대로 분리해 보여준다.
![클릭하여 확대 자연 티어업 — len 8 은 어긋난 인덱스 7로 a[7]=8.1 반환, len 4 는 같은 인덱스가 OOB라 SIGTRAP](/images/blog/dreamhack-exp-nan-writeup/07_hardening_trap.png)
최적화된 코드를 디스어셈블하면 트랩의 정체가 보인다. CheckBounds가 사라지지 않고 남아 있고, 범위 밖 분기는 값을 돌려주는 대신 abort 시퀀스로 빠진다.
$ ./out/d8 --allow-natives-syntax --print-opt-code dis.js
0x..c25c cmpl [rbp-0x28],0x4 ; 인덱스 vs length(4) 검사 ← 안 빠지고 남음
0x..c260 jnc 0x..c371 <+0x331> ; 범위 밖이면 점프 →
0x..c26e vmovsd xmm1,[r8+r9*8+0x7] ; (in-bounds 경로) a[index] 로드
...
0x..c371 call [r13+0x48] ; OOB 분기 = abort 루틴 (deopt 아님 → 트랩)
0x..c423 cc ; int3 ← 함수에 박힌 unreachable 마커typer가 "인덱스는 Range(0,0)이라 항상 < 4"라고 추측했기 때문에, TurboFan은 검사를 통째로 빼는 대신 OOB 분기를 abort 경로로 컴파일했다(call [r13+...] → int3). 우리 버그가 런타임에 그 추측을 깨면 deopt로 안전하게 빠지는 게 아니라 그 자리에서 abort(SIGTRAP) 한다. --trace-deopt를 걸어도 트리거 시점엔 deopt 로그가 안 찍힌다 — 정상 deopt가 아니라는 뜻이다.

kAbortOnOutOfBounds이 트랩은 V8 11.2가 typer-혼동 익스를 막으려고 SimplifiedLowering에 무조건 박아둔 하드닝이다(이 commit 6538a20a엔 이걸 끄는 플래그조차 없다). CheckBounds를 낮출 때, typer가 인덱스를 in-bounds로 증명하면 검사를 없애는 게 아니라 abort 플래그를 붙인다.
// src/compiler/simplified-lowering.cc — VisitCheckBounds
if (index_type.Min() >= 0.0 && index_type.Max() < length_type.Min()) {
// The bounds check is redundant if we already know that
// the index is within the bounds of [0.0, length[.
new_flags |= CheckBoundsFlag::kAbortOnOutOfBounds; // ← 제거가 아니라 abort
}낮춤 단계는 이렇게 이어진다.
SimplifiedLowering : CheckBounds → CheckedUint32Bounds[kAbortOnOutOfBounds]
EffectControlLinearization: → Uint32LessThan(idx, len) ? LoadElement : UnreachablekAbortOnOutOfBounds가 붙으면 OOB 분기가 DeoptimizeIfNot(안전 deopt)이 아니라 Unreachable(=abort)이 된다. 옛 V8은 증명된 인덱스의 CheckBounds를 통째로 제거(검사 없는 silent OOB)했지만, 11.2는 제거 대신 abort 가드를 남긴다. 그래서 런타임이 그 증명을 깨면 wild read가 아니라 그 자리에서 즉사(SIGTRAP) 다. 앞 디스어셈블리의 jnc → call abort → int3가 정확히 이 Unreachable이다.
핵심은 — 우리 버그는 어긋남을 인덱스까지 보내는 데 성공했지만, 그게 하필 11.2가 정조준해 막은 형태(typer 증명 기반 검사 제거) 라는 것. 어긋남이 인덱스에 정확히 도달할수록 abort에 더 정확히 걸린다. (이후 V8은 이 하드닝을 turbo_typer_hardening이라는 READONLY 플래그로 토글 가능하게 바꿨다 — 뒤의 HTB 대조 참고.)
이 하드닝의 공개 우회는 OOB-load-mode다. 배열 접근이 인터프리터 단계에서 범위 밖 인덱스를 본 적이 있으면, IC가 그 슬롯을 "OOB 허용 로드"로 기록하고, TurboFan은 abort하는 CheckBounds 대신 NumberLessThan(idx,len) + (실패 시 undefined) 패턴으로 컴파일한다. typer가 idx < len을 증명하면 이 NumberLessThan이 폴딩돼 사라지고 — doar-e는 거기서 검사 없는 OOB를 얻었다.
그런데 11.2의 element-access 빌더 소스를 보면, 그 NumberLessThan 안쪽에 또 하나의 CheckBounds[kAbortOnOutOfBounds] 를 박아뒀다. 주석이 의도를 그대로 적어놨다.
// src/compiler/js-native-context-specialization.cc — LOAD_IGNORE_OUT_OF_BOUNDS
Node* check = NumberLessThan(index, length); // ← doar-e 가 폴딩으로 지우려는 검사
// ... if_true 분기 안:
// Do a real bounds check against {length}. This is in order to
// protect against a potential typer bug leading to the elimination of
// the NumberLessThan above.
index = CheckBounds(kConvertStringAndMinusZero | kAbortOnOutOfBounds, index, length, ...);즉 NumberLessThan을 typer로 지워도 그 뒤 abort하는 CheckBounds가 또 막는다 — 주석이 "a potential typer bug leading to the elimination of the NumberLessThan"이라고 doar-e식 우회를 콕 집어 무력화한다고 적어놨다.
게다가 우리 버그는 그 우회를 시도할 자격조차 못 갖춘다. 두 조건이 서로 배타적이라서다.
b=true인 호출이 필요하다(인덱스 = b*K = K ≥ length).NumberLessThan이 폴딩되려면 인덱스가 Range(0,0)으로 증명돼야 한다 → Object.is가 항상 false인 피드백(즉 b=false만)이 필요하다.b=true를 한 번이라도 관측하면 Object.is 피드백이 Boolean이 돼 인덱스가 Range(0,K)로 넓어지고, 그러면 NumberLessThan이 폴딩되지 않아 그냥 undefined. 반대로 b=false만 주면 OOB-load-mode가 안 켜져 CheckBounds[abort] → 트랩. doar-e의 버그(lastIndexOf)는 결과를 정적으로 작은 범위로 오타입해서 피드백과 무관하게 Range(0,0)이 나왔지만, Math.exp는 결과를 PlainNumber(넓은 타입)로 오타입할 뿐이라 인덱스를 작게 만드는 건 위의 피드백 추측에 의존하고, 그게 OOB-load-mode와 충돌한다.
직접 측정한 결과도 정확히 이 충돌을 보여준다.
| 하니스 | bounds 검사 | 트리거 결과 |
|---|---|---|
자연 "0"만 (b=false) | CheckBounds[abort] (단일) | SIGTRAP |
자연 "z"만 / "0"+"z" 혼합 | NumberLessThan (이중) | undefined (폴딩 안 됨) |
어느 쪽도 wild OOB가 아니다. 인덱스가 작다고 증명되면 트랩, OOB-mode가 켜지면 안 좁혀짐 — 설령 좁혀지더라도 위에서 본 안쪽 CheckBounds[abort]가 또 막는다.
이게 단순히 "어렵다"가 아니라 빌드/버전의 차이라는 걸 보여주는 대조군이 있다. HTB GCSB 2026의 한 TurboFan typer 문제(ToNumber/Receiver 오타입)는 같은 종류의 어긋남을 쓰는데, 그 V8 버전엔 이 하드닝을 끄는 플래그가 생겼고 출제자가 빌드에서 그걸 꺼버렸다.
- DEFINE_BOOL_READONLY(turbo_typer_hardening, true, ...)
+ DEFINE_BOOL_READONLY(turbo_typer_hardening, false, ...) // 하드닝을 끔
v8_enable_sandbox = false // 샌드박스도 끔하드닝을 꺼서 typer-OOB가 그냥 통하게 만들어 둔 것이다. 우리 EXP-NaN은 11.2라 그 플래그 자체가 아직 없고(하드닝이 코드에 무조건 박혀 있다) v8_enable_sandbox=true까지 켜진 빌드라, 같은 가젯이 트랩으로 끝난다 — 그래서 다이아1이다.
"인덱스 말고 다른 어긋남이 없나" 해서 Math.min/max도 재봤다. 흥미로운 게 하나 나왔다. typer가 "NaN 아님"을 믿으면 Math.max/Math.min의 NaN 보정을 생략하고 bare maxsd/minsd로 낮추는데, x86 maxsd(0, NaN)은 (스펙상 Math.max(0,NaN)=NaN과 달리) 피연산자 하나를 그대로 돌려준다.
Math.max(0,t) OPT=0 SPEC=NaN DIVERGE # maxsd(0, NaN) = 0
Math.min(t,5) OPT=5 SPEC=NaN DIVERGE # minsd(NaN, 5) = 5진짜 어긋남(최적화 결과 ≠ 스펙)이긴 한데, 런타임 값이 항상 typer 범위 안이다. 여러 연산을 같은 deopt 하니스로 한꺼번에 재본 결과다(t = Math.exp(NaN)).
| 연산 | 최적화 결과(OPT) | 스펙(NaN 처리) | 어긋남? | 런타임이 typer 범위 밖? |
|---|---|---|---|---|
Math.round(t) / floor / ceil / trunc | NaN | NaN | — | — |
Math.sign(t) | 0 | NaN | 어긋남 | 아니오 (0 ∈ {-1,0,1}) |
t|0, ~~t, t>>>0 | 0 | 0 | — | — |
Math.max(0, t) | 0 | NaN | 어긋남 | 아니오 (0 ∈ [0,∞)) |
Math.min(t, 5) | 5 | NaN | 어긋남 | 아니오 (5 ∈ (-∞,5]) |
Math.min(Math.max(0,t), 3) | 0 | NaN | 어긋남 | 아니오 (0 ∈ [0,3]) |
Math.max(Math.min(t,3), 0) | 3 | NaN | 어긋남 | 아니오 (3 ∈ [0,3]) |
|
../extracted/deploy/out/d8 --allow-natives-syntax probe_minmax.js
Math.max(0,t)는 typer [0,∞)·런타임 0, Math.min(t,5)는 typer (-∞,5]·런타임 5. 클램프형은 전부 "typer 범위에 들어있는 끝값"이라, OOB 인덱스(런타임이 typer 범위 밖)로도, Array(len) 길이 불일치로도 키울 수가 없다. 양쪽을 다 클램프하면 NaN이 소모돼 어긋남이 사라지고, 한쪽만 하면 typer 범위가 무한대로 열린다. 표 마지막 줄처럼 "런타임이 typer 범위를 벗어나는" 어긋남은 불리언뿐인데, 그건 위에서 본 대로 (값으로 쓰면) 폴딩되거나 (인덱스로 쓰면) 트랩이다.
지금까지의 모든 경로를 한 표로 모았다. 같은 버그(typer가 Math.exp를 NaN 없는 PlainNumber로 믿음)를 두고, 어긋남을 OOB로 키우려는 각 시도가 정확히 어느 메커니즘에 막혔는지다.
| # | 시도 | 어긋남이 런타임에 도달? | 막은 메커니즘 | 결과 |
|---|---|---|---|---|
| 1 | arr[t], arr[t&3], arr[t%4] | — | ToInt32(NaN)=0 / 이름 조회 | a[0] 또는 undefined |
| 2 | arr[min(max(t,0),3)] | — | NaN 클램프 | a[3] (in-bounds) |
| 3 | (t===t)?0:8 등 분기/폴딩 | 아니오 | ConstantFolding (self-consistent) | 상수, 어긋남 소멸 |
| 4 | Object.is(t, NaN리터럴)*K | 아니오 | SameValue 조기 폴딩 → 상수 false | a[0] |
| 5 | Object.is(t, {s:NaN}.s)*K | 부분 | SameValue가 으로 남음(재타입 X) |
7번에서 처음으로 어긋남이 인덱스까지 온전히 도달했지만, 그게 바로 turbo_typer_hardening이 노리는 형태라 트랩으로 끝났다. 8번(우회)은 이 버그의 "넓은 오타입" 성질 때문에 조건이 충돌한다. 9·10번은 어긋남이 OOB로 쓸 수 있는 형태로 안 나온다.
여기까지가 "공개된 정석으로는 막힌다"의 전모다. 측정으로 확정한 것들:
Math.exp가 PlainNumber로 오타입되고, speculation을 꺼면 f(NaN)=111로 타입혼동이 잡힌다.Object.is 불리언은 리터럴 센티넬이면 조기 폴딩(self-consistent), 객체 필드 센티넬이면 SameValue가 Boolean으로 남아 안 좁혀진다 — 11.2는 abiondo가 기댄 늦은 재타입을 안 해준다.b*7 → 7), turbo_typer_hardening 이 OOB 분기를 int3로 막아 wild OOB 대신 SIGTRAP.NumberLessThan 폴딩)는, Math.exp가 넓은 타입을 오타입하는 탓에 "OOB-mode를 켜는 조건"과 "인덱스를 Range(0,0)로 만드는 조건"이 상호배타라 그대로는 안 붙는다.정리하면, 이 문제의 진짜 난이도는 버그가 아니라 그 버그가 만든 어긋남이 하필 turbo_typer_hardening이 정확히 노리는 형태(typer-증명 기반 CheckBounds 제거) 라는 데 있다. 공개 자료의 익스(abiondo·doar-e·Quick-Maffs)는 전부 하드닝/샌드박스가 꺼진 구버전 V8 기준이라 그대로는 옮겨지지 않는다.
2편 — 소스 부검. 그래서 다음 편은 이 벽을 V8 11.2 소스로 직접 부검한다. CheckBounds가 기계어가 되는 경로(simplified-lowering.cc → effect-control-linearizer.cc), typer가 인덱스를 in-bounds로 증명하면 검사를 제거가 아니라 abort로 바꾸는 무조건 하드닝(kAbortOnOutOfBounds), 그리고 doar-e식 OOB-load-mode 우회를 소스 주석으로 콕 집어 막아둔 element-access 빌더의 두 번째 CheckBounds까지 — 줄 번호째로 짚는다. 끝으로 같은 시기 하드닝 켜진 V8을 실제로 뚫은 익스(구글 v8CTF·openECSC)가 왜 값-range typer 버그를 버리고 length/element-kind를 직접 손상시키는 다른 버그 클래스로 갈아탔는지 대조한다. 결론부터 말하면 — 이 값-range 버그는 표준 OOB 경로로는 11.2에서 죽은 클래스다.
turbo_typer_hardening 같은 심층 방어가 실제로 일한다. 같은 버그·같은 가젯이 하드닝이 꺼진 빌드(HTB)에선 바로 OOB가 되고, 켜진 빌드(이 문제)에선 트랩으로 끊긴다. typer 버그가 100% 막히지 않더라도, "typer 증명 기반 검사 제거"라는 특정 익스 형태를 비싸게 만드는 것만으로 공격 난이도가 크게 오른다.backing_store를 케이지에 묶고 WASM 코드를 trusted space로 분리한 일련의 하드닝이, 정확히 이런 익스 체인의 마지막 단계를 차단하기 위한 것이다(2편에서 다룬다).이 글을 쓰며 대조한 자료들. typer 버그 익스의 정석(구버전)부터, 그 정석을 막은 하드닝, 그리고 하드닝 켜진 최신 V8을 뚫은 다른 버그 클래스까지.
src/compiler/{simplified-lowering,effect-control-linearizer,js-native-context-specialization}.cc (commit 6538a20a). kAbortOnOutOfBounds와 doar-e 우회 방어 주석의 1차 출처.SameValue가 SimplifiedLowering에서 false로 재타입되는 타이밍(이 빌드에선 안 일어남).NumberLessThan 폴딩 우회(11.2가 콕 집어 패치).CheckBounds → CheckedUint32Bounds → Uint32LessThan/Unreachable 낮춤 흐름.MaybeGrowFastElements/NumberLessThan 우회.·샌드박스를 끄고 출제한 대조군.이미지 출처 — IEEE-754 double: Codekaizen, Wikimedia Commons (CC BY-SA 4.0). V8 실행 파이프라인·sea-of-nodes: V8 공식 블로그(v8.dev). 나머지 흐름도·터미널 캡쳐는 직접 제작/측정.
측정 결과:
| 게이트 | 정상 입력 | NaN 입력 | 해석 |
|---|---|---|---|
arr[t], arr[t%4] | undefined | undefined | 비정수/NaN → 이름 조회 |
arr[t&3] | arr[3] | arr[0] | ToInt32(NaN)=0 |
arr[min(max(t,0),3)] | arr[3] | arr[3] | NaN을 경계로 클램프 |
t|0, ~~t | 정수 | 0 | ToInt32(NaN)=0 |
어느 경로도 큰 정수 인덱스를 만들지 못했다.
| 0(폴딩) / 7(자연) |
| 7 |
| 어긋남 |
| 예 ← 불리언만 |
CheckBounds 생존 → undefined |
| 6 | arr[t*0] (cvttsd2si) | 예(INT_MIN) | CheckedUint64Bounds | undefined (걸러짐) |
| 7 | 자연 티어업 a[b*K] | 예(인덱스=K) | turbo_typer_hardening (int3) | SIGTRAP |
| 8 | OOB-load-mode + b*K | 예 | 커플링(피드백 Boolean ↔ OOB-mode) | 폴딩 안 됨 → undefined |
| 9 | Array(b*K) 길이 구성 | 아니오 | b가 값으로는 0 폴딩 | Array(0) |
| 10 | Math.max/min 어긋남 | 예 | 런타임이 typer 범위 안 | OOB 불가 |
0x8000… 변환 근거.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…