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?

URL を入れたら AWS の鍵が漏れた ── 入力を実行に混ぜる SQLi と SSRF

0
Posted at

はじめに ── URL を入れたら画像を取ってくる、その便利機能が AWS の鍵を漏らした

以前の私は、ユーザーの入力をそのままプログラムに渡すことに、あまり抵抗がありませんでした。

たとえば検索機能は、フォームに入った文字列をそのまま SQL に連結して書いていました。

// 検索キーワードをそのまま SQL 文に連結していた(当時のイメージ)
$sql = "SELECT * FROM customers WHERE name LIKE '%".$keyword."%'";

もうひとつ、「URL を入れたら画像やフィードを取ってくる」便利機能も素朴に作っていました。ユーザーが渡した URL を、サーバー側でそのまま取得して返すだけの実装です。

// 渡された URL をサーバーが代理で取得して返していた(当時のイメージ)
return file_get_contents($request->input('url'));

どちらも「入力を受け取って、そのまま使う」だけの、ありふれたコードのつもりでした。ところがある日、この便利機能に対して次の URL を渡すデモを見せられました。

http://169.254.169.254/latest/meta-data/iam/security-credentials/app-ec2-role

すると、AWS の一時認証情報(アクセスキーとシークレット)が JSON でそのまま返ってきたのです。私のアプリは EC2 上で動いていて、その URL は「EC2 自身がクラウドの鍵を受け取るための内部アドレス」でした。ユーザーの入力を宛先として素直に取得しただけで、クラウドの鍵が外に漏れてしまったのです。

前者が SQL インジェクション(SQLi)、後者が SSRF(Server-Side Request Forgery) と呼ばれる脆弱性です。原因はどちらも同じでした。信頼できない入力を、そのまま"実行"に混ぜてしまったこと。SQLi は入力をコード(SQL)に混ぜ、SSRF は入力を宛先(URL)に混ぜていたのです。

この記事は、そんなちょっと前の私に向けて書きます。

想定読者:

  • 検索フォームの値を、そのまま SQL に連結している(していた)Web アプリ開発者
  • 「URL を入れたら取ってくる」機能を、宛先の検証なしに素朴に実装している人
  • AWS(EC2)で PHP アプリを動かしていて、SSRF やメタデータエンドポイント(169.254.169.254)を知らない人

この記事のゴールは、入力を"実行"に混ぜると何が奪われるのかを理解し、入力をデータとして扱い、コード(SQL)や宛先(URL)から分離する設計の第一歩を踏み出すことです。SQLi と SSRF を実演で体感し、フレームワーク(Laravel / FuelPHP)と AWS での守り方までを順に見ていきます。

先に結論(この記事で伝えたい3点)

細かい説明に入る前に、いちばん伝えたいことを3つ先に出します。

  1. 入力をコードに混ぜない。SQL の値はプレースホルダでバインドし、データとして分離します。文字列連結でつなぐと SQLi になります。
  2. 入力を宛先に混ぜない。外部から受け取った URL は、許可リストと名前解決後の IP で検証してから取得します。素通しすると SSRF になります。
  3. 基盤とアプリの多層で守る。SSRF は AWS 側(IMDSv2 の必須化)とアプリ側(URL 検証)の両輪で塞ぎます。片方だけでは破られます。

この3点を、以降の本文でほどいていきます。

この記事で扱うこと・扱わないこと

この記事は、社内で開催しているセキュリティ勉強会(全6回)の第2回にあたります。シリーズは次の順で進みます。第1回=認証・認可、第2回=インジェクション・SSRF(本記事=現在地)、第3回=XSS・CSRF・セッション、第4回=暗号・シークレット、第5回=ネットワーク・WAF、第6回=ログ・追跡。公開済みの回は本文からリンクでたどれます。当日の発表スライドも公開しているので、あわせてご覧ください。

対象環境とバージョンは次のとおりです。

  • Laravel 13.x(2026年時点の現行安定版)のクエリビルダ / raw 系
  • FuelPHP 1.x(レガシー)の DB クラス
  • AWS EC2 インスタンスメタデータサービス(IMDS) の v1 / v2
  • OWASP: A03:2021 Injection、A10:2021 SSRF、API Security Top 10 の API7:2023、CWE-89 / CWE-918
  • 言語は PHP(8.x を想定)。掲載する SQL は特定の RDBMS 方言に依存しない汎用的な書き方にしています(MySQL / PostgreSQL などを想定)

