おい、そこ。ちょっと手を止めて画面を見てくれ。
お前らが普段何気なくデプロイしているそのDockerイメージ、本当に「安全」だと言い切れるか? 「公式の node:latest を使っているから大丈夫」「CI/CDパイプラインでビルド時にスキャンを通しているから問題ない」――そんな甘い認識でいるなら、今すぐその考えを改めたほうがいい。
俺たちは日々、数々のペネトレーションテストやインシデントレスポンスで企業のインフラをハックしている。その現場で、攻撃者が真っ先に狙うのはどこだと思う? クラウドの高度なIAM設定でも、難解な暗号アルゴリズムでもない。「コンテナの中に野放しにされた、使われていない脆弱なOSパッケージ」だ。
今回は、レッドチームの視点からコンテナイメージの現実と、それを完全に封殺するための実践的なアプローチを叩き込んでやる。心して聞け。
—
1. 脆弱性スキャンの限界と、攻撃者がコンテナ内部で見る景色
まずは、よくある勘違いから正しておこう。「Trivy」や「Grype」といった素晴らしいコンテナスキャナーをCI/CDに組み込んでいれば、セキュリティは万全だと思っていないか?
確かに、これらのツールはCVEデータベースと照らし合わせて既知の脆弱性を一網打尽にしてくれる。しかし、レッドチームの観点から言えば、スキャナーは「敵の侵入経路のリスト」を作ってくれているに過ぎない。
例えば、よくある ubuntu や debian をベースにしたPythonやNode.jsのイメージを考えてみる。開発の利便性を考慮して apt-get install で curl や git、果ては vim や netcat まで一緒に入れたまま本番環境に持ってきていないか?
攻撃者がWebアプリケーションの脆弱性(RCE:リモートコード実行など)を突いてコンテナ内に足場(シェル)を築いた瞬間、彼らが最初に実行するのは何だと思う?
そう、コンテナ内に残された curl や bash を使った外部からの追加ペイロードのダウンロードだ。ベースイメージに余計なものが含まれているということは、「侵入された後に攻撃者が暴れ回るためのツールボックスを、親切に用意してあげている」のと同じなのだ。
—
2. 攻撃対象領域を極限まで削る:Distrolessイメージという選択
では、どうすればこのリスクを根絶できるのか? 答えはシンプルだ。「攻撃者に使わせるツールをすべて奪う」こと。
そこで登場するのが、Googleが提唱する Distroless(ディストレス)イメージ だ。
Distrolessイメージには、パッケージマネージャー(apt や apk)も、シェル(bash や sh)も、テキストエディタも、デバッグツールも一切入っていない。入っているのは、アプリケーションのランタイムと、その実行に必要な最低限の共有ライブラリだけだ。
シェルがないということは、万が一RCEの脆弱性を突かれても、攻撃者は対話型のシェルを起動して内部を探索したり、外部からマルウェアを勝手に持ち込んで実行したりすることが極めて難しくなる(いわゆるFilelessな攻撃か、極めて限定的なメモリ上の操作しかできなくなる)。
百聞は一見にしかず。実際に、Node.jsのWebアプリケーションを例にとって、セキュアなマルチステージビルドとDistrolessの採用を見ていこう。
—
3. 【実践】マルチステージビルドとDistrolessによる鉄壁のDockerfile
以下のDockerfileを見てほしい。これは、開発環境のツール(ビルドツールやnpmのキャッシュなど)を本番用イメージに持ち込まず、最終的に純粋なDistrolessイメージへ成果物だけをコピーする模範的な実装だ。
# ==========================================
# ステージ1: ビルド環境(開発用ツールを含む)
# ==========================================
# ビルドには通常の公式イメージを使用し、TypeScriptのコンパイルや依存関係のインストールを行う
FROM node:20-bookworm AS builder
WORKDIR /app
# 依存関係の定義ファイルをコピー
COPY package*.json ./
# 本番用も含めて依存関係をインストール
RUN npm ci
# アプリケーションのソースコードをコピー
COPY . .
# TypeScriptのビルドを実行(JavaScriptにトランスパイル)
RUN npm run build
# 不要な開発用依存関係(devDependencies)を削除してスリム化
RUN npm prune --production
# ==========================================
# ステージ2: 本番実行環境(Distrolessイメージ)
# ==========================================
# シェルもパッケージマネージャーも含まれない、Google製の本番用最小限イメージ
FROM gcr.io/distroless/nodejs20-debian12:nonroot
WORKDIR /app
# ステージ1のビルド成果物と、最小限となったnode_modulesのみをコピー
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
# 【重要】rootユーザーではなく、非特権ユーザー(nonroot: 65532)で実行する
# これにより、万が一コンテナが破られてもホストや他プロセスへの影響を最小化する
USER 65532:65532
# コンテナがリッスンするポートを指定
EXPOSE 3000
# エントリーポイントの指定(Distrolessではシェルが使えないため配列形式で直接実行ファイルを指定)
CMD ["dist/index.js"]
このDockerfileのポイントを3つ頭に叩き込め。
1. マルチステージビルドの活用: builder ステージで重たいコンパイル作業やnpmのインストールを行い、最終イメージにはバイナリと最小限のランタイムしか持ち込まない。これにより、イメージサイズが劇的に小さくなり、脆弱性の保有数(CVE数)も桁違いに減る。
2. Distrolessの採用 (gcr.io/distroless/nodejs20-debian12:nonroot): シェルが存在しないため、脆弱性スキャナーが検出するOSパッケージ起因の脆弱性がほぼゼロになる。
3. 非特権ユーザー(Non-root)の徹底: USER 65532:65532 を指定し、デフォルトの root ユーザーで動かさない。コンテナの脱獄(Container Escape)を防ぐための基本中の基本だ。
—
4. 運用時の注意点とレッドチームからのアドバイス
「よし、今日から全部Distrolessにするぞ」と意気込むのはいいが、現場で運用する上での「落とし穴」についても言及しておこう。
- デバッグが困難になる: シェルが入っていないため、本番コンテナに
docker exec -it <container_id> bashで飛び込んでログを見たり、設定ファイルを書き換えたりすることは一切できなくなる。 - *対策*: デバッグが必要な場合は、デバッグ用の別タグ(例:
gcr.io/distroless/...:debug)を用いた一時的なコンテナを検証環境で用意するか、アプリケーション側のロギングとトレーシング(OpenTelemetry等)を徹底して、コンテナ内部に入らずとも状態が把握できるように設計すること。 - C/C++のネイティブモジュールに注意:
bcryptやcanvasなど、OSのネイティブライブラリに依存するnpmパッケージを使用している場合、Distrolessの純粋な環境では動かないことがある。その場合は、必要な共有ライブラリをステージ1から適切にコピーするか、静的リンクされたライブラリを選ぶ必要がある。
—
5. まとめ
セキュリティとは、完璧な城壁を築くことではなく、「侵入されたときに敵にどれだけ不便を強いるか(縦深防御)」のゲームだ。
どれだけアプリケーション層のバリデーションを厳しくしても、未知のゼロデイや設定ミスによって侵入を許す日は来る。その最悪の瞬間が訪れたとき、目の前にあるのが「何でもできるフル装備のUbuntuコンテナ」なのか、「シェルすらない冷え切ったDistrolessコンテナ」なのかで、被害の規模は天と地ほど変わる。
今日書いたコードと設計思想を自チームのパイプラインに持ち帰り、今すぐベースイメージの棚卸しを始めろ。お前らの健闘を祈る。
コメント