본문 바로가기

모각코/[2023_하계] 모각코

모각코 5일차 결과 (2023.08.10)

Stack_Canary : mitigation

Stack Canary란 Stack Buffer Overflow로 인한 return address를 보호하는 기법이다.

이 기법은 함수의 프롤로그에서 스택 버퍼와 반환 주소 사이에 임의의 값을 삽입하고 에필로그에서 변조 유/무를 확인하여 변조가 됐을 시 프로그램을 강제 종료하는 기법이다.

// Name: canary.c

#include <unistd.h>

int main() {
  char buf[8];
  
	read(0, buf, 32);
  
	return 0;
}

위 예제는 스택 버퍼 오버플로우 취약점이 존재하는 코드이다. 이 코드를 카나리를 활성화한 바이너리와 비활성화한 바이너리를 비교하여 살펴보도록 하자.

 

canary가 존재하지 않는 바이너리는 정상적인 return address가 아니기 때문에 segmentation fault가 발생한다.

canary가 존재하는 바이너리는 스택 버퍼 오버플로우가 발생했음을 알 수 있기 때문에 stack smashing detected/Aborted가 발생한다.

no canary
canary

위처럼 함수의 프롤로그에선 특정 값을 가져오는 것과 에필로그에선 그 값을 비교하는 루틴이 생긴 것을 알 수 있다.

리눅스에서는 프로세스 실행에 필요한 여러 데이터가 저장된 **Thread Local Storage(TLS)**를 가리키는 포인터로 fs 레지스터를 사용한다. 프로세스가 시작될 때 fs:0x28에 랜덤 값을 저장하는데, 따라서 이를 rax를 통해서 rbp-0x8에 저장하는 것을 알 수 있다.

 

fs 레지스터는 arch_prctl(int code, unsigned addr) 시스템 콜을 이용하여 설정되는데, arch_prctl(ARCH_SET_FS, addr) 형태로 호출될 때 fs 값이 addr 값으로 설정된다.

 

init_tls() 안에서 rdi 값이 0x1002(ARCH_SET_FS)일 때, rsi 값이 TLS를 저장하는 주소이다. 즉 fs는 이를 가리키게 된다. 그리고 security_init()이 TLS+0x28(fs:0x28, canary) 값을 설정해준다. 이 때 canary는 첫 바이트 값이 널 바이트인 것이 특징이다.

 

카나리를 우회하는 방법으로는 무차별 대입(Brute Force), TLS 접근, Stack Canary Leak이 존재한다.

이 중에서 무차별 대입은 연산량이 너무 많아 현실적으로 불가능하기 때문에 다른 방법으로 시도한다.

TLS 접근은 카나리는 TLS에 전역 변수로 저장되며, 매 함수마다 이를 참조해서 사용한다. TLS의 주소는 매 실행마다 바뀌지만 만약 실행 중에 TLS의 주소를 알 수 있고, 임의 주소에 대한 읽기 또는 쓰기가 가능하다면 TLS에 설정된 카나리 값을 읽거나, 이를 임의의 값으로 조작할 수 있다.

Stack Canary Leak은 함수의 프롤로그에서 스택에 카나리 값을 저장하므로 이를 읽어내면 에필로그의 검사를 통과할 수 있다.

Stack_Canary : mitigation

Stack Canary란 Stack Buffer Overflow로 인한 return address를 보호하는 기법이다.

이 기법은 함수의 프롤로그에서 스택 버퍼와 반환 주소 사이에 임의의 값을 삽입하고 에필로그에서 변조 유/무를 확인하여 변조가 됐을 시 프로그램을 강제 종료하는 기법이다.

// Name: canary.c

#include <unistd.h>

int main() {
  char buf[8];
  
	read(0, buf, 32);
  
	return 0;
}

위 예제는 스택 버퍼 오버플로우 취약점이 존재하는 코드이다. 이 코드를 카나리를 활성화한 바이너리와 비활성화한 바이너리를 비교하여 살펴보도록 하자.

canary가 존재하지 않는 바이너리는 정상적인 return address가 아니기 때문에 segmentation fault가 발생한다.

canary가 존재하는 바이너리는 스택 버퍼 오버플로우가 발생했음을 알 수 있기 때문에 stack smashing detected/Aborted가 발생한다.

no canary

canary

위처럼 함수의 프롤로그에선 특정 값을 가져오는 것과 에필로그에선 그 값을 비교하는 루틴이 생긴 것을 알 수 있다.

리눅스에서는 프로세스 실행에 필요한 여러 데이터가 저장된 **Thread Local Storage(TLS)**를 가리키는 포인터로 fs 레지스터를 사용한다. 프로세스가 시작될 때 fs:0x28에 랜덤 값을 저장하는데, 따라서 이를 rax를 통해서 rbp-0x8에 저장하는 것을 알 수 있다.

