コンテナの中のrootはホストのrootだ
一言でいうと
ユーザーネームスペースを有効にしていないなら、コンテナのrootは、ホストのrootと同じ UID 0です。「コンテナは分離されているから、中でrootでもかまわない」という言葉は、間違いです。
なぜ必要なのか
公式イメージの大半が、いまだにrootで起動します。docker run --rm node:22-bookworm-slim id
を実行してみると、uid=0(root)と出ます。ここまでは驚きませんが、次の1行が
問題です。
docker run --rm -v /etc:/host-etc alpine:3.20 sh -c 'echo "# injected" >> /host-etc/hosts'
これがそのまま通ります。ホストの/etc/hostsが変更されます。コンテナが箱だったなら
ありえないことですが、前のコースで見たとおり、コンテナは箱ではなく、視野が制限された
プロセスであり、マウントで開いた経路には、そのプロセスのUID 0の権限がそのまま適用
されます。分離が破れたのではなく、最初からそう設計されているのです。
どう動くのか
定石は、イメージ自体を非特権ユーザーにすることです。
RUN useradd --uid 10001 --create-home --shell /usr/sbin/nologin app
COPY --chown=10001:10001 . /app
USER 10001:10001
名前ではなく数値のUIDでなければならない理由があります。Kubernetesの
runAsNonRootは、イメージのUSERが名前だと、rootかどうかを判別できずに
Podを拒否します。
Error: container has runAsNonRoot and image has non-numeric user (app),
cannot verify user is non-root
非特権ユーザーは、1024未満のポートを開けません。ここでNET_BIND_SERVICE
capabilityを加えたくなりますが、たいていはアプリのポートを8080に変更し、サービス
層で80として公開するほうが、はるかに単純です。権限を加える選択は、最後に
取っておいてください。
capabilityは、root権限を40ほどの断片に分けたもので、コンテナのデフォルトのセットは、そのうち
14個です(CapEff: 00000000a80425fb)。cap_chown、cap_dac_override、
cap_fowner、cap_setuid、cap_setgid、cap_net_bind_service、cap_net_raw、
cap_sys_chroot、cap_mknod、cap_setfcapなどが入っていて、SYS_ADMIN、NET_ADMIN、
SYS_PTRACEは入っていません。
推奨される組み合わせは次のとおりです。
--cap-drop=ALL --cap-add=NET_BIND_SERVICE
--security-opt no-new-privileges:true
--read-only --tmpfs /tmp
--user 10001:10001
seccompのデフォルトプロファイルは、400あまりのシステムコールのうち約44個を防ぎます。その中には、
コンテナ脱出に実際に使われたopen_by_handle_at、そしてkeyctl、kexec_load
が含まれています。
現場での姿
--privilegedは、上の保護を一度にすべて解除するスイッチです。そして、
Dockerソケットをマウントすることも、事実上同じ意味です。そのソケットがあれば、ホストの
ルートファイルシステムをマウントした特権コンテナを、新しく起動できるからです。CIランナーの
設定で、この2つを習慣のように有効にしているケースが多くありますが、その瞬間に、残りのセキュリティ設定は
飾りになります。
実際の事故も、この構図の上にあります。CVE-2019-5736は、/proc/self/exeを通じて
ホストのruncバイナリを上書きし、CVE-2024-21626は、runcがファイルディスクリプターを
閉じなかったために、WORKDIRの操作だけでホストのファイルシステムにアクセスできました。
どちらもカーネルのバグではなくランタイムのバグでしたが、コンテナがホストと同じカーネルの
下にあるために成立しました。
権限を実際にどこまで削れるか
「rootで動かさない」は、始まりにすぎません。コンテナで削れるものは、ユーザー、 権限(capability)、ファイルシステム、システムコールの4つの層です。4つの層をすべて調整すれば、侵害のあとに できることが大きく減ります。
securityContext:
runAsNonRoot: true
runAsUser: 10001
allowPrivilegeEscalation: false # setuid 로 권한을 되찾는 길을 막는다
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
seccompProfile:
type: RuntimeDefault
allowPrivilegeEscalation: falseが静かに大きな役割を果たします。これが有効になっていれば、
コンテナ内のsetuidバイナリで権限を再び上げられることはありません。ユーザーを下げておいても、
この値を無効にしていなければ、半分しかやっていないことになります。
権限はすべて捨てて、必要なものだけを戻します。1024未満のポートを開く必要があるなら、
NET_BIND_SERVICEだけを加えます。より良い答えは、そもそも8080で待ち受けさせて、サービスが
80にマッピングすることであり、権限を加える理由自体がなくなります。
読み取り専用ルートは、書き込みが必要な場所を明らかにします。有効にすると、たいていは最初に壊れますが、
その場所が、そのプログラムがどこに書き込むかです。emptyDirを/tmpとキャッシュのパスにだけ
付ければ、残りは書き込めなくなります。攻撃者がツールをダウンロードして保存する場所がなくなること
が、この設定の本当の価値です。
ファイルの所有権のせいで起動できないことが、最もよくあるつまずきです。イメージがroot所有で
作られているのに、別のユーザーで実行すると、読み取れません。イメージを作るときに
COPY --chown=10001:10001で、あらかじめ合わせておくほうがよいでしょう。ボリュームなら、fsGroupが
マウントの時点でグループを変更してくれます。
seccompは、デフォルトのプロファイルだけで十分な場合が多くあります。RuntimeDefaultは、
unshare、ptraceなど危険なシステムコールを数十個防ぎます。自作のプロファイルは
正確ですが、カーネルやランタイムが変わると静かに壊れるという維持コストが付きます。
デフォルトを有効にするだけで、ほとんどの利点が得られます。
次のラボですること
デフォルトのイメージがrootで起動することを確認し、ランタイムのフラグとDockerfileの2つの 方法で非特権での実行を作り、読み取り専用ルート、capabilityの全面的な削除、 権限昇格の遮断を、1つずつ適用します。最後に、これらの条件をすべて満たすイメージを 自分で作ります。