[Exploit Tech Analysis] Master Canary 심층분석

일반적으로 바이너리에 canary가 적용되어 있으면 ROP가 까다로워진다. 함수의 시작 부분에서 SFP의 앞부분에 카나리를 저장하고, 끝 부분에서 실제 카나리와 스택에 저장해놨던 카나리를 비교해 일치하지 않으면 __stack_chk_fail()을 실행해 프로그램을 강제로 종료시켜버리기 때문이다. 여기서 카나리를 조금 깊게 공부한 사람이라면 결국 카나리 값은 fs register가 가리키고 있는 TLS 구조체에 담겨 있다는 사실을 알고 있을 것이다. 이 말은 결국 TLS도 메모리 상의 어딘가에 저장되어있다는 것이고, 그렇다면 “AAW 프리미티브가 있디면 원본 카나리 값을 변경할 수 있지 않을까?”라는 생각을 해 볼 수 있지만 fs register의 값을 읽어들일 방법이 없기 때문에 이건 거의 불가능하다.

그러나 위는 프로그램이 싱글 쓰레드일때를 가정한 것이다. 만약 프로그램이 멀티쓰레드를 사용한다면, 생성된 쓰레드의 원본 카나리 값을 변경하는 것이 가능해진다. 버퍼 오버플로우를 할 수 있다면 버퍼로부터 원본 카나리까지의 offset이 동일하다는 점을 이용하는 것인데, 도대체 어떻게 이게 가능한지 의문점이 들어 조금 깊게 파 봤다.

1. 쓰레드의 메모리 할당

우선 쓰레드가 생성될 때 메모리가 어떻게 할당되는지 알아야 한다. thread_mem.gif 아마 위 그림이 쓰레드의 메모리 할당을 가장 잘 설명해주는 것 같다. 그림에서 보이듯 heap, static(아마 data를 이렇게 표현한 것 같다), code 영역은 모든 쓰레드들이 공유한다. 이렇게 영역을 공유하므로써

  1. 한 프로세스 내부의 쓰레드들은 프로세스의 함수를 메모리 낭비 없이 호출이 가능함 (code section 공유)
  2. 한 프로세스 내부의 쓰레드들은 프로세스의 전역변수에 접근이 가능함 (data section 공유) *이때 경쟁이 발생하는 변수에 대해 적절히 mutex를 구현하지 않으면 race condition이 발생할 수 있다.
  3. 한 프로세스 내부의 쓰레드들은 프로세스의 힙에 접근이 가능함 (heap 공유)

위와 같은 특징이 생기고, 특히 data 영역과 heap 영역을 공유하기 때문에 별도의 IPC 구현 없이도 쓰레드 간 통신이 가능하다.

여기서 가장 주목해야 할 사실은 새로운 쓰레드는 stack영역만 할당하면 된다는 것이다. 쓰레드에 할당하는 stack은 main thread와는 달리 조금 특이한 방식으로 할당된다. 이 과정을 자세히 분석하기에 앞서 우선 main thread가 생성될 때 (즉, main()이 실행될 때) 메모리를 어떻게 할당받고 TLS가 어느 영역에 할당되는지 살펴보면 이해에 도움이 된다. 우선 분석을 위해 사용한 테스트 코드는 다음과 같다.

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

void *thread_routine() {
    char buf[0x10];

    printf("thread created\n");
    scanf("%s", buf);
    sleep(3);
    
    printf("thread finished with %s\n", buf);
}

int main() {
    pthread_t thread;

    pthread_create(&thread, NULL, thread_routine, NULL);
    printf("created thread\n");
    pthread_join(thread, NULL);
    printf("thread joined\n");

    return 0;
}

2. main() 실행 시 TLS 할당

main() 실행 시 _dl_allocate_tls_storage() 라는 glibc에 정의된 함수가 TLS 할당을 담당한다. 소스를 살펴보면

// Def. in elf/dl-tls.c, line 507 (@glibc-2.41)

void *_dl_allocate_tls_storage (void)
{
  size_t size = _dl_tls_block_size_with_pre ();

  /* Perform the allocation.  Reserve space for alignment storage of
     the pointer that will have to be freed.  */
  _dl_tls_allocate_begin ();
  void *allocated = malloc (size + GLRO (dl_tls_static_align)
			    + sizeof (void *));

	...
}

다음과 같이 malloc()로 힙 영역에 TLS 구조체를 위한 영역을 할당받는 것을 볼 수 있다. 실제로 main()의 카나리 설정 직후 fs register의 값과 vmmap을 관찰하면 main_tls.png 다음과 같이 [anon_7ffff7d9c] 영역에 TLS가 할당된 것을 볼 수 있다1. 즉 stack과는 아무런 관련 없는 section에 TLS가 할당되었기에 ASLR의 영향으로 stack상의 변수에서부터 fs까지의 offset은 항상 달라진다. 게다가 힙에 할당되기 때문에 힙의 상태에 따라 할당되는 주소가 완전히 달라지므로 ASLR이 꺼져 있더라도 offset은 항상 달라질 수밖에 없다.

2. thread_routine() 실행 시 TLS 할당

이에 앞서 pthread_create() 함수를 살펴보면 다음과 같다.

// Def. in npti/pthread_create.c, line 444 (@glibc-2.17)
int
__pthread_create_2_1 (newthread, attr, start_routine, arg)
     pthread_t *newthread;
     const pthread_attr_t *attr;
     void *(*start_routine) (void *);
     void *arg;
{
  STACK_VARIABLES;
  
  ...

  struct pthread *pd = NULL;
  int err = ALLOCATE_STACK (iattr, &pd);
  
  ...
}

이 함수는 위와 같이 ALLOCATE_STACK()을 통해 생성할 thread를 위한 stack을 할당받는 것을 볼 수 있고, 이 함수를 조금 살펴보면

// Def. in npti/allocatestack.c, line 51 (@glibc-2.17)
# define ALLOCATE_STACK(attr, pd) allocate_stack (attr, pd, &stackaddr)

// Def. in npti/allocatestack.c, line 343 (@glibc-2.17)
static int
allocate_stack (const struct pthread_attr *attr, struct pthread **pdp,
		ALLOCATE_STACK_PARMS)
{
	...
  mem = mmap (NULL, size, prot,
      MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0);
  ...
  pd = (struct pthread *) ((uintptr_t) attr->stackaddr
       - TLS_TCB_SIZE - adj);
  ...
}

위처럼 mmap()을 통해 빈 공간을 할당받고 할당받은 메모리의 top 부근에 TLS를 위한 공간을 마련해놓는 것을 볼 수 있다. 실제로 테스트 소스에서 thread_routine()의 카나리 설정 직후 fsrsp를 살펴보면

thread_tls.png

위에서 봤던 [anon_7ffff7d9c] 영역의 크기가 확 늘어났고 이 영역의 끝자락에 TLS가, 그로부터 조금 떨어진 지역에서 stack이 자라고 있음을 확인해볼 수 있다. 그림으로 표현하면 다음과 같다.

thread_tls_fig.jpg

즉 새로 생긴 쓰레드 입장에서는 TLS가 stack에 들어있는 것이기 때문에 이때는 ASLR이 켜져 있어도 offset은 동일할 수밖에 없다. (stack은 deterministic하기 때문이다. 잘 생각해 보면 저기까지 도달하는 경로에 있는 함수들이 항상 정해진 크기의 stack frame을 생성하고, 값을 다를지라도 항상 정해진 크기의 변수들을 생성하니까 당연하다는 것을 알 수 있다.) 따라서 offset을 잘 계산하면 충분히 TLS에 접근할 수 있고, 따라서 버퍼 오버플로우만으로 마스터 카나리를 덮어버릴 수 있다. 이 사실을 실제로 어떻게 공격에 사용하는지는 이미 많은 글들이 있어 생략해도 될 것 같다.

  1. 이때 heap 영역으로 표시되지 않고 data 영역으로 표시되는 건 malloc()가 내부적으로 특정한 조건 아래에서 mmap()을 대신 사용하기 때문이다. 이 내용은 나중에 다뤄보려고 한다. 

Leave a comment