Upgrade to Pro — share decks privately, control downloads, hide ads and more …

入力を信じた瞬間、DBの中身もAWSの鍵も奪われる ── SQLi と SSRF を両フレーム...

入力を信じた瞬間、DBの中身もAWSの鍵も奪われる ── SQLi と SSRF を両フレームワークで塞ぐ

「検索フォームの値をそのまま SQL につなぐ」「入力された URL の画像をサーバが取ってくる便利機能」。どちらも、入力を"実行"に混ぜた瞬間に牙をむきます。SQL インジェクション(SQLi)では DB の中身が、SSRF ではクラウド(AWS)の一時鍵まで奪われます。

社内エンジニア向けセキュリティ勉強会(全6回)の第2回です。Laravel と FuelPHP の両方で危険なコードと安全なコードを並べ、「入力はデータとして扱い、コード(SQL)や宛先(URL)から分離する」という一本の原則で、新旧どちらのフレームワークでも両脆弱性を塞ぎます。

重心を置く SSRF では、url= のような便利機能から 169.254.169.254(EC2 インスタンスメタデータ)を叩かせ、IMDSv1 経由で IAM の一時認証情報が丸見えになるまでを疑似デモで追跡します。そのうえで、基盤(IMDSv2)・アプリ(送信先 URL の検証)・権限・検知の多層防御へ落とし込みます。

扱うトピック
- SQLi:文字列連結/補間の危険性、プレースホルダによる分離、識別子(カラム名・並び順)は許可リストで(Laravel の whereRaw/DB::raw、FuelPHP の DB::query の穴と対策)
- SSRF:攻撃が効く理由(内部ネットワークの信頼を悪用)、EC2 メタデータ経由の IAM 鍵窃取、IMDSv1 と IMDSv2 の違い
- 対策:許可リスト・内部IP遮断・DNS解決後のIP検証・リダイレクト追従禁止、IMDSv2 の強制、そして多層で守る考え方

持ち帰る3つの結論
1. 入力はコードに混ぜない = プレースホルダで分離する
2. 入力は宛先に混ぜない = 送信先 URL を検証する
3. 基盤と入口の多層で守る = IMDSv2 + URL 検証

レガシー保守(FuelPHP 1.x)でも「明日からできる」棚卸しと設定確認まで示します。

想定読者:入力をそのまま実行に混ぜていた"ちょっと前の自分"。
分類:OWASP A03:2021 Injection / CWE-89、OWASP A10:2021 SSRF / CWE-918

Avatar for kuroneko

kuroneko

July 23, 2026

More Decks by kuroneko

Other Decks in Technology

Transcript

  1. 昔の私は、入力をそのまま実行に混ぜていた その入力、データのつもりですか? ── SQLi と SSRF、2つの失敗を告白します ❖ 検索フォームの値を SQL 文字列にそのまま連結

    していた ❖ 「URL を入れたら画像を取ってくる」便利機能 まで作っていた ❖ どちらも入力をデータではなく実行対象として 扱っていた // 検索: 入力をそのまま SQL に連結 $sql = "... WHERE name='".$q."'"; // 便利機能: 入力の URL をそのまま取得 file_get_contents($_GET['url']);
  2. 今日のゴール: 「入力を実行に混ぜない」を持ち帰る 入力を実行に混ぜると、DBの中身(SQLi)もAWSの鍵(SSRF)も奪われる 第1回 認証・認可 第2回 第3回 インジェクション /SSRF XSS/CSRF

    /セッション 第4回 第5回 第6回 暗号/シークレット ネットワーク/WAF ログ/追跡 想定読者は「ちょっと前の自分」。今日は SSRF に重心、SQLi は要点だけ扱います。 話者: kuroneko
  3. 今日の道順と、持ち帰る結論3つ 道順: S1 共通原理 → S2 SQLインジェクション → S3 SSRF(重心)

    結論① 結論② 結論③ 入力はコードに混ぜない = プレースホルダで分離 入力は宛先に混ぜない = 送信先 URL を検証 基盤と入口の多層で守る = IMDSv2 + URL 検証
  4. 今日の背骨: SQLi=コード / SSRF=宛先 どちらも入力をデータではなく実行対象として扱った瞬間に起きる SQLi(コード側) SSRF(宛先側) 入力を SQL(コード)に混ぜる →

    DBの中身が漏れる 入力を宛先(URL)に混ぜる → クラウドの鍵が漏れる 対策は共通 ──「入力をデータとして分離する」
  5. SQLインジェクション: 入力を SQL に混ぜて DB に実行させる 第1回 IDOR と同じ billpay

    の URL ── 危ういのは custID を文字列連結する点 ❖ 未検証入力を使い、Web アプリ経由で SQL を DB に実行させる(CCT 定義) ❖ 正常: GET /api/v1/cust/459 → 顧客1件を取得 ❖ 危ういのは custID を SQL 文に連結している点 GET http://billpay.com/api/v1/cust/459 -- API が組み立てるクエリ(文字列連結): SELECT * FROM Customers where custID='"+custID+"' -- custID=459 なら ...where custID='459'
  6. デモで見るもの: ID の場所に SQL 断片を1つ混ぜるだけ 変えるのは 459 の部分だけ ── 注目

    = 1件のはずが何件返ってくるか ❖ 変えるのは 459 の部分だけ(それ以外は正常リクエストと同じ) ❖ 注目 = 1件のはずが、何件返ってくるか ❖ 混ぜるのは ' or '1'='1 という SQL の断片(URL エンコードして送る) # 変えるのは 459 の部分だけ .../cust/459 # ↓ SQL 断片 ' or '1'='1 を混ぜる(URLエンコード) .../cust/%27%20or%20%271%27%3D%271
  7. ' or '1'='1 を混ぜたら、全顧客が返ってきた 全件返る ── 入力が条件(コード)として実行された(値は説明用フィクスチャ) ❖ 正常時は顧客1件(custID:459 /

    name:Acme Corp)の はずだった ❖ 組み立てられる SQL の条件が常に真 = 全件返却 ❖ 入力がデータではなく条件(コード)として実行 された $ curl ".../cust/%27%20or%20%271%27%3D%271" # サーバ内で組み立てられる SQL: SELECT * FROM Customers where custID='' or '1'='1' # → 条件が常に真 = 全顧客が返る [{"custID":1,...},{"custID":2,...} ...全件]
  8. なぜ連結はダメで、バインドは安全か 骨組みを先に確定し、値は後から差し込む ── だから ' or '1'='1 もただの文字列 連結(混在) バインド(分離)

    入力が SQL 文の一部として 解釈・実行される SQL の骨組みを先に確定 値はデータとして差し込む プレースホルダ = コードとデータを別便で送る ── これが SQLi 対策の核
  9. Laravel: 既定で安全、穴は raw 系に連結したとき before ── raw 系にユーザー入力を文字列連結すると SQLi。連結行が危険 ❖

    Eloquent/クエリビルダは既定で PDO バインド= 安全(サニタイズ不要) ❖ 穴 = whereRaw/DB::raw/orderByRaw 等に入力を 文字列連結したとき ❖ 公式警告「raw は文字列として注入される。 SQLi に極めて注意」 // Laravel 13.x CustomerController $keyword = $request->input('q'); // ↓ 入力を SQL 文字列に連結 = SQLi return DB::table('customers') ->whereRaw("name LIKE '%".$keyword."%'") ->get();
  10. Laravel: 値はバインド、識別子は許可リストで塞ぐ after ── 値は ? でバインド。カラム名/ソート方向はバインド不可 → 許可リスト ❖

    whereRaw('name LIKE ?', ['%'.$kw.'%']) で値をバイ ンド(データとして分離) ❖ カラム名/ソート方向は PDO ではバインドでき ない → 許可リストで検証 // 値はプレースホルダでバインド ->whereRaw('name LIKE ?', ['%'.$keyword.'%']) // カラム名/ORDER BY はバインド不可 → 許可リスト $allowed = ['name', 'created_at']; if (in_array($sort, $allowed, true)) $query->orderBy($sort);
  11. FuelPHP: DB::query() に値を文字列補間すると穴が開く before ── レガシーで書きがちな DB::query への文字列補間。入力が SQL 文へ直接混入

    ❖ レガシーで書きがちな DB::query(...) への文字列 補間パターン ❖ 入力が SQL 文へ直接混入 = SQLi // FuelPHP 1.x $keyword = Input::get('q'); // ↓ DB::query に文字列補間 = SQLi $sql = "SELECT * FROM customers" ." WHERE name LIKE '%".$keyword."%'"; DB::query($sql)->execute();
  12. FuelPHP: :kw + parameters() でバインドする after ── :kw + parameters()

    でバインド。DB::select()->where() は値を既定でエスケープ ❖ :kw(名前付きプレースホルダ)+ parameters() で値 をバインド ❖ クエリビルダ DB::select()->where() は値を既定で エスケープ // 名前付きプレースホルダ + parameters() DB::query('SELECT * FROM customers' .' WHERE name LIKE :kw') ->parameters(array('kw'=>'%'.$keyword.'%')) ->execute(); // QB なら値は既定でエスケープされる: // DB::select()->where('name','LIKE',$kw)
  13. SSRF: サーバに代理でリクエストを叩かせる 外から直接は無理でも、公開サーバに代理で叩かせれば内部に届く(CCT) 攻撃者 公開 Web サーバ 内部サーバ 外部から内部へは 直接届かない

    SSRF 脆弱性を悪用され 代理でリクエスト送信 同一ネットワークとして 信頼し応答してしまう 成功すると内部ポートスキャン・内部到達・ファイル読取・RCE まで可能
  14. 入口はどこにでもある: ?url= の便利機能が宛先を握られる url を localhost や内部に変えると、サーバ上のローカルリソースが見える ❖ CCT 例

    feed.php?url=externalsite.com/feed(外部リ ソースを取り込む便利機能) ❖ url を localhost/内部に変えると、サーバ上のロ ーカルリソースが見えてしまう ❖ 画像取得・Webhook・PDF生成・URLプレビュー … 全部が入口候補 # 便利機能: 外部 URL を取りに行く feed.php?url=externalsite.com/feed # ↓ 宛先を内部に差し替える feed.php?url=localhost
  15. このアプリは EC2 の内側 ── 宛先を 169.254 に差し替えます 注目 = ①

    iam/ が出るか → ② 一時鍵が出るか、の2段 ❖ このアプリは EC2 の上で動く = 社内ネットワークの内側から AWS に触れる立場 ❖ これから便利機能の ?url= の宛先を 169.254.169.254 に差し替える ❖ 注目 = ① iam/ が出るか → ② 一時鍵が出るか、の2段
  16. 169.254.169.254 を叩かせると、メタデータが返ってきた GET だけで一覧が返る = IMDSv1(トークン不要)。iam/ が出た = ロールが紐づく証拠 ❖

    便利機能に、宛先としてメタデータIP 169.254.169.254 を渡す ❖ ヘッダ無しの単純 GET だけで一覧が返る = IMDSv1 ❖ 注目1段目「iam/ が出るか」→ 出た(IAMロール が紐づいている証拠) # 便利機能の宛先にメタデータIPを渡す(GET) $ curl ".../169.254.169.254/latest/meta-data/" ami-id hostname iam/ ← これがある = IAMロールが紐づく instance-id local-ipv4 ...
  17. security-credentials/<ロール名> で、一時鍵が丸見えになった 山場 ── AccessKeyId/SecretAccessKey/Token が JSON で返る(値は AWS 公式の

    EXAMPLE 値) ❖ security-credentials/ → ロール名 app-ec2-role が 返る ❖ ロール名を指定 → 一時認証情報が JSON で返る( 下記) ❖ AccessKeyId/SecretAccessKey/Token = サーバ本人 として AWS API を叩ける $ curl ".../credentials/app-ec2-role" { "Code" : "Success", "AccessKeyId" : "ASIAIOSFODNN7EXAMPLE", "SecretAccessKey" : "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", "Token" : "IQoJb3Jp...EXAMPLETOKEN", "Expiration" : "2026-07-02T22:39:16Z" }
  18. 今起きたこと: SSRF → メタデータ → IAM 鍵 → AWS 操作

    入力(url)が宛先として実行された = SSRF。内部の信頼を借りて鍵まで届いた 公開 Web サーバ 169.254.169.254 IAM 一時鍵 AWS API ?url= を乗っ取る 内部メタデータへ到達 認証情報が丸見え サーバ本人として操作 鍵は一時的でも十分悪用可能 ── 入力を宛先に混ぜた結果
  19. なぜ抜けた: IMDSv1 はトークン不要の GET だけだから SSRF で投げられるのはまさに単純 GET ── だから

    url= 一発で鍵まで届いた ❖ IMDSv1 = ヘッダ無しの単純 GET で応答する ❖ SSRF で投げられるのは、まさにその単純 GET ❖ だから ?url= 一発でメタデータ、そして鍵まで到達した
  20. 対策①(基盤): IMDSv2 が SSRF を止める SSRF はこの3点(PUT/ヘッダ/hop)が越えられない。required 時は無トークンで 401 ①

    PUT でトークン ② ヘッダ付き GET ③ hop 制限 /latest/api/token に PUT 単純 GET では取れない X-aws-ec2-metadata-token が無ければ弾かれる PUT 応答の hop 制限(既定1) で多段経由も止まる
  21. 対策②(アプリ): 送信先 URL を検証して内部IPを遮断 基盤だけに頼らず入口でも塞ぐ多層防御 ── deny-list はバイパスされやすい ❖ 許可リスト(スキーム/ホスト)で宛先を限定する(deny-list

    は避ける) ❖ 169.254.0.0/16・127.0.0.0/8・RFC1918(10/8・172.16/12・192.168/16)を遮断 ❖ DNS 解決後の IP を検証する・リダイレクト追従は禁止する ❖ 別IP表記・URLパーサ差異・DNS リバインディングでバイパスされうる
  22. 対策コード: 許可リストと IP 検証を通してから fetch する before/after ── Laravel(Guzzle)も FuelPHP(curl)も検証の考え方は同一

    ❖ before = 受け取った url をそのまま HTTP クライ アントに渡す ❖ after = スキーム/ホスト許可リスト + 名前解決後 の IP を検証してから送信 ❖ 考え方はフレームワーク非依存(具体クライアン ト API は断定しない) // before: そのまま取得 = SSRF file_get_contents($url); // after: 検証してから送信 $p = parse_url($url); // (1)スキーム (2)ホスト を許可リスト照合 // (3)解決後の IP が内部レンジなら拒否 if (isInternalIp($p['host'])) throw new Exception('blocked'); // (4)リダイレクト追従は無効化 [要確認]
  23. レガシー/EC2 でも今日から: IMDSv2 強制 + URL 検証 既存 EC2 も

    IMDSv2 必須に強制(IMDSv1 無効化)──「レガシーだから無理」で終わらせない ❖ aws ec2 modify-instance-metadata-options で既存 EC2 も IMDSv2 強制 ❖ --http-tokens required で IMDSv1 を無効化・-http-put-response-hop-limit 1 ❖ アプリ側 URL 検証はどの FW でも書ける # 既存 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
  24. SSRF は鍵を抜かれて終わりではない ── 多層で守る 基盤 / アプリ / 権限 /

    検知 の多層で防御を広げる ❖ 基盤 = IMDSv2 強制 / アプリ = 送信先 URL 検証(ここまでで確立) ❖ 権限 = IAM ロールを最小権限に(抜かれても被害を限定)・鍵の短命化 ❖ 検知 = 入口を境界で塞ぐ・攻撃試行を検知/追跡する
  25. 脅威→対策マッピング: 入力を実行に混ぜない 全編の背骨を対策視点で回収 ── 共通は「入力を実行から分離する」 脅威(入力の混ぜ先) 対策 効く分離 SQLi(入力 →

    SQL コード) プレースホルダ + 許可リスト(識別子) 入力をコードから分離 SSRF(入力 → 宛先 URL) IMDSv2 強制 + URL 検証(多層) 入力を宛先から分離 共通 入力を実行から分離し、データとして扱う これが核心メッセージ
  26. Take-away: 入力を実行に混ぜるな 核心: 入力を実行に混ぜた瞬間、DBの中身も AWS の鍵も奪われる 結論① 結論② 結論③ 入力はコードに混ぜない

    = プレースホルダで分離 入力は宛先に混ぜない = 送信先 URL を検証 基盤と入口の多層で守る = IMDSv2 + URL 検証
  27. Next action: 明日からできる3つ 抽象で終わらせず具体で ── 特に生クエリの棚卸しと IMDSv2 確認が効く 1. 生クエリを棚卸し

    2. URL 検証を足す 3. IMDSv2 を確認 raw/DB::query を grep 連結・補間をバインドに置換 外部 URL を叩く処理に 送信先検証(内部IP遮断) EC2 に HttpTokens=required を確認・強制する
  28. A1: SQLi 対策コード(Laravel/FuelPHP の after 抜粋) 本編コードの after 抜粋 ──

    完全な before/after 両FW全文は記事に収録 ❖ Laravel: whereRaw の値は ? でバインド、カラム 名は許可リスト ❖ FuelPHP: :kw + parameters() でバインド、QB は既 定でエスケープ ❖ before(連結/補間)は本編を参照。全文は記事へ // Laravel after(値=バインド/識別子=許可リスト) ->whereRaw('name LIKE ?', ['%'.$kw.'%']); $allow = ['name','created_at']; if (in_array($sort,$allow,true)) $q->orderBy($sort); // FuelPHP after(名前付きプレースホルダ) DB::query('... LIKE :kw') ->parameters(array('kw'=>$kw))->execute();
  29. A2: SSRF 対策コード全文 + IMDSv2 強制コマンド URL 検証ヘルパ + IMDSv2

    強制 CLI ── 全文は記事に収録 ❖ URL 検証 = スキーム/ホスト許可リスト + 名前解 決後の IP 検証 + リダイレクト無効化 ❖ 遮断レンジ = 169.254.0.0/16・127.0.0.0/8・ RFC1918・::1 等 ❖ IMDSv2 強制 CLI は本編、完全な整形版は記事へ // URL 検証(考え方・FW非依存) $p = parse_url($url); // (1)(2) スキーム/ホストを許可リスト照合 if (!allowed($p['scheme'], $p['host'])) throw new Exception('not allowed'); // (3) 解決後の IP が内部レンジなら拒否 if (isInternalIp($p['host'])) throw new Exception('blocked'); // IMDSv2 強制は HttpTokens=required
  30. A3: インジェクションの他の種類 API インジェクションは SQL 以外にも及ぶ(本編で触れなかった受け皿) ❖ コマンドインジェクション = 入力検証の欠陥に悪意あるコマンドを注入(CCT)

    ❖ LDAP インジェクション / XPath インジェクション / XSLT インジェクション ❖ API インジェクションは SQL だけでなく JSON/JavaScript/XPath/XSLT でも起こる
  31. A4: SSRF の悪用とバイパス手法 OWASP SSRF Prevention Cheat Sheet ほか ──

    deny-list がバイパスされやすい理由 悪用の種類 バイパス手法 内部ポートスキャン 別IP表記(Hex/Octal/Dword/Mixed) 内部サービス列挙 IPv6 の :: 圧縮表記 Webサーバのファイル読取 DNS リバインディング ホストベース認証バイパス URL パーサの実装差 リモートコード実行(RCE) リダイレクトチェーン
  32. A5: IMDSv1 vs IMDSv2 詳細 + 既定化タイムライン 本編で省いた細部(TTL 上限・X-Forwarded-For 拒否)と

    v2 既定化の年表 項目 IMDSv1 IMDSv2 リクエスト方式 ヘッダ無しの単純 GET PUT でトークン → ヘッダ付き GET トークン 不要 X-aws-ec2-metadata-token が必要 TTL 上限 ─ 21600 秒(6時間) hop-limit 既定 ─ 1 無トークン時(required) ─ 401 Unauthorized X-Forwarded-For 付き PUT ─ 拒否
  33. A6: 用語・出典リンク集(OWASP / CWE) SQLi=A03:2021 / CWE-89、SSRF=A10:2021 / CWE-918、API の

    SSRF=API7:2023 項目 OWASP / CWE 出典 SQLi A03:2021 Injection / CWE-89 owasp.org/Top10/2021/A03 SSRF A10:2021 SSRF / CWE-918 owasp.org/Top10/2021/A10 API の SSRF API7:2023 SSRF owasp.org/API-Security 2023 SSRF 対策 SSRF Prevention Cheat Sheet cheatsheetseries.owasp.org IMDS/IAM EC2 IMDS・一時認証情報 docs.aws.amazon.com