[Exploit Tech Analysis][Heap] Unsafe Unlink / Safe Unlink

heap에 대한 기초 지식이 필요합니다.
(chunk, bin, tcache, consolidation 등)

heap을 공부하다 보면 필연적으로 bin의 개념을 마주칠 수밖에 없다. 여기서 중요한 것은 fastbin을 제외한 모든 bin은 double linked list로 관리된다는 것이다1. 이로 인해 리스트에 변동이 생기면 unlink가 발생할 수밖에 없는데, 살펴볼 unsafe unlink / safe unlink는 청크가 병합(consolidation)되는 과정에서 일어나는 unlink를 악용하여 임의 주소 쓰기가 가능하도록 한다.

1. Precondition

  1. 힙을 관리하는 변수의 주소를 알고 있어야 한다.
    이런 특징으로 PIE가 걸려 있지 않은 바이너리에서 전역 변수로 힙 영역의 메모리가 관리될 때 유용하게 사용할 수 있다.
  2. Heap Overflow 취약점이 존재해야 한다.
    이를 통해 청크의 헤더를 조작해야 하기 때문이다.

2. Unlink 과정

일반적으로 unlink는 다음과 같이 이루어진다. 예시를 위해 unlink 대상인 원소를 P라 하자.
원소 P의 다음 원소는 P->fd이며, 원소 P의 이전 원소는 P->bk이다.

  1. FD라는 변수에 P->fd를 저장한다.
  2. BK라는 변수에 P->bk를 저장한다.
  3. FD->bk(=P->fd->bk)를 BK(=P->bk)로 설정한다.
  4. BK->fd(=P->bk->fd)를 FD(=P->fd)로 설정한다.

과정별로 그림을 그려 보면 다음과 같다. (양방향 화살표 ⟷가 단방향 화살표 ↤↦로 바뀌는 것에 주목하자)

unlink_step.jpg

앞으로 계속 쓰이니 흐름을 확실하게 직접 따라가보고 이해해야 한다.

3. Unsafe Unlink

3-1. 공격 상황

다음과 같은 heap 상태가 주어졌다고 하자.

unsafe_unlink_1.jpg

이때 chunk 0에 대한 heap overflow 취약점이 발생해, 공격자가 다음과 같은 상황을 만들 수 있다고 하자.

  • 해제된 청크의 모습을 닮은 fake chunk를 구성할 수 있음
  • fd, bk는 쓰고 싶은 임의 주소 값을 넣음
  • chunk 1의 prev_size를 구성한 fake chunk의 크기로 맞춤
  • size 필드의 P(prev_in_use) 비트를 0으로 만듦 그림으로 표현하면 다음과 같다.
unsafe_unlink_2.jpg

3-2. 공격 과정

위와 같은 상황이 갖춰졌을 때 chunk 1을 해제하면, free()는 해제 대상 청크의 P(prev_in_use) flag가 0인 것을 확인한다. 그렇다면 이전 청크와 병합이 가능하다는 뜻이므로, free() 함수는 다음과 같이 병합을 시도한다.

  • 현재 청크의 prev_size 값을 읽어, current_chunk - prev_size에 있는 청크를 P 청크로 설정한다. (여기서 P는 flag가 아니다!)
  • P를 대상으로 위에서 봤던 unlink 작업을 그대로 수행한다.

위 예시에서 공격자가 prev_size값을 미리 fake chunk의 크기로 맞춰두었기 때문에, P는 공격자가 구성한 fake chunk가 된다2. 이때 fdbk는 공격자가 임의로 설정할 수 있는 주소이기 때문에, AAW 프리미티브로 사용할 수 있다.

4. Safe Unlink

4-1. Mitigation

예전의 glibc-2.13 이전에는 아무 검증 없이 이 과정을 수행했기 때문에, 위에서처럼 해제하고자 하는 청크(P)의 fd와 bk를 잘 조작해주면 임의 주소에 연결해 쓰는 것이 가능했다. 그러나 glibc-2.13 이후 버전에서 처음으로 이에 대한 mitigation이 등장했고, glibc-2.3에서 더욱 엄격한 검증이 이루어지도록 바뀌었다. 주요 변경점은 다음과 같다.

// Def. in /malloc/malloc.c, line 1608 (@glibc-2.39) 

static void
unlink_chunk (mstate av, mchunkptr p)
{
  if (chunksize (p) != prev_size (next_chunk (p)))
    malloc_printerr ("corrupted size vs. prev_size");
  ...
  if (__builtin_expect (fd->bk != p || bk->fd != p, 0))
    malloc_printerr ("corrupted double-linked list");
  ...
}

다음과 같은 2개의 조건을 동시에 만족해야 함을 볼 수 있다.

  1. 대상 청크(p)의 크기가 다음 청크의 prev_size와 같아야 한다.
  2. fd->bk = p이며 동시에 bk->fd=p여야 한다.

두 조건은 조금만 생각해 보면 정상적인 상황에서는 당연한 조건들이다.

4-2. Bypass 1st condition

첫 번째 조건을 테스트할 때 사용하는 next_chunk 매크로는 다음과 같이 정의되어 있다.

// Def. in /malloc/malloc.c, line 1396 (@glibc-2.39) 

/* Ptr to next physical malloc_chunk. */
#define next_chunk(p) ((mchunkptr) (((char *) (p)) + chunksize (p)))

이로부터 현재 청크의 주소에 현재 청크의 사이즈를 더한 주소를 구하는 매크로임을 알 수 있다. 그러나 만약 fake chunk의 크기를 악의적으로 0으로 설정한다면, 이 매크로의 결과는 항상 자기 자신을 가리키게 된다. fake_chunk + 0 = fake_chunk이니 당연한 논리이다. 이렇게 첫 번째 조건은 fake chunk를 구성할 때 sizeprev_size 모두 0으로 설정하므로써 우회할 수 있다.

