- SSP(Stack Smashing Protector)
SSP는 메모리 커럽션 취약점 중 스택 버퍼 오버플로우 취약점을 막기 위해 개발된 보호 기법으로 스택 버퍼와 스택 프레임 포인터 사이에 랜덤 값을 삽입하여 함수 종료 시점에서 랜덤 값 변조 여부를 검사함으로써 스택이 망가뜨려졌는지를 확인한다. SSP 보호 기법이 적용되어 있다면 함수에서 스택을 사용할 때 카나리가 생성되는데, 카나리는 스택 버퍼와 스택 프레임 포인터 사이에 랜덤으로 삽입된는 값으로 카나리 값의 변조 여부를 통해 BOF를 감지할 수 있다.
이렇게 랜덤으로 생성된 카나리 값은 모든 쓰레드에서 동일하게 사용할 수 있도록 전역변수로 사용되는 TLS 영역에 저장한다. 이렇게 TLS영역에 저장된 카나리를 마스터 카나리라고 하고, 스택 버퍼와 SFP 사이에 있는 값을 마스터 카나리와 비교해서 BOF 여부를 알아낼 수 있다.
다음 예제는 master1.c이다. 해당 예제는 SSP 보호기법이 적용되어 있고 지역 변수를 사용하므로 main 함수에서 카나리를 삽입하고 검사하는 루틴이 존재한다.
master1.c를 gdb로 디버그 해보면 gs:0x14에서 카나리를 얻어와서 $eax에 저장하고, $eax 값을 다시 ebp아래 영역에 저장하는 것을 알 수 있다.
main+20에 breakpoint를 걸고 $eax 값을 출력하면 카나리 값을 알 수 있다.
master1의 프로세스 메모리 맵 중 TLS영역인 0xf7dff000 - 0xf7e00000의 header.stack_guard에 마스터 카나리가 존재한다.
find 명령어를 사용하면 마스터 카나리를 찾을 수 있다.
다음은 ssp적용 여부에 따른 디스어셈블리 결과를 확인해보겠다. 왼쪽은 ssp를 적용시키지 않고, 오른쪽은 ssp를 적용시켜 컴파일한다.
왼쪽은 no_ssp 디스어셈블리 결과이고, 오른쪽은 ssp 디스어셈블리 결과다. 왼쪽과 달리 오른쪽은 함수의 프롤로그와 에필로그에 스택 카나리 검증 루틴이 추가됐다. 함수 프롤로그에서 생성된 카나리를 $eax에 담고 ebp 아래 영역에 저장한다. 에필로그에는 ebp 영역 아래 저장된 카나리를 $eax에 옮기고 gs:0x14와 비교해서 스택의 변조 여부를 검사한다.
두 바이너리에 각각 buf 배열의 사이즈보다 긴 값을 인자로 전달해 스택 버퍼 오버플로우 취약점을 트리거해보겠다. no_ssp 바이너리의 경우 Segmentation fault 예외가 출력되며 프로그램이 비정상 종료되는 반면, ssp 바이너리는 __stack_chk_fail 함수가 호출되어 "stack smashing detected" 문자열을 출력하며 프로그램이 종료된다.
dreamhack의 중간 테스트를 해봤다.
현재 카나리는 0x41424300으로 지정되어 있다.
input을 버퍼에 8비트 stack_canary 영역에 \0CBA를 주면 아무런 일이 발생하지 않는다.
반면 stack_canary 영역의 값을 조금 바꿔주면 __stack_chk_fail() 함수가 실행되면서 다음과 같은 문자열이 출력된다.
-> *** stack smashing detected ***: ssp_test terminated
- Bypassing SSP(1)
SSP 보호기법을 우회하기 위해선 스택 메모리에 존재하는 스택 카나리의 값을 변조시키지 않은 채로 익스플로잇을 해야한다. 다음 example6.c 예제를 가지고 실습해보겠다.
example6.c의 main 함수에서 read 함수를 호출할 때, buf의 크기보다 더 큰 크기를 입력받아 스택 버퍼 오버플로우가 발생한다. 그러나 example6 바이너리에는 SSP가 설정되어 있기 때문에, 스택 카나리의 값을 알아내지 못한다면 스택 버퍼 오버플로우 취약점만으로는 실행 흐름을 조작할 수 없다.
example6.c의 line 18을 보면, %s 포맷 스트링을 이용해 buf의 내용을 출력하는 코드가 있다. printf 함수의 %s 포맷스트링은 NULL 바이트를 만날 때까지 출력해주기 때문에 buf 배열의 끝이 NULL 바이트가 아니라면 buf 배열 밖의 메모리까지 출력할 수 있게 된다.
다음 dreamhack의 leak test를 진행해보자. 아래는 leak.c 코드다. buf에 입력을 통해 secret 버퍼의 내용까지 출력해야 한다. 앞서 설명한 내용에 %s 포맷스트링은 NULL 바이트를 만나기 않으면 buf 배열 밖의 메모리까지 출력할 수 있다고 했다. 마침 leak.c에도 %s 포맷스트링이 사용되고 있다. buf의 크기에 딱 맞게 입력을 주면 buf 다음 메모리인 secret 배열의 내용도 같이 출력된다.
...
우선 gdb를 이용해 buf 배열부터 스택 카나리 + 1까지의 오프셋을 구한다. gdb로 확인한 결과 오프셋은 0x21인 것을 알아냈다. 이를 이용하면 스택 카나리의 값을 구하는 파이썬 스크립트를 작성할 수 있다. 파이썬 스크립트는 dreamhac 스크립트를 사용하겠다.
스크립트 확인: dreamhack.io/learn/4#10 (example6_leak.py)
이제는 구한 스택 카나리 값을 이용해 main 함수의 2번째 read 함수에서 스택 버퍼 오버플로우를 익스플로잇할 수 있다. gdb를 이용해 give_shell 함수의 주소를 구한 후 리턴 주소를 give_shell 함수의 주소로 덮어 셸을 획득하자.
example6의 리턴 주소를 give_shell 함수의 주소인 0x804854b으로 바꾸어 셸을 획득하는 파이썬 스크립트, example6.py를 실행하면 다음과 같이 셸이 획득된다.
스크립트 확인: dreamhack.io/learn/4#11 (example6.py)
- Bypassing SSP(2)
ssp_fork1.c는 fork 함수를 사용하여 자식 프로세스를 생성하는 코드이고, fork 함수는 부모 프로세스의 TLS 영역과 스택 메모리 등을 복제해 자식 프로세스를 생성한다.
gdb를 이용해 ssp_fork1의 부모 프로세스와 자식 프로세스의 카나리 값을 확인하면 부모 프로세스와 자식 프로세스의 스택 카나리 값이 동일하다는 것을 알 수 있다.
//ssp_fork1.c
코드확인: dreamhack.io/learn/4#13 (ssp_server.c)
ssp_server는 fork를 이용한 서버 프로그램으로 31337번 포트에 TCP 서버를 연 후 클라이언트의 연결이 들어오면 자식 프로세스를 생성한 후 handler 함수를 호출한다. handler 함수에서는 32 바이트 버퍼에 1024 바이트의 입력을 클라이언트로부터 받기 때문에 스택 버퍼 오버플로우가 존재하지만 ssp_server에는 SSP 보호기법이 적용되어 있기 때문에 스택 버퍼 오버플로우를 익스플로잇하기 위해서는 SSP를 우회해야 한다.
ssp_server의 부모 프로세스에서는 자식 프로세스의 시그널을 처리하는 루틴이 없기 때문에 자식 프로세스에서 SIGSEGV나 SIGABRT 예외가 발생해도 부모 프로세스는 종료되지 않는다. handler 함수가 정상적으로 리턴된다면 bye 문자열이 출력되고, 스택 카나리 검사가 실패해 자식 프로세스가 SIGABRT 예외로 종료된다면 bye 문자열을 출력하지 않는다.
bye 문자열의 출력 유무를 통해 스택 카나리 검사 통과 여부를 확인하고, 새로운 연결로부터 생성된 자식 프로세스와 부모 프로세스의 스택 카나리가 같다는 점을 이용하면 브루트 포싱 공격을 통해 스택 카나리의 값을 한 바이트씩 알아낼 수 있다.
코드확인: dreamhack.io/learn/4#14 (ssp_server.py)
ssp_server.py는 브루트 포싱 공격을 통해 ssp_server의 스택 카나리 값을 알아낸 후 handler 함수의 리턴 주소를 critical 함수의 주소인 0x80486db로 덮어 critical 함수를 호출하는 공격 코드이다.
line 26 - line 45에서 스택 카나리의 상위 3 바이트를 알아내고, line 58 - line 65에서 알아낸 스택 카나리를 이용해 handler 함수의 리턴 주소를 critical 함수의 주소로 덮는다. 그리고 line 53에서 알아낸 클라이언트의 소켓 파일 디스크립터 client_fd를 critical 함수의 인자로 전달한다.
다음은 ssp_server.py의 실행 결과이다. critical 함수가 호출되어 critical_msg가 출력된다.
- SSP 설정여부 확인
SSP가 적용되어 있는 바이너리의 디스어셈블리 결과를 보면, 스택 카나리가 변조되었을 때 함수의 에필로그에서 stack_chk_fail 함수를 호출하는 코드를 확인할 수 있다.
stack_chk_fail은 표준 라이브러리인 libc.so.6 에 존재하는 함수이기 때문에, 바이너리에 이 함수의 심볼이 존재하는지 확인하는 것만으로 SSP 적용 여부를 알 수 있다.
< readelf를 이용해 SSP 보호기법의 적용 여부를 확인하는 방법 >
- Bypassing RELRO
바이너리가 실행되는 도중, 함수가 처음 호출될 때 주소를 찾는 방식을 Lazy Binding이라고 한다.
ex) GOT에는 처음에 라이브러리 함수의 주소를 구하는 바이너리 코드 영역 주소가 저장되어 있다가, 함수가 처음 호출될 때 라이브러리 함수의 실제 주소가 저장됨.
Lazy Binding을 할 때는 프로그램이 실행되고 있는 도중 GOT에 라이브러리 함수의 주소를 덮어써야 하기 때문에 GOT에 쓰기 권한이 있어야 하는데, GOT에 값을 쓸 수 있다는 특징 때문에 GOT Overwrite와 같은 공격이 가능하다. 하지만 Relocation Read-Only(RELRO) 보호기법이 설정되어 있으면 GOT와 같은 다이나믹 섹션이 읽기 권한만을 가지게 된다.
코드확인: dreamhack.io/learn/4#17 (example7.c)
example7은 example7.c를 RELRO 옵션과 함께 컴파일한 바이너리이다. 이 바이너리에서 system 함수의 주소를 구하기 위해선 일단 바이너리를 디스어셈블 한다. 그러면 menu 함수에 있는 puts@plt로부터 puts@got의 위치를 찾을 수 있다. 해당 예제에서 puts@got의 주소는 0x8049fec이다.
코드확인: dreamhack.io/learn/4#19 (example7_leak.py)
example7_leak.py는 1번 메뉴에 puts의 GOT 주소인 0x8049fec를 입력하여 puts 함수의 주소를 구하는 코드로 example7_leak.py의 line 56 ~ line 61에서 arb_read 함수를 구현하여 1번 메뉴의 사용을 구현됐다. example7_leak.py를 실행하면 다음과 같이 puts 함수의 주소가 출력된다.
다음은 2번 메뉴의 기능인 arbitrary write를 통해 실행 흐름을 조작할 수 있다. example7의 메모리 권한을 보면 GOT에 쓰기 권한이 없다. 하지만 RELRO 보호기법이 적용되어 있더라도 스택 등 동적으로 데이터를 써야 하는 메모리에는 여전히 쓰기 권한이 있다. 2번 메뉴를 이용해 스택 메모리에 값을 쓰려면 스택의 주소를 알아야 한다.
ibc.so.6 라이브러리의 전역 변수에는 프로그램의 argv, 즉 스택 메모리 주소가 존재, gdb의 find 명령어를 통해 main 함수의 두 번째 인자인 argv의 주소를 libc.so.6 라이브러리에서 찾을 수 있다.
dreamhack.io/learn/4#21의 디버깅 화면에서 main 함수에 브레이크포인트를 설정한 후, 라이브러리에서 argv 포인터를 검색해 라이브러리의 베이스 주소부터 argv 포인터 위치까지의 오프셋을 계산했다.
다음으로 argv 주소부터 main 함수의 리턴 주소까지의 오프셋과 리턴 주소에 덮을 system 함수와 "/bin/sh" 문자열 주소의 오프셋을 각각 구한다. argv 주소부터 main 함수의 리턴 주소까지의 오프셋은 0x98, 라이브러리 베이스 주소에 대한 system 함수와 "/bin/sh" 문자열의 오프셋은 각각 0x3ada0, 0x15ba0b이다.
코드확인: dreamhack.io/learn/4#22 (example7.py)
example7.py는 example7에 대한 익스플로잇 코드이다. example7.py의 line 64 ~ line 70에서 arb_write 함수를 선언해 2번 메뉴의 사용을 구현하고, arb_write을 이용해 main 함수의 리턴 주소를 system 함수 주소로, main의 리턴 주소+8을 "/bin/sh" 문자열의 주소로 덮은 후 0번 메뉴로 main 함수를 리턴시켜 셸을 획득한다.
- RELRO가 설정되어 있는지 확인하는 방법
RELRO는 바이너리 섹션에 Read Only가 적용된 정도에 따라 크게 No RELRO, Partial RELRO, Full RELRO 세 단계로 나뉠 수 있다.
- No RELRO는 바이너리에 RELRO 보호기법이 아예 적용되어 있지 않은 상태
- Partial RELRO는 .init_arry나 .fini_array 등 non-PLT GOT 에 대한 쓰기 권한을 제거한 상태
- Full RELRO는 GOT 섹션에 대한 쓰기 권한까지 제거해 .bss 영역을 제외한 모든 바이너리 섹션에서 쓰기 권한이 제거된 상태
˙ELF 바이너리에 설정된 RELRO 보호기법을 체크방법 - readelf
readelf -a 의 출력 결과에 BIND_NOW 문자열을 grep해서 해당 바이너리가 Now Binding을 하는지 Lazy Binding을 하는지에 대한 검사를 통해 Full RELRO 적용 여부 확인가능
readelf 출력 결과에 BIND_NOW 문자열이 없으면, GNU_RELRO 문자열의 검사를 통해 바이너리의 Partial RELRO 적용 여부를 확인가능
readelf -a 출력 결과에서 두 문자열 모두 존재하지 않는다면, 해당 바이너리는 RELRO 보호기법이 적용되어 있지 않다고 볼 수 있음
- PIE
PIE는 Executable, 즉 바이너리가 로딩될 때 랜덤한 주소에 매핑되는 보호기법이다. 컴파일러는 바이너리가 메모리 어디에 매핑되어도 실행에 지장이 없도록 바이너리를 위치 독립적으로 컴파일하기 때문에 코드 영역의 주소 랜덤화가 가능하다. PIE가 설정되어 있으면 코드 영역의 주소가 실행될 때마다 변하기 때문에 ROP와 같은 코드 재사용 공격을 막을 수 있다.
main 함수의 주소를 출력해주는 소스코드를 PIE 보호기법을 적용하지 않고 컴파일 한 것과 적용하고 컴파일 한 것을 실행시키면 다음과 같다.
PIE 보호기법을 적용하지 않은 바이너리는 main 함수의 주소가 일정하지만, PIE 보호기법을 적용한 바이너리는 main 함수의 주소가 계속 바뀌었다. 이처럼 PIE 가 설정되어 있으면 코드, 힙, 라이브러리, 스택 등 모든 메모리 영역의 주소가 랜덤화된다.
- Bypassing PIE
PIE 보호기법을 우회하기 위해서는 코드 영역의 주소를 알아내야 한다. PIE 보호기법이 설정되어 있을 때 코드 영역은 공유 라이브러리처럼 메모리에 로딩되기 때문에 libc.so.6 라이브러리 주소를 구하는 과정과 같이 특정 코드 영역의 주소를 알아낸다면 코드 영역 베이스 주소를 구할 수 있다. 코드 영역 베이스 주소를 구한다면, 오프셋 계산을 통해 코드나 데이터 영역의 주소를 구할 수 있다.
코드확인: dreamhack.io/learn/4#26 (example8.c)
example8.c에는 line 13, line 18에 존재하는 스택 버퍼 오버플로우와 line 15에 존재하는 포맷 스트링 버그 취약점이 존재한다. example8은 example8.c를 PIE를 적용하고 컴파일한 바이너리다. example8을 디스어셈블하면 주소가 오프셋 형태로 출력된다.
바이너리에는 셸을 실행시켜주는 give_shell 함수가 있다. 포맷 스트링 버그를 이용해 give_shell 함수의 주소를 구한 후, 스택 버퍼 오버플로우 취약점으로 리턴 주소를 give_shell 함수의 주소로 덮어 셸을 실행시킨다.
printf(buf)를 실행하는 시점에 브레이크포인트를 설정해 스택 메모리를 살펴보면 0xffffd550에 바이너리 코드 영역의 주소인 0x565557be이 저장되어 있는 것을 볼 수 있다.
0x565557be는 vuln 함수의 리턴 주소, 즉 main+66의 주소이다. 포맷 스트링 버그를 이용하면 0xffffd550에 있는 값을 출력시킬 수 있다. 0x61616161이 저장되어 있는 0xffffd528이 첫 번째 포맷 스트링에 대한 인자 위치이므로 11번째 포맷에서 스택에 저장된 0x565557be를 출력시킬 수 있다.
이 주소를 이용해 give_shell의 주소를계산할 수 있다. give_shell 함수의 주소는 0x565557be - 0xee
코드확인: dreamhack.io/learn/4#28 (example8_leak.py)
example8_leak.py는 give_shell 함수의 주소를 구하는 파이썬 스크립트이고, 스크립트를 실행하면 다음과 같이 give_shell 함수의 주소가 출력됨.
example8.c의 18번째 줄에 존재하는 스택 오버플로우 취약점을 이용해 vuln 함수의 리턴 주소를 give_shell 주소로 덮어 셸을 실행할 수 있다. buf로부터 리턴 주소까지의 오프셋은 40 바이트이므로 최종 공격 페이로드는 다음과 같이 구성된다.
→ "A" * 40 + give_shell
코드확인: dreamhack.io/learn/4#29 (example8.py)
example8.py는 example8에 대한 익스플로잇 코드로 example8.py를 실행하면 다음과 같이 셸이 획득된다.
- PIE가 설정되어 있는지 확인하는 방법
PIE 보호기법이 적용되어있는 ELF 바이너리는 실행될 때 메모리의 동적 주소에 로딩됨. 때문에 readelf를 이용해 바이너리의 type header를 검사하는 것으로 바이너리의 PIE 적용 여부를 체크가능.
type header의 경우 일반적인 실행 파일은 EXEC, 라이브러리와 같은 shared 파일은 DYN
PIE가 적용되어 있지 않은 바이너리의 타입은 EXEC, PIE가 적용되어 있는 바이너리의 타입은 DYN
'Study > System Hacking' 카테고리의 다른 글
[dreamhack] Computer Architecture (0) | 2022.03.25 |
---|---|
[lazenca] Protection Tech / RELRO & PIC & PIE (0) | 2021.02.22 |
[LOB] troll -> vampire (0) | 2021.02.12 |
[lazenca] Protection Tech / ASLR & Canaries (0) | 2021.02.12 |
[dreamhack] Linux Exploitation & Mitigation Part 2 / ASLR & PLT, GOT Section (0) | 2021.02.12 |