テック技報最新号へ →
セキュリティ

SSRF 対策は「リダイレクト後の宛先」まで検証する(アプリ解析起点の攻撃への備え)

外部 URL を取りに行く API で最初の URL だけを検証しても、攻撃者のサーバーが 302 で内部アドレスへ飛ばせば突破される。リダイレクトは自動追従させず、1 ホップごとに宛先を検証し直す。

@MalwareBibleJP · 2026-10-17
#ssrf#redirect#api#mobile#flutter#a2b#typescript#backend

背景

JPCERT/CC が国内で相次いだ不正アクセス事案の攻撃元サーバーを調べたところ、攻撃者は Android アプリ(APK)を解析して API を特定し、AI も使って既知脆弱性のスキャンや API 経由の攻撃をしていた。サーバー上には、特定のリクエストに 302 リダイレクトを返す HTTPS サーバーの Python スクリプトがあり、SSRF の検証に使われたとみられる。Flutter アプリも Blutter や jadx で解析されており、コンパイル済みでも埋め込みの秘密鍵や署名ロジックは取り出せる前提に立つ必要がある。

実装のポイント

  1. 自動追従を切る: HTTP クライアントのリダイレクト追従を無効にし、自前でループする(最大ホップ数も決める)。
  2. 毎ホップで検証する: スキーム(https のみなど)、ポート、ホストの許可リスト、そして名前解決した IP がプライベート・ループバック・リンクローカル(169.254.169.254 のクラウドメタデータ含む)でないこと。
  3. 解決した IP に接続する: 検証後に改めて名前解決すると DNS リバインディングで別の IP になりうる。検証した IP へ接続するか、送信プロキシ側で宛先 IP を強制する。
  4. ネットワーク側でも塞ぐ: アプリの検証が漏れても届かないよう、URL 取得を担うワーカーの出口からメタデータや社内セグメントへの通信を遮断する。

TypeScript の例(Node.js、要点のみ)

ts
import { lookup } from "node:dns/promises";
import net from "node:net";

const MAX_HOPS = 3;
const ALLOWED_HOSTS = new Set(["images.example.com", "cdn.example.net"]);

function isPrivate(ip: string): boolean {
  const list = new net.BlockList();
  for (const [a, p] of [["10.0.0.0", 8], ["172.16.0.0", 12], ["192.168.0.0", 16],
                        ["127.0.0.0", 8], ["169.254.0.0", 16], ["0.0.0.0", 8], ["100.64.0.0", 10]] as const) {
    list.addSubnet(a, p, "ipv4");
  }
  for (const [a, p] of [["::1", 128], ["fc00::", 7], ["fe80::", 10]] as const) {
    list.addSubnet(a, p, "ipv6");
  }
  return list.check(ip, net.isIPv6(ip) ? "ipv6" : "ipv4");
}

async function assertSafe(url: URL): Promise<void> {
  if (url.protocol !== "https:") throw new Error("https only");
  if (!ALLOWED_HOSTS.has(url.hostname)) throw new Error("host not allowed");
  const addrs = await lookup(url.hostname, { all: true });
  if (addrs.some((a) => isPrivate(a.address))) throw new Error("private address");
}

export async function safeFetch(input: string): Promise<Response> {
  let url = new URL(input);
  for (let hop = 0; hop <= MAX_HOPS; hop++) {
    await assertSafe(url);                         // 毎ホップで検証
    const res = await fetch(url, { redirect: "manual" });
    if (res.status < 300 || res.status >= 400) return res;
    const loc = res.headers.get("location");
    if (!loc) return res;
    url = new URL(loc, url);                       // 相対 URL も解決してから再検証
  }
  throw new Error("too many redirects");
}

この例は名前解決と接続が別々なので、リバインディング対策としては送信プロキシや IP 固定の接続と組み合わせる。IPv4 射影 IPv6(::ffff:127.0.0.1)なども正規化してから判定する。

あわせてやること(JPCERT/CC の推奨より)

出典

X の元の投稿を開く