fs 레지스터는 arch_prctl(int code, unsigned addr) 시스템 콜을 이용하여 설정되는데, arch_prctl(ARCH_SET_FS, addr) 형태로 호출될 때 fs 값이 addr 값으로 설정된다.

init_tls() 안에서 rdi 값이 0x1002(ARCH_SET_FS)일 때, rsi 값이 TLS를 저장하는 주소이다. 즉 fs는 이를 가리키게 된다. 그리고 security_init()이 TLS+0x28(fs:0x28, canary) 값을 설정해준다.

이 때 canary는 첫 바이트 값이 널 바이트인 것이 특징이다.

카나리를 우회하는 방법으로는 무차별 대입(Brute Force), TLS 접근, Stack Canary Leak이 존재한다.

이 중에서 무차별 대입은 연산량이 너무 많아 현실적으로 불가능하기 때문에 다른 방법으로 시도한다.

TLS 접근은 카나리는 TLS에 전역 변수로 저장되며, 매 함수마다 이를 참조해서 사용한다. TLS의 주소는 매 실행마다 바뀌지만 만약 실행 중에 TLS의 주소를 알 수 있고, 임의 주소에 대한 읽기 또는 쓰기가 가능하다면 TLS에 설정된 카나리 값을 읽거나, 이를 임의의 값으로 조작할 수 있다.

Stack Canary Leak은 함수의 프롤로그에서 스택에 카나리 값을 저장하므로 이를 읽어내면 에필로그의 검사를 통과할 수 있다.

 

ASLR & NX : mitigation

ASLR (Address Space Layout Randomization)

바이너리가 실행될 때마다 스택, 힙, 공유 라이브러리 등을 임의의 주소에 할당하는 보호 기법이다.

일반적으로는 임의의 값을 쓸 수 있는 buffer의 주소를 바이너리에서 알려주지 않기 때문에 ASLR이 적용되어 있으면 먼저 buf의 주소를 획득해야 할 것이다.

ASLR은 커널 차원에서 지원하는 보호 기법으로,

위 명령어로 설정 옵션을 확인할 수 있다.

  • No ASLR(0): ASLR을 적용하지 않음
  • Conservative Randomization(1): 스택, 힙, 라이브러리, vdso 등
  • Conservative Randomization + brk(2): (1)의 영역과 brk로 할당한 영역

위처럼 옵션에 따라 0~2의 값을 가질 수 있다.

// Name: addr.c
// Compile: gcc test_aslr.c -o test_aslr -ldl -no-pie -fno-PIE

#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); // 코드 영역의 함수 주소
}

위 코드를 실행시켜보면,

위와 같은 실행 결과가 나오는데 이는 아래와 같은 특징을 가지고 있다.

  1. Code 영역의 main 함수를 제외한 다른 영역의 주소들은 실행할 때마다 변경된다.
  2. libc_base나 printf의 하위 12bit 값은 변경되지 않는다. 이는 ASLR이 적용될 때, 파일을 page 단위로 매핑하기 때문에 변경되지 않는다.
  3. libc_base와 printf의 주소 차이(Offset)는 항상 같다. printf 함수는 라이브러리 파일 안에 존재하므로 라이브러리 파일이 매핑됨에 따라 주소를 따라간다.

NX(No-eXcute/Never-eXecute) bit

실행과 쓰기에 사용되는 메모리 영역을 각각 분리하기 위한 보호 기법이다. 이를 적용하지 않으면 Stack 영역에 실행 권한이 존재한다.

5.4.0 미만의 버전인 Linux 커널에선 읽기 권한이 있는 모든 페이지(스택, 힙, 데이터 등)에 실행 권한을 부여한다.

하지만 ASLR과 NX bit를 모두 적용해도 Code 영역에는 실행 권한이 있고, 할당되는 주소도 고정이다. 위와 같은 특징을 이용해서 RTL(Return To Library)와 ROP(Return Oriented Programming) 공격을 할 수 있다.

 

PIE & RELRO : mitigation

PIE (Position-Independent Executable)

기존에 배웠던 ASLR은 스택, 힙, 공유 라이브러리 등의 주소가 무작위로 매핑되었지만, 데이터와 코드 영역은 고정된 주소로 매핑되어 가젯을 활용한 ROP 공격이 통했다. 하지만 PIE를 적용하면 고정되었던 영역들도 ASLR이 적용된다. 원래는 보안을 위해 도입된 것은 아니다.

리눅스에서는 ELF가 실행 파일(Executable)과 공유 오브젝트(Shared Object, SO)로 두 가지가 존재한다. 이 중 공유 오브젝트는 기본적으로 재배치(Relocation)가 가능하게 설계되어 있어 메모리의 어느 주소에 적재되어도 코드의 의미가 훼손되지 않는데 이를 ******PIC(Position-Independent Code)******라고 부른다.

