"V8이 너무 느리다, V12로 갈아타자 (* 내부 함수 없이 *)" — array-shift Torque 빌트인에서 빈 배열 가드 한 줄을 주석 처리한 게 전부인 버그다. 길이 0 배열에 .shift()를 부르면 newLength = 0-1 = -1, 그게 그대로 length 필드로 들어가 length(0xFFFFFFFF) ≫ capacity 인 OOB 배열이 만들어진다. 이 글은 그 OOB에서 케이지 임의 R/W·addrof를 세우고 — 한 번은 "V8 샌드박스가 절대주소 누출을 막아 코드 실행이 불가능하다"고 잘못 결론 내렸다가 — 출제자가 의도한 정석 기법을 찾아 뒤집은 기록이다. 핵심은 WasmInstanceObject의 imported_function_targets: 임포트 함수의 호출 타깃을 담은 "케이지 안 ByteArray"가 실은 raw(샌드박스 밖) 포인터라, 케이지 쓰기만으로 import 호출을 임의 주소로 보낼 수 있다. 거기에 jump_table_start로 RWX를 직접 읽고, 셸코드 실행 순간 살아있는 r14(케이지 베이스)로 wasm 메모리를 가리켜 execve("flag_reader") — 절대주소 누출 없이 샌드박스를 빠져나와 라이브 flag까지 회수했다.
문제: DreamHack — V12 Revenge
분류: Pwnable (Browser / V8 JIT)
난이도: 💠 Platinum 1
설명: I think V8 is too slow. I want to shift to V12!! (* without internal functions... *)
설명이 두 가지를 흘린다. "shift" 는 말장난이 아니라 진짜 버그가 있는 빌트인이고, "without internal functions" 는 채점 서버가 %-natives 없이(자연 티어업만) 페이로드를 돌린다는 제약이다. "V12"는 그냥 살짝 망가뜨린 V8이다.
이 글은 그 한 줄짜리 버그에서 OOB → 케이지 임의 R/W → addrof → wasm RWX 셸코드 → execve → flag 까지 간 기록이다. 솔직히 중간에 한 번 크게 헤맸다 — 케이지 R/W까지 다 세워놓고 "V8 샌드박스가 절대주소 누출을 막아서 코드 실행은 구조적으로 불가능"이라고 결론 내렸었다. 그게 틀렸다. 출제자가 의도한 길은 따로 있었고, 그 길을 찾아 뒤집은 과정까지 그대로 남긴다.
🧭 30초 용어 정리
V8 / d8: 크롬의 자바스크립트 엔진(V8)과 그 CLI 셸(d8). 이 문제는 패치된 d8 바이너리가 대상.
Torque (.tq): V8이 빌트인(Array.prototype.shift 등)을 작성하는 내부 타입드 언어. 버그가 여기 있다.
JSArray length vs capacity: JSArray는 헤더에 length(보이는 길이)를, 별도 백킹(FixedDoubleArray)에 실제 원소를 둔다. 정상이면 length ≤ capacity. 둘이 어긋나면() 백킹 밖을 읽고 쓰는 이 된다.
원래 fast-shift는 빈 배열이면 곧장 Undefined를 반환하고 끝난다. 패치는 그 가드를 주석 처리했다. 그래서 길이 0 배열에 .shift()를 부르면:
newLength = array.length - 1 = 0 - 1 = -1
witness.ChangeLength(newLength) → length 필드에 -1을 그대로 기록
그 다음 MoveElements(0, 1, -1)은 음수 길이로 크래시하니, 패치가 친절하게 if (newLength < 0) return result;로 그 줄만 건너뛴다 — 하지만 length는 이미 -1로 바뀐 뒤다.
두 번째 변경은 d8 락다운이다. read/load/os.system/Realm 같은 파일·OS 접근을 전부 주석 처리한다. flag를 파일로 읽을 길이 없으니, 이 문제는 순수 메모리 손상으로 코드 실행을 잡아 execve("./flag_reader-…") 를 직접 부르는 것만 의도한다.
💥 트리거 — 길이를 -1로
가장 짧은 트리거: 백킹이 있는 배열을 길이 0까지 줄인 뒤 한 번 더 shift.
let a = [1.1, 2.2, 3.3, 4.4];a.length = 1; // 백킹(cap 4)은 두고 길이만 1a.shift(); // 1 -> 0 (정상 경로)let r = a.shift();// 0 -> BUG: newLength=-1, ChangeLength(-1)// 이제 a.length == -1, a[8]/a[20] 은 백킹 밖
길이 0 배열 .shift() → a.length = -1, a[8]/a[20]이 인접 힙 더블 누출
a.length가 -1로 찍히고, a[8]·a[20]이 백킹 스토어(cap 4) 밖의 인접 힙 더블을 읽어온다.
왜 -1이 거대 길이가 되나
ChangeLength(-1)은 length 필드에 -1을 쓴다. JSArray length는 부호 있는 정수로 저장되지만 경계검사는 인덱스를 부호 없는 32비트로 length와 비교한다 — -1은 부호 없이 보면 0xFFFFFFFF(약 42억). 그래서 a[7], a[100], a[1000]이 전부 "length 안"으로 통과하고, 백킹 FixedDoubleArray(cap 4)의 끝을 넘어 인접 힙을 읽고 쓴다. FixedDoubleArray는 더블을 raw로 담으니 a[i]는 인접 객체의 비트를 double로 보고(addrof 방향), a[i] = v는 그 자리에 double 비트를 쓴다. EXP-NaN의 typer 버그가 무가드 read 하나로 끝났던 것과 달리, 여기선 읽기와 쓰기가 한 배열에서 동시에 나온다 — 훨씬 강한 출발점이다.
🧰 프리미티브 — OOB에서 임의 R/W·addrof로
상대 OOB 하나에서 표준 프리미티브를 세운다.
기준 잡기: OOB 배열 oob 뒤에 float 배열 rwf와 객체 배열 oa를 인접 배치하고, rwf의 첫 원소에 고유 마커를 박아 oob에서 그 인덱스 M을 찾는다.
케이지 AAR/AAW: oob로 rwf의 elements 포인터를 임의 압축주소 U로 바꿔치기하면 rwf[0] 읽기/쓰기가 곧 케이지 주소 U의 read/write가 된다. FixedDoubleArray 원소 = elements + 8(헤더) - 1(태그) 이라 elements = U - 7로 세팅.
function setRWF(U){ oob[RWF_EP] = lohi2f((U-7)>>>0, 0x1000); } // rwf.elements = U-7function r64(U){ setRWF(U); return f2i(rwf[0]); } // 케이지 U 읽기function w64(U,v){ setRWF(U); rwf[0] = i2f(v); } // 케이지 U 쓰기
addrof: oa[0] = victim 후, oob에서 oa.elements[0]이 보이는 슬롯을 읽으면 victim의 압축 주소가 나온다.
프리미티브 self-check — addrof OK, 케이지 AAR(map round-trip) OK
핫패스는 전부 32비트 절반(Smi-only)으로 다뤄 임계구간에 HeapNumber 할당이 안 뜨게 한다 — v8_enable_verify_heap이 켜진 빌드라, 손상된 배열 상태에서 GC/heap-verify가 돌면 즉시 트랩이기 때문이다. (이 함정은 뒤에서 한 번 더 발을 건다.)
🔬 호출 체인 부검 — 코드 실행으로 가려면 어디를 건드려야 하나
flag는 execve로만 읽히니 결국 임의 코드 실행이 필요하다. V8에서 가장 흔한 코드 실행 경로는 WASM이다 — wasm 함수는 JIT되어 RWX 메모리에 올라가니까. 그 RWX로 점프하게 만들면 된다.
먼저 wasm 함수 호출이 실제로 어디로 가는지 gdb로 따라갔다(빌드에 _v8_internal_Print_Object 심볼이 살아 있어 객체를 라벨째 찍을 수 있다). 체인은 JSFunction(+0xc) → SharedFunctionInfo(+0x4) → WasmExportedFunctionData(+0x4) → WasmInternalFunction:
call target: 0x3229b0cc4000 — RWX 진입점이다. 그럼 이 필드를 케이지 AAW로 덮으면 끝일까? 여기서 한참 헤맸다.
▶🥲 삽질 — "샌드박스가 막아서 불가능하다"고 잘못 결론 낸 길
코드 실행으로 가려는 직관적인 길은 전부 11.2 샌드박스가 막아 놨다. 하나씩 실측으로 닫혔다.
① jump_table_start(인스턴스+0x60) 는 호출이 안 읽는다. RWX 점프 테이블 베이스라 매력적이지만, 케이지 AAW로 완전히 엉뚱한 값(0x4141414141)으로 덮어도 main()은 멀쩡히 2를 반환한다 — 호출 경로가 이 필드가 아니라 캐시된 타깃을 쓴다.
extracted/deploy/x64.release/d8 work/jt_test2.js
jump_table_start@0x60을 wild로 덮어도 main()=2 — 호출은 이 필드를 안 읽음
② call_target은 code-pointer-table 핸들이다. 위 gdb가 "call target: 0x32..(RWX)"로 보여줬지만, 실제 객체에 저장된 값(+0x10)은 작은 핸들(예 0x9a0)이다. 11.2 샌드박스는 코드 진입점을 케이지 밖 테이블로 간접화하고 객체엔 인덱스만 둔다. 핸들을 바꿔도 다른 함수의 시작점으로 갈 뿐, 셸코드 immediate 중간으로 진입할 수가 없다.
③ memory_start는 인코딩 범위에 갇힌다. wasm 메모리 베이스는 (인스턴스[0x20] >> 24) + r14로 인코딩돼 있어(movq rcx,[rsi+0x1f]; shrq rcx,24; addq rcx,r14), store는 [r14, r14+2^40) 만 쓸 수 있는데 RWX는 그 너머에 있다.
④ 그래서 — "절대주소를 못 누출한다" 고 단정했다. 케이지 안에 저장된 포인터는 전부 압축 32비트라 절대값이 아니고, 절대 포인터(isolate·RWX)는 전부 케이지 밖을 가리켜 케이지/샌드박스 절대주소가 아니다. r14 자체를 못 얻으니 어떤 절대 타깃도 계산할 수 없다 — 고 결론 냈다.
이 결론이 틀렸다. 위 ①~④는 전부 "함수 호출 타깃을 바꿔서 점프시킨다"는 한 가지 발상에 묶여 있었고, 결정적으로 — 나는 import가 없는 최소 wasm 모듈만 만들어 테스트하고 있었다. 그래서 다음 절의 표면을 아예 못 봤다.
🔑 돌파 — imported_function_targets
막혔다 싶을 때 출제자 의도를 다시 봤다. 같은 계열(Theori가 정리한 in-the-wild V8 샌드박스 탈출)의 핵심은 WasmInstanceObject의 raw 포인터 필드다. wasm 모듈이 JS 함수를 임포트하면, 인스턴스에 imported_function_targets라는 ByteArray가 생긴다 — 임포트 함수들의 호출 타깃(raw 코드 포인터) 을 담는 배열이다.
핵심은 이거다: 이 ByteArray는 케이지 안에 있는데, 그 안의 엔트리는 샌드박스 밖을 가리키는 raw 64비트 포인터다. 즉 케이지 쓰기만으로 import 호출의 점프 목적지를 임의 주소로 바꿀 수 있다. code-pointer-table 같은 간접화가 없다 — 옛날 방식의 날 포인터가 그대로 노출돼 있다.
imported_function_targets(인스턴스+0x18)는 ByteArray[8] = 임포트 1개의 raw 콜타깃 8바이트. 그 엔트리(ByteArray + 8)를 케이지 AAW로 덮고, import를 호출하는 export(g = call import0)를 부르면 — RIP가 내가 쓴 값으로 간다.
imported_function_targets[0]을 0x4141414141로 덮고 g() 호출 → SEGV at 0x4141414141 (RIP 완전 제어)
imported_function_targets[0] 을 0x4141414141로 바꾸고 g()를 부르니 정확히 그 주소에서 SEGV — RIP 완전 제어. 그리고 RWX 주소는 jump_table_start(인스턴스+0x60)로 그냥 읽으면 된다. 절대주소 누출은 처음부터 필요 없었다. ②~④의 벽은 "핸들/인코딩을 우회한다"는 길에서만 벽이었고, 이 raw 포인터 앞에서는 의미가 없었다.
d8 stderr만으로는 미덥지 않으니 gdb로도 박아 두자. 프리미티브 도출 뒤 imported_function_targets[0]을 0x4141414141로 덮고 g()까지 가는 PoC를 gdb로 띄우면, SIGSEGV 순간 rip = 0x4141414141 이 레지스터에 그대로 찍히고 백트레이스 프레임 #0도 그 주소다.
gdb로 v12 익스 실행 — import 콜타깃을 0x4141414141로 덮고 호출, SIGSEGV 시 rip 레지스터가 정확히 0x4141414141 (RIP 완전 제어)
V8 샌드박스 — 객체 포인터는 케이지 안 32비트 오프셋. 하지만 wasm 인스턴스의 일부 raw 필드는 케이지 밖을 직접 가리킨다
출처: v8.dev/blog/sandbox (Google V8 공식 블로그)
🐚 wasm RWX에 셸코드 심기
RIP는 잡았다. 이제 점프할 셸코드를 RWX에 올려야 한다. cage AAW로는 RWX(케이지 밖)에 직접 못 쓰지만, V8이 대신 써준다 — wasm 함수의 i64.const 상수가 컴파일되면 RWX에 그대로 박힌다.
Liftoff가 i64.const X를 어떻게 내는지 디스어셈블해 보면:
; func "s": (i64.const A) drop (i64.const B) drop ...0x..1b 48b8 4c89f7909090eb02 movq rax, 0x02eb909090f7894c ; 48 B8 + 8바이트 값0x..25 48b8 b93800000090eb02 movq rax, 0x02eb9000000038b9 ; 10바이트 간격0x..2f 48b8 48c1e11c9090eb02 movq rax, 0x02eb90901ce1c148
프롤로그(0x1b)를 지나면 각 i64.const는 48 B8 <8바이트> = movabs rax, 값이다. 그 8바이트 값을 내가 통제한다. 정상 호출이면 그냥 mov rax(무해)지만, imported_function_targets[0]을 그 8바이트 값의 주소(48 B8 다음)로 정확히 보내면 — 값이 코드로 실행된다.
각 청크는 6바이트 셸코드 + eb 02(다음 청크 값으로 점프, 사이의 48 B8 스킵) 로 인코딩한다. 그렇게 이어 붙인 execve 셸코드:
여기서 결정적인 한 수: 셸코드가 실행되는 순간 r14(케이지 베이스)가 그대로 레지스터에 있다. wasm 선형 메모리는 r14 + 0x380000000이니, path 문자열(flag_reader-…)을 JS에서 Memory.buffer에 써두면 셸코드가 r14로 그 주소를 계산해 execve — 절대주소 누출 없이 깔끔하게 끝난다.
s()를 꼭 호출해야 한다. wasm 함수는 lazy 컴파일이라, s()를 안 부르면 jt+0x800엔 셸코드가 아니라 컴파일 스텁이 있다. 정상 호출(s())은 청크가 mov rax 오퍼랜드로 들어가 무해하면서 컴파일만 시킨다.
arrow 함수(=>)를 쓰면 안 된다. 헬퍼를 arrow로 짰더니 손상된 oob가 살아있는 상태에서 GC가 돌아 verify_heap CHECK로 죽었다. function 선언으로 바꾸니 할당이 줄어 깔끔히 통과했다. ((x)>>>0 + 8 이 (x)>>>(0+8)로 파싱되는 연산자 우선순위 함정도 한 번.)
🚩 로컬 검증 → 라이브 flag
flag_reader는 setreuid(geteuid(), geteuid()); system("/bin/bash") 다 — execve 하면 권한이 유지된 셸이 뜬다. 로컬(deploy/의 flag_reader + flag = DH{Test})에서 풀체인을 돌리면:
그런데 imported_function_targets / jump_table_start 같은 wasm 인스턴스의 raw 포인터 필드는 케이지 안에 그대로 노출돼 있었다. 케이지 R/W를 쥔 공격자에게 이건 그냥 열린 문이다. 절대주소 누출이 필요 없었던 이유가 바로 이것.
이후 V8은 이 표면도 닫아 나간다(wasm 콜 타깃의 trusted/code-pointer-table 전면화). 즉 이 문제는 샌드박스 하드닝이 진행 중이던 시점의 빈틈을 정확히 찌르는 교육용 표적이다.
내 입장에서의 교훈은 더 단순하다 — "구조적으로 불가능"이라는 결론을 너무 빨리 내렸다. 벽이라고 본 ①~④는 전부 한 가지 발상(호출 타깃 우회)에 묶인 벽이었고, 표면 하나(import 모듈)를 안 열어봤을 뿐이었다. 막혔다고 느낄 때 내가 테스트한 범위가 좁은 건 아닌지 부터 의심해야 한다.
면책: 인가된 CTF 풀이 기록이다. 익스는 출제자가 제공한 로컬/대회 서버의 패치된 d8 안에서만 동작하고 외부 대상이 없다. V8 11.2.214.14 한정.
📚 참고
V8 소스 — src/builtins/array-shift.tq(버그), WasmInstanceObject(imported_function_targets·jump_table_start), WasmExportedFunctionData/WasmInternalFunction(호출 체인)
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…