この記事は入力を実行に混ぜることで起きる SQLi と SSRF に集中します。範囲を絞るために、次の項目は本記事では扱わないことを先に断っておきます。

  • 出力側のインジェクション(XSS)や CSRF、セッション管理は扱いません(第3回)。
  • SSRF で抜かれた鍵の悪用や、最小権限・ローテーションといったシークレット管理は扱いません。
  • WAF やネットワーク境界での防御は扱いません。
  • 攻撃試行のログ検知やインシデント追跡は扱いません。

これらはいずれも重要ですが、1記事に詰め込むと焦点がぼやけます。それぞれ本シリーズの続く回で扱う予定です(近日公開予定。どの回で扱うかは本文中で都度ふれます)。ここでは「入力をどこに混ぜると何が起きるか」と「アプリと基盤でどう塞ぐか」に絞ります。

共通原理 ── 入力は「データ」、コードや宛先と混ぜない

SQLi と SSRF は別の脆弱性に見えますが、根っこは同じです。まずその共通原理を先に立てておきます。

インジェクションとは何か

CCT の教材では、インジェクションを「信頼できないデータをコマンドやクエリの一部として解釈・実行させる Web アプリケーションの脆弱性」と定義しています。悪意あるコマンドやクエリによって、データの損失・破損や、アクセス拒否などを引き起こします。SQL・LDAP・XPath のクエリでよく見られる、とされています(出典: CCT eBook 書籍235/PDF246)。

ポイントは「信頼できないデータを、コマンドやクエリの一部として解釈・実行させる」という部分です。本来はただのデータであるはずのユーザー入力が、プログラムの命令の一部として実行されてしまう。ここに穴の本質があります。

なお、インジェクションは SQL だけではありません。CCT でもコマンドインジェクションなどが挙げられています(出典: CCT eBook 書籍235/PDF246)。ただ、この記事ではもっとも身近な SQL インジェクションを代表例として扱います。

2つの型: コードに混ぜる(SQLi)/宛先に混ぜる(SSRF)

この記事で扱う2つの脆弱性は、「入力をどこに混ぜたか」で整理できます。

  • SQLi は、入力を"コード"に混ぜる。ユーザー入力を SQL 文の一部として連結すると、入力が SQL コードとして解釈・実行されます。
  • SSRF は、入力を"宛先"に混ぜる。ユーザー入力を、サーバーがアクセスする URL(宛先)として使うと、サーバーが本来アクセスすべきでない先へリクエストを送ってしまいます。

どちらも「入力はデータのはずなのに、実行に混ざってしまった」状態です。だから対策の方向も共通します。入力はデータとして扱い、コード(SQL)や宛先(URL)から明示的に分離する。これが本記事を貫く原理です。

以降、まず SQLi でこの原理を確認し、そのうえで本記事の重心である SSRF に進みます。

SQLインジェクション ── 入力を SQL に混ぜると全件漏れる

SQL インジェクションは、CCT でも「インターネット上で最も一般的な Web サイトの脆弱性」とされています。入力が検証されない脆弱性を利用し、Web アプリケーションを介して SQL コマンドをバックエンドの DB で実行させる攻撃です(出典: CCT eBook 書籍235/PDF246)。

CCT には、次のような具体例が載っています。顧客詳細を返す API があり、正常な URL は http://billpay.com/api/v1/cust/459 です。この API は内部で SELECT * FROM Customers where custID='"+custID+"' のように、URL の値(custID)を文字列連結して SQL を組み立てます。custID が 459 なら SELECT * FROM Customers where custID='459' になります。ここに攻撃者が ' or '1'='1 を注入すると、SQL は SELECT * FROM Customers where custID='' or '1'='1' となり、条件が常に真になって全顧客が返ってしまう、というものです(出典: CCT eBook 書籍262-263/PDF273-274)。

本シリーズの第1回(認証・認可 / IDOR)では、この同じ billpay.com/api/v1/cust/459 を使い、URL の ID を書き換えて他人のデータに触れる例(IDOR)を見ました。今回は同じ URL の「値の場所」に SQL 断片を混ぜます。ID を書き換えると他人1人のデータ、SQL を混ぜると全件。入口は同じ URL でも、混ぜる中身で被害が変わります。