// Name: pic.c
// Compile: gcc -o pic pic.c
// 	      : gcc -o no_pic pic.c -fno-pic -no-pie

#include <stdio.h>

char *data = "Hello World!";

int main()
{
    printf("%s", data);
    return 0;
}

위 코드를 예시로 PIC를 알아보도록 하자.

no_pic

no_pic는 “%s” 문자열이 0x402011라는 절대 주소로 문자열을 참조하지만

 

pic

pic는 rip+0xeaf라는 주소로 상대 참조한다.

 

PIE를 우회하는 방법은 라이브러리의 베이스 주소를 구했던 것처럼 코드 영역의 임의의 주소를 알아서 오프셋을 빼주어 코드 영역의 베이스 주소를 구하는 방법이 있다. 또한, ASLR 특성 상 하위 12bit의 값은 항상 동일하기 때문에 사용하려는 코드 가젯의 주소가 반환 주소와 하위 한 바이트만 다르면 이 값만 덮어서 원하는 코드를 실행시킬 수 있다. 그러나 두 바이트 이상이 다른 주소로 옮기면 브루트포싱 방법으로 해야한다.

RELRO (RELocation Read-Only)

이전에 GOT overwrite를 배웠을 때 우리는 함수가 처음 호출될 때 주소를 구하고 이를 GOT에 적는 Lazy Binding을 알았는데, 이는 바이너리를 실행 중에 GOT를 업데이트 해야하기 때문에 쓰기 권한이 부여된다. 하지만 이는 바이너리를 취약하게 만드는 원인이 된다. RELRO는 이를 방지하기 위해 쓰기 권한이 불필요한 데이터 영역에 쓰기 권한을 제거한다.

부분적으로 적용하는 Partial RELRO와 가장 넒은 영역에 적용하는 Full RELRO가 있다.

// Name: relro.c
// Compile: gcc -o prelro relro.c -no-pie -fno-PIE
//        : gcc -o frelro relro.c

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main()
{
    FILE *fp;
    char ch;
    fp = fopen("/proc/self/maps", "r");
    while (1)
    {
        ch = fgetc(fp);
        if (ch == EOF)
            break;
        putchar(ch);
    }
    return 0;
}

위 두 가지를 비교하기 위해 예시 코드를 활용해보자.

  • Partial RELRO

위에 보면 0x404000~0x405000 영역은 쓰기 권한이 존재하는 것을 알 수 있다.

이 부분은 .got.plt, .data, .bss들이 존재하는 것을 알 수 있다. 이 때 .got와 .got.plt의 차이점은 전역 변수 중 실행되는 시점에 바인딩(now binding)되는 변수는 .got 영역에 위치한다. 바이너리가 실행될 때는 이미 바인딩이 되므로 쓰기 권한이 없다. 반면, 실행 중에 바인딩(lazy binding)되는 변수는 .got.plt 영역에 위치한다. 해당 영역은 실행 중에 업데이트 되야하므로 쓰기 권한이 부여된다.

 

  • Full RELRO

위에 보면 0x563083774000~0x563083775000 영역은 쓰기 권한이 존재하는 것을 알 수 있다.

이 부분은 .data, .bss 영역만 존재하는 것을 알 수 있다. Full RELRO 적용 시 라이브러리의 함수들의 주소가 바이너리의 로딩 시점에서 모두 바인딩 되므로 GOT에는 쓰기 권한이 부여되지 않는다.

 

위에서 알아봤듯이 Partial RELRO 방식은 .got.plt 영역에 대한 쓰기 권한이 존재하므로 GOT overwrite 공격이 가능하지만, Full RELRO 방식의 경우 해당 공격이 불가능하다. 그래서 공격자들은 덮어쓸 수 있는 다른 함수 포인터를 찾다가 라이브러리에 위치한 hook을 찾아냈다. 원래는 동적 메모리의 할당과 해제 과정에서 발생하는 버그를 쉽게 디버깅하기 위해 만들어진 함수 포인터이다.

void *
__libc_malloc (size_t bytes)
{
  mstate ar_ptr;
  void *victim;
  void *(*hook) (size_t, const void *)
    = atomic_forced_read (__malloc_hook); // read hook
  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);
  // ...

위는 malloc 함수의 코드 중 일부분인데, 시작 부분에서 __malloc_hook이 존재하는지 검사하고 이를 호출한다. __malloc_hook은 libc.so에서 쓰기 가능한 영역에 위치하는데 공격자가 libc가 매핑된 주소를 알 때, 이 변수를 조작하고 malloc을 호출하여 실행 흐름을 조작할 수 있다. 이와 같은 공격 기법을 Hook Overwrite라고 부른다.