Link
Notice
Recent Posts
Recent Comments
«   2026/07   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
Archives
Today
Total
관리 메뉴

hiariz 님의 블로그

[Dice CTF 2026] garden 본문

CTF, Wargame

[Dice CTF 2026] garden

hiariz 2026. 3. 11. 10:57
 

https://ctf.dicega.ng/challenges?challenge=pwn_garden

 

ctf.dicega.ng

문제 구조

이 바이너리는 간단한 VM 인터프리터입니다. VM 오브젝트는 별도의 4GB VM heap에 올라가고, off-heap object의 실제 데이터는 calloc()으로 glibc heap에 할당됩니다.
또 VM operand stack도 realloc()으로 커지기 때문에 glibc heap 위에 존재합니다. 즉 메모리 레이어가 세 개 있습니다.

  • VM heap
  • glibc heap
  • native stack

처음에는 이 셋이 잘 분리된 것처럼 보이지만, 실제 취약점은 이 경계를 무너뜨리는 데에 있습니다.

핵심 버그

가장 중요한 코드는 allocator 쪽입니다.

typedef uint32_t ref_t;

ref_t alloc_off_heap(size_t obj_size) {
    size_t total_size = sizeof(off_heap_obj_t) + obj_size * sizeof(uint32_t);
    ...
    ref_t arr_ref = (ref_t)heap_used;
    off_heap_obj_t *obj = (off_heap_obj_t *)(heap_base + arr_ref);
    ...
    heap_used += total_size;
    return arr_ref;
}

 

여기서 heap_used는 64비트 size_t인데, VM이 관리하는 참조값 ref_t는 32비트입니다. 즉 아주 큰 off-heap allocation을 만들면 내부적으로는 4GB를 넘어가도 반환되는 ref는 하위 32비트만 남습니다.

 

이게 왜 문제냐면, VM heap 자체는 아예 4GB 영역으로 잡혀 있으므로 ref가 wrap되면 다음 object가 VM heap 초반부에 다시 배치될 수 있습니다. 이미 존재하던 object 메타데이터 위에 새 allocation이 겹쳐질 수 있다는 뜻입니다.

 

그리고 off-heap object의 read/write는 메타데이터를 그대로 신뢰합니다.

case GET_OFFHEAP_NUM: {
    ...
    if (idx >= arr->obj_size) {
        printf("GET_OFFHEAP_NUM index out of bounds\n");
        exit(1);
    }
    ...
}

case SET_OFFHEAP_NUM: {
    ...
    if (idx >= arr->obj_size) {
        printf("SET_OFFHEAP_NUM index out of bounds\n");
        exit(1);
    }
    ...
}

 

즉 overlap을 통해 obj_sizedata를 망가뜨릴 수만 있으면, 이 VM instruction이 그대로 glibc heap primitive가 됩니다.

이 문제가 사실상 전부입니다.

1단계: 작은 off-heap object를 glibc heap OOB primitive로 바꾸기

먼저 아주 작은 off-heap object 하나를 만들었습니다. 글에서는 이걸 O1이라고 부르겠습니다. 원격 기준 이 object의 ref는 136이었습니다.

이후 크기가 매우 큰 off-heap allocation을 하나 더 만들면 ref wrap이 일어납니다. 실제로 안정적으로 동작했던 크기는 다음 값이었습니다.

0x3ffffff6

이 allocation 뒤에 오는 VM allocation이 O1의 메타데이터와 겹치도록 만들 수 있습니다. 중요한 건 O1 전체를 날리는 게 아니라 O1.obj_size의 상위 dword를 덮는 것입니다.

 

그렇게 되면 O1.data는 원래의 작은 glibc chunk를 가리키고 있는데, O1.obj_size만 비정상적으로 커집니다. 이후 GET_OFFHEAP_NUM(O1, idx)SET_OFFHEAP_NUM(O1, idx)는 사실상 glibc heap에 대한 forward OOB read/write가
됩니다.

 

여기서 처음으로 “walled garden” 바깥으로 나갈 수 있게 됩니다.

2단계: stack realloc을 이용해 libc leak 만들기

다음으로 본 건 VM stack입니다. push()를 보면 stack이 꽉 찼을 때 realloc()으로 크기를 두 배로 늘립니다.

void push(ref_t val) {
    if (sp >= stack_size) {
        stack_size *= 2;
        stack = realloc(stack, stack_size * sizeof(ref_t));
    }
    stack[sp++] = val;
}

DUP를 많이 하면 단순히 VM stack depth만 커지는 게 아니라, glibc heap layout도 함께 바뀝니다.

원격에서 안정적으로 먹힌 시퀀스는 아래와 같았습니다.

  1. O1 생성
  2. 첫 번째 wrap 유도
  3. DUP 1100회
  4. 크기 0x1000짜리 실제 off-heap object 하나 생성 후 정리
  5. 다시 DUP 1000회

이 과정을 거치면 realloc()으로 이동한 예전 stack chunk 중 하나가 unsorted bin에 들어가고, O1의 OOB primitive를 통해 그 freed chunk 안의 unsorted bin 포인터를 읽을 수 있습니다.

