最小権限でコンテナを締める
このラボは本物のVM上で動きます
この箱はPodではなく、KubeVirtが起動した仮想マシンです。Linuxカーネルが
別に動き、systemdが実際にサービスを管理し、dockerは偽物ではなく
本物のDockerエンジンです。docker runで起動したコンテナは、実際にプロセスに
なり、docker execもdocker logsもそのまま動作します。
以前は、このラボはPodの中で動いていました。カーネルの権限をすべて下ろした箱だったため、 コンテナを起動するステップが塞がれており、そのためイメージのアーカイブを自分で展開して みるという回り道で学んでいました。もう回り道は必要ありません。
知っておくべきことが2つあります。
- 最初の起動に1分ほどかかります。VMが起動してDockerをインストールするためです。Podのラボ(通常は40秒)より遅くなります。
- ブラウザープレビューはありません。VMに入ってくる接続は、採点ポート
1つだけが開いています。Webサーバーを起動したなら、VMの中で
curlで確認してください。
目標
コンテナを非特権ユーザーで実行する2つの方法(ランタイムのフラグとイメージへの組み込み)を どちらも作ってみて、読み取り専用ルートファイルシステム・capabilityの全面的な削除・権限昇格の遮断を 1つずつ適用します。最後に、これらの条件を満たすイメージを自分で完成させます。
なぜ重要なのか
「コンテナは分離されているから、中でrootでもかまわない」という言葉が間違っている理由は、単純です。
ユーザーネームスペースを使わなければ、コンテナのUID 0はホストのUID 0と同じ値であり、
ボリュームで開いた経路には、その権限がそのまま適用されます。そのため、最小権限は選択ではなく
デフォルトであるべきです。特にUSERを数値で書く習慣が重要です。
KubernetesのrunAsNonRootは、名前で書かれたユーザーをrootではないと判別
できずにPodを拒否し、そのエラーメッセージを初めて見ると、原因を探すのにとても時間がかかります。
capabilityも同じです。必要なものだけを取り除く方式は、いつか取りこぼしますが、すべて
捨ててから必要な1つだけを加える方式は、取りこぼしようがありません。
ステップ
/root/sec1ディレクトリを作成し、alpine:3.20をユーザーを何も指定せずに実行したときの数値のUIDだけを/root/sec1/default-uid.txtに保存します。ファイルには0だけがある必要があります。 コンテナを起動しなくても、はっきり確認できます。実行ユーザーはイメージ設定のconfig.Userフィールドに書かれており、空ならランタイムはuid 0を使います。skopeo inspect --config oci-archive:/opt/images/alpine_3.20.tar | jq -r '.config.User'で確認してください。alpine:3.20でsec-uコンテナを--user 1000:1000でバックグラウンド実行し、採点の時点まで起動したままにします。/root/sec1/Dockerfileを作成して、labhub/sec:v1イメージをビルドします。USER命令で数値のUID 10001を指定して、docker run --rm labhub/sec:v1 id -uが10001を出力する必要があります。- 非特権ユーザーで
/etcの下にファイルを作成しようと試み、出力されたPermission deniedエラーを、標準エラー出力まで含めて/root/sec1/denied.txtに保存します。 sec-roコンテナを--read-onlyでバックグラウンド実行し、その中でファイルの書き込みを試みて出力されたRead-only file systemエラーを/root/sec1/ro.txtに保存します。sec-capsコンテナを--cap-drop=ALL --cap-add=NET_BIND_SERVICEでバックグラウンド実行します。sec-nnpコンテナを--security-opt no-new-privileges:trueでバックグラウンド実行します。/root/sec1/hardened.Dockerfileを作成して、labhub/sec:v2イメージをビルドします。次の4つをすべて満たす必要があります。FROMが具体的なタグで固定されていて、latestではないこと(例:alpine:3.20)- 最後の
USERが数値のUIDで、値が10000以上であること org.opencontainers.image.sourceラベルがあること- 実際に実行したとき、
id -uがそのUIDを出力すること
参考
- 数値のUIDだけを得るには、
id -uを使います。idだけを実行すると、複数の数字が混ざって出力されます。 - 標準エラー出力までファイルに入れるには、
명령 > 파일 2>&1の形を使います(プレースホルダーはコマンドとファイルです)。 - ステップ4は、コンテナなしでもできます。
setpriv --reuid=1000 --regid=1000 --clear-groups <명령>は、uid/gidを下ろしてコマンドを実行します(プレースホルダーはコマンドです)。コンテナランタイムが--userで行うことと同じです。 - alpineでユーザーを作成するときは、
adduser -D -u 10001 appの形を使います。 - ラベルは、
LABEL org.opencontainers.image.source="https://example.com/repo"のように書きます。 - よくあるミス1: ステップ1で
idの全体の出力を保存すると、uid=0(root) gid=0(root)の数字がすべて該当してしまい、失敗します。 - よくあるミス2: ステップ2・5・6・7のコンテナを
--rmや、すぐに終わるコマンドで起動すると、採点の時点で消えています。 - よくあるミス3: ステップ8で
USER appのように名前を書くと失敗します。Kubernetesが拒否するのと同じ理由です。
デフォルトのイメージはrootで起動する
/root/sec1ディレクトリを作成し、alpine:3.20をユーザーを何も指定せずに実行したときの数値のUIDだけを/root/sec1/default-uid.txtに保存してください。ファイルには0だけがある必要があります。
コンテナを起動しなくても、はっきり確認できます。実行ユーザーはイメージ設定のconfig.Userフィールドに書かれており、空ならランタイムはuid 0を使います。skopeo inspect --config oci-archive:/opt/images/alpine_3.20.tar | jq -r '.config.User'で確認してください。
idの全体の出力ではなく、数値のUIDだけを保存する必要があります。uid=0(root) gid=0...の形をそのまま入れると、数字が複数混ざって失敗します。数字だけを出力するオプションがあります。
ランタイムのフラグでUIDを変更する
alpine:3.20でsec-uコンテナを--user 1000:1000でバックグラウンド実行し、採点の時点まで起動したままにしてください。
イメージを再ビルドしなくても、実行時にユーザーを変更するフラグがあります。UID:GIDの形式で指定します。採点がコンテナの中でコマンドを実行するので、コンテナが起動し続けている必要があります。
イメージ自体を非rootにする
/root/sec1/Dockerfileを作成して、labhub/sec:v1イメージをビルドしてください。USER命令で数値のUID 10001を指定して、docker run --rm labhub/sec:v1 id -uが10001を出力する必要があります。
実行する人がフラグを忘れても安全であるには、イメージにデフォルトが組み込まれている必要があります。ユーザーを先に作成してからUSERで切り替え、名前ではなく数値で指定してください。alpineには、useraddの代わりにadduserがあります。
権限がないと何が起きるか
非特権ユーザーで/etcの下にファイルを作成しようと試み、出力されたPermission deniedエラーを、標準エラー出力まで含めて/root/sec1/denied.txtに保存してください。
エラーメッセージは、標準出力ではなく標準エラー出力に出ます。リダイレクトするときに標準エラー出力まで一緒に入れないと、ファイルが空になって失敗します。
読み取り専用ルートファイルシステム
sec-roコンテナを--read-onlyでバックグラウンド実行し、その中でファイルの書き込みを試みて出力されたRead-only file systemエラーを/root/sec1/ro.txtに保存してください。
ルートファイルシステムを読み取り専用にするフラグを付けてコンテナを起動し、その中で書き込みを試みて出力されたエラーを保存します。ここでも、標準エラー出力を一緒に入れる必要があります。実際のサービスなら、書き込みが必要なパスには、tmpfsを別に付けます。
capabilityをすべて捨てて、1つだけ加える
sec-capsコンテナを--cap-drop=ALL --cap-add=NET_BIND_SERVICEでバックグラウンド実行してください。
順序が重要です。必要なものだけを取り除く方式ではなく、すべて捨ててから、必要な1つだけを再び加える方式である必要があります。加えるのは、低いポートへのバインドに使われる、そのcapability 1つだけです。
権限昇格の経路を塞ぐ
sec-nnpコンテナを--security-opt no-new-privileges:trueでバックグラウンド実行してください。
setuidビットが付いたバイナリを実行して権限が上がる経路を、カーネルのレベルで塞ぐセキュリティオプションがあります。--security-optで指定し、値の後ろに:trueを付けます。
強化したイメージを完成させる
/root/sec1/hardened.Dockerfileを作成して、labhub/sec:v2イメージをビルドしてください。次の4つをすべて満たす必要があります。
FROMが具体的なタグで固定されていて、latestではないこと(例:alpine:3.20)- 最後の
USERが数値のUIDで、値が10000以上であること org.opencontainers.image.sourceラベルがあること- 実際に実行したとき、
id -uがそのUIDを出力すること
4つを同時に満たす必要があります。タグの固定(latestの禁止)、数値のUIDで10000以上、出所のラベル、そして実際に実行したときにそのUIDで起動することです。ラベルのキーはOCIの標準の名前なので、スペルが正確である必要があります。