SSRFは、攻撃者ではなくサーバー自身に、内側のネットワークを叩かせる攻撃です。「画像のURLを入れてください」「通知を送るWebhookのURLは?」——こうした入力欄の裏では、サーバーがあなたの代わりにそのURLを取得しています。この代理取得が検証なしだと、攻撃者はクラウドの認証情報や社内の管理画面を、サーバーの手を借りて読み出せます。

本記事は連載「事故から逆算するWebセキュリティ」の第13回です。URLを受け取って取得する機能を持つサービスの開発者に向けて、SSRFがどこに潜むか、127.0.0.1を禁止する素朴なブロックリストがなぜ破られるか、そして正しい対策を、手元で動かした結果つきで整理します。セキュリティ全体の地図は「サプライチェーン攻撃とは|手口・防御ツール比較・npm 12の新機構まで実践解説」にまとめています。

WHO MAKES THE REQUESTSSRFの正体

攻撃者ではなく、サーバー自身に内側を叩かせる

  1. URLを渡す

    プレビュー・Webhook・取り込み欄に

  2. サーバーが代理で取得

    サーバーのネットワークから発信

  3. 内部ネットワークに到達

    FWの内側・localhost・別VPC

  4. 秘密を読み取る

    メタデータ・管理画面・内部API

SSRF = Server-Side Request Forgery(CWE-918)。出典: OWASP SSRF Prevention Cheat Sheet

30秒でわかる

  • ・何が起きるか:サーバーが代理でURLを取得する機能を踏み台に、内部ネットワークへリクエストを送らせる(CWE-918)
  • ・どこに潜むか:URLプレビュー・OGP取得・Webhook・画像/PDF変換・URL取り込み・AIエージェントのfetchツール
  • ・何に届くか:クラウドのメタデータ(169.254.169.254)・社内の管理画面・localhost・同じVPCの別ホスト
  • ・効く対策:宛先を許可リストで絞る/名前解決したIPを検証してそのIPへ繋ぐ/リダイレクト無効化/ネットワーク層で既定拒否/AWSはIMDSv2

10月13日に NICT が注意喚起した「A2B(App to Backend)攻撃」は、公開アプリを解析して API の接続先を割り出し、裏のサーバを直接攻撃する手口です。NICT が事業者に重点的な点検を求める脆弱性には、認可不備(IDOR/BOLA)、SQL インジェクション、RCE と並んで SSRF が挙がっています。URL を受け取る API がアプリの通信に出ていれば、それも A2B の対象になります。

SSRFとは|サーバーに内側を叩かせる攻撃

SSRF(Server-Side Request Forgery、サーバーサイドリクエストフォージェリ)は、脆弱性分類の CWE-918 に対応します。OWASP Top 10 2021 ではコミュニティ投票で新設された A10 として独立カテゴリに入りました。その定義はこうです。「SSRFの欠陥は、Webアプリケーションがユーザー提供のURLを検証せずにリモートリソースを取得するときに起きる。攻撃者はアプリケーションを強制して、ファイアウォール・VPN・その他のネットワークACLで守られていても、予期しない宛先へ細工したリクエストを送らせることができる」(OWASP Top 10 A10:2021 の Description より)。

鍵は「誰がリクエストを送るか」です。通常の攻撃は攻撃者のマシンから飛んできますが、SSRFではサーバー自身がリクエストの発信元になります。だからファイアウォールの内側、普段は外から触れない宛先にも届いてしまいます。

なお OWASP Top 10 の2025年版では、SSRF は独立カテゴリではなくなり、首位の A01:2025 Broken Access Control に統合されました(2025年版 Introduction に「SSRF has been rolled into this category」と明記)。カテゴリ番号は変わりましたが、CWE-918 という脆弱性そのものと対策は変わりません。

WHERE IT HIDESURLを取る所すべて

