2026-06-07·1분 읽기·
1편에서 타입혼동을 인덱스까지 살려 보냈지만 거기서 트랩으로 끊겼다. 2편은 "왜 트랩인가"를 V8 11.2 소스로 부검한다. CheckBounds가 기계어가 되는 경로, typer가 인덱스를 in-bounds로 증명하면 검사를 제거가 아니라 abort로 바꾸는 무조건 하드닝(kAbortOnOutOfBounds), 그리고 doar-e식 우회를 정조준해 막아둔 element-access 빌더의 두 번째 CheckBounds까지 — 소스 주석째로 들고 본다. 끝으로, 같은 시기 하드닝 켜진 V8을 실제로 뚫은 익스(구글 v8CTF·openECSC)가 왜 값-range typer 버그가 아니라 length/element-kind를 직접 손상시키는 다른 버그 클래스를 쓰는지 대조한다.
이 글이 도움이 됐나요?
문제: DreamHack — EXP-NaN (Dreamhack CTF Season 4 Round #5, 🚩Div1 출제작) 분류: Pwnable (Browser / V8 JIT) 난이도: 💎 Diamond 1 이 글: 시리즈 2편 — 소스 부검. (1편: 버그·트리거와 하드닝의 벽)
1편에서 Math.exp 한 줄 패치로 타입혼동을 트리거했고, 자연 티어업에서 그 어긋남을 배열 인덱스까지 살려 보내는 데 성공했다. 그런데 인덱스가 범위를 벗어나는 순간, wild OOB가 아니라 int3 트랩(SIGTRAP) 으로 끊겼다.
이 2편은 그 트랩을 V8 11.2 소스로 부검한다. 왜 11.2에선 typer를 속여 CheckBounds를 지우는 정석(abiondo·doar-e·Quick-Maffs)이 통째로 무력화되는지 — 그게 단순히 "어렵다"가 아니라 소스에 박힌 무조건 하드닝과, doar-e식 우회를 콕 집어 막은 두 번째 검사 때문임을 줄 번호째로 짚는다. 그리고 같은 시기 하드닝이 켜진 V8을 실제로 뚫은 익스들이 왜 값-range typer 버그를 버리고 다른 버그 클래스로 갈아탔는지 대조하며 끝낸다.
소스는 챌린지
patches/README.md가 지정한 빌드 기준 — V8 commit6538a20a(11.2.214.14). 인용한 줄 번호는 그 commit의 것이다.
1편 결론을 한 줄로: 버그·트리거는 살아 있고, 어긋남은 인덱스까지 도달하는데, 거기서 트랩이다.
핵심 가젯은 이거였다 — b = Object.is(Math.exp(x), NaN)은 typer엔 false(=0), 런타임엔 true(=1). 비교와 배열 접근 사이에 store를 끼워 b를 폴딩 못 하게 살려두면, a[b*K]의 인덱스가 typer엔 0, 런타임엔 K로 갈린다. 배열 길이만 8과 4로 바꿔 돌리면 한 화면에 다 나온다.
$ ./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 # 128 + 5 = SIGTRAPlen 8은 인덱스 7이 길이 8 안이라 a[7]=8.1을 무사히 돌려준다 — 어긋남이 런타임 인덱스까지 살아 도달했다는 직접 증거(typer는 0이라 믿었다). len 4는 똑같은 인덱스 7이 이번엔 범위 밖이고, 그 순간 즉사한다. 배열 길이 하나만 바꿨는데 결과가 갈리니, 트랩의 원인이 경계검사라는 건 분명하다. 남은 질문은 하나 — 왜 wild read가 아니라 트랩인가?
부검 전에 하나 짚고 가자. Math.exp 반환타입을 PlainNumber로 바꾼 패치는 typer.cc의 JSCallTyper에 있다 — 즉 Math.exp가 JSCall 노드로 남아 있을 때만 탄다. 그런데 평범하게 Math.exp(x)를 호출하고 hot해지면, JSCallReducer::ReduceMathUnary가 그걸 NumberExp라는 저수준 노드로 낮춰버린다. NumberExp의 타입은 OperationTyper가 매기는데 거긴 패치가 없어 정직하게 NaN을 포함한다 — 그래서 패치된 typer가 타지를 않는다. 버그가 있는데도 평범하게 짜면 재현이 안 되는 이유다.
ReduceMathUnary는 콜사이트 speculation이 꺼져 있으면(kDisallowSpeculation) 낮추기를 포기하고 Math.exp를 JSCall로 둔다. 그제서야 패치된 JSCallTyper → PlainNumber가 적용돼 버그가 깨어난다. speculation은 한 번 deopt가 나면 그 콜사이트에서 꺼지므로 — 또는 처음부터 숫자 아닌 인자(문자열)로 부르면 숫자 speculation이 안 깔리므로 — 둘 중 하나로 끄면 된다. 1편 트리거(f(NaN)=111)가 그 "deopt로 speculation off → JSCall 유지 → PlainNumber" 경로고, 서버용 자연 티어업 버전은 문자열 인자(Math.exp("z")=NaN)로 같은 효과를 낸다. 요컨대 이 버그는 기본 경로에선 잠들어 있고, speculation을 꺼야 비로소 깨어난다.
arr[i]의 경계검사는 TurboFan 그래프에서 CheckBounds 노드 하나로 시작해, 여러 단계를 거쳐 기계어가 된다. 우리 부검에 필요한 세 단계만 추리면 이렇다.
SimplifiedLowering : CheckBounds → CheckedUint32Bounds(flags)
EffectControlLinearization : CheckedUint32Bounds → Uint32LessThan(idx, len)
→ 비교 결과에 따라 LoadElement 또는 (deopt | Unreachable)여기서 운명을 가르는 건 CheckedUint32Bounds에 붙는 flags 다. 그 플래그에 kAbortOnOutOfBounds가 있느냐 없느냐가, 범위를 벗어났을 때 "안전하게 deopt"할지 "그 자리에서 abort(트랩)"할지를 정한다. 그리고 1편에서 봤듯 옛 V8엔 셋째 선택지 — 검사를 통째로 제거(= 검사 없는 wild OOB) — 가 있었다. 11.2는 그 셋째 선택지를 없앴다.
simplified-lowering.ccCheckBounds를 낮추는 VisitCheckBounds의 핵심은 이 한 조각이다.
// src/compiler/simplified-lowering.cc — VisitCheckBounds (요약)
Type const index_type = TypeOf(node->InputAt(0));
Type const length_type = TypeOf(node->InputAt(1));
...
if (lower<T>()) {
if (index_type.IsNone() || length_type.IsNone() ||
(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;
읽는 법: typer가 index.Max() < length.Min() — 즉 "인덱스는 항상 길이보다 작다"를 증명하면, 옛날엔 이 CheckBounds를 지웠다(redundant니까). 11.2는 지우는 대신 kAbortOnOutOfBounds 플래그를 붙인다. 이게 전부다. 그리고 이 빌드(commit 6538a20a)엔 이 동작을 끄는 플래그 자체가 없다 — 코드에 무조건 박혀 있다.
우리 가젯이 정확히 이 함정에 걸린다. 자연 티어업에서 b는 피드백상 항상 false라 typer가 b*K를 Range(0,0)으로 좁힌다 → index.Max()=0 < length.Min()=4 → 플래그 ON. typer는 "이 인덱스는 절대 4 미만"이라 증명했고, 런타임이 그 증명을 깨는 순간이 곧 abort다.
한 가지 곁가지 — 위 분기는 length_type.Is(Type::Unsigned31())일 때만이다(일반 JSArray는 길이가 2³¹ 미만이라 여기 해당). length가 그보다 큰 경우(예: TypedArray, 길이 최대 2⁵³)는 else 가지로 빠져 CheckedUint64Bounds가 되는데, 그쪽엔 abort 플래그를 안 붙인다. 즉 TypedArray OOB는 트랩이 아니라 deopt(안전 복귀)다. 솔깃하지만 결국 wild는 아니다 — deopt도 인터프리터로 빠져 undefined를 줄 뿐이고, 게다가 TypedArray는 backing이 분리돼 있어 OOB가 다른 객체로 넘어가지도 않는다. 어느 분기든 "검사 없는 로드"는 없다.
Unreachable의 정체 — effect-control-linearizer.cc그 abort가 실제로 무슨 기계어가 되는지는 다음 단계에 있다.
// src/compiler/effect-control-linearizer.cc — LowerCheckedUint32Bounds
Node* check = __ Uint32LessThan(index, limit);
if (!(params.flags() & CheckBoundsFlag::kAbortOnOutOfBounds)) {
__ DeoptimizeIfNot(DeoptimizeReason::kOutOfBounds, ..., check, frame_state); // 안전 deopt
} else {
auto if_abort = __ MakeDeferredLabel();
auto done = __ MakeLabel();
__ Branch(check, &done, &if_abort);
__ Bind(&if_abort);
__ Unreachable(); // ← abort. 범위 밖이면 여기로.
__ Bind(&done);
}
return index;플래그가 없으면 DeoptimizeIfNot — 범위를 벗어나면 deopt(인터프리터로 안전 복귀, undefined 반환). 플래그가 있으면 Branch → Unreachable() — 범위를 벗어나면 죽는다. 이 Unreachable이 1편 디스어셈블리에서 본 jnc → call [r13+…] → int3다. deopt가 아니라 트랩인 이유가 여기 있다. typer가 "불가능"이라고 약속한 분기를 컴파일러가 도달 불가로 굳혀버렸으니, 그 약속이 깨지면 갈 곳이 int3뿐이다.
정리하면 — typer가 인덱스를 in-bounds로 증명할수록 코드는 더 위험해진다. 증명이 곧 abort 가드를 부르고, 우리 버그는 바로 그 증명을 거짓으로 만든다.
여기가 이 문제에서 가장 약 오르는 부분이다. 자연 티어업으로 컴파일한 가젯을 디스어셈블해보면, abiondo가 OOB를 얻으려고 필요로 했던 조건을 이 빌드가 다 만들어준다. 그런데도 트랩이다.
$ ./out/d8 --allow-natives-syntax --print-opt-code dis.js
...
0x.. setzl al ; b = Object.is(...) ← 런타임에 *계산*된다 (상수 아님)
0x.. imull rcx, rax, 0x14 ; rcx = b * 20 ← 곱셈 노드가 살아있다
0x.. cmpl [rbp-0x28], 0x4 ; idx vs length(4)
0x.. jnc <+0x331> ; 범위 밖이면 → abort 시퀀스(call → int3)
0x.. vmovsd xmm1,[r8+rcx*8+0x7] ; a[idx] 로드모순처럼 보인다 — b가 setzl로 런타임에 계산되고(상수 0으로 폴딩됐으면 이 명령 자체가 없다), b*20도 imull로 살아있다(곱셈 노드 생존). 즉 벽 1을 통과했다: 어긋남이 죽지 않고 인덱스까지 흐른다. 동시에 cmpl idx, 0x4에 abort가 붙었다는 건, typer가 idx를 Range(0,0)(= 항상 < 4)으로 좁혔다는 뜻이다.
이게 정확히 abiondo가 원했던 그림이다 — "런타임엔 계산되는데 typer는 작다고 믿는 인덱스". 옛 V8이었다면 그 "typer가 작다고 믿음"이 곧 CheckBounds 제거였고, 런타임 b=1 → idx=20이 검사 없이 통과해 wild OOB가 됐을 것이다. 11.2는 그 "믿음"을 검사 제거가 아니라 abort 가드로 바꿨다. 그래서 같은 그림이 wild OOB 대신 즉사다.
약 오르는 건 — 어긋남을 인덱스까지 살려보내는 어려운 부분은 다 됐는데, 마지막 한 칸(검사 제거 ↔ abort)에서 막힌다는 점이다. 1편에서 객체 필드 센티넬이 Boolean으로 남아 못 좁혀졌던 것과 달리, 자연 티어업은 피드백("b는 항상 false")으로 b를 Range(0,0)까지 좁히면서도 노드를 살려둔다. abiondo·doar-e 시대였다면 여기서 게임이 끝났다. 11.2에선 여기가 시작이고 동시에 끝이다.
js-native-context-specialization.cc여기까지는 "표준 경계검사" 이야기다. 그런데 doar-e가 2019년에 보여준 우회가 있다 — OOB-load-mode. 배열 접근이 인터프리터 단계에서 범위 밖 인덱스를 본 적이 있으면, IC가 그 슬롯을 "OOB 허용 로드"로 기록한다. 그러면 TurboFan은 abort하는 CheckBounds 대신 NumberLessThan(idx, len) + (실패 시 undefined) 패턴으로 컴파일한다. 그리고 이 NumberLessThan은 순수 비교 노드라, typer가 idx < len을 증명하면 ConstantFoldingReducer가 통째로 지워버린다 — abort 가드 없이. doar-e는 거기서 검사 없는 OOB를 얻었다.
11.2의 element-access 빌더 소스를 열어보면, 그 NumberLessThan 안쪽에 두 번째 CheckBounds가 박혀 있다. 그리고 주석이 의도를 토씨 하나 안 숨기고 적어놨다.
// src/compiler/js-native-context-specialization.cc — LOAD_IGNORE_OUT_OF_BOUNDS
Node* check = graph()->NewNode(simplified()->NumberLessThan(), index, length);
Node* branch = graph()->NewNode(common()->Branch(BranchHint::kTrue), check, control);
Node* if_true = graph()->NewNode(common()->IfTrue(), branch);
{
// 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 = etrue = graph()->NewNode(
simplified()->CheckBounds(
FeedbackSource(), CheckBoundsFlag::kConvertStringAndMinusZero |
CheckBoundsFlag::kAbortOnOutOfBounds), // ← 또 abort
NumberLessThan을 typer로 지워도, if_true 분기 안에서 kAbortOnOutOfBounds가 붙은 두 번째 CheckBounds 가 또 막는다. 주석이 그대로 — "protect against a potential typer bug leading to the elimination of the NumberLessThan above" — "NumberLessThan을 지우는 typer 버그"를 콕 집어 방어한다고 선언한다. doar-e식 우회를 정조준한 무덤이다. 그리고 이 똑같은 주석+패턴이 jnscs.cc에 네 번(load 3243, store/grow 3662·3742·3822) 박혀 있다 — load든 store든 grow든, OOB 허용 모드를 쓰는 모든 element-access 경로에 같은 방어를 깔아둔 것이다.
게다가 우리 버그는 이 우회를 시도할 자격조차 못 갖춘다. OOB-load-mode를 켜려면 훈련 중 범위 밖 접근을 봐야 하고(= b=true 호출 필요), 그러면 Object.is 피드백이 Boolean이 돼 인덱스가 Range(0,K)로 넓어진다 → NumberLessThan이 폴딩 안 됨. 반대로 b=false만 주면 OOB-mode가 안 켜져 첫 번째 CheckBounds[abort]. 둘이 상호배타다. doar-e의 lastIndexOf 버그는 결과를 정적으로 작은 범위로 오타입해서 피드백과 무관하게 Range(0,0)이 나왔지만, Math.exp는 결과를 넓은 PlainNumber로 오타입할 뿐이라 인덱스를 작게 만드는 게 피드백 추측에 의존하고, 그게 OOB-mode와 충돌한다.
![클릭하여 확대 V8 11.2 소스(jnscs.cc) — doar-e식 NumberLessThan 폴딩 우회를 콕 집어 막은 두 번째 CheckBounds[kAbortOnOutOfBounds]. load·store·grow 경로마다 같은 주석+패턴이 박혀 있다](/images/blog/dreamhack-exp-nan-writeup-2/01_doare_patch.png)
"인덱스가 안 되면 다른 sink는?" 싶어 가능한 출구를 하나씩 다 두드려봤다. 같은 어긋남(typer는 작다 믿고 런타임은 큼/NaN)을 어디에 꽂아도 막힌다. 각 출구가 어느 코드에 막히는지까지 적는다.
| 출구 | 결과 | 막은 메커니즘 (소스) |
|---|---|---|
a[b*K] 인덱스 로드/스토어 | SIGTRAP | kAbortOnOutOfBounds (simplified-lowering.cc) |
OOB-load-mode + b*K | undefined / 트랩 | 안쪽 두 번째 CheckBounds[abort] (jnscs.cc) |
Array(b*K) 길이 구성 | 실제 4096칸 할당 | b*K가 상수 노드 아님 → 런타임값으로 정상 생성 |
arr.length = b*K | backing 실제 grow(FixedDoubleArray[4096]) | 런타임값으로 grow, 길이/용량 불일치 없음 |
store-and-grow a[b*K]=v | SIGTRAP | MaybeGrowFastElements 하드닝 |
t*0 → cvttsd2si(NaN)=INT_MIN | undefined | CheckedUint64Bounds (음수→걸러짐) |
Math.max(0,t)/Math.min(t,5) |
마지막 줄이 흥미롭다. Project Zero의 "Chrome Infinity Bug"는 induction 변수가 -Inf + Inf로 큰 유한값이 되며 typer 범위를 벗어나 OOB가 됐다. 같은 트릭을 Math.exp(NaN)으로 시도하면 — NaN은 산술에서 NaN으로 전파되니 — induction 변수가 그냥 NaN이 된다.
$ ./out/d8 --allow-natives-syntax induction.js
for(i<1;i+=exp) ret i => NaN # i += NaN → NaN, 루프 즉시 탈출
while(i<exp)i++ ret i => 0 # NaN > 0 false → 루프 0회
arr[ind] OOB? => undefined # NaN 인덱스 → 이름 조회
Array(ind*4096) => RangeError # Array(NaN) 예외이게 이 버그의 본질적 한계다 — NaN은 끈적하다. 산술에 넣으면 NaN으로 전파되고(→ ToInt32=0 / Array=예외), 비교에 넣으면 불리언이 되는데 그건 폴딩되거나(상수) 인덱스로 쓰면 트랩이다. 큰 유한 정수 인덱스로 자라는 길이 없다.

표의 각 줄은 추측이 아니라 로컬 d8 측정이다. 막힌 방식이 출구마다 미묘하게 달라서, 그 차이가 결국 "값-range는 어디로도 안 자란다"를 가리켰다.
Array(b*K) — 불일치 없음. typer가 b*K를 Range(0,0)으로 믿으니 size-0 할당 + 런타임 length로 불일치(Bug-1051017식)를 노렸다. 그런데 b*K는 상수 노드가 아니라 계산 노드라, JSCreateArray가 인라인 고정 할당을 안 하고 런타임값으로 생성자를 호출한다.
$ ./out/d8 --allow-natives-syntax len_live.js
Array(b*4096).length [TF] => len=4096
Array(b*4096) a[500] oob? [TF] => len=4096 a[500]=undef # 진짜 4096칸, 불일치 Xarr.length = b*K — backing이 실제로 큰다. typer가 length-set을 "0으로 줄임(shrink)"으로 믿어 grow를 생략하길(→ length만 거대, capacity 작음) 기대했다. %DebugPrint로 보면 backing이 그냥 4096칸으로 자란다.
$ ./out/d8 --allow-natives-syntax shrink_probe.js
a.length = 4096
DebugPrint: [JSArray]
- elements: <FixedDoubleArray[4096]> [HOLEY_DOUBLE_ELEMENTS] # capacity 도 4096 — 불일치 Xstore-and-grow a[b*K]=v — 트랩. MaybeGrowFastElements 경로도 하드닝돼 있어, 빈 배열에서 grow 훈련 후 트리거하면 SIGTRAP().
왜 이렇게 한 곳도 안 뚫리는지, 두 벽이 직교한다.
벽 1 — fold ↔ live. 어긋남이 익스에 쓸모 있으려면 ① 컴파일 타임에 상수로 폴딩되면 안 되고(폴딩되면 typer 값 = 런타임 값, self-consistent), ② 그러면서 typer가 그 타입을 좁게 믿어줘야 한다(검사 제거의 근거). 리터럴 NaN 센티넬은 ①을 어겨 즉시 폴딩되고, 객체 필드 센티넬은 ②를 못 얻어 SameValue가 Boolean으로 남는다. abiondo가 기댄 "필드에 숨기면 늦게 false로 재타입된다"는 동작이 11.2에선 일어나지 않는다(전 phase 추적으로 확인 — SameValue는 SimplifiedLowering까지 Boolean).
벽 2 — 좁힌 순간 abort. 자연 티어업은 놀랍게도 벽 1을 통과한다 — b가 폴딩 안 되고(store로 live) typer가 Range(0,0)으로 좁힌다. abiondo가 필요로 한 조건을 다 만족한다. 그런데 정확히 그 "typer가 좁혔다"가 kAbortOnOutOfBounds를 부른다(벽 2). 옛 V8에선 좁힘 = 검사 제거 = wild OOB였는데, 11.2에선 좁힘 = abort 가드다.
그래서 두 벽 사이에 통과 지점이 없다. 안 좁히면 폴딩 안 돼도 검사가 살아 deopt(undefined), 좁히면 검사가 abort. 그 사이 "검사가 통째로 사라지는" 구간을 11.2가 메워버렸다.

여기서 자연스러운 의문 — 그럼 하드닝 켜진 요즘 V8은 아무도 못 뚫나? 아니다. 다만 버그 클래스가 갈렸다. 같은 시기(하드닝+샌드박스 ON) V8을 실제로 뚫은 익스 둘을 보면 답이 보인다.
kAbortOnOutOfBounds가 발동할 일이 없다.valueOf() 부수효과로 인자 강제변환과 타입 검사 사이에 배열의 element kind를 바꾸는 type confusion. 역시 length를 직접 손상시켜 OOB 배열을 만든다.조금 더 구체적으로, v8CTF Issue 1472121의 체인이 전형적이다. clone IC 버그로 source 객체의 in-object 프로퍼티들이 target 객체의 더 작은 슬롯에 그대로 복사돼, 5~9번 프로퍼티가 프로퍼티 스토어 포인터·메타데이터와 겹친다. 이 겹침으로 인접 배열의 length 필드를 큰 값으로 덮으면 그게 곧 OOB 배열이다. 거기서부터는 정석 — OOB 배열로 옆 객체 주소를 읽어 addrof, ArrayBuffer의 (40비트 샌드박스) backing 포인터·크기를 덮어 케이지 내부 임의 R/W, 마지막으로 WASM의 RWX 영역에 ROP/셸코드를 얹어 샌드박스 탈출. 시작점만 다를 뿐 뒷부분은 우리가 1편에서 매핑한 것과 같은 길이다.
openECSC의 Array.xor는 버그 모양이 또 다르다 — TOCTOU(검사-사용 시점차). 빌트인이 인자를 숫자로 강제변환하기 전에 배열의 element kind가 PACKED_DOUBLE인지 검사하는데, 그 인자가 악의적 valueOf를 가진 객체라 강제변환 도중 arr[0] = {}로 배열을 PACKED_ELEMENTS(객체 포인터 저장)로 전이시켜 버린다. 검사는 통과했고, 정작 XOR 연산은 객체 포인터를 raw 64비트로 주무른다 → 포인터 손상 → 가짜 배열 → OOB. 여기서도 typer를 속이는 게 아니다. 검사와 실제 동작 사이의 시점차를 비집어 객체의 종류를 바꾼다. 값-range 버그가 "typer의 믿음"을 공격한다면, 이 둘은 "객체의 형태/종류"를 직접 어그러뜨린다 — kAbortOnOutOfBounds가 손댈 수 없는 지점이다.
핵심은 이거다 — 이 익스들은 bounds 검사를 우회하지 않는다. 검사가 보는 length 자체를 거짓으로 만든다. typer를 속여 "작은 인덱스"를 만드는 게 아니라, 버그로 length 필드를 직접 키워 인덱스가 진짜로 in-bounds가 되게 한다. kAbortOnOutOfBounds는 "인덱스 ≥ length"일 때만 트랩이니, length를 키워두면 발동하지 않는다. 즉 11.2의 하드닝은 "typer 증명으로 검사를 없애는" 형태만 정조준하지, "검사가 참조하는 length를 손상시키는" 형태는 손대지 않는다 — 손댈 수가 없다(그건 정상 코드의 정상 동작이니까).
그런데 length/element-kind를 직접 손상시키려면 OOB 쓰기나 element-kind 혼동 같은 초기 프리미티브가 필요하다. Math.exp 버그가 주는 건 값-range 어긋남(typer는 작다 믿고 런타임 큼)뿐 — 이건 정확히 kAbortOnOutOfBounds가 정조준해 막은 클래스고, length를 직접 건드릴 손잡이가 없다. 그래서 이 버그는 하드닝 켜진 11.2에서 이 표준 경로로는 안 자란다.
| 버그 클래스 | 예 | 하드닝 11.2에서 |
|---|---|---|
| 값-range (typer가 값의 범위를 오인) | Math.exp(이 문제), Math.expm1(-0), lastIndexOf | kAbortOnOutOfBounds에 죽음 — 좁힌 인덱스 = abort |
| layout / element-kind / IC (객체 형태·종류를 오인) | v8CTF clone, openECSC Array.xor | 살아있음 — length/capacity를 직접 손상 → in-bounds 접근 |

같은 버그·같은 가젯이라도 어느 V8이냐가 운명을 가른다. kAbortOnOutOfBounds 자체는 11.2 소스에 무조건 박혀 있지만, 이후 V8은 이걸 turbo_typer_hardening이라는 READONLY 플래그로 토글 가능하게 바꿨다. HTB GCSB 2026의 한 TurboFan typer 문제는 — 같은 값-range 어긋남을 쓰는데 — 출제자가 그 플래그를 꺼버리고(turbo_typer_hardening=false) 샌드박스까지 끈(v8_enable_sandbox=false) 빌드로 냈다. 그러니 거기선 옛 정석이 그대로 통한다.
우리 EXP-NaN은 11.2라 그 플래그 자체가 아직 없고(코드에 무조건), v8_enable_sandbox=true까지 켜진 빌드다. 같은 부류의 버그가, 한쪽 대회에선 "교육용 워밍업"이고 다른 쪽(이 문제)에선 "다이아1"인 이유가 정확히 이 빌드 설정 차이다.
이 버그는 abiondo가 2019년에 터뜨린 Math.expm1(-0) 버그와 정확히 같은 클래스다 — "typer가 어떤 빌트인의 출력에서 특수값(그쪽은 -0, 이쪽은 NaN)을 빠뜨려, Object.is(빌트인, 특수값)이 typer엔 false·런타임엔 true인 불리언이 된다." 가젯의 골격이 똑같다.

그런데 NaN은 -0보다 한 겹 더 갑갑하다. -0은 산술에서 사실상 0처럼 흐른다(-0 + x = x, ToInt32(-0)=0) — 즉 "거의 0인 유한값"이라, 불리언으로 안 가도 값 자체를 어떻게든 흘려볼 여지가 (옛 빌드에선) 있었다. 반면 NaN은 모든 산술에서 NaN으로 전파된다. NaN + x = NaN, NaN * 0 = NaN, Math.max(NaN, k)도 (보정 없으면) NaN, 루프에서 i += NaN도 NaN. 그리고 그 NaN을 정수로 떨구면 ToInt32(NaN)=0(작아짐) 아니면 cvttsd2si(NaN)=INT_MIN(음수→걸러짐), 비교에 넣으면 항상 false(폴딩). 큰 유한 정수로 자라는 길이 산술 어디에도 없다.
그래서 NaN 버그는 사실상 불리언 가젯 하나에 운명을 건다. min/max·induction·length-construction 같은 "값으로 흘리는" 우회가 1편·2편 내내 전멸한 게 우연이 아니다 — NaN의 끈적함이 그 길들을 원천봉쇄한다. 그리고 그 유일한 불리언 가젯이 11.2의 kAbortOnOutOfBounds에 정조준돼 막혔다. 출제자가 -0이 아니라 NaN을, 그것도 하드닝 켜진 11.2에 올린 선택은 — 고전 가젯이 왜 안 통하는지 소스까지 내려가 부딪혀보라는, 꽤 의도적인 난이도 설계로 읽힌다.
이 부검의 결론은 "첫 OOB가 안 열린다"지만, 만약 열린다면 그 뒤는 11.2 기준으로 이미 정석이 잡혀 있다(1편에서 매핑한 후반부). 무게중심이 어디 쏠려 있는지 보이게 기록해둔다.
ArrayBuffer의 backing_store(JSArrayBuffer+0x14)를 손상시켜 그 위 DataView/TypedArray로 4GB 케이지 내부를 임의로 읽고 쓴다. (11.2는 backing_store가 caged sandboxed pointer라 이것만으론 케이지를 못 벗어난다.)WasmInstanceObject.jump_table_start(instance+~0x60) — 케이지 밖 RWX WASM 코드 페이지를 가리키는 raw 64비트 포인터다(11.2엔 아직 raw; WASM trusted-space 분리는 2024/~v8 12.5). 케이지 내부 R/W는 케이지 밖 RWX에 직접 못 닿으니, jump_table_start를 export 함수 본문의 mov [rax],rdx(48 89 10) Liftoff 가젯 주소로 덮고, 시그니처 미검증 export 호출로 인자를 rax/rdx에 실어 케이지 밖에 8바이트씩 셸코드를 흘려넣은 뒤, jump_table_start를 복구하고 export(main)를 호출해 실행한다. (체인: JSFunction → SFI(+0x0C) → WasmExportedFunctionData(+0x08) → instance(+0x10).)execve("./flag_reader-<md5>")(상대경로, 서버 cwd=). d8엔 // 등 입출력이 다 제거됐으니, 이 setuid 바이너리를 실행하는 것만이 flag로 가는 길이다.왜 4번(WASM 탈출)이 따로 필요한지는 V8 샌드박스 구조를 보면 한눈에 들어온다. 샌드박스는 V8 힙을 하나의 큰 영역(케이지)에 가두고, 그 안의 객체는 서로를 오프셋으로만 가리키게 한다. 케이지 밖 객체(예: ArrayBuffer의 실제 backing)는 직접 포인터가 아니라 포인터 테이블의 인덱스로만 닿는다. 그래서 케이지 안에서 아무리 임의 R/W가 있어도, 그것만으론 케이지 밖 진짜 메모리를 건드릴 수 없다 — 별도의 탈출구가 필요한 이유다.

11.2에서 이 케이지를 벗어나는 유일한 틈이 jump_table_start였다 — 케이지 밖 RWX 페이지를 가리키는, 아직 보호받지 않는 raw 포인터. (2024년 WASM trusted-space 분리로 그 틈마저 닫혔다.)
정말 1번(첫 OOB)만 열리면 2~5번은 막히는 데 없이 흐른다. 그래서 이 문제의 무게중심은 전적으로 "첫 OOB를 어떻게 여느냐" 에 쏠려 있고, 그게 이 부검의 주제였다 — 그리고 11.2의 답은 "표준 값-range 경로로는 안 연다"였다.
이 부검의 결론은 명료하다. Math.exp의 NaN 오타입 같은 값-range typer 버그는, 하드닝이 켜진 V8 11.2에서 표준 OOB 경로로는 죽은 클래스다.
CheckBounds를 제거 → 검사 없는 wild OOB. abiondo·doar-e·Quick-Maffs가 산 시대.JIT 컴파일러에서 타입은 "힌트"가 아니라 안전 검사를 제거하는 근거다. 그 제거를 "제거 대신 abort"로 바꾼 한 줄(new_flags |= kAbortOnOutOfBounds)이, typer-혼동 익스의 한 세대를 통째로 닫았다. 정확히 그 한 줄을 줄 번호로 짚어내는 것 — 그게 이 다이아1 문제의 진짜 학습 포인트라고 본다. 100% 막는 게 아니라(layout 클래스는 살아있다), "typer 증명 기반 검사 제거"라는 특정 익스 형태를 비싸게 만드는 best-effort 하드닝이, 한 버그류의 공격 난이도를 워밍업에서 다이아1로 끌어올린다.
버그를 못 뚫었다고 빈손은 아니다. "왜 안 뚫리나"를 줄 번호로 답할 수 있다는 것 자체가, 이 빌드의 방어가 어디에 어떻게 쳐져 있는지의 지도다. 그 지도를 다음 절에 정리한다.
--assert-types로 런타임 타입 단언을 켜는 옵션도 있다). 이 버그는 메모리 관리 실수가 아니라 컴파일러가 한 값에 대해 가진 믿음의 오류였다 — Math.exp의 반환에서 NaN 한 칸을 뺀 것뿐. JIT에서 타입은 "힌트"가 아니라 안전 검사를 제거하는 근거라, 실제보다 좁은 타입은 그대로 메모리 안전성 구멍이 된다.kAbortOnOutOfBounds는 typer 버그를 100% 막진 못한다(layout/element-kind 클래스는 살아있다). 하지만 "typer 증명 기반 검사 제거"라는 특정 익스 형태를 비싸게 만드는 것만으로, 같은 부류 버그의 난이도를 워밍업(하드닝 off 빌드)에서 다이아1(이 문제)로 끌어올린다. best-effort 하드닝의 가치가 정확히 이 격차다.backing_store가 케이지에 묶여 ArrayBuffer로는 못 나가지만 jump_table_start가 아직 raw RWX라 WASM으로 탈출이 가능했다. 2024년 WASM trusted-space 분리(~v8 12.5) 이후엔 그 마지막 손잡이도 닫혔다. 익스를 짤 땐 그 버전의 메모리 레이아웃을 정확히 확인해야 하고, 방어자 입장에선 한 겹(typer)이 뚫려도 다음 겹(bounds 하드닝 → 샌드박스 → trusted-space)이 비용을 계속 올리도록 쌓는 게 핵심이다.kAbortOnOutOfBounds로 사실상 사장됐지만, layout/element-kind/IC/TOCTOU 버그는 length를 직접 손상시켜 살아있다. 퍼징·리뷰 자원을 후자(객체 형태·전이·clone·valueOf 재진입)에 더 쏟는 게 합리적이다.src/compiler/{simplified-lowering,effect-control-linearizer,js-native-context-specialization}.cc. 인용한 kAbortOnOutOfBounds·doar-e 패치 주석의 1차 출처.SameValue 재타입 타이밍.turbo_typer_hardening·샌드박스를 끄고 출제한 대조군.이미지 출처 — V8 샌드박스: V8 공식 블로그(v8.dev/blog/sandbox). 부동소수점 구성: Wikimedia Commons (CC BY-SA 3.0). 흐름도·터미널 캡쳐는 직접 제작/측정.
다음 — 3편(예정). 이 부검은 "표준 값-range 경로는 11.2에서 닫혔다"까지다. 첫 OOB를 여는 그 비표준 한 수를 찾으면, 위 후반부 체인을 그대로 태워 라이브 flag까지 가는 3편으로 잇는다.
| divergence 진짜지만 런타임∈typer범위 |
| OOB/length로 못 키움 |
루프 induction var i+=Math.exp | i = NaN | NaN sticky — 큰 유한 인덱스가 안 나옴 |
exit 133t*0 → cvttsd2si(NaN) = INT_MIN. float→int 변환에서 NaN 보정 없는 vcvttsd2si가 emit돼 INT_MIN(거대 음수)이 나오지만, CheckedUint64Bounds가 unsigned로 걸러 undefined.
Math.max/min — 어긋남은 진짜인데 무용. typer가 no-NaN을 믿어 maxsd/minsd의 NaN 보정을 생략하므로, Math.max(0,t)가 스펙(NaN)과 달리 0을 돌려준다(진짜 miscompile). 하지만 런타임 값이 항상 typer 범위 안이라(0 ∈ [0,∞), 5 ∈ (-∞,5]) OOB로도 length로도 못 키운다.
$ ./out/d8 --allow-natives-syntax probe_minmax.js
Math.max(0,t) OPT=0 SPEC=NaN DIVERGE # maxsd(0,NaN)=0, 그러나 0∈[0,∞)
Math.min(t,5) OPT=5 SPEC=NaN DIVERGE # minsd(NaN,5)=5, 그러나 5∈(-∞,5]여덟 출구의 공통 결론 — 어긋남이 값으로 쓰이면 폴딩되거나(상수) 런타임이 typer 범위 안이고, 인덱스로 쓰이면 abort다. 그 사이가 없다.
/home/pwnreadloados
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…