실제로 유용했던 인덱스는 다음이었습니다.

O1[8] = leak low
O1[9] = leak high

 

이 값으로 libc 주소를 복원할 수 있습니다.

여기서 한 번 삽질을 했습니다. 처음에는 main_arena 관련 오프셋을 잘못 잡아서 계산한 libc base가 페이지 정렬이 전혀 맞지 않았고, 이후 environ을 읽으려고 하면 계속 0만 나왔습니다.

 

결국 leak 근처에서 가능한 후보를 직접 확인해 보면서 다시 맞췄고, 제공된 Ubuntu 2.39 libc에서 실제로 맞는 값은 다음이었습니다.

MAIN_ARENA_LEAK_OFFSET = 0x2041c0


따라서 libc base 계산은 아래처럼 하면 됩니다.

libc_base = unsorted_leak - 0x2041c0

 

이 값을 고친 뒤부터 익스플로잇이 한 번에 이어졌습니다.

3단계: 두 번째 fake off-heap object 만들기

O1만으로도 어느 정도 읽고 쓸 수는 있지만, 본격적으로 exploit을 짜기에는 조금 불편합니다. 그래서 두 번째 overlap을 이용해 metadata를 직접 제어할 수 있는 새 off-heap object를 만들었습니다.

여기서 사용한 두 번째 wrap용 크기는 다음 값입니다.

0x3fffeff3

 

이후 작은 numeric array가 다음 off-heap object 메타데이터 위에 정확히 올라가도록 배치할 수 있습니다.

원격에서 안정적으로 맞았던 ref는 다음과 같았습니다.

O1_REF     = 136
O2_REF     = 172
CTRL_REF   = 176
MASTER_REF = 200

핵심은 CTRL_REFO2 메타데이터 내부 필드와 맞물린다는 점입니다. 그래서 CTRL numeric array를 통해 O2의 중요한 필드를 직접 조작할 수 있습니다.

  • CTRL[0] -> O2.data 하위 32비트
  • CTRL[1] -> O2.data 상위 32비트
  • CTRL[2] -> O2.obj_size 하위 32비트

결과적으로 O2는 임의 주소를 32비트 단위로 읽고 쓰는 객체가 됩니다.

이 시점부터는 exploit이 훨씬 단순해집니다.

4단계: live stack 바닥을 덮어서 VM 제어권 되찾기

glibc heap primitive를 얻었다고 해도, 이후에 O1, O2, CTRL 참조를 안정적으로 들고 다닐 방법이 필요합니다. 여기서 다시 O1 OOB write를 사용해 live stack buffer 자체를 덮었습니다.

stack growth가 끝난 뒤 원격에서 live stack의 바닥이 보이던 위치는 다음이었습니다.

O1_STACK_BASE_IDX = 0x1810

 

여기에 아래 네 개의 raw ref를 직접 써 넣습니다.

[136, 172, 176, 200]

그 다음 수천 번의 DROP으로 중간의 더미 stack entry를 전부 버리면, 결국 VM stack은 아래 형태만 남게 됩니다.

[O1, O2, CTRL, MASTER]

이후 MASTER object array 안에 O1, O2, CTRL을 저장해 두면, 이후 helper 루틴으로 참조를 안정적으로 꺼내 쓸 수 있습니다.

이 단계가 exploit 전체를 스크립트화하는 데 가장 중요했습니다.

5단계: environ 읽고 saved RIP 찾기

이제 libc base를 알고 있으니 다음 목표는 environ입니다.

제공된 libc 기준 주요 오프셋은 아래와 같습니다.

environ = 0x20ad58
system  = 0x58750
"/bin/sh" = 0x1cb42f

CTRLO2.datalibc_base + environ으로 맞춘 뒤, O2를 이용해 *environ 값을 읽으면 현재 stack 주소를 얻을 수 있습니다.

원격 환경에서 실제로 필요했던 saved return address 위치는 다음과 같았습니다.

saved_rip = environ_value - 0x130

 

이 값은 환경과 시작 인자 배치에 영향을 받기 때문에, 원격 libc와 실제 원격 실행 환경을 기준으로 맞추는 것이 중요했습니다.

6단계: saved RIP에 ROP 체인 쓰기

마지막은 비교적 정직합니다. O2가 이미 arbitrary read/write가 되었으므로 saved_rip를 가리키게 만든 뒤 libc ROP 체인을 써 주면 됩니다.

사용한 체인은 아래와 같습니다.

ret
pop rdi ; ret
"/bin/sh"
system

인터프리터가 끝나고 main이 return할 때 이 체인으로 흐름이 넘어가면서 쉘이 뜹니다.
그 다음 solver에서 다음 명령을 보내 플래그를 읽었습니다.

cat flag.txt

최종 결과는 다음과 같습니다.

dice{m1tig4ti0ns_nev3r_w0rk_y0u_escap3d_th3_w4ll3d_g4Rd3n}

 

'CTF, Wargame' 카테고리의 다른 글

[Plaid CTF 2024] maglev  (0) 2026.03.16
[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