「URLを入れてください」の裏にサーバーの代理取得がある

  1. 1.URLプレビュー・OGP取得

    貼られたリンクのタイトルや画像を自動取得

  2. 2.Webhook・画像/PDF変換・URL取り込み

    宛先URLや変換元URLをユーザーが指定

  3. 3.AIエージェントのfetchツール

    モデルが渡したURLをそのまま取得させる

出典: OWASP SSRF Prevention Cheat Sheet(Context)・OWASP Top 10 A10:2021

OWASPのチートシートは、SSRFを生む代表例として次を挙げています。

・外部サーバーの画像:アバター画像のURLをユーザーが入れ、アプリが取得して使う
・Webhook:通知先やコールバックのURLをユーザーが指定する
・内部サービスとの連携:別サービスを呼ぶ処理に、ユーザーのデータが混ざる

近年はここにAIエージェントのfetchツールが加わりました。モデルが生成したURLをそのまま取得させる設計は、プロンプトインジェクションと組み合わさると強力なSSRFの入口になります。

SSRFで攻撃者が届く先|メタデータ・内部管理画面・localhost

SSRFが深刻なのは、到達先が「秘密そのもの」だからです。OWASP Top 10 A10:2021 の例示シナリオは4つあります。

・内部サーバーのポートスキャン:接続の成否や応答時間から、内部のポートの開閉を地図にする
・機微情報の露出:file:///etc/passwd や http://localhost:28017/ のようにローカルファイルや内部サービスを読む
・クラウドのメタデータ参照:http://169.254.169.254/ から認証情報を読み取る
・内部サービスの悪用:内部サービスを足がかりにRCEやDoSへ発展させる

とりわけ危険なのが 169.254.169.254——クラウドのインスタンスメタデータサービス(IMDS)です。ここには一時的なIAM認証情報が置かれており、読み取れればクラウド全体への横移動の起点になります。この経路の実例が、自己ホストのNext.jsで起きたSSRF脆弱性です。WebSocketのアップグレードリクエストを踏み台にサーバーを操り、IMDSやKubernetesのサービスアカウントトークンへ到達できる可能性が指摘されました(詳しくは「Next.js CVE-2026-44578|WebSocket SSRF(CVSS 8.6)の仕組みと緊急対策」)。Next.jsでは以降もSSRFを含む脆弱性の公開が続いており、同じエンジンでも版ごとに影響範囲が違います(「Next.js脆弱性まとめ|2026年に公開された深刻度別の一覧と対応の優先順位」)。

なぜSSRFの素朴なブロックリストは破られるのか

最初に思いつく対策は「127.0.0.1 や localhost や 169.254.169.254 を含むURLを弾く」でしょう。これは破られます。理由は、同じ宛先が無数の表記で書けること、そして検証したURLと実際に繋ぐ宛先がずれることです。

すり抜けの手口 素朴な文字列ブロックリスト 解決→検証→pin する安全版
127.0.0.1 をそのまま 弾ける 拒否(loopbackと判定)
10進数 2130706433 すり抜け(文字列が一致しない) 拒否(解決すると127.0.0.1)
8進・16進 0177.0.0.1 / 0x7f.0.0.1 すり抜け 拒否(同上)
IPv4射影IPv6 [::ffff:127.0.0.1] 表記次第ですり抜け 拒否(内側のv4で判定)
@ の userinfo http://[email protected]/ ホスト誤認ですり抜け 拒否(解決先で判定)
DNSリバインディング(検証後に内部へ) 検証時だけ公開IP・接続時に内部 無効化(解決したIPへ繋ぐ)
許可風ホストからの302リダイレクト 追従して内部に到達 拒否(リダイレクトを追わない)

ポイントは2つです。1つは表記の多様さ。127.0.0.1 は 10進数・8進数・16進数・IPv4射影IPv6などに書き換えられ、文字列の照合では網羅できません。もう1つは検証と接続のずれ。URLを検証した瞬間は公開IPに見えても、接続する瞬間に名前解決が内部IPへ変わる(DNSリバインディング)、あるいは許可された見た目のホストが302で内部へ飛ばす——このとき「検証したもの」と「実際に繋ぐもの」が別物になります。

