들어가며
Exploit Tech: Return Address Overwrite 강의에서는 스택의 반환 주소(Return Address)를 조작하여 실행 흐름을 획득하는 공격 방법을 배웠다. |
- 스택 버퍼 오버플로우를 이용한 공격은 매우 강력하면서, 역사가 오래되었기 떄문에
관련 보호 기법도 등장하였다.
스택 카나리(Stack Canary)
- 스택 버퍼 오버플로우로부터 반환 주소를 보호하는 것
- 함수의 프롤로그에서 스택 버퍼와 반환 주소 사이에 임의의 값을 삽입하고,
함수의 에필로그에서 해당 값의 변조를 확인하는 보호 기법이다.
💡카나리 값의 변조가 확인되면 프로그램은 즉시 종료한다.
스택 버퍼 오버플로우로 반환 주소를 덮으려면 먼저 카나리를덮어야 한다.
하지만 카나리 값을 모르면 반환 주소를 덮을 때 카나리 값을 변조하게 된다.
이 경우 에필로그에서 변조가 확인되어 공격자는 실행 흐름을 획득하지 못한다.
👇 카나리 보호 기법이 적용된 스택 버퍼
카나리의 작동 원리
카나리 정적 분석
👇 아래 예제 코드에는 스택 버퍼 오버플로우 취약점이 존재한다.
// Name: canary.c
#include <unistd.h>
int main() {
char buf[8];
read(0, buf, 32);
return 0;
}
카나리를 활성화하여 컴파일한 바이너리와 비활성화하여 한 바이너리를 비교하여 원리를 살펴본다.
카나리 비활성화
- gcc는 기본적으로 스택 카나리를 적용하여 바이너리를 컴파일한다.
컴파일 옵션으로 -fno-stack-protector 를 추가해야 카나리 없이 컴파일 할 수 있다.
👇 아래 명령어로 예제를 컴파일하고 길이가 긴 입력을 주면 Segmentation fault 가 발생한다.
$ gcc -o no_canary canary.c -fno-stack-protector
$ ./no_canary
HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH
Segmentation fault
카나리 활성화
- 카나리를 정용하여 다시 컴파일하면 Segemtation fault 가 아니라
stack smashingdetected 와 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>
카나리 동적 분석
카나리 저장
main+8은 fs:0x28의 데이터를 읽어서 rax에 저장한다.
리눅스는 프로세스가 시작될 때 fs:0x28에 랜덤 값을 저장한다. ( fs는 세그먼트 레지스터의 일종 )
따라서 main+8의 결과로 rax에는 리눅스가 생성한 랜덤 값이 저장된다.
$ 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
👇 첫 바이트가 널 바이트인 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> print /a $rax
$1 = 0xf80f605895da3c00
이로 인해 카나리 값은 첫바이트가 널 바이트라는것을 알 수 있다.
print /a $rax
- 'print': GDB에서 변수, 레지스터, 메모리 위치 등의 값을 출력하는데 사용된다.
- '/a': 출력되는 값을 주소로 해석하라는 뜻이다.
- '$rax': 'rax'레지스터를 나타낸다.
추가 정보
'0xf80f605895da3c00':
- 마지막 '00'은 16진수에서 첫번쨰 바이트 이며 널 바이트 ('\0')이다.
- 16진수에서 각 바이트는 두자리 숫자로 표현되는데 첫 바이트가 '00'이면 이는 널 바이트이다.
추가
- 컴퓨터 안에서 데이터는 일반적으로 리틀 엔디안 형식으로 저장된다.
- 리틀 엔디안에선 낮은 유효 바이트가 메모리의 낮은 주소에 위차한다.
그래서 '00'이 '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> x/gx $rbp-0x8
0x7fffffffe238: 0xf80f605895da3c00
👆생성한 랜덤 값은 main+17에서 rbp-0x8에 저장된다.
💡fs |
CPU에는 다양한 세그먼트 레지스터가 존재한다. 초기 세그먼트 레지스터
cs, ds,es는 사용목적이 명시됬지만 fs, gs는 목적이 없다. 운영체제가 임의로 사용할 수 있다. 리눅스는 fs를 Thread Local Storage(TLS)를 가리크는 포인터로 사용한다. TLS: 카나리를 비롯하여 프로세스 실행에 필요한 여러 데이터가 저장된다. |
카나리 검사
이제 추가된 에필로그 코드에 중단점을 설정하고 바이너리를 계속 실행시킨다.
main+58은 rbp-8에 저장한 카나리를 rcx로 옮긴다.
그 후 main+54에서 rcx를 fs:0x28에 저장된 카나리와 xor한다.
두 값이 동일하면 연산 결과가 0이 되면서 js의 조건을 만족하고, main 함수가 반환된다.
그러나 동일하지 않으면 __stack_chk_fail이 호출되고 강제 종료된다.
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+64에서 main+70으로 분기하지 않고
ain+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.
카나리 생성 과정🔬
카나리 값은 프로세스가 시작될 때, TLS에 전역변수로 저장되고,
각 함수의 프롤로그와 에필로그에서 이 값을 참조한다.
TLS의 주소 파악
fs는 TLS를 가리킨다. fs의 값을 알면 TLS의 주소를 알 수 있다.
리눅스에서 fs값은 특정 시스템 콜을 사용해야만 조회, 설정 할 수 있다.
gdb에서 다른 레지스터의 값을 출력하듯 inforegister fs, print $fs와 같은 방식으로는 값을 알 수 없다.
fs의 값을 설정할 때 호출되는 arch_prctl(int code, unsigned long addr) 시스템 콜에 중단점을 설정하여 조사한다.
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 명령어를 실행한다.
catchpoint에 rdi 값이 0x1002이 값은 ARCH_SET_FS 의 상숫 값이다.
rsi 값이 0x7ffff7d7f740 이므로, TLS를 0x7ffff7d7f740에 저장하고 fs는 이를 가릴 킬 것이다.
fs+0x28(0x7ffff7d7f740+0x28) 의 값을 보면 어떠한 값도 설정되지 않음을 확인 할 수 있다.
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>
카나리 값 설정
TLS의 주소를 알았고, gdb의 watch명령어로 TLS+0x28에 값을 쓸떄 프로세스를 중단시킨다.
watch는 특정 주소에 저장된 값이 변경되면 프로세스를 중단시키는 명령어입니다.
pwndbg> watch *(0x7ffff7d7f740+0x28)
Hardware watchpoint 4: *(0x7ffff7d7f740+0x28)
pwndbg> continue
Continuing.
Hardware watchpoint 4: *(0x7ffff7d7f740+0x28)
Old value = 0
New value = 2005351680
security_init () at rtld.c:870
870 in rtld.c
👆 watchpoint를 설정하고 계속 진행하면 security_init에서 멈춘다.
여기서 TLS+0x28의 값을 조회하면 0x8ab7f53277873d00이 카나리롤 설정된것을 볼 수 있다.
pwndbg> x/gx 0x7ffff7d7f740+0x28
0x7ffff7d7f768: 0x8ab7f53277873d00
pwndbg> b *main
Breakpoint 3 at 0x555555555169
👆실제로 이 값이 main함수에서 사용하는 카나리값인지 확인하려고 main함수에 중단점을 하고 계속 한다.
mov rax,QWORD PTR fs:0x28 를 실행하고 rax를 보면
security_init에서 설정한 값과 같은 것을 확인할 수 있다.
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>
카나리 우회
무차별 대임 (Brute Force)
먼저 브루트포스 알고리즘이 생각나다.
- x64아키텍처에서 8바이트의 카나리가 생성되고, x86 아키텍처에선 4바이트의 카나리가 생성된다.
각각의 카나리에는 NULL바이트가 포함되어있다. - 실제론 7바이트 3바이트의 랜덤한 값이 포함된다.
그래서 무차별 대입으로 x64 에서는 최대 256^7번,
x86 에서는 최대 256^3번의 연산이 필요하다.
💡연산량이 너무 많아서 무차별 대입으로 알아내는 것 자체가 현실적으로 어렵다.
TLS 접근
- 카나리는 TLS에 전역변수로 저장되며, 매 함수마다 이를 참조해서 사용한다.
- TLS의 주소는 매 실행마다 바뀌지만 실행중에 주소를 알 수 있고, 읽기 또는 쓰기가 가능하다면
TLS에 설정된 카나리 값을 읽거나 조작할 수 있다.
그 뒤 스택 버퍼 오버플로우를 수행할 떄
알아낸 카나리 값으로 스택 카나리를 덮으면 함수의 에필로그에 있는 카나리 검사를 우회할 수 있다.
스택 카나리 릭
스택 카나리를 읽을 수 있는 취약점이 있으면 검사를 우회할 수 있다.
// 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;
}
마치며
강의 요약📝
카나리
- 함수가 시작할때 스택 버퍼와 Return Address 사이에 랜덤 값을 삽입한 후
- 함수가 종료하면 랜덤 값이 바뀌었는지 확인하여 메모리 오염을 확인하는 보호 기법이다.
카나리 생성
- security_init함수에서 TLS에 랜덤 값으로 카나리를 설정하면,
- 함수에서 이를 참조하여 사용한다.
카나리 우회 깁법
- 무차별 대입 공격(Brute Force Attack):
- 무차별 대입으로 카나리 값을 구하는 방법
- 현실적으로 거의 불가능 하다.
- TLS 접근:
- 카나리는 TLS에 전역 변수로 저장되므로,
- 이 값을 읽거나 조작할 수 있으면 카나리를 우회할 수 있다.
- 스택 카나리 릭:
- 함수의 프롤로그에서 스택에 카나리 값을 저장하므로
- 이를 읽어낼 수 있으면 카나릴를 우회할 수 있다.
(가장 현실적인 기법이다.)
끝
'hacking > pwnable' 카테고리의 다른 글
[Dream hack] Mitigation: NX & ASLR (0) | 2024.06.26 |
---|---|
[Dream hack] Quiz: Stack Canary (0) | 2024.06.13 |
[Dream hack] basic_exploitation_000 (0) | 2024.06.10 |
[Dream hack] Exploit Tech: Return Address Overwrite (0) | 2024.06.03 |
[Dream hack] Quiz: x86 Assembly 3-1 (0) | 2024.06.02 |