seccomp・AppArmor・capabilityで締める
目標
Podスペックだけでコンテナがカーネルに要求できることを減らす方法を手に覚えさせ、クラスターに残っているprivilegedコンテナを見つけ出す検知の手順を作ります。
なぜ重要なのか
コンテナはホストカーネルを共有します。脱出の脆弱性は、ほとんどがシステムコールやcapabilityを通じてカーネルを突きます。そのため、防御は侵入を防ぐ方向ではなく、侵入したプロセスにできることをあらかじめ減らしておく方向で設計します。seccompは呼び出せるシステムコールを、AppArmorはアクセスできるファイルと機能を、capabilitiesはroot権限の断片を、それぞれ制限します。3つの層が重なっているので、1つが破られても残りが残ります。逆にprivileged: trueはこの3つの層を一度に無効にするため、本番クラスターでprivilegedコンテナを数えることが、そのままリスクの測定になります。
注意: このラボ環境には実際のコンテナランタイムがありません。seccomp・AppArmorのプロファイルが本当に強制されているかは確認できず、ノードにプロファイルファイルがあるかも検査しません。採点はPodスペックのフィールドが正確に書かれているかだけを見ます。CKS試験で採点されるのも、結局このスペックです。
ステップ
-
ネームスペース
cks-sysを作成し、その中にPodseccomp-default(イメージnginx:1.27-alpine)を作成してください。PodレベルのspecのsecurityContext、つまりspec.securityContext.seccompProfile.typeはRuntimeDefaultにします。 -
cks-sysにPodseccomp-customを作成してください。PodレベルのseccompProfileのtypeはLocalhost、localhostProfileはprofiles/audit.jsonにします。 -
cks-sysにPodapparmor-app(コンテナ名app、イメージnginx:1.27-alpine)を作成し、AppArmorプロファイルk8s-custom-profileを適用してください。コンテナのsecurityContext.appArmorProfileのtypeはLocalhost、localhostProfileはk8s-custom-profileにします。(旧バージョンの形式であるcontainer.apparmor.security.beta.kubernetes.io/app: localhost/k8s-custom-profileアノテーションを使っても合格します。) -
cks-sysにPodcapped(コンテナ名app)を作成してください。コンテナのsecurityContextで、capabilities.dropはALLの1つ、capabilities.addはNET_BIND_SERVICEの1つとし、allowPrivilegeEscalationはfalseにします。 -
cks-sysにPodno-host-nsを作成してください。hostNetwork、hostPID、hostIPC、shareProcessNamespaceをすべてfalseとして明示します。ここには、監査で必ず知っておくべき事実が1つ隠れています。PodSpecでは、前の3つのフィールドは
bool+omitemptyなので、falseと書いてもシリアライズの段階でまるごと抜け落ちます。つまり、保存されたオブジェクトだけを見ると、「falseと書いた」のか「そもそも書かなかった」のかを区別できません。そのため、実際の監査基準は「falseと書かれているか」ではなく、trueになっていないかです。一方、shareProcessNamespaceは*boolなので、falseがそのまま残ります。採点ツールもこの違いをそのまま反映しています。適用したら、kubectl get pod no-host-ns -n cks-sys -o yamlで自分で確認してみてください。 -
cks-sysにPodlegacy-agent(コンテナ名agent)を作成してください。コンテナのsecurityContextのprivilegedをtrueにします(レガシーワークロードの再現)。そのあと、/root/cks-system-hardening/find-privileged.shに検知スクリプトを書き、その実行結果を/root/cks-system-hardening/privileged.txtに保存してください。結果は、cks-sysネームスペースでprivilegedコンテナ(initコンテナを含む)を持つPodを、cks-sys/<파드이름>形式(プレースホルダーはPod名です)で、1行に1つずつ辞書順に並べたものです。 -
cks-sysにPodhardened(コンテナ名app)を作成してください。コンテナのsecurityContextにreadOnlyRootFilesystem: true、runAsNonRoot: true、runAsUser: 1000、allowPrivilegeEscalation: false、capabilities.drop: [ALL]をすべて入れ、PodレベルのseccompProfile.typeはRuntimeDefaultにします。書き込みが必要な/tmpは、名前がtmpのemptyDirボリュームでマウントします。
参考
kubectl run seccomp-default --image=nginx:1.27-alpine -n cks-sys --dry-run=client -o yaml > pod.yamlでひな形を出力して編集するほうが速いです。kubectl explain pod.spec.securityContext.seccompProfileでフィールド名を確認してください。- 検知スクリプトのヒント:
kubectl get pods -n cks-sys -o json | jq -r '...'の中で、.spec.containers[]と.spec.initContainers[]を併せて走査します。 - よくある間違い1:
capabilitiesをPodレベルのsecurityContextに入れてしまいます。capabilitiesはコンテナレベルにしかありません。 - よくある間違い2:
localhostProfileに絶対パスを書いてしまいます。ノードのseccompルートを基準にした相対パスでなければなりません。
ランタイムのデフォルトseccompプロファイル
ネームスペースcks-sysを作成し、その中にPodseccomp-default(イメージnginx:1.27-alpine)を作成してください。PodレベルのspecのsecurityContext、つまりspec.securityContext.seccompProfile.typeはRuntimeDefaultにします。
seccompProfileは、Podレベルのspec.securityContextにも、コンテナレベルにも書けます。ここではPodレベルです。
カスタムseccompプロファイルを指定する
cks-sysにPodseccomp-customを作成してください。PodレベルのseccompProfileのtypeはLocalhost、localhostProfileはprofiles/audit.jsonにします。
タイプがLocalhostのときは、localhostProfileが必ず一緒に必要で、パスはノードのseccompルートを基準にした相対パスです。
AppArmorプロファイルを適用する
cks-sysにPodapparmor-app(コンテナ名app、イメージnginx:1.27-alpine)を作成し、AppArmorプロファイルk8s-custom-profileを適用してください。コンテナのsecurityContext.appArmorProfileのtypeはLocalhost、localhostProfileはk8s-custom-profileにします。(旧バージョンの形式であるcontainer.apparmor.security.beta.kubernetes.io/app: localhost/k8s-custom-profileアノテーションを使っても合格します。)
1.30以降は、コンテナのsecurityContext.appArmorProfileフィールドを使います。古いクラスターなら、container.apparmor.security.beta.kubernetes.io/<컨테이너이름>(プレースホルダーはコンテナ名です)アノテーション形式です。どちらか一方で構いません。
capabilityをすべて捨てて1つだけ取り戻す
cks-sysにPodcapped(コンテナ名app)を作成してください。コンテナのsecurityContextで、capabilities.dropはALLの1つ、capabilities.addはNET_BIND_SERVICEの1つとし、allowPrivilegeEscalationはfalseにします。
dropにALLを入れ、addには本当に必要なものだけを書きます。capability名にはCAP_の接頭辞を付けません。
ホストネームスペースを遮断する
cks-sysにPodno-host-nsを作成してください。hostNetwork、hostPID、hostIPC、shareProcessNamespaceをすべてfalseとして明示します。
hostNetwork、hostPID、hostIPC、shareProcessNamespaceは、すべてPodスペックの最上位のフィールドです。4つともfalseと書いておきますが、保存されたオブジェクトを読み直すと、前の3つは消えてshareProcessNamespaceだけが残ります。その理由は、指示文のステップ5を見てください。
privilegedコンテナを検知する
cks-sysにPodlegacy-agent(コンテナ名agent)を作成してください。コンテナのsecurityContextのprivilegedをtrueにします(レガシーワークロードの再現)。そのあと、/root/cks-system-hardening/find-privileged.shに検知スクリプトを書き、その実行結果を/root/cks-system-hardening/privileged.txtに保存してください。結果は、cks-sysネームスペースでprivilegedコンテナ(initコンテナを含む)を持つPodを、cks-sys/<파드이름>形式(プレースホルダーはPod名です)で、1行に1つずつ辞書順に並べたものです。
kubectl get pods -o jsonをjqで走査しますが、initContainersも一緒に見ないと見落とします。出力形式は네임스페이스/파드이름です(プレースホルダーはネームスペース名とPod名です)。
読み取り専用のルートファイルシステムで仕上げる
cks-sysにPodhardened(コンテナ名app)を作成してください。コンテナのsecurityContextにreadOnlyRootFilesystem: true、runAsNonRoot: true、runAsUser: 1000、allowPrivilegeEscalation: false、capabilities.drop: [ALL]をすべて入れ、PodレベルのseccompProfile.typeはRuntimeDefaultにします。書き込みが必要な/tmpは、名前がtmpのemptyDirボリュームでマウントします。
ルートを読み取り専用にすると、一時ファイルのパスが塞がれます。emptyDirボリュームをそのパスにマウントしてください。前のステップで使ったフィールドを、1つのPodにすべて集めます。