- 오래된 힙 취약점 중 하나인 Unsafe Unlink에 대해서 알아보자.
Unsafe Unlink
- 단일 연결리스트로 구성된 bin을 제외하고 freelist에서 병합이 일어날때 unlink과정이 수행된다.
- glibc 2.23버전까지는 이러한 unlink과정이 매크로로 정의되어 수행되었으며 검사를 제대로 진행하지 않았다.
- 또한 이 기법이 발표된 2000년 초반에는 nx같은 보안기법도 기본적으로 적용되지 않아 쉘코드등을 heap에 적어놓고 실행해버리는 무시무시한 짓도 가능했다.
FD = P->fd;
BK = P->bk;
FD->bk = BK;
BK->fd = FD;
- unlink란 어렵게 생각할 것 없이 위 코드의 역할이다.
- P는 unlink될 청크를 가리키는 포인터이다.
- 따라서 unlink될 청크의 bk를 fd에 존재하는 청크의 bk에 덮어쓰고 fd를 bk에 존재하는 청크의 fd에 덮어쓰는 것이다.
- 말로 하면 헷갈리니 그림을 보자.
- 그림을 보면 현재 3개의 청크가 double linked list로 이어져 있다.
- 가운데 청크를 뺀다고 생각하면 나머지 두 청크를 이어주기 위해 P의 bk를 따라가서 그 청크의 fd에 P의 fd를 써주고 P의 fd를 따라가서 거기에 P의 bk를 써주면 다시 이어질 것이다.
- 이러한 unlink방식은 생각해보면 간단하다.
- ptmalloc 이전의 dlmalloc의 경우 이러한 방식을 아무 검증없이 매크로로 구현해 놓았고 따라서 공격자가 unlink될 청크의 fd와 bk를 조작 가능할 시 임의 쓰기 취약점이 바로 터진다.
Exploit Scenario
- 검증없는 unlink 구현
- heap, libc Leak 가능
- NX 미적용(stack, heap)
- heap overflow 발생
- 을 기준으로 진행한다. 여기서는 unlink취약점이 어떻게 이루어지는지만 이해하고 Safe Unlink에서 현재의 검증과 익스플로잇을 소개한다.
- 현재 프로그램에서 smallbin에 해당하는 size인 0x90크기의 청크 2개를 할당하였다.
- 사용자는 청크의 Data부분에 값을 쓸 수 있고 overflow가 존재하여 다음 청크의 prev_size와 size 필드를 덮어 쓸 수 있다.
- 이때 생각해봐야 하는 것이 다음 청크의 size필드를 덮을 때 prev_inuse비트를 0으로 만들 수 있다는 것이다.
- 그렇다면 조작된 chunk2를 free할 때 chunk1이 free된 상태라고 dlmalloc은 판단하게 될 것이다.
- smallbin의 경우 병합의 대상이 되기 때문에 chunk1이 해제된 상태라고 오해한 dlmalloc할당자는 unlink과정을 수행하게 된다.
- 따라서 위와 같이 Fake fd와 bk를 작성하며 overflow를 일으켜 다음 청크의 prev_size와 size의 prev_inuse비트를 조작 가능하다.
- 그 후 chunk2를 해제하게 되면 공격자가 작성한 Fake fd와 Fake bk를 참조하여 unlink과정이 일어나게 되는 것이다.
- 그러므로 Fake fd와 Fake bk를 어떤 값으로 설정할지 생각해보아야 한다.
- 먼저 Fake fd에는 덮고 싶은 주소 - 0x18을 넣어야 한다. 왜냐하면 fd를 참조하여 그곳의 bk 포인터에 조작된 Fake bk를 쓰게 되는것이기 때문이다.
- 위 그림을 보면 이해 될 것이다. __free_hook - 0x18주소에 하나의 청크가 있다고 가정하면 bk포인터의 위치가 __free_hook주소가 되게 된다.
- 따라서 Fake bk에 넣는 값이 __free_hook에 써질 것이다.
- 하지만 주의할 점이 있는데 이것은 Fake bk에 쓴 주소에도 어딜 덮는지만 달라졌을 뿐 똑같이 진행된다는 것이다.
- Fake bk + 0x10 주소에 우리가 Fake fd에 넣은 __free_hook - 0x18주소가 써질 것이다.
- 이러한 점도 고려해서 payload를 구성해야 한다.
- heap에 nx가 걸려있지 않으므로 쉘코드를 실행할 수 있다.
- Fake fd에 __free_hook - 0x18을 넣어 쉘 코드가 저장되어 있는 Chunk1 + 0x20 주소를 __free_hook에 덮어쓴다.
- chunk1 + 0x30에 __free_hook - 0x18의 주소가 쓰이게 되지만 jmp shellcode를 통해 그 부분은 건너 뛰고 shellcode를 실행할 수 있다.
- 이렇게 payload를 구성 후 chunk2를 free하게 된다면 unsafe unlink공격이 진행되면서 __free_hook에 shellcode의 주소가 써지게 된다.
- chunk1을 한번더 free하게 된다면 __free_hook에 등록된 쉘코드의 주소가 호출되면서 쉘을 획득할 수 있을 것이다.
Safe Unlink
- 위에서는 dlmalloc의 unlink구현의 익스플로잇을 진행해보았다.
- 현재의 glibc의 경우 ptmalloc2를 기본 할당자로 사용하고 unlink매크로의 경우 unlink_chunk라는 함수로 변경되고 검증이 추가되어 위와 같이 쉽게 exploit이 가능하지 않다.
- https://elixir.bootlin.com/glibc/glibc-2.35/source/malloc/malloc.c#L1625
/* Take a chunk off a bin list. */
static void
unlink_chunk (mstate av, mchunkptr p)
{
if (chunksize (p) != prev_size (next_chunk (p)))
malloc_printerr ("corrupted size vs. prev_size");
mchunkptr fd = p->fd;
mchunkptr bk = p->bk;
if (__builtin_expect (fd->bk != p || bk->fd != p, 0))
malloc_printerr ("corrupted double-linked list");
fd->bk = bk;
bk->fd = fd;
if (!in_smallbin_range (chunksize_nomask (p)) && p->fd_nextsize != NULL)
{
if (p->fd_nextsize->bk_nextsize != p
|| p->bk_nextsize->fd_nextsize != p)
malloc_printerr ("corrupted double-linked list (not small)");
if (fd->fd_nextsize == NULL)
{
if (p->fd_nextsize == p)
fd->fd_nextsize = fd->bk_nextsize = fd;
else
{
fd->fd_nextsize = p->fd_nextsize;
fd->bk_nextsize = p->bk_nextsize;
p->fd_nextsize->bk_nextsize = fd;
p->bk_nextsize->fd_nextsize = fd;
}
}
else
{
p->fd_nextsize->bk_nextsize = p->bk_nextsize;
p->bk_nextsize->fd_nextsize = p->fd_nextsize;
}
}
}
- unlink_chunk함수의 소스코드는 위와 같다.
- unlink과정이 일어나기 전 2개의 검증이 존재한다.
if (chunksize (p) != prev_size (next_chunk (p)))
- unlink될 p 청크의 size가 다음 청크의 prev_size와 같아야 한다.
if (__builtin_expect (fd->bk != p || bk->fd != p, 0))
- unlink될 p 청크의 fd가 가리키는 청크의 bk 포인터가 p청크여야 한다.
- unlink될 p 청크의 bk가 가리키는 청크의 fd 포인터가 p청크여야 한다.
- 따라서 위 2가지의 검증을 우회하여 unlink공격을 수행해야 한다.
Exploit Scenario
- BSS영역에서 할당된 청크 데이터 관리하는 변수 존재
- 변수의 heap 포인터를 바탕으로 Data Write & Overflow
- no PIE
- Libc Leak 가능
- 을 조건으로 한다.
- 위의 시나리오에서 shellcode의 주소를 넘겨주는 대신 one_gadget등의 주소를 넘겨주면 되지 않나?? 라고 생각할 수 있지만 검증을 빼더라도 NX가 존재하여 덮이지 않는다.
- 현재 0x90크기의 청크 2개가 할당되었고 overflow로 인해 size와 prev_size까지 덮혔다고 생각해보자.
- 또한 BSS영역의 malloc_ptr에서 청크의 포인터와 크기를 기록해놓고 이를 통해 청크에 접근한다.
if (chunksize (p) != prev_size (next_chunk (p)))
- 먼저 위 검증을 우회해 보자.
- 이 것은 그냥 prev_size를 0x90으로 덮으면 해결되는 것이다.
- 어차피 prev_size를 이상한 값으로 덮게되면 chunk2를 해제할 때 chunk1을 찾을 때 prev_size로 찾기 때문에 segfault가 발생할 것이다.
if (__builtin_expect (fd->bk != p || bk->fd != p, 0))
- 따라서 가장 중요한 검증은 이 검증이다.
- 아 그럼 그냥 malloc_chunk의 주소를 fake fd bk로 잘 조작해서 하면 되겠구나! 할 수 있지만 그렇게 하면 검증에서 걸리게 된다.
- 왜냐하면 소스코드에서 malloc을 호출하여 얻는 포인터는 메타데이터 영역부터 시작하는 것이 아닌 사용자가 조작할 수 있는 영역의 포인터이기 때문이다.
- 따라서 malloc_ptr에서 chunk1을 가리키는 포인터는 0x601000이 아닌 0x601010이고 이를 fake fd혹은 bk로 조작할 시 fd->bk !=p 검증을 통과하지 못한다.
- 하지만 조금만 생각해보면 검증을 통과할 수 있다.
- 현재 prev_size값 또한 조작이 가능하다.
- chunk2가 free될때 전 청크를 찾는 과정이 prev_size를 통해 진행되므로 만약 prev_size를 chunk1의 시작이 아닌 chunk1 + 0x10을 가리키도록 한다면?
- 위와 같이 Fake Chunk를 구상할 수 있다.
- prev_size를 0x90으로 주는 것이 아닌 0x80으로 주어 chunk1 + 0x10을 가리키도록 하고 그 주소에 0x80짜리 Fake chunk를 만드는 것이다.
- 이렇게 되면 Fake fd, bk에 malloc_ptr을 통해 값을 넣어도 검증을 통과할 수 있게 된다.
- 따라서 Fake fd에 malloc_ptr - 0x8, Fake bk에 malloc_ptr-0x10을 넣게 되면
- malloc_ptr + 0x10에 Fake bk값인 malloc_ptr - 0x10, 즉 0x401000이 들어가게 되고
- Fake bk인 0x401000의 fd에 Fake fd값이 들어가게 되므로 0x401010에 0x401008이 들어가게 된다.
- 이렇게 되면 malloc_ptr을 통해 청크에 접근하여 값을 쓰기 때문에 공격자가 chunk2에 접근하여 값을 쓰게 되면 0x401000부터 마음대로 값을 덮을 수 있게 되는것이다.
- 따라서 chunk2에 접근하여 더미값과 함께 chunk1의 포인터를 __free_hook의 주소로 덮을 수 있다.
- 다시 한번 chunk1에 접근하면 __free_hook을 원하는 값으로 덮을 수 있다.
- one_gadget으로 덮어주고 chunk1을 free하면 쉘을 획득할 수 있다.