【実演】ID の場所に ' or '1'='1 を混ぜると全件返る

言葉だけだとピンと来ないので、実演します。以下は説明用のフィクスチャで、example.com を使った架空のデータです。

まず、正常に顧客 ID を指定すると1件だけ返ります。次に、ID の場所に SQL 断片(' or '1'='1)を URL エンコードして混ぜます。

# 正常: 顧客IDで1件取得
$ curl "http://app.example.com/api/v1/cust/459"
[{"custID":459,"name":"Acme Corp"}]

# 攻撃: ID の場所に SQL 断片を混ぜる(' or '1'='1 を URL エンコード)
$ curl "http://app.example.com/api/v1/cust/%27%20or%20%271%27%3D%271"
# サーバー内で組み立てられる SQL:
#   SELECT * FROM Customers where custID='' or '1'='1'
# → 条件が常に真 = 全顧客が返る
[{"custID":1,"name":"..."},{"custID":2,"name":"..."}, ... 全件]

ID を1件指定したつもりが、混ぜた SQL 断片によって条件が常に真になり、全顧客が返りました。共通原理でいう「入力をコードに混ぜた」結果です。

なぜ効くのか / プレースホルダで「データ」に戻す

原因は、共通原理で述べたとおりです。入力を SQL 文字列に連結すると、入力が SQL コードとして解釈されてしまうからです。' or '1'='1 という文字列が、ただのデータではなく SQL の条件式として実行されました。

対策は、入力をデータに戻すことです。具体的には、値を SQL 文に直接埋め込まず、プレースホルダ(バインド変数)で渡す。こうすると、入力は「SQL の一部」ではなく「あとから当てはめる値」として扱われ、SQL コードとして解釈されなくなります。

実演では第1回とそろえて CCT の cust/459(ID 指定)を借りましたが、ここからは冒頭で挙げた自分の検索機能(name LIKE)を直します。攻撃した場所は違っても、「入力を SQL に連結した」という原因は同じです。だから直し方も共通で、値をプレースホルダに逃がすだけです。

以降、Laravel 13.x と FuelPHP 1.x で、穴あき(before)から修正後(after)への差分を見ていきます。コードは説明のためのスニペット抜粋です。

Laravel 13.x でどう塞ぐか

Laravel のクエリビルダと Eloquent は、既定で PDO のパラメータバインドを使うため、SQL インジェクションから保護されます。公式ドキュメントも、クエリバインディングとして渡す文字列はクリーン化やサニタイズが不要だと述べています(拙訳)。穴が開くのは、whereRawDB::raw などの raw 系にユーザー入力を文字列連結したときです。公式も、raw 式を使うクエリについては SQL インジェクションから保護されることを保証できないと警告しています(拙訳。出典: https://laravel.com/docs/13.x/queries)。

まず before です。whereRaw にキーワードを文字列連結しているため、' or '1'='1 が刺さります。

// app/Http/Controllers/CustomerController.php  (Laravel 13.x)
public function search(Request $request)
{
    $keyword = $request->input('q');
    // ↓ ユーザー入力を SQL 文字列に連結している = SQLi(raw 系は文字列として注入される)
    return DB::table('customers')
        ->whereRaw("name LIKE '%".$keyword."%'")   // ← 連結。' or '1'='1 が刺さる
        ->get();
}

after は、raw 式を使う場合でも、値を第2引数のバインド配列で渡します。あわせて、ソートに使うカラム名にも注意が必要です。PDO はカラム名をバインドできないため、公式は、クエリが参照するカラム名(order by のカラムを含む)をユーザー入力に決めさせてはならないとしています(拙訳)。カラム名やソート方向は許可リストで検証します(出典: https://laravel.com/docs/13.x/queries)。

// app/Http/Controllers/CustomerController.php  (Laravel 13.x)
public function search(Request $request)
{
    $keyword = $request->input('q');
    $sort    = $request->input('sort', 'name');

    // 値はプレースホルダでバインド(データとして分離)
    $query = DB::table('customers')
        ->whereRaw('name LIKE ?', ['%'.$keyword.'%']);   // ← ? でバインド

    // カラム名/ソート方向はバインドできない → 許可リストで検証
    $allowed = ['name', 'created_at'];
    if (in_array($sort, $allowed, true)) {
        $query->orderBy($sort);
    }
    return $query->get();
}

肝は次の1行です。連結をやめ、値を ? でバインドするだけで、' or '1'='1 は SQL ではなくただの文字列として扱われます。

-        ->whereRaw("name LIKE '%".$keyword."%'")       // 連結 = SQLi
+        ->whereRaw('name LIKE ?', ['%'.$keyword.'%'])   // ? でバインド

差分はこの whereRaw のバインド化と、カラム名を許可リストで絞ったことの2点です。

FuelPHP 1.x でどう塞ぐか

レガシーな FuelPHP 1.x でも考え方は同じです。危険なのは、DB::query() にユーザー入力を文字列補間するパターンです。

// fuel/app/classes/controller/api/customers.php  (FuelPHP 1.x)
public function get_search()
{
    $keyword = Input::get('q');
    // ↓ DB::query に文字列補間 = SQLi
    $sql = "SELECT * FROM customers WHERE name LIKE '%".$keyword."%'";
    return $this->response(DB::query($sql)->execute());   // ← 補間。' or '1'='1 が刺さる
}

after は、名前付きプレースホルダ(コロン接頭辞 :varname)と ->parameters() で値をバインドします。FuelPHP の公式ドキュメントにもこのバインド方法が示されています。なお、クエリビルダ(DB::select()->where())を使う場合は、値が既定でエスケープされます(出典: https://fuelphp.com/docs/classes/database/usage.html)。

// fuel/app/classes/controller/api/customers.php  (FuelPHP 1.x)
public function get_search()
{
    $keyword = Input::get('q');
    // 名前付きプレースホルダ + parameters() でバインド
    $result = DB::query('SELECT * FROM customers WHERE name LIKE :kw')
        ->parameters(array('kw' => '%'.$keyword.'%'))   // ← :kw にバインド
        ->execute();
    // クエリビルダなら値は既定でエスケープされる:
    // DB::select()->from('customers')->where('name', 'LIKE', '%'.$keyword.'%')->execute();
    return $this->response($result);
}

Laravel でも FuelPHP でも、やっていることは同じです。入力を SQL 文に混ぜず、プレースホルダで値として渡す。共通原理の「入力をコードに混ぜない」を、フレームワークの機能で実現しているだけです。

SQLi はここまでにして、本記事の重心である SSRF に進みます。

【重心】SSRF ── 入力の"宛先"を乗っ取るとクラウドの鍵が漏れる

冒頭の失敗談で私が本当に肝を冷やしたのは、SQLi ではなく SSRF のほうでした。ここからが本記事の山場です。

SSRF とは ── 公開サーバーを"踏み台"に内部へ

CCT では SSRF を次のように説明しています。攻撃者は公開 Web サーバーの SSRF 脆弱性を悪用し、内部サーバーやバックエンドサーバーに細工したリクエストを送信します。成功すると、ポートスキャンやネットワークスキャン、IP アドレスの発見、Web サーバーファイルの読み取り、ホストベースの認証のバイパス、重要なプロトコルとの相互作用、リモートコードの実行など、さまざまな活動が可能になります(出典: CCT eBook 書籍251/PDF262)。

入口の例も CCT に載っています。サーバサイドのリクエストは、外部リソースから情報を取得してアプリに取り込むために開始されます。たとえば https://xyz.com/feed.php?url=externalsite.com/feed/to のように、url パラメータで取得先を指定する機能です。ここで攻撃者が urllocalhost に変更できると、サーバー上のローカルリソースを見られてしまいます(出典: CCT eBook 書籍251/PDF262)。

まさに私の「URL を入れたら取ってくる」便利機能がこれでした。図にすると、攻撃者は公開サーバーを踏み台にして、本来は外から届かない内部やメタデータへリクエストを送らせます。

なぜ効くのか ── 内部は「同一ネットワークからのリクエスト」を信じる

なぜ、外から直接は届かないはずの内部アドレスにリクエストが通ってしまうのでしょうか。

CCT はその理由も述べています。内部サーバーは通常ファイアウォールで保護されていますが、同一ネットワーク上の Web サーバーからのリクエストだと思い込んで応答してしまうからです(出典: CCT eBook 書籍251/PDF262)。

攻撃者が自分のマシンから 169.254.169.254 やプライベート IP を直接叩いても届きません。しかし、公開サーバーの「URL を取ってくる機能」を経由すれば、リクエストは公開サーバー自身から出ます。内部側から見れば「隣のサーバーからの正当なリクエスト」に見えるため、素直に応答してしまう。これが SSRF が効く仕組みです。

【実演】169.254.169.254 → メタデータ → IAM 一時鍵

では、公開サーバーの便利機能を踏み台に、AWS の鍵を抜くところを実演します。以下も説明用のフィクスチャで、値は AWS 公式ドキュメントに載っている EXAMPLE 値(明らかに偽の例示値)です。

EC2 には、インスタンス自身の情報を返すインスタンスメタデータサービス(IMDS)169.254.169.254 で待っています。まず、便利機能にこの IP を宛先として渡し、メタデータの一覧を取ります。

# アプリの便利機能に、宛先としてメタデータIPを渡す(IMDSv1=トークン不要のGET)
$ curl "http://app.example.com/fetch?url=http://169.254.169.254/latest/meta-data/"
ami-id
hostname
iam/                      ← これがある = IAMロールが紐づいている
instance-id
local-ipv4
mac
...

一覧に iam/ があります。これは、この EC2 に IAM ロールが紐づいている印です。ここまで来たら、あと一歩です。iam/security-credentials/ でロール名を取り、そのロール名を指定すると、一時認証情報が JSON で返ってきます

# まず security-credentials/ でロール名
$ curl "http://app.example.com/fetch?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/"
app-ec2-role

# ロール名を指定すると、一時認証情報が JSON で返る
$ curl "http://app.example.com/fetch?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/app-ec2-role"
{
  "Code" : "Success",
  "LastUpdated" : "2026-07-02T16:39:16Z",
  "Type" : "AWS-HMAC",
  "AccessKeyId" : "ASIAIOSFODNN7EXAMPLE",
  "SecretAccessKey" : "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
  "Token" : "IQoJb3JpZ2luX2Vj...(省略)...EXAMPLETOKEN",
  "Expiration" : "2026-07-02T22:39:16Z"
}

AccessKeyIdSecretAccessKey、そして Token が返ってきました。これは、この EC2 に紐づいた IAM ロールの一時認証情報です。AWS の公式ドキュメントによれば、この認証情報は iam/security-credentials/ のロール名から取得できます。応答には Code / LastUpdated / Type / AccessKeyId / SecretAccessKey / Token / Expiration が含まれ、認証情報は一時的で自動ローテーションされます。公式は、HTTP プロキシや、XML インクルージョンに対応した XML プロセッサなどがこの認証情報を露出させうると警告しています(拙訳)。まさに SSRF の経路です(出典: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instance-metadata-security-credentials.html)。

URL を1つ渡しただけで、クラウドの鍵が手に入りました。冒頭で私が肝を冷やしたのは、この瞬間です。抜かれた鍵を使って攻撃者が何をできるか、そして最小権限やローテーションでどう被害を抑えるかは、本シリーズの第4回で扱う予定です(近日公開予定)。ここでは「便利機能から鍵が抜ける」ところまでを押さえます。

根因は IMDSv1、IMDSv2 で止める

なぜ、URL を渡すだけで鍵が取れてしまったのでしょうか。根っこは IMDSv1 という古い方式にあります。

AWS の IMDS には2つの方式があります。IMDSv1 はリクエスト/レスポンス方式で、トークン不要の単純な GET でメタデータを取得できます。curl http://169.254.169.254/latest/meta-data/... のように、ヘッダもトークンも無しで叩けます。IMDS の IPv4 アドレスは 169.254.169.254 です(出典: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html)。

SSRF はまさにこの「単純な GET」が得意です。便利機能に GET させるだけで、メタデータも鍵も取れてしまいます。

一方の IMDSv2 はセッション指向方式です。まず PUT http://169.254.169.254/latest/api/token にヘッダ X-aws-ec2-metadata-token-ttl-seconds(TTL、最大 21600 秒=6時間)を付けてトークンを取得し、以降の GET には X-aws-ec2-metadata-token: <token> を付けます。トークンの使用が必須(required)の場合、トークンの無いリクエストや期限切れのリクエストは 401 Unauthorized になります。さらに PUT 応答のホップ制限(hop limit)は既定で 1 で、X-Forwarded-For ヘッダを含む PUT は拒否されます(出典: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html)。

この違いを図にすると、次のようになります。

SSRF の多くは、任意のヘッダを付けたり PUT を送ったりが難しく、ホップ制限も越えられません。だから IMDSv2 を必須にすると、SSRF 経由の鍵の抜き取りを大半防げます。ポイントは、IMDSv2 が「単純な GET では鍵を渡さない」ようにしていることです。

SSRF を多層で塞ぐ ── 基盤(IMDSv2)とアプリ(入力検証)は両輪

IMDSv2 が効くとわかっても、それだけでは足りません。SSRF はメタデータ以外の内部サービスも狙えますし、アプリ側で URL を素通ししている限り、踏み台にされる余地は残ります。SSRF は基盤(AWS)とアプリの両輪で塞ぎます。

基盤側: 既存 EC2 も IMDSv2 を必須にする

まず基盤側です。ここで大事なのは、2026年時点でも IMDSv1 が残っている前提で考えることです。

AWS の状況を整理すると、既定では多くのインスタンスが IMDSv1 と IMDSv2 の両方を使用可能で、IMDSv1 が残りうる状態です。細かくは次のような経緯があります(出典: https://aws.amazon.com/about-aws/whats-new/2024/03/set-imdsv2-default-new-instance-launches/ / https://aws.amazon.com/blogs/aws/amazon-ec2-instance-metadata-service-imdsv2-by-default/)。

  • Amazon Linux 2023(AL2023)は、2023年3月の提供時から IMDSv2 のみが既定です。
  • 2024年3月、アカウント単位(リージョン別)で、新規インスタンスの起動を IMDSv2 必須(HttpTokens=required)に既定設定できる機能が追加されました。
  • 2024年半ば以降にリリースされる新しい EC2 インスタンスタイプは IMDSv2 のみです。
  • ただし、これらの既定設定は既存のインスタンスには影響せず、上書きも可能です。

つまり、既存やレガシーの EC2 では IMDSv1 が残っている可能性が高いということです。新規起動の既定に任せきりにせず、既存インスタンスは明示的に IMDSv2 必須へ強制する必要があります。次の CLI で、HttpTokens=required にして IMDSv1 を無効化します(出典: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html)。

# 既存 EC2 も IMDSv2 必須に強制する(IMDSv1 を無効化)
aws ec2 modify-instance-metadata-options \
  --instance-id i-1234567890abcdef0 \
  --http-tokens required \
  --http-put-response-hop-limit 1 \
  --http-endpoint enabled

--http-tokens required が IMDSv1 を無効化する肝です。これで、トークン無しの単純な GET は 401 になり、SSRF 経由でメタデータや鍵を取られにくくなります。

アプリ側: 宛先を許可リスト + 名前解決後の IP 検証

次にアプリ側です。そもそも「外部から受け取った URL を素通しで取得する」のをやめます。OWASP の SSRF Prevention Cheat Sheet では、許可リスト(allow-list)を優先することが推奨されています。拒否リスト(deny-list)はバイパスされやすいためです。あわせて、最低限ブロックすべき宛先も挙げられています。AWS IMDS の 169.254.169.254、localhost(127.0.0.0/8 など)、RFC1918 のプライベート IP(10.0.0.0/8 / 172.16.0.0/12 / 192.168.0.0/16)などです。さらに、受け取ったドメインは許可リストと厳密に比較します。加えて、A/AAAA レコードを解決して IP を検証すること、Web クライアントのリダイレクト追従を無効化することも示されています(出典: https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html)。

考え方はフレームワークに依存しません。Laravel でも FuelPHP でも、次の擬似コードの流れで塞ぎます。

// SSRF 対策の考え方(フレームワーク非依存の擬似コード)
// Laravel なら HTTP クライアント(Guzzle)、FuelPHP なら curl だが「検証の考え方」は共通

// before: 受け取った url をそのまま取得している = SSRF
function fetchFeed(string $inputUrl): string
{
    return file_get_contents($inputUrl);   // ← 宛先を検証していない。169.254.169.254 も叩ける
}

// after: 許可リスト + 名前解決後の IP 検証を通してから取得
// (resolveIp() / isInternalIp() / httpGetWithoutRedirectPinnedTo() は
//  標準関数ではなく、自前で実装する想定のヘルパー。名前は説明用)
function safeFetch(string $inputUrl): string
{
    $parts = parse_url($inputUrl);
    // (1) スキームの許可リスト(http/https だけ)
    if (!in_array($parts['scheme'] ?? '', ['http', 'https'], true)) {
        throw new \RuntimeException('scheme not allowed');
    }
    // (2) 宛先ホストの許可リスト(叩いてよい先だけ)
    $allowHosts = ['feeds.example.com'];
    if (!in_array($parts['host'] ?? '', $allowHosts, true)) {
        throw new \RuntimeException('host not allowed');
    }
    // (3) 名前解決して、その IP が内部/リンクローカルでないか検証
    //     169.254.0.0/16, 127.0.0.0/8, 10/8, 172.16/12, 192.168/16, ::1 等を遮断
    $ip = resolveIp($parts['host']);
    if (isInternalIp($ip)) {
        throw new \RuntimeException('internal address blocked');
    }
    // (4) 検証した IP に直接つなぎ、名前は再解決しない(DNS リバインディング対策)。
    //     リダイレクトも追従しない(クライアント別の実装は下記)
    return httpGetWithoutRedirectPinnedTo($ip, $parts['host'], $inputUrl);
}

(4) の「リダイレクトを追従しない」は、使う HTTP クライアントによって設定方法が違います。許可リストで宛先を絞っても、その先が 169.254.169.254 へリダイレクトを返したら意味がないため、追従を必ず無効化します。肝は、既定の取得に1つオプションを足すことです。

- $response = Http::get($inputUrl);                                  // 既定はリダイレクト追従
+ $response = Http::withOptions(['allow_redirects' => false])->get($inputUrl);  // 追従しない

Laravel と FuelPHP それぞれの実コードは次の1行です。

// Laravel(HTTP クライアント = Guzzle): allow_redirects を false に
// 出典: https://laravel.com/docs/13.x/http-client (Guzzle Options), Guzzle の allow_redirects
$response = Http::withOptions(['allow_redirects' => false])->get($inputUrl);
// FuelPHP(curl): CURLOPT_FOLLOWLOCATION を false に(Location ヘッダを追従しない)
// 出典: https://www.php.net/manual/en/function.curl-setopt.php
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false);

Laravel の公式ドキュメントには、Http::withOptions([...]) で Guzzle のリクエストオプションを渡せること、そして allow_redirects => false を既定設定にする例が載っています(出典: https://laravel.com/docs/13.x/http-client)。Guzzle 本体でも allow_redirectsfalse にするとリダイレクトを追従しません(出典: https://docs.guzzlephp.org/en/stable/request-options.html)。curl は CURLOPT_FOLLOWLOCATIONfalse にすることで Location ヘッダを追従しません(出典: https://www.php.net/manual/en/function.curl-setopt.php)。

ただし、この URL 検証も単層です。後述のとおり別の IP 表記や URL パーサの実装差などで抜けられる余地は残る(IP を固定しても、複数の A レコードや DNS キャッシュの隙を突いた抜け道は残ります)ので、次の基盤側の対策と必ず重ねます。

なお、これらはアプリの出口での防御です。攻撃リクエストがそもそもアプリに届く前に、WAF やネットワーク境界で入口を塞ぐ話は、本シリーズの第5回で扱う予定です(近日公開予定)。

なぜ両方いるのか ── 単層は必ずバイパスされる

「IMDSv2 を必須にしたなら、アプリ側の検証はいらないのでは?」と思うかもしれません。逆もまた然りです。しかし、片方だけでは破られます

OWASP は、SSRF の許可リストや拒否リストを回避するバイパス手法をいくつも挙げています。別の IP 表記(16進・8進・Dword・混在表記)、DNS リバインディング(正規ドメインが2回目の名前解決で内部 IP に変わる)、URL パーサの実装差、IPv6 の :: 圧縮表記、リダイレクトチェーンなどです(出典: https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html)。

アプリの URL 検証だけに頼ると、これらのバイパスで抜けられる余地が残ります。一方、IMDSv2 だけに頼ると、メタデータは守れても、内部の別サービスへの SSRF は防げません。だから、基盤(IMDSv2 必須)とアプリ(URL 検証)を重ねる多層防御が要ります。片方が破られても、もう片方で止める。これが結論です。

脅威 → 対策マッピング

ここまでの脅威と対策を1枚に整理します。呼び名や場所は違っても、原因は「入力を実行に混ぜた」で共通し、対策は「入力をデータとして分離する」に収束します。

脅威(呼び名) 出典 原因 対策
SQL インジェクション OWASP A03:2021 / CWE-89 入力を SQL(コード)に連結し、SQL として実行させる 値をプレースホルダでバインド。カラム名は許可リスト
SSRF(サーバサイドリクエストフォージェリ) OWASP A10:2021 / CWE-918 入力を宛先(URL)に混ぜ、サーバーに内部へリクエストさせる URL を許可リスト + 名前解決後 IP で検証。リダイレクト追従を無効化
API の SSRF OWASP API7:2023 API に渡す URL 経由で内部リソースへアクセスさせる 同上。API の入力 URL も検証対象にする
IMDS 経由の鍵露出 AWS ドキュメント IMDSv1 の単純 GET で一時認証情報を取得される IMDSv2 を必須化(HttpTokens=required)して IMDSv1 を無効化
共通 ── 入力を実行(コード/宛先)に混ぜた 入力を実行から分離し、データとして扱う(=核心)

SQLi と SSRF が別々の CWE(CWE-89 / CWE-918)として整理され、いずれも OWASP Top 10 に載っていること、API の SSRF は API Security Top 10 の API7:2023 に位置づけられていることは、公式の一覧で確認できます(出典: https://owasp.org/Top10/2021/A03_2021-Injection/ / https://owasp.org/Top10/2021/A10_2021-Server-Side_Request_Forgery_%28SSRF%29/ / https://owasp.org/API-Security/editions/2023/en/0xa7-server-side-request-forgery/)。

まとめ ── 入力を"実行"に混ぜるな

最後に、この記事の要点を振り返ります。

  • 入力をコードに混ぜない。SQL の値は文字列連結でつながず、プレースホルダでバインドしてデータとして分離します。カラム名やソート方向はバインドできないので、許可リストで絞ります。
  • 入力を宛先に混ぜない。外部から受け取った URL は、許可リストと名前解決後の IP で検証し、リダイレクト追従を無効化してから取得します。素通しすると SSRF になり、SSRF 経由で AWS の一時認証情報まで抜かれます。
  • 基盤とアプリの多層で守る。SSRF は AWS 側(IMDSv2 の必須化)とアプリ側(URL 検証)の両輪で塞ぎます。既存やレガシーの EC2 は IMDSv1 が残りがちなので、明示的に HttpTokens=required へ強制します。

信頼できない入力を"実行"に混ぜた瞬間、DB の中身(SQLi)もクラウドの鍵(SSRF)も奪われます。入力はデータとして扱い、コード(SQL)や宛先(URL)から分離する。これがこの記事を通じて伝えたかった一点です。

ちょっと前の私がいちばん助かったであろうポイントは、「URL を取ってくるだけの便利機能が、クラウドの鍵の抜け穴になる」という一点でした。ここに気づけば、自分のコードのどこで入力を実行に混ぜているかが見えてきます。

Next action: 3つの棚卸し

理解を定着させる最短ルートは、いま動いているコードを棚卸しすることです。次の3つをおすすめします。

  • 生クエリ・raw 系を grep で洗い出すwhereRaw / DB::query などにユーザー入力を連結・補間していないか探し、プレースホルダに置き換えます。
  • 外部 URL を取得している処理に、送信先の検証を1つ足す。許可リスト・名前解決後の IP 検証・リダイレクト追従の無効化のうち、まず1つから入れます。
  • EC2 の HttpTokens=required を確認・強制する。既存インスタンスが IMDSv1 のままなら、modify-instance-metadata-options で必須化します。

なお、こうした攻撃の試行をログから検知し、追跡する話は、本シリーズの第6回で扱う予定です(近日公開予定)。まずは自分のコードと EC2 設定の棚卸しから始めてみてください。

あわせて読みたい

参考

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?