おい、手を止めてこっちをくれ。
最近のペネトレーションテストやレッドチームの案件を振り返っていて、一番「まだここを突かせるのか…」と頭を抱えたくなるポイントがどこだか分かるか?
そう、クラウド環境におけるSSRF(サーバーサイドリクエストフォージェリ)を発起点としたメタデータサービス(IMDS)の悪用だ。
インフラ担当者が「うちはWAFを入れてるから大丈夫です」「APIのバリデーションは厳しくしています」と胸を張る現場に限って、アプリケーション層のわずかなロジックの隙を突かれ、インスタンスのIAMロールが丸裸にされている。AWSで言えば 169.254.169.254 というマジックナンバーだ。このアドレスにリクエストが通るようになっていれば、攻撃者は一瞬でクラウドの王様権限を奪い取るパスポートを手に入れる。
今日は、なぜ従来のIMDSv1が致命的なのか、そしてそれを根本から粉砕するためのIMDSv2の強制と、実践的なSSRF対策・実装コードについて、俺の現場の知見を総動員して解説する。後輩のお前たちには、二度と「知らなかった」では済まされないセキュアな設計を叩き込んでもらう。
—
1. なぜIMDSv1は「終わっている」のか?(攻撃者の視点)
まず敵を知ることから始めよう。AWSを筆頭とするクラウドのインスタンス内から、自分自身のメタデータ(インスタンスID、ネットワーク設定、そして最も重要な一時クレデンシャル)を取得するためのエンドポイントが IMDS(Instance Metadata Service)だ。
歴史的な背景もあり、初期の仕様である IMDSv1 は非常にシンプルだった。
例えば、WebアプリにURLを入力して画像を取得するような機能(SSRFの温床になりやすい機能)があったとしよう。攻撃者はターゲットのアプリに対して、以下のようなリクエストを送りつける。
GET /fetch?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/ HTTP/1.1
Host: vulnerable-app.internal
これだけで、アプリがバックエンドでこのURLにアクセスしてくれた場合、インスタンスにアタッチされているIAMロール名がレスポンスとして返ってくる。ロール名が分かれば、次の一手はこうだ。
GET /fetch?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/MyAdminRole HTTP/1.1
Host: vulnerable-app.internal
ビンゴだ。数秒後には AccessKeyId、SecretAccessKey、そして Token が攻撃者の手元に届く。攻撃者はこのクレデンシャルを自分のローカル環境に設定し、aws s3 ls や aws iam コマンドを叩いて、クラウドインフラ全体の乗っ取りを完了させる。
これが、脆弱なアプリケーションを起点としたクラウド侵害の王道パターンだ。HTTPのGETリクエスト一発、認証もトークンもなしで機微情報が引けてしまうIMDSv1は、攻撃者にとって「喉から手が出るほど美味しい踏み台」なのだ。
—
2. IMDSv2のメカニズム:なぜSSRFを防げるのか?
この状況に危機感を抱いたクラウドベンダーが生み出したのが IMDSv2 だ。
IMDSv2がIMDSv1と決定的に違うのは、「セッション指向(Session-Oriented)」の仕組みを導入した点にある。
IMDSv2では、メタデータにアクセスする前にPUTリクエストによってセッションの「トークン」を取得しなければならない。そして、実際のメタデータ取得リクエストの際には、そのトークンをHTTPヘッダー(X-aws-ec2-metadata-token)に付与することが強制される。
ここで、セキュリティエンジニアとして賢いお前らならピンと来たはずだ。
一般的なWeb脆弱性であるSSRFの多くは、攻撃者が任意のURLを指定して「単純なHTTP GETリクエスト」を強制させるものだ。HTMLの <img> タグの src 属性や、脆弱なHTTPクライアントライブラリ経由のGETリクエストでは、事前にPUTリクエストを発行してトークンを獲得し、それをカスタムヘッダーに詰めて次のリクエストを送るという一連のステートフルな処理を完結させることが極めて困難になる。
つまり、IMDSv2を強制することは、万が一アプリケーションにSSRFの脆弱性が存在したとしても、メタデータサービスへの不正アクセスを構造的にシャットアウトする最強の防壁となるのだ。
—
3. インフラ・クラウド層での設定:IMDSv2の強制
アプリケーションコードを直す前に、まずは足元であるクラウドインフラ(AWSを例にする)でIMDSv1を完全に殺し、IMDSv2を強制する設定を確認しよう。Terraformを使うのが現代のインフラ管理の常道だ。
以下のTerraformコードをインフラ定義に適用し、既存のインスタンスも含めてIMDSv2を強制(http_tokens = "required")してほしい。
# TerraformによるEC2インスタンスでのIMDSv2強制設定
resource "aws_instance" "secure_server" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.medium"
# メタデータサービスのオプション設定
metadata_options {
http_endpoint = "enabled" # メタデータサービス自体は有効化
http_tokens = "required" # ★超重要: IMDSv2のトークンを必須化(v1を拒否)
http_put_response_hop_limit = 1 # ホップ数を1に制限し、コンテナ等からの踏み台化を防ぐ
}
tags = {
Name = "SecureProductionInstance"
}
}
ここで http_put_response_hop_limit = 1 に設定している点もポイントだ。Dockerなどのコンテナや、インスタンス内で稼働するリバースプロキシを介したルーティング(ホップ数が2以上になるケース)によるメタデータアクセスを制限し、横展開の攻撃リスクをさらに低減させている。
—
4. アプリケーション層でのSSRF対策:コピペで使えるセキュア実装サンプル
インフラ側でIMDSv2を強制したからといって、アプリケーション側のSSRFを放置していい免罪符にはならない。SSRFはクラウドの乗っ取りだけでなく、社内ネットワーク(プライベートIPや内部マイクロサービス)への不正アクセスの踏み台にも使われるからだ。
ここでは、外部URLを取得するような危険な機能を安全に実装するためのPHP(Laravel風)およびPython(Requests使用)のセキュアなサンプルコードを示す。
【Python】安全なURLフェッチ実装例
URLスキームの厳格なバリデーション、プライベートIP(RFC 1918等)やリンクローカルアドレス(169.254.169.254 を含む)へのリクエストを明示的にブロックするロジックを組み込んでいる。
import ipaddress
import socket
from urllib.parse import urlparse
import requests
def secure_fetch_url(target_url: str) -> str:
"""
SSRF対策を施した安全なURLフェッチ関数
"""
# 1. URLのパース
parsed = urlparse(target_url)
# 2. 許可されたスキーム(http/https)のチェック
if parsed.scheme not in ['http', 'https']:
raise ValueError("無効なURLスキームです。httpおよびhttpsのみ許可されています。")
hostname = parsed.hostname
if not hostname:
raise ValueError("ホスト名が不正です。")
try:
# 3. ホスト名をIPアドレスに名前解決し、内部IPへのアクセスをブロックする
ip_addr_str = socket.gethostbyname(hostname)
ip_obj = ipaddress.ip_address(ip_addr_str)
# プライベートIP、ループバック、リンクローカル(IMDS含む)の判定
if ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_link_local:
raise ValueError(f"セキュリティポリシー違反: 内部IPアドレス ({ip_addr_str}) へのアクセスは禁止されています。")
except socket.gaierror:
raise ValueError("ホスト名の名前解決に失敗しました。")
# 4. リダイレクトを追跡しない、または厳しく制限してリクエストを実行
# allow_redirects=False にすることで、安全なURLから内部IPへのオープンリダイレクト経由のSSRFを防ぐ
try:
response = requests.get(
target_url,
timeout=5,
allow_redirects=False
)
response.raise_for_status()
return response.text
except requests.RequestException as e:
raise RuntimeError(f"外部リクエストの取得に失敗しました: {e}")
# --- 使用例 ---
# 以下は 169.254.169.254 を指定しているため、バリデーションで弾かれます
try:
# content = secure_fetch_url("http://169.254.169.254/latest/meta-data/")
content = secure_fetch_url("https://example.com/api/data")
print("取得成功:", content[:100])
except ValueError as err:
print(f"【ブロック成功】 {err}")
【PHP】安全なURLフェッチ実装例
PHPで同様のバリデーションを行う場合の実装だ。filter_var と gethostbyname を組み合わせ、危険なIP帯への接続を断ち切る。
“`php
*/
function secureFetchUrl(string $targetUrl): string {
// 1. URLのパース
$parsedUrl = parse_url($targetUrl);
if (!isset($parsedUrl[‘scheme’], $parsedUrl[‘host’]) ||
!in_array(strtolower($parsedUrl[‘scheme’]), [‘http’, ‘https’], true)) {
throw new InvalidArgumentException(“無効なURL形式またはスキームです。”);
}
$host = $parsedUrl[‘host’];
// 2. ホスト名をIPアドレスに解決
$ip = gethostbyname($host);
if ($ip === $host && !filter_var($host, FILTER_VALIDATE_IP)) {
throw new RuntimeException(“ホスト名の名前解決に失敗しました。”);
}
// 3. IPアドレスのフィルタリング(プライベートIP、ループバック、リンクローカル)
// FILTER_FLAG_NO_PRIV_RANGE および FILTER_FLAG_NO_RES_RANGE を利用
$filterFlags = FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE;
// リンクローカル(169.254.0.0/16 や IPv6のfe80::/10)を明示的にチェック
if (
!filter_var($ip, FILTER_VALIDATE_IP, $filterFlags) ||
preg_match(‘/^169\.254\./’, $ip) ||
preg_match(‘/^127\./’, $ip) ||
$ip === ‘0.0.0.0’
) {
throw new DomainException(“セキュリティポリシー違反: 許可されていないIPアドレスへのアクセスです。”);
}
// 4. cURLを使った安全なリクエスト実行(リダイレクト追跡を禁止)
$ch = curl_init($targetUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false); // オープンリダイレクト経由のSSRFを防ぐ
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if (curl_errno($ch)) {
$error = curl_error($ch);
コメント