TECH MEMO / FIELD NOTEDockerで学ぶSQL・コマンドインジェクションの仕組みと対策
はじめに:なぜDockerで検証環境を作ったのか
Webアプリケーションのセキュリティ対策やセキュアコーディングを学ぶ際、座学や脆弱性のペイロード集を眺めるだけでは、「なぜそれが動いてしまうのか」「どう直せば安全になるのか」の皮膚感覚がなかなか掴めません。
そこで今回、Docker Composeを使って、壊してもすぐに初期状態へ戻せるローカル検証環境(Go製API + MySQL + Angular製UI)を立ち上げました。
ローカルに閉じたコンテナ環境であれば、外部に影響を与えず、意図的に危険なコードと安全な実装を並べて比較できます。ブラウザからのリクエスト、APIのログ、ソースコードを照らし合わせながら、インジェクションの仕組みと多層防御の考え方を整理しました。
入力値が「命令」として解釈される仕組み
SQLインジェクションとOSコマンドインジェクションは、どちらも「利用者が入力したデータ」が、システムが解釈する「命令(構文)」に変わってしまうことで起きます。
1. OSコマンドインジェクション:シェルに文字列を解釈させない
検証環境のPing機能では、HTTPパラメータの入力をそのままシェルに渡してしまう脆弱な実装を用意しました。
// 危険な実装例
cmd := r.URL.Query().Get("cmd")
c := exec.Command("sh", "-c", cmd)
sh -c は、渡された文字列全体をシェルスクリプトの文法として評価します。そのため、入力に ; や &&、| などのメタ文字が含まれていると、開発者が意図したコマンドの後ろに別のコマンドが連結されてしまいます。
例えば、入力欄に 127.0.0.1 ; id ; uname -a と入力すると、Pingの実行結果に続いてコンテナ内のユーザー情報やOS情報が出力されます。
バックエンドでの対策
対策の基本は、「そもそもシェル(sh -c)を介さないこと」 です。実行するバイナリと引数を明確に分離し、入力を単なる引数データとして渡します。
// 安全な実装例
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
// 用途に合ったパーサーで入力を検証
ip := net.ParseIP(input)
if ip == nil {
http.Error(w, "invalid IP address", http.StatusBadRequest)
return
}
// シェルを介さず、引数として渡す
cmd := exec.CommandContext(ctx, "ping", "-c", "3", ip.String())
さらに、タイムアウト(context.WithTimeout)の設定や、実行可能な処理を限定する許可リスト方式を組み合わせることで、想定外のプロセス実行を防止します。
2. SQLインジェクション:文字列連結をやめてプレースホルダーを使う
SQLインジェクションも同様です。検索処理でユーザー入力を直接SQL文字列へ埋め込んでしまうと、入力値がSQL構文として解釈されます。
// 危険な実装例(文字列連結)
q := fmt.Sprintf(
"SELECT id, name, email FROM users WHERE name LIKE '%%%s%%' OR email LIKE '%%%s%%'",
search,
search,
)
rows, err := db.Query(q)
クォートを閉じるような文字(')が入力に含まれていると、入力の続きがSQL構文(OR '1'='1' や UNION SELECT など)として評価され、全件取得や情報漏洩につながります。
さらに今回の検証環境では、学習用としてDB接続文字列に multiStatements=true を設定していました。これが有効になっていると、1回のリクエストでセミコロン区切りの複文が実行可能になり、DELETE などの破壊的なクエリまで流し込めてしまいます。
バックエンドでの対策
対策は、「値は必ずプレースホルダー(プリペアドステートメント)へ渡す」 ことに尽きます。
// 安全な実装例(プレースホルダーを使用)
rows, err := db.Query(
`SELECT id, name, email
FROM users
WHERE name LIKE ? OR email LIKE ?`,
"%"+search+"%",
"%"+search+"%",
)
プレースホルダーを使うと、入力値はSQL構文の一部ではなく、値として扱われます。あわせて、multiStatements を無効にし、アプリ専用の低権限DBユーザーを使うことで、実装ミスが起きたときの影響を抑えられます。
クラウド・インフラ側で被害を抑え、検知する
バックエンドでデータと命令を分けることが、インジェクションへの第一の対策です。それでも、本番運用では脆弱性が残る場合や、対策をすり抜けられる場合を想定し、インフラ側で被害を抑えて検知する仕組みも必要です。
1. WAFだけに頼らず、Egressや権限も絞る
WAF(AWS WAFやCloud Armorなど)のマネージドルールは、明らかな攻撃リクエストをアプリケーションの手前で減らすのに役立ちます。
ただし、WAFの運用では誤検知に注意が必要です。業務アプリで記号、複雑な検索クエリ、Markdown、JSONなどを送ったとき、正常なリクエストがWAFに弾かれることがあります。問い合わせや障害を避けようと、Countモードのまま放置したり、除外ルールを増やしすぎたりして、実質的に機能しなくなるおそれがあります。
WAFはアプリ側の対策の代わりにはなりません。検知ログを継続的に確認し、誤検知と攻撃を切り分けながらルールを調整する前提で運用します。
その上で、インフラ側では次の対策を組み合わせます。
- Egress(外向き通信)の制限: 攻撃ツールのダウンロードやC2サーバーへの接続に備え、セキュリティグループやクラウドファイアウォールなどで、コンテナからの送信先とポートを必要最小限に絞ります。
- 長期利用する静的シークレットを減らす: コマンド実行を許すと、
env、printenv、cat /proc/1/environなどから環境変数を読み取られるおそれがあります。AWS Secrets ManagerやSSM Parameter Storeなどのシークレット管理サービスに加え、AWS IAMデータベース認証やCloud SQL IAMデータベース認証などを利用し、長期利用するDBパスワードをコンテナに置かない構成を検討します。 - コンテナの最小権限化: rootユーザーでの実行を避け、専用の一般ユーザーで動かします。
readOnlyRootFilesystem: trueでルートファイルシステムを読み取り専用にし、書き込み可能なボリュームやディレクトリも必要最小限に絞ります。 - DBの権限分離: アプリケーションからDBへの接続に
rootを使わず、必要なテーブルのSELECTやINSERTだけを許可した専用ユーザーを使用します。
2. 侵入の兆候をランタイムとログから検知する
ゼロデイ脆弱性や実装ミスにより、インジェクションが成功する可能性は残ります。防止だけでなく、侵入の兆候を検知し、誰がどのように確認するかまで決めておきます。
- コンテナのランタイム脅威検知(プロセス・振る舞い監視):
Amazon GuardDuty Runtime MonitoringやGoogle Cloud Security Command CenterのContainer Threat Detectionなどで、コンテナ内の不審なプロセス実行や通信を検知します。例えば、Web APIのコンテナで通常は必要ない
sh、bash、uname、id、whoamiなどの実行は、確認すべき兆候です。利用するサービスで何を検知できるかを確認し、アラートの通知先と初動対応を決めておきます。 - DBエラーログの急増監視: 攻撃者が SQL インジェクションを仕掛ける際、最初は構文を手探りで調整するため、クォートの不一致やシンタックスエラーを大量に発生させがちです。CloudWatch Logs や Cloud Logging で、DB の構文エラー(Syntax Error)やアクセス拒否(Access Denied)ログの急増をメトリクスフィルターで検知することで、侵入試行の初期段階で察知できます。
- インスタンスメタデータ(IMDS)の保護: コンテナが侵害されると、クラウドのインスタンスメタデータから一時クレデンシャルを読み取られるおそれがあります。AWSではIMDSv2を強制し、コンテナからIMDSへのアクセスが不要ならネットワークで遮断します。実行環境ごとにアクセス制御方法が異なるため、プラットフォームの推奨設定も確認します。
3. CI/CD層:脆弱性の再混入を自動で止める
インフラや運用での検知に加え、ソースコードが本番へデプロイされる手前の CI/CD(GitHub Actions)で自動検査を走らせる仕組みを整えました。
実際にパイプラインを組んでgosecを実行したところ、今回の文字列連結やsh -cの実装パターンは検出されず、チェックを通過しました。汎用的なSASTツールが追跡できるデータフローやフレームワークの範囲には限界があります。「ツールを入れたから万全」とは限らないことを痛感しました。
そこで、組織やプロジェクトとして禁止すべきパターン(生のWHERE句結合や sh -c の呼び出し、multiStatements=true の設定など)を明文化し、決定的に検査するポリシーゲートを併用しました。
実行結果の通り、gosec単体ではすり抜けたリスクをポリシーゲートで検知できました。Job SummaryとAnnotationsに問題のあるファイルと行を表示し、ジョブを失敗させています。
おわりに:SREとしてセキュリティとどう付き合うか
インジェクション対策を振り返ると、フロントエンド、バックエンド、インフラ、CI/CDのそれぞれが異なる役割を持っています。
- フロントエンド: 誤入力を防ぎ、分かりやすいエラーメッセージを返す(ただし信頼境界ではないため、バックエンドでの検証が必須)。
- バックエンド: 入力を決して命令として評価させず、データとして扱う(根本防御)。
- インフラ・クラウド: WAFでノイズを落としつつ、Egress制限やパスワードレス(IAM認証)で侵害後の活動を封じ、ランタイム検知やDBログ監視で侵入をいち早く察知する(防波堤と被害の局所化・早期検知)。
- CI/CD: 危険な実装の再混入を早期に検知し、マージをブロックする(品質ゲート)。
「セキュリティ警察」の罠と、専任がいない組織のDevSecOps
セキュリティを語るとき、SREが「セキュリティ警察(ゲートキーパー)」として振る舞ってしまうと、組織として非常に危険なアンチパターンに陥ります。 「セキュリティのことはSREが口うるさく言うから、SREに全部任せよう」「何かあったらSREの責任だ」と、いつの間にかSREが“セキュリティ担当”に祭り上げられ、すべてのPRのセキュリティレビューや承認を押し付けられてパンクしてしまうのです。
本来、セキュリティには各階層で担うべき責務があります。アプリケーション開発者とSREのどちらか一方に任せず、それぞれの領域で取り組むのが理想です。
しかし、専任のセキュリティ担当者やSOCがいない組織も少なくありません。その場合は、開発者とOps/SREが通常の開発・運用の中でセキュリティに取り組む必要があります。
私は、セキュリティを個人のチェック業務にせず、普段の開発サイクルとインフラの仕組みに組み込むことが、専任担当者のいない組織でDevSecOpsを続ける鍵だと考えています。
- SREが安全な開発基盤を用意する: Terraformで定義したEgress制限、非rootコンテナ、IAMデータベース認証、CI/CDのポリシーゲートなど、最初から安全側に倒した構成を提供します。これにより、すべてのPRの目視確認をSREに集めずに済みます。
- セキュリティのセルフサービス化(Shift Left): 開発者が、いつものテストやリンターと同じように、GitHub ActionsのCI上でフィードバックを受け取り、自分で修正できる環境を整えます。
- 実装ミスがあっても被害を広げない: どれだけ気をつけていても、ミスは起きます。実装ミスがCIをすり抜けた場合に備え、長期利用するパスワードをコンテナに置かず、Egressを絞り、ランタイム脅威検知を有効にして、Blast Radiusを小さくします。
セキュリティを信頼性(Reliability)の一部として扱い、日常の開発・運用の仕組みに組み込む。開発者が迷わず使える標準的な開発経路(Paved Road)を用意することが、専任担当者がいない組織でSREと開発者が無理なくDevSecOpsを続ける方法だと感じています。
雑記:Codexにセキュリティ警告で止められた話
今回の検証環境を準備するにあたり、意図的に脆弱なコードや攻撃検証のパターンを作成しようとAI(Codex)に相談していたところ、突然強い警告画面が表示されました。
「This content can’t be shown. We take extra care with some cybersecurity requests. If you’re doing authorized security work, apply for Daybreak to get broader access.」というメッセージが表示され、回答は表示されませんでした。
教育用や検証目的であっても、サイバーセキュリティ関連のリクエストは内容によって制限されると分かりました。AIのガードレールとセキュリティ検証の扱いの難しさを実感した、ちょっとしたハプニングでした。