手元で確かめる|素朴なブロックリストと安全なフェッチャ

実際に動かして確かめます。URLを受け取って取得するエンドポイントを模し、内部メタデータの模擬サービスを 127.0.0.1 に、信頼する取得先を別アドレスに立てました(Python 3.13・標準ライブラリのみ、2026-10-17 実測)。

2種類のフェッチャを同じ入力で比べる

素朴版は最初のURLだけを文字列で照合し、あとはリダイレクトに追従します。安全版は、ホスト名を名前解決し、ipaddress で private / loopback / link-local / reserved かを判定し、検証したIPへ繋ぎ、リダイレクトを拒否し、宛先を許可リストで絞ります。

import socket, ipaddress, urllib.parse, http.client

BLOCK = ["127.0.0.1", "localhost", "169.254.169.254"]
def fetch_naive(url):                    # 素朴版: 最初のURLだけ文字列検査→あとは追従
    if any(b in url.lower() for b in BLOCK):
        return "BLOCKED(文字列一致)"
    return _get_follow(url, 0)           # 302 を無検査で追従

def forbidden(ip):                       # 解決済みIPを内部宛かどうかで判定
    o = ipaddress.ip_address(ip); o = getattr(o, "ipv4_mapped", None) or o
    return (o.is_private or o.is_loopback or o.is_link_local
            or o.is_reserved or o.is_multicast or o.is_unspecified)

ALLOW = {"203.0.113.10"}                 # 信頼する宛先(本番は到達先のグローバルIP)
def fetch_safe(url):                      # 安全版: 解決→検証→pin→リダイレクト拒否
    u = urllib.parse.urlsplit(url)
    if u.scheme not in ("http", "https"): return "REFUSED(scheme)"
    try: ips = sorted({i[4][0] for i in socket.getaddrinfo(u.hostname, u.port or 80)})
    except socket.gaierror: return "REFUSED(名前解決不可)"
    pinned = ips[0]
    if pinned not in ALLOW:              # 許可外は内部宛かどうかに関わらず拒否
        return f"REFUSED(内部/許可外: {pinned})"
    c = http.client.HTTPConnection(pinned, u.port or 80, timeout=3)  # 検証したIPへ繋ぐ
    c.putrequest("GET", u.path or "/", skip_host=True); c.putheader("Host", u.hostname); c.endheaders()
    r = c.getresponse()
    if r.status in (301, 302, 303, 307, 308): return "REFUSED(リダイレクト)"  # 追従しない
    return "FETCHED: " + r.read().decode(errors="replace")

同じ4つの攻撃と、1つの正規アクセスを両方に通した結果がこれです。

=== (1) 素朴な文字列ブロックリスト ===
  そのまま127.0.0.1   -> BLOCKED(文字列一致)
  10進数2130706433    -> FETCHED: AKIA_FAKE_ROOT_CREDENTIAL   ← 内部の秘密に到達
  8進数0177.0.0.1     -> FETCHED: AKIA_FAKE_ROOT_CREDENTIAL
  16進0x7f.0.0.1      -> FETCHED: AKIA_FAKE_ROOT_CREDENTIAL
  許可風ホスト→302    -> FETCHED: AKIA_FAKE_ROOT_CREDENTIAL
  正規の信頼先         -> FETCHED: OK public-like content

=== (2) 安全なフェッチャ(解決→検証→pin→リダイレクト拒否) ===
  そのまま127.0.0.1   -> REFUSED(内部/許可外: 127.0.0.1)
  10進数2130706433    -> REFUSED(内部/許可外: 127.0.0.1)   ← 解決すると同じIP
  8進数0177.0.0.1     -> REFUSED(内部/許可外: 127.0.0.1)
  16進0x7f.0.0.1      -> REFUSED(内部/許可外: 127.0.0.1)
  許可風ホスト→302    -> REFUSED(リダイレクト)
  正規の信頼先         -> FETCHED: OK public-like content   ← 正規は通る(陽性対照)

素朴版は、127.0.0.1 という文字列そのものだけを弾き、3通りのエンコードと1つのリダイレクトで内部の秘密に到達しました。安全版は4つの攻撃をすべて拒否し、同時に許可リストに登録した正規の宛先は正しく取得できています。「全部ブロックしているだけ」ではないことを、この陽性対照が示します。

この検証は1台のマシン内で完結しており、実環境のグローバルIPを束ねられないため、正規の信頼先は許可リストに登録した代役のアドレスで確認しました。名前解決が実際のDNSに依存するDNSリバインディングの時間差そのものは再現していませんが、「解決したIPへpinして繋ぐ」設計がその攻撃を無効化する理屈は表のとおりです(この点は未検証)。

SSRF対策|許可リスト・解決してpin・IMDSv2

手元の結果が示す対策を、OWASPの指針に沿って層ごとに整理します。

RESOLVE THEN PIN対策の門

名前解決したIPで判定し、そのIPへ繋ぐ

解決→検証→pin

  • 127.0.0.1ループバック 拒否10進・8進・16進でも解決後は同じ
  • 169.254.169.254メタデータ 拒否link-local として判定
  • 302 リダイレクト許可風ホスト経由 拒否追従しない/各ホップ再検証
  • 許可済みの宛先グローバルIP 通すallowlist 登録済み
出典: OWASP SSRF Prevention Cheat Sheet(IP address / URL / Network layer)

アプリケーション層。OWASP Top 10 A10:2021 の「How to Prevent」はこう挙げています。

・URLスキーム・ポート・宛先を許可リスト(ポジティブリスト)で強制する:http/https だけ、既知の宛先だけ
・ユーザーからURL全体を受け取らない:IPかドメインだけを受け取り、検証した値からスキーム・ポート・パスを自分で組み立てる
・名前解決した結果のIPを検証し、そのIPへ繋ぐ:A/AAAAを引いてprivate・loopback・link-local・reserved を弾き、検証したIPへpinする(DNSリバインディング対策)
・HTTPリダイレクトを無効化する:追従する場合は各ホップで再検証する
・生のレスポンスをそのまま返さない:エラー内容や本文から内部情報が漏れる

ネットワーク層。「既定拒否(deny by default)」のファイアウォールで、必要な内部通信以外をすべて止める。取得処理を別ネットワーク・別サービスに分離してSSRFの影響範囲を小さくする。許可・拒否の両方のフローをログに残す。アプリ層の検証を1箇所すり抜けても、ネットワーク層で止まるのが多層防御です。

クラウド層(AWS)。EC2では IMDSv2 を必須化します。IMDSv2 はメタデータ取得の前に、PUT リクエストでセッショントークンを先に取る必要がある方式です(AWS公式ドキュメントより、トークンのTTLは最大6時間=21,600秒)。単純なGETだけのSSRFではトークンを取れず、メタデータに届きにくくなります。さらにPUTレスポンスのホップ制限(hop limit)が効きます。AWS公式ドキュメントは「既定では PUT リクエストへの応答はIPプロトコル層でホップ制限(TTL)が 1」と記載しています。ホップ制限1なら、コンテナなど1ホップ離れた場所からのトークン取得が通らず、SSRF経由の奪取がさらに難しくなります(コンテナで正規に使うなら2以上に広げる調整が公式に案内されています)。

この回の要点

  • ・SSRFは、URLを取得する機能を踏み台にサーバー自身に内部を叩かせる(CWE-918)
  • ・127.0.0.1 を文字列で弾くだけでは、エンコードとリダイレクトで破られる(手元で4通り確認)
  • ・効くのは許可リスト・ユーザーのURLをそのまま使わない・解決したIPへpin・リダイレクト無効化・ネットワーク層の既定拒否
  • ・AWSはIMDSv2とホップ制限1がメタデータ奪取への追加の防御層になる

参照ソース