01 Stack Canary란?
스택 카나리는 함수의 프롤로그에서 스택 버퍼와 반환 주소 사이에 임의의 값을 삽입하고, 함수의 에필로그에서 해당 값의 변조를 확인하는 보호 기법으로, 카나리 값의 변조가 확인되면 프로세스는 강제로 종료된다.
02 Canary의 작동 원리
(1) 카나리 정적 분석
📌 스택 버퍼 오버플로우 발생 예제 코드
// Name: canary.c #include <unistd.h> int main() { char buf[8]; read(0, buf, 32); return 0; }
우분투 22.04 버전의 gcc는 기본적으로 스택 카나리를 적용하여 컴파일 하기 때문에, 카나리 비활성화를 위해 -fno-stack-protector 옵션을 추가하여 카나리 없이 컴파일 할 수 있도록 진행
📌 컴파일 명령어 예제
$ gcc -o no_canary canary.c -fno-stack-protector $ ./no_canary HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH Segmentation fault
이후 코드를 실행하면 위와 같이 반환 주소가 덮여 Segmentation fault가 발생하는 것을 확인할 수 있다.
(2) 카나리 정적 분석 | 카나리 활성화
카나리를 적용하여 다시 컴파일하고, 긴 입력을 주면 Segmentation fault가 아니라 stack smashing detected와 Aborted라는 에러가 발생한다. 이는 스택 버퍼 오버플로우가 탐지되어 프로세스가 강제 종료되었음을 의미한다.
📌 컴파일 예제 화면
$ gcc -o canary canary.c $ ./canary HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH *** stack smashing detected ***: <unknown> terminated Aborted
no_canary와 디스어셈블 결과를 비교해보면 main 함수의 프롤로그와 에필로그에 다음 코드들이 추가되어 있는 것을 확인할 수 있다.
📌 추가된 코드들
0x00000000000006b2 <+8>: mov rax,QWORD PTR fs:0x28 0x00000000000006bb <+17>: mov QWORD PTR [rbp-0x8],rax 0x00000000000006bf <+21>: xor eax,eax 0x00000000000006dc <+50>: mov rcx,QWORD PTR [rbp-0x8] 0x00000000000006e0 <+54>: xor rcx,QWORD PTR fs:0x28 0x00000000000006e9 <+63>: je 0x6f0 <main+70> 0x00000000000006eb <+65>: call 0x570 <__stack_chk_fail@plt>
(3) 카나리 동적 분석
추가된 프롤로그 코드에 중단점을 설정하고 바이너리를 실행시켜보자.
📌 바이너리 실행
$ gdb -q ./canary pwndbg> break *main+8 Breakpoint 1 at 0x6b2 pwndbg> run ► 0x5555555546b2 <main+8> mov rax, qword ptr fs:[0x28] <0x5555555546aa> 0x5555555546bb <main+17> mov qword ptr [rbp - 8], rax 0x5555555546bf <main+21> xor eax, eax
- main+8은 fs:0x28의 데이터를 읽어서 rax에 저장
- fs는 세그먼트 레지스터의 일종으로, 리눅스는 프로세스가 시작될 때 fs:0x28에 랜덤 값을 저장
▶ 따라서 main+8의 결과로 rax에는 리눅스가 생성한 랜덤 값이 저장됨
📌 코드 실행 결과
pwndbg> ni 0x5555555546b2 <main+8> mov rax, qword ptr fs:[0x28] <0x5555555546aa> ► 0x5555555546bb <main+17> mov qword ptr [rbp - 8], rax 0x5555555546bf <main+21> xor eax, eax pwndbg> print /a $rax $1 = 0xf80f605895da3c00
코드를 한 줄 실행하면 rax에 다음과 같이 첫 바이트가 널 바이트인 8바이트 데이터가 저장되어 있다.
📌 코드 실행 결과
pwndbg> ni 0x5555555546b2 <main+8> mov rax, qword ptr fs:[0x28] <0x5555555546aa> 0x5555555546bb <main+17> mov qword ptr [rbp - 8], rax ► 0x5555555546bf <main+21> xor eax, eax pwndbg> x/gx $rbp-0x8 0x7fffffffe238: 0xf80f605895da3c00
생성한 랜덤값은 main+17에서 rbp-0x8에 저장되는 것을 볼 수 있다.
✅ fs
- cs, ds, es는 CPU가 사용 목적을 명시한 레지스터인 반면, fs와 gs는 목적이 정해지지 않아 운영체제가 임의로 사용할 수 있는 레지스터이다
- 리눅스는 fs를 Thread Local Storage(TLS)를 가리키는 포인터로 사용한다
- 우선 TLS에 카나리를 비롯하여 프로세스 실행에 필요한 여러 데이터가 저장된다
(4) 카나리 동적 분석 | 카나리 검사
다음으로 추가된 에필로그의 코드에 중단점을 설정하고 바이너리를 계속 실행시켜보자
main+50은 rbp-8에 저장한 카나리를 rcx로 옮긴다. 그 후, main+54에서 rcx를 fs:0x28에 저장된 카나리와 xor한다. 두 값이 동일하면 연산 결과가 0이되면서 je의 조건을 만족하게 되고, main함수는 정상적으로 반환된다. 그러나 두 값이 동일하지 않으면 __stack_chk_fail이 호출되면서 프로그램이 강제로 종료된다.
다음으로 16개의 H를 입력으로 카나리를 변조하고 실행 흐름을 확인해보자
📌 코드 실행 결과
pwndbg> break *main+50 pwndbg> continue HHHHHHHHHHHHHHHH Breakpoint 2, 0x00000000004005c8 in main () ► 0x5555555546dc <main+50> mov rcx, qword ptr [rbp - 8] <0x7ffff7af4191> 0x5555555546e0 <main+54> xor rcx, qword ptr fs:[0x28] 0x5555555546e9 <main+63> je main+70 <main+70> ↓ 0x5555555546f0 <main+70> leave 0x5555555546f1 <main+71> ret
코드를 한 줄 실행시키면, 아래와 같이 rbp-0x8에 저장된 카나리 값이 버퍼 오버플로우로 인해 '0x4848484848484848'이 된 것을 확인할 수 있다.
📌 코드 실행 결과
pwndbg> ni 0x5555555546dc <main+50> mov rcx, qword ptr [rbp - 8] <0x7ffff7af4191> ► 0x5555555546e0 <main+54> xor rcx, qword ptr fs:[0x28] 0x5555555546e9 <main+63> je main+70 <main+70> pwndbg> print /a $rcx $2 = 0x4848484848484848
이후 main+54의 연산 결과가 0이 아니므로 main+63에서 main+70으로 분기하는 것이 아니라 main+65의 __stack_chk_fail을 실행하게 되는 것을 확인할 수 있다. 다음으로 이 함수가 실행되면 다음의 메세지가 출력되며 프로세스가 강제로 종료된다.
📌 코드 실행 결과
pwndbg> ni pwndbg> ni pwndbg> ni 0x5555555546dc <main+50> mov rcx, qword ptr [rbp - 8] <0x7ffff7af4191> 0x5555555546e0 <main+54> xor rcx, qword ptr fs:[0x28] 0x5555555546e9 <main+63> je main+70 <main+70> ► 0x5555555546eb <main+65> call __stack_chk_fail@plt <__stack_chk_fail@plt> *** stack smashing detected ***: <unknown> terminated Program received signal SIGABRT, Aborted.
03 Canary 생성 과정
(1) 카나리 생성 과정
카나리 값은 프로세스가 시작될 때, TLS에 전역 변수로 저장되고, 각 함수마다 프롤로그와 에필로그에서 이 값을 참조하게 된다. TLS에 카나리 값이 저장되는 과정을 자세히 살펴보자.
TLS 주소 파악
fs의 값을 설정할 때 호출되는 arch_prctl(int code, unsigned long addr) 시스템 콜에 중단점을 설정하여 fs가 어떤 값으로 설정되는지 확인해보자. 이 시스템 콜을 arch_prctl(ARCH_SET_FS, addr)의 형태로 호출하면 fs의 값은 addr로 설정된다.
gdb에는 특정 이벤트가 발생했을 때, 프로세스를 중지시키는 catch라는 명령어가 있다. 이 명령어로 arch_prctl에 catchpoint를 설정하고 실습에 사용했던 canary를 실행해보자.
📌 코드 실행 결과
$ gdb -q ./canary pwndbg> catch syscall arch_prctl Catchpoint 1 (syscall 'arch_prctl' [158]) pwndbg> run
이후 init_tls() 함수 안에서 catchpoint에 도달할 때까지 continue 명령어를 실행한다.
📌 코드 실행 결과
pwndbg> c ... pwndbg> c Continuing. Catchpoint 1 (call to syscall arch_prctl), init_tls (naudit=naudit@entry=0) at ./elf/rtld.c:818 818 ./elf/rtld.c: No such file or directory. ... ─────────────[ REGISTERS / show-flags off / show-compact-regs off ]───────────── *RAX 0xffffffffffffffda *RBX 0x7fffffffe090 ◂— 0x1 *RCX 0x7ffff7fe3e1f (init_tls+239) ◂— test eax, eax *RDX 0xffff80000827feb0 *RDI 0x1002 *RSI 0x7ffff7d7f740 ◂— 0x7ffff7d7f740 ... ──────────────────────[ DISASM / x86-64 / set emulate on ]────────────────────── ► 0x7ffff7fe3e1f test eax, eax 0x7ffff7fe3e21 jne init_tls+320 ↓ 0x7ffff7fe3e70 lea rsi, [rip + 0x11641] 0x7ffff7fe3e77 lea rdi, [rip + 0x11672] 0x7ffff7fe3e7e xor eax, eax 0x7ffff7fe3e80 call _dl_fatal_printf <_dl_fatal_printf> 0x7ffff7fe3e85 nop dword ptr [rax] 0x7ffff7fe3e88 xor ecx, ecx 0x7ffff7fe3e8a jmp init_tls+161 0x7ffff7fe3e8f lea rcx, [rip + 0x11be2] <__pretty_function__.14> 0x7ffff7fe3e96 mov edx, 0x31b ... pwndbg> info register $rdi rdi 0x1002 4098 pwndbg> info register $rsi rsi 0x7ffff7d7f740 140737351513920 pwndbg> x/gx 0x7ffff7d7f740 + 0x28 0x7ffff7d7f768: 0x0000000000000000 pwndbg>
catchpoint에 도달했을 때, rdi의 값이 0x1002인데 이 값은 ARCH_SET_FS의 상숫값이다. rsi의 값이 0x7ffff7d7f740이므로, 이 프로세스는 TLS를 0x7ffff7d7f740에 저장할 것이며, fs는 이부분을 가리키게 된다.
카나리가 저장될 fs+0x28(0x7ffff7d7f740+0x28)의 값을 보면, 아직 어떠한 값도 설정되어 있지 않음을 확인할 수 있다.
(2) 카나리 값 설정
확인한 TLS의 주소로 gdb의 watch 명령어를 활용하여 TLS+0x28에 값을 쓸 때 프로세스를 중단시켜보자.
▶ watch 명령어 : 특정 주소에 저장된 값이 변경되면 프로세스를 중단시킴
📌 코드 실행 결과
pwndbg> watch *(0x7ffff7d7f740+0x28) Hardware watchpoint 4: *(0x7ffff7d7f740+0x28)
watchpoint를 설정하고 프로세스를 계속 진행시키면 security_init 함수에서 프로세스가 멈추는 것을 아래와 같이 확인할 수 있다.
📌 코드 실행 결과
pwndbg> continue Continuing. Hardware watchpoint 4: *(0x7ffff7d7f740+0x28) Old value = 0 New value = 2005351680 security_init () at rtld.c:870 870 in rtld.c
여기서 TLS+0x28의 값을 조회하면 0x8ab7f53277873d00의 주소값이 카나리로 설정된 것을 확인할 수 있다.
📌 코드 실행 결과
pwndbg> x/gx 0x7ffff7d7f740+0x28 0x7ffff7d7f768: 0x8ab7f53277873d00
실제로 이 값이 main함수에서 사용하는 카나리값인지 확인하기 위해 main함수에 중단점을 설정하고, 계속 실행시켜보자. 그 결과, mov rax,QWORD PTR fs:0x28를 실행하고 rax 값을 확인해보면 security_init에서 설정한 값과 같은 것을 확인할 수 있다.
📌 코드 실행 결과
pwndbg> b *main Breakpoint 3 at 0x555555555169 Breakpoint 3, 0x00005555555546ae in main () pwndbg> x/10i $rip ► 0x555555555169 <main> endbr64 0x55555555516d <main+4> push rbp 0x55555555516e <main+5> mov rbp, rsp 0x555555555171 <main+8> sub rsp, 0x10 0x555555555175 <main+12> mov rax, qword ptr fs:[0x28] 0x55555555517e <main+21> mov qword ptr [rbp - 8], rax 0x555555555182 <main+25> xor eax, eax 0x555555555184 <main+27> lea rax, [rbp - 0x10] 0x555555555188 <main+31> mov edx, 0x20 0x55555555518d <main+36> mov rsi, rax 0x555555555190 <main+39> mov edi, 0 pwndbg> ni 0x000055555555516d in main () pwndbg> ni 0x000055555555516e in main () pwndbg> ni 0x0000555555555171 in main () pwndbg> ni 0x0000555555555175 in main () pwndbg> ni 0x000055555555517e in main () pwndbg> i r $rax rax 0x8ab7f53277873d00 9995727495074626816 pwndbg>
04 Canary 우회
(1) 카나리 우회
무차별 대입 (Brute Force)
x64 아키텍처에서는 8바이트의 카나리가 생성되며, x86 아키텍처에서는 4바이트의 카나리가 생성된다. 각각의 카나리에는 NULL 바이트가 포함되어 있으므로, 실제로는 7바이트와 3바이트의 랜덤한 값이 포함된다.
즉, 무차별 대입으로 x64 아키텍처의 카나리 값을 알아내려면 최대 256^7번, x86 에서는 최대 256^3 번의 연산이 필요하다. 매우 많은 연산을 거쳐야하기 때문에 x64 아키텍처의 카나리는 무차별 대입으로 알아내는 것이 현실적으로 어려우며, x86 아키텍처는 구할 순 있지만, 실제 서버를 대상으로 저정도 횟수의 무차별 대입을 시도하는 것은 불가능하다.
TLS 접근
카나리는 TLS에 전역변수로 저장되며, 매 함수마다 이를 참조해서 사용한다. TLS의 주소는 매 실행마다 바뀌지만 만약 실행중에 TLS의 주소를 알 수 있고, 임의 주소에 대한 읽기 또는 쓰기가 가능하다면 TLS에 설정된 카나리 값을 읽거나, 이를 임의의 값으로 조작할 수 있다.
이후, 스택 버퍼 오버플로우를 수행할 때 알아낸 카나리 값 또는 조작한 카나리 값으로 스택 카나리를 덮으면 함수의 에필로그에 있는 카나리 검사를 우회할 수 있다.
(2) 카나리 값 구하기
실습을 통해 카나리 검사 우회 과정을 살펴보자.
우선, 드림핵에서 제공해주고 있는 코드는 다음과 같다.
📌 카나리 우회 코드
// Name: bypass_canary.c // Compile: gcc -o bypass_canary bypass_canary.c #include <stdio.h> #include <unistd.h> int main() { char memo[8]; char name[8]; printf("name : "); read(0, name, 64); printf("hello %s\n", name); printf("memo : "); read(0, memo, 64); printf("memo %s\n", memo); return 0; }
제공된 코드 옆에 직접 실행을 통해 카나리 검사 우회를 살펴보면 정상적인 크기로 입력했을 경우 이름과 메모가 입력한 그대로 출력되는 것을 볼 수 있다.
Hexdump 부분 또한, 오류가 없이 값이 들어가 있는 것을 볼 수 있다.
하지만, 이번에는 첫 번째 입력에서 오버플로우를 발생시켜 카나리 값을 얻어내고 얻어낸 카나리 값을 통해 스택 보호 기법을 우회할 수 있었다.
출처 : 드림핵 시스템해킹 로드맵 (https://dreamhack.io/lecture/roadmaps/2)
'Security > System Hacking' 카테고리의 다른 글
[Dreamhack] __environ write-up (0) | 2024.09.23 |
---|---|
[Stack Canary] Return to Shellcode (2) | 2024.09.23 |
Return Address Overwrite (1) | 2024.09.15 |
[Stack Buffer Overflow] Return Address Overwrite (3) | 2024.09.15 |
Memory Corruption: Stack Buffer Overflow (0) | 2024.09.15 |