4-3. Bypass 2nd condition

두 번째 조건을 최대한 우회하기 위해 힙 영역의 메모리가 관리되는 주소를 알아야 한다. 결론적으로 말하면 fd에는 우리가 원하는 주소에서 0x18을 뺀 값, bk에는 우리가 원하는 주소에서 0x10을 뺀 주소를 넣으면 된다. 이에 대한 설명은 직접 unlink 과정을 따라가면서 봐야 한다.

4-4. 공격 과정

우선 다음과 같이 청크가 주어져 있다고 하자. 0x18 주소에 쓰는 것이 목표인 상황이고, 0x18보라색 청크의 주소를 가리키고 있는 상태이다.

safe_unlink_1.jpg

이제 fake chunk를 만들고, 파란색 청크의 prev_sizeP flag를 조작한다.

  • 만든 가상 청크의 prev_size, size는 전부 0
  • 파란색 청크의 prev_size값은 fake chunk의 사이즈
  • 파란색 청크의 P flag는 0
  • fd에는 [목표 주소 - 24]
  • bk에는 [목표 주소 - 16]
safe_unlink_2.jpg

마지막으로 파란색 청크를 해제하면 위에서 말한 조건 검사를 시작한다. 위에서 말했듯

chunksize(P) == prev_size (next_chunk(P))

조건부터 보면, next_chunk(P) = fake + 0x0이므로 자기 자신이고, 이것의 prev_size는 0으로 미리 설정해 뒀다. 마찬가지로 자신의 size역시 0으로 설정해 두었기 때문에 조건을 만족한다.

두 번쨰로 FD->bk == P && BK->fd == P 조건을 만족해야 한다. 이때 이 상황을 그림으로 표현해보면 이해가 쉽다.

safe_unlink_3.jpg

p->fd가 가리키는 주소인 0x0으로부터 청크를 그려 보면 노란색으로 표시해 둔 것처럼 target이 있는 자리가 원래 bk가 있는 자리임을 알 수 있고, 위에서 target은 이미 보라색 청크를 가리키고 있는 상태라 말했다. 따라서 FD->bk == P를 만족한다. BK->fd == P도 마찬가지이므로 조금만 생각해 보면 쉽게 이 조건도 만족함을 알 수 있다.

따라서 두 조건 모두 만족했으므로 다음과 같이 unlink가 진행되고, 결과적으로 target에는 0x0이 써진다.

safe_unlink_4.jpg
safe_unlink_5.jpg

이제 target에 접근해 쓰거나 읽으면 결과적으로 0x0에 쓰는 것이 된다. 굉장히 제약이 많은 AAW가 가능하나 만약 청크가 배열로 관리된다면 굉장히 강력해진다.

이를 어떻게 활용하는지는 다음에 다뤄 볼 예정이다.

5. Safer(?) Unlink

2024년 1월에 릴리즈된 glibc-2.39 버전부터는 원래 _int_free()unlink_chunk()로 이어지던 흐름이 _int_free()_int_free_merge_chunk()unlink_chunk()로 바뀌며 새로운 검사가 하나 더 추가되었다. 추가된 함수를 간단히 살펴보면

static void
_int_free_merge_chunk (mstate av, mchunkptr p, INTERNAL_SIZE_T size)
{
  ...
  /* Consolidate backward.  */
  if (!prev_inuse(p))                                         // (1)
    {
      INTERNAL_SIZE_T prevsize = prev_size (p);               // (2)
      size += prevsize;
      p = chunk_at_offset(p, -((long) prevsize));             // (3)
      if (__glibc_unlikely (chunksize(p) != prevsize))        // (4)
        malloc_printerr ("corrupted size vs. prev_size while consolidating");
      unlink_chunk (av, p);
    }
  ...
}

    (1) 지금은 p가 파란색 청크(free된 청크)이다. P flag를 검사한다.
    (2) 파란색 청크의 prev_sizeprevsize에 저장한다.
    (3) 여기서 p가 가리키는 대상이 fake chunk로 바뀐다.
    (4) 조건을 검사한다.

위와 같이 동작함을 알 수 있다. 풀어 설명하면 추가된 조건인 chunksize(p) == prevsize

파란색 청크의 prev_size가 fake chunk의 size와 같은가

를 검사하는 조건이다. 만약 기존처럼 fake chunk의 size에 0을 써 두면 이 조건에서 걸리게 된다.

safer_unlink.jpg

그러나 이는 생각보다 간단히 우회가 가능하다. 위 그림과 같이 fake chunk의 size를 실제 크기로 맞춰주면 된다. 이렇게 하면 새로 추가된 조건과 기존 조건을 모두 통과할 수 있다. 새로 추가된 조건을 통과하게 되는 것은 당연하고, 기존 조건이 왜 통과되는지를 조금 생각해 보면

  1. fake chunk + size값이 이제 자기 자신이 아니라 파란색 청크를 가리킴
  2. 파란색 청크의 prev_size도 결국 fake chunk의 사이즈로 설정해 두었으므로 두 값이 같음

이므로 둘 다 조건을 만족하는 것을 알 수 있다.

  1. 물론 tcache는 single linked list로 관리된다. 이는 fastbin도 마찬가지이다. 

  2. 잘 생각해보면 당연한 사실이다. chunk 1의 시작 주소에서 fake chunk의 사이즈만큼 뒤로 가면 당연히 fake chunk의 시작점이다. 

Leave a comment