스택 카나리, NX&ASLR, PLT&GOT
스택 카나리
카나리 보호 기법은 스택 버퍼 오버플로우 공격을 방어하기 위한 보안 기술 중 하나로, 함수의 프롤로그에서 스택 버퍼와 반환 주소 사이에 임의의 값을 삽입하고, 함수의 에필로그에서 해당 값의 변조를 확인하여 공격을 탐지하는 방법이다. 만약 카나리 값의 변조가 감지되면 프로세스는 강제로 종료된다.
이 기법의 이름인 "카나리"는 19세기와 20세기에 탄광에서 발생한 일산화탄소 중독 사건과 관련이 있는데…. 당시엔 일산화탄소 농도를 측정하는 기술이 부족했기 때문에 광부들이 카나리아와 함께 광산에 들어갔다. 카나리아는 일산화탄소에 민감하게 반응하여 먼저 증상을 보였고, 이를 통해 광부들은 일산화탄소 농도가 위험 수준에 도달했을 때 대피할 수 있었다. 이러한 역사적 배경으로 카나리아는 "위험을 알려주는 새"로서 상징적인 의미를 가지게 되었다.
소프트웨어 분야에서도 "카나리 버전"이라는 용어가 사용되는데, 이는 소프트웨어를 출시하거나 업데이트할 때 베타 테스트용으로 공개하는 버전을 가리킨다. 마찬가지로 카나리 보호 기법은 반환 주소가 덮인 것을 알려주는 방식으로, 그 이름이 "카나리"로 지어졌다. 이를 통해 소프트웨어와 보안 분야에서 위험을 탐지하고 방어하는데 사용되는 중요한 기술 중 하나이다.
#include <unistd.h>
int main() {
char buf[8];
read(0, buf, 32);
return 0;
}

Ubuntu 22.04의 gcc는 바이너리를 컴파일할 때 기본적으로 스택 카나리를 사용한다. 스택 카나리를 사용하지 않고 컴파일하려면 -fno-stack-protector 옵션을 추가해야 한다. 그리고 아래 명령어로 예제를 컴파일한 후, 길이가 긴 입력을 제공하면 반환 주소가 덮여서 Segmentation fault 오류가 발생하게 된다.

카나리를 적용하여 다시 컴파일한 후, 긴 입력을 제공하면 Segmentation fault가 아닌 "stack smashing detected"와 "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는 리눅스에서 사용되는 세그먼트 레지스터 중 하나로, 프로세스가 시작될 때 fs:0x28에는 랜덤 값을 저장한다. 따라서 main+8을 실행하면 rax 레지스터에는 리눅스가 생성한 랜덤 값이 저장된다.

이제 추가된 에필로그 코드에 중단점을 설정하고 바이너리를 계속 실행한다.
"main+50"에서는 rbp-8에 저장된 카나리 값을 rcx 레지스터로 복사한다. 그런 다음, "main+54"에서 rcx와 fs:0x28에 저장된 카나리 값을 xor 연산하여 비교해본다. 만약 두 값이 동일하면 연산 결과가 0이 되어서 je (Jump if Equal) 조건을 만족하게 되고, main 함수는 정상적으로 반환될 것이다. 그러나 두 값이 동일하지 않으면 __stack_chk_fail 함수가 호출되어 프로그램이 강제로 종료된다.

16개의 H를 입력으로 사용하여 카나리 값을 변조해보았다.

TLS(쓰레드 지역 저장소)의 주소를 파악하기 위해서는 리눅스에서 fs 레지스터의 값을 확인하거나 설정하기 위해 특정 시스템 콜을 사용해야 한다. 이러한 작업은 gdb에서 다른 레지스터 값을 출력하는 것과 같이 간단하게 처리할 수 없다.
그러나 fs 레지스터의 값을 설정할 때 호출되는 arch_prctl(int code, unsigned long addr) 시스템 콜에 중단점을 설정하여 fs가 어떤 값으로 설정되는지 조사할 수 있다. 이 시스템 콜은 arch_prctl(ARCH_SET_FS, addr) 형태로 호출되며, 이를 통해 fs 레지스터의 값을 addr로 설정할 수 있다.
init_tls() 함수 내에서 catchpoint에 도달할 때까지 "continue" 명령어를 실행한다.
catchpoint에 도달하면 rdi 레지스터의 값이 0x1002로 설정되는데, 이 값은 ARCH_SET_FS의 상수값이다. rsi 레지스터의 값은 0x7ffff7d7f740이며, 이것은 TLS를 0x7ffff7d7f740에 저장하겠다는 것을 나타낸다. 따라서 fs 레지스터는 이 값을 가리키게 된다.
카나리가 저장될 fs+0x28(0x7ffff7d7f740+0x28)의 값을 확인해보면, 아직 어떠한 값도 설정되어 있지 않음을 확인할 수 있다.
카나리 우회
- 무차별 대입 (Brute Force): 카나리는 x64 아키텍처에서 8바이트로 생성되며, x86 아키텍처에서는 4바이트로 생성된다. 각각의 카나리에는 NULL 바이트가 포함되어 있으므로 사실상 7바이트와 3바이트의 랜덤한 값이 포함된다. 따라서 x64 아키텍처의 카나리 값을 무차별 대입으로 알아내려면 최대 256^7번, x86에서는 최대 256^3번의 연산이 필요하다. 이는 연산량이 매우 많아서 x64 아키텍처의 카나리를 무차별 대입으로 알아내는 것이 현실적으로 어렵고, x86 아키텍처의 경우에도 실제 서버에서 이러한 횟수의 무차별 대입을 시도하는 것은 불가능하다.
- TLS 접근: 카나리는 TLS에 전역 변수로 저장되며, 각 함수에서 TLS를 참조하고 사용한다. TLS의 주소는 실행마다 변하지만, 실행 중에 TLS 주소를 알 수 있고 임의 주소에 대한 읽기 및 쓰기가 가능하다면 TLS에 설정된 카나리 값을 읽거나 변경할 수 있다. 이후, 스택 버퍼 오버플로우를 수행할 때 알아낸 카나리 값 또는 변경한 카나리 값을 사용하여 스택 카나리를 덮으면 함수의 에필로그에서 카나리 검사를 우회할 수 있다.
NX & ASLR
return to shellcode 워게임 문제에서 r2s를 대상으로 셸 코드를 실행시킬 수 있었던 이유는 몇 가지 조건이 만족되었기 때문이라고 한다.... 첫째로, 공격자는 반환 주소를 임의의 주소로 덮을 수 있었다. 둘째로, 사용자가 데이터를 입력할 수 있는 버퍼의 주소를 알 수 있었다. 마지막으로, 해당 버퍼가 실행 가능한 권한을 가졌다. 이 중 첫 번째 조건을 어렵게 만들기 위해 카나리를 도입했지만, 나머지 두 조건은 충분히 방어되지 않았기 때문에 카나리만 우회하면 공격자가 셸을 획득할 수 있었다.
따라서 r2s를 통한 공격을 더 어렵게 만들기 위해서는 공격자가 메모리에서 임의 버퍼의 주소를 알기 어렵게 하고, 메모리 영역에서 불필요한 실행 권한을 제거하는 추가적인 보호 기법이 필요하다. 이를 위해 Address Space Layout Randomization(ASLR)과 No-eXecute(NX)와 같은 기술이 개발되었고 시스템에 적용됐다.
ASLR
Address Space Layout Randomization(ASLR)은 바이너리가 실행될 때마다 스택, 힙, 공유 라이브러리 등을 임의의 주소에 할당하여 공격자가 특정 주소를 예측하기 어렵게 만드는 보호 기법이다.

cat /proc/sys/kernel/randomize_va_space
리눅스에서 ASLR (Address Space Layout Randomization)은 다음과 같은 세 가지 값(0, 1, 또는 2)을 가질 수 있으며, 각각의 값에 따라 ASLR이 적용되는 메모리 영역이 다르다:
0 - No ASLR: ASLR을 적용하지 않음
1 - Conservative Randomization: 스택, 힙, 라이브러리, vdso 등에 ASLR을 적용
2 - Conservative Randomization + brk: (1)의 영역과 brk로 할당한 영역에 ASLR을 적용
#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); // 코드 영역의 함수 주소
}

코드 영역의 main 함수를 제외한 다른 영역의 주소들은 실행할 때마다 변경된다. 이는 ASLR이 적용되어 메모리 주소가 무작위로 할당되기 때문이다.
실행할 때마다 주소가 변경되기 때문에, 바이너리를 실행하기 전에 해당 영역들의 주소를 예측할 수 없다.
바이너리를 반복해서 실행해도 libc_base 주소 하위 12비트 값과 printf 주소 하위 12비트 값은 변경되지 않는다. 이는 리눅스가 ASLR을 적용할 때 파일을 페이지(page) 1 단위로 매핑하기 때문이다. 페이지의 크기가 12비트 이하이기 때문에 해당 비트 값들은 일정하게 유지된다.
libc_base와 printf의 주소 차이는 항상 같다. ASLR이 적용되면 라이브러리는 임의 주소에 매핑되지만 라이브러리 파일을 그대로 매핑하기 때문에 매핑된 주소로부터 라이브러리의 다른 심볼들 까지의 거리(Offset)는 항상 동일하다.
NX (No-eXecute)는 실행에 사용되는 메모리 영역과 쓰기에 사용되는 메모리 영역을 분리하는 보호 기법이다. 이로써 코드 영역 외에는 실행 권한이 없어 공격자가 있다면.. 코드를 수정하여 실행할 수 없다. CPU가 NX를 지원하면 컴파일러 옵션을 통해 바이너리에 NX를 적용할 수 있으며 NX가 적용된 바이너리는 실행될 때 필요한 권한만을 부여받는다.
checksec을 써보면 다음과 같이 바이너리에 NX가 적용됐는지 확인할 수 있다.
PLT(Procedure Linkage Table)와 GOT(Global Offset Table)
라이브러리에서 동적 링크된 심볼의 주소를 찾을 때 사용하는 테이블이다.
바이너리가 실행될 때 ASLR에 의해 라이브러리가 임의의 주소로 매핑된다. 이 상황에서 라이브러리 함수를 호출하면, 함수 이름을 바탕으로 라이브러리에서 심볼들을 찾아보고, 해당 함수의 정의를 찾으면 그 주소로 실행 흐름을 이동한다. 이 과정을 통틀어 "runtime resolve"라고 한다.
하지만 만약 반복해서 호출되는 함수의 정의를 계속 찾아야 한다면 효율적이지 않을 것이다. 그래서 ELF(Executable and Linkable Format)는 GOT라는 테이블을 사용하여 이미 해결된 함수의 주소를 저장한다. 그런 다음 나중에 동일한 함수를 호출할 때 저장된 주소를 꺼내와서 사용한다.
#include <stdio.h>
int main() {
puts("Resolving address of 'puts'.");
puts("Get address from GOT");
}
got.c


먼저 got.c를 컴파일하고 실행한 직후에, GOT(Global Offset Table)의 상태를 확인하기 위해 "got" 명령어를 쓰는데, 이때 puts 함수의 GOT 엔트리인 0x404018에는 아직 puts 함수의 주소를 찾지 못한 상태이다. 그래서 함수 주소 대신 .plt 섹션 어딘가의 주소인 0x401030이 기록되어 있다.

이제 main() 함수에서 puts@plt를 호출하는 지점에 중단점을 설정하고 실행을 따라가보면, PLT(Procedure Linkage Table)에서는 먼저 puts 함수의 GOT(Global Offset Table) 엔트리에 저장된 값인 0x401030으로 실행 흐름을 이동시킨다. pwndbg 컨텍스트에서 DISASM 부분은 프로그램에서 명령어가 실행되는 순서인 제어 흐름(Control flow)을 보여주며, 이를 따라가면 _dl_runtime_resolve_fxsave 함수가 호출될 것임을 확인할 수 있다.



여기서 코드를 조금 더 실행시키면 _dl_runtime_resolve_fxsave라는 함수가 실행되며, 이 함수에서 puts의 주소를 구하고, GOT엔트리에 해당 주소를 기록한다.
실제로 ni(Next Instruction) 명령어를 반복적으로 수행하여 _dl_runtime_resolve_fxsave 함수 내부로 진입한 후, finish 명령어로 함수를 빠져나오면, puts의 GOT 엔트리에 libc 영역 내 실제 puts 함수의 주소인 0x7ffff7e02ed0가 저장되어 있는 것을 확인할 수 있다.
시스템 해킹의 관점에서 PLT와 GOT는 동적 링크된 바이너리에서 라이브러리 함수의 주소를 찾고 기록하는 데 사용되는 중요한 테이블이다. 그러나 PLT에서 GOT를 참조하여 실행 흐름을 이동할 때, GOT의 값을 검증하지 않는다는 보안상의 취약점이 존재한다.


따라서, 만약 puts 함수의 GOT(Global Offset Table) 엔트리에 저장된 값을 공격자가 임의로 변경할 수 있다면, puts 함수가 호출될 때 공격자가 의도한 코드가 실행될 가능성이 존재한다!!
GOT 엔트리에 저장된 값을 임의로 변조할 수 있는 수단이 있다고 가정하고, 이 공격 기법이 가능한지를 확인하기 위해 gdb를 사용하여 실습해보았다.
got 바이너리에서 main() 함수 내부에서 두 번째로 호출되는 puts() 함수 직전에 puts의 GOT 엔트리를 "AAAAAAAA"로 변경한 후 프로그램을 실행하면, 실제로 "AAAAAAAA"로 전환된 것을 볼 수 있었다.