ステータス:ドラフト (2026-10-16)、私たちのマニフェスト・コード + 一部実測。 ポリシーと権限はリポジトリのマニフェストに従っており、執筆時点にクラスターに適用されているポリシー・Roleを読み取り専用で取得してリポジトリと一致しているか比較しました(異なる点は本文に記載しています)。内部アドレスとサーバー名は公開記事のため一般化しています。執筆ルール:実測データが基本であり、測れなかったものは「未実測」、ドキュメントから見つからなかったものは「未確認」と表示します。
学生VMが起動するまで 6:隔離
目次
- 1. 入ってくる方向:デフォルト拒否、例外は1つ
- 2. 出る方向:インターネットのみ
- 3. バックエンドの権限:使わない文は閉じる
- 4. コンソール:セッションの持ち主のみに
- 5. 複数の層を1行に
- 6. 残された隙間
- 参考
VMは実習用Podより 広い権限を持つ場所です。学生はゲスト内のrootであり、カーネルモジュールをロードし、任意のパッケージをインストールし、ネットワークツールを自由に使います。実習用Podのようにcapabilityを0に制限する方法は、VM内では通用しません。そのため、VMの隔離は「ゲスト内で何をさせないか」ではなく 「ゲストの外へ何が届くか」 を防ぐ方向に設計しました。
1. 入ってくる方向:デフォルト拒否、例外は1つ
ゲスト内の採点エージェントは受け取った文字列を シェルで実行します(第1回 ③)。エージェントはすべてのインターフェース(8899ポート)でリッスンします。そのため、このポートに到達できる側は、そのVM内でrootとして任意のコマンドを実行できます。真っ先に防がなければならない経路です。
最初は実習用PodがVMに到達できないのを見て安心していました。しかし、それは実習用Podの 出る方向 のポリシーのおかげであって、VM側の防御ではありませんでした。Podのポリシーが1行変わるだけで消え去る保護です。「 防御は守りたいものの傍らに置く」という原則に基づき、VMネームスペースに入ってくる方向のポリシーを別途適用しました(Cilium NetworkPolicy)。
- デフォルト拒否。 virt-launcher Podへの接続はすべてブロックします。ポリシーが選択する対象はVMではなくvirt-launcher Podのラベルです(第2回)。
- 例外は1つ。 バックエンドのWeb Podから8899番ポートへ向かう通信のみを許可します。開発と本番の2つの環境のバックエンドを両方とも記述しました。片方だけを記述すると、その環境でのみ採点が行われ、もう一方は「環境が準備されていません」と繰り返すことになります。
ポリシー適用後に両側から測定しました(2026-08-29)。バックエンド → VMの8899番ポートは通過し、実習用Pod → VMの8899番ポートは6秒のタイムアウトでブロックされました。同じネームスペースの 他の学生のVM から来る接続も、このデフォルト拒否に引っかかります。
2つ目の層:VMごとに異なるトークン
エージェントは、VMごとに異なるトークンが一致するリクエストのみを受け付けます(定数時間比較)。このトークンは 学生にとって秘密ではありません。 学生は自分のVMのrootであるため、ユニットファイルを読み取れば見えてしまいます。防ぎたいのは学生ではなく、 ネットワークポリシーに不備があった際に到達してしまった別のPodです。ポリシーが第1層、トークンが第2層です。1つのVMのトークンを知っていても、他のVMには使えません。
トークンはVMのアノテーションではなく、cloud-init Secretに配置します。アノテーションは kubectl get vm -o yaml そのまま一括でコピーされ、その出力内容を人々が互いに貼り付けてしまうためです。
2. 出る方向:インターネットのみ
VM実習ではaptでパッケージを取得し、k3sやkubeadmをインストールする必要があるため、最初は出る方向の通信を許可していました。しかし、許可された範囲が広すぎました。必要なのは インターネットであって、 自宅のネットワークではありません。当時、ゲスト内のrootがアクセスできた場所は、共有ストレージ、イメージレジストリ、ソースリポジトリとデプロイツール、ルーターの管理画面、Kubernetes API、内部AIサービスでした。
現在は、実習用Podと同じ形状の出る方向のポリシーを適用しています。
- DNS はクラスターDNSのみ。
- パブリックインターネットの80・443番ポート のみ。プライベートアドレス帯全体(RFC 1918の3つの帯域)、リンクローカルとメタデータのアドレス、ループバック、キャリア共有アドレス、マルチキャストは例外として除外します。
適用した日(2026-09-05)にゲスト内から直接測定しました。パブリックなインストールスクリプトのアドレスは200を返し、内部リポジトリ・レジストリ・ルーター・Kubernetes API・内部AIサービスはすべて3秒のタイムアウトになりました。同じポリシーの条件下で、10個のVM実習の正解検証(apt・k3s・Docker・バインドマウント実習)がすべて通過しました。
「学生VMはクラスターに手を出せない」の最大の理由がこのポリシーです。ゲストにはKubernetesのサービスアカウントトークンがなく(VMにサービスアカウントボリュームをマウントしません)、トークンがあったとしてもAPIサーバーのアドレスへの経路がブロックされています。
セッション専用ネットワーク:同じセッションのVM同士のみ
3ノードのkubeadm実習では、VM同士が通信する必要があります。セッションごとにポリシーを1つ作成し、 同じセッションのVM同士でのみ トンネル(UDP 4789)を開きます。実測において、他のセッションのVMがそのトンネルに割り込もうとしてUDP 4789を10回送信したところ、相手のゲストでキャプチャされたパケットは0個であり、ポリシーエンジンによって破棄されたという記録が残っています(2026-09-24)。
このポリシーはバックエンドが作成および削除するため、バックエンドにはネットワークポリシーの作成権限が必要です。しかし、 RBACはcreateを名前で絞り込むことができません(Kubernetes RBACドキュメント:新規オブジェクトの名前は権限を評価する時点で決まっていない場合がある)。権限さえ与えれば、バックエンドはどのような形状のポリシーでも作成可能であり、前述のデフォルト拒否ポリシーを削除してしまうことも可能です。攻撃者でなくても、セレクターを誤って書いたコード1行がデフォルト拒否を削除すれば、すべての学生VMの8899番ポートが開き、その事故はいかなるエラーも発生させません。そのため、APIサーバーのAdmission Policy(ValidatingAdmissionPolicy)により、バックエンドからのリクエストを絞り込んで3つの事項を強制します。名前はセッションポリシーの形式の1つのみ、修正は拒否、構造も1つ(同じセッションのラベル、UDP 4789が1つ)です。定義に誤りがある場合は、遮断する方向に失敗します(Fail-closed)。
3. バックエンドの権限:使わない文は閉じる
VMネームスペースはvirt-launcherの特権ゆえに、Podセキュリティ基準を最も緩く設定しています(第2回)。したがって、このネームスペースでは Podを作成できる権限がそのままノードのrootへの扉になります。そのため、バックエンドのサービスアカウントのRoleは次のように構成されています。
| できること | 理由 |
|---|---|
| VM・VMIの作成・読み取り・削除、開始・停止 | セッションの主たる目的 |
| VMIのconsoleサブリソース | 学生のターミナル(第1回 ⑨・⑩) |
| Secret・サービスの作成・読み取り・削除 | cloud-init Secret、3ノードセッションの隣接ノード名 |
| Podの読み取り、ログの読み取り | ステータス確認 |
| セッションネットワークポリシーの作成・削除 | 上記第2節、形状はAdmission Policyで固定 |
| PVCの 読み取り、ゴールデンの名前12個のみ | ゴールデン判定(第4回) |
| できないこと | 理由 |
|---|---|
| Podの作成 | 特権ネームスペースにおいてノードのrootへ通じる扉。2026-09-24にマルチVMセッション権限を追加した際、使っていなかったこの扉を同時に閉じた |
| PVCの作成・削除、DataVolume | ゴールデンはオペレーターが作成する。バックエンドが削除できると、回収ミス1つですべてのベースが消滅する |
| PVCの一覧表示 | ゴールデンの名前がルールで決まっているため、総なめにする必要がない |
PVC名で絞り込んだ理由
ゴールデンディスクを最初に導入した際、PVCの読み取り権限は名前を指定せず「このネームスペースのPVC get」となっていました。2026-10-14のセキュリティ監査において、それではネームスペース内の すべて のPVC(オペレーター用VMのディスクを含む)を読み取ることができると指摘されました。必要なのはゴールデンのみであるため、 resourceNames によりゴールデンの名前12個(OS 6種 × サイズ 2種)のみに絞り込みました。
- apiGroups: [""]
resources: ["persistentvolumeclaims"]
verbs: ["get"]
resourceNames: [golden-ubuntu-24.04-10gi, golden-ubuntu-24.04-20gi, …] # 12개
resourceNames は名前を指定するリクエスト(get・update・delete等)には有効ですが、createやdeletecollectionは絞り込めず、list・watchを絞り込むにはクライアント側で名前のフィールドセレクターを付与する必要があります(RBACドキュメント)。そのため、「名前のルール1つ + get1つ」というゴールデンの設計がこの絞り込みを可能にしました。OSを追加する場合はこのリストにも追加する必要がありますが、仮に漏れてもそのOSは読み取りが403になり、コンテナディスクとして起動するだけです(第4回の代替策)。リポジトリのリストとバックエンドの名前規則が食い違っていればテストが検知します。
執筆時点でクラスターに適用されているRoleを読み取ったところ、PVCのルールはまだありませんでした。ゴールデンはまだ有効化されておらず、この権限はゴールデンを有効化する際に合わせて適用すると記録されています。その間にゴールデン実習が追加されても、読み取りが403になるためコンテナディスクとして起動します。ブロックされることなく 安全側(遮断)に 倒れる順序になっています。
ゴールデンを学生が変更できるのか
できません。ベースイメージはvirt-launcher Podに 読み取り専用で マウントされ(第4回)、ゲストから見えるのはベースと書き込みレイヤーを合わせた1つのvirtioディスクのみです。ゲスト内のrootがどれだけ dd を行おうとも、書き込みは自身の書き込みレイヤーに向かいます。ベースファイル自体はゲストのアドレス空間の外側、Podのファイルシステム上に存在します。
4. コンソール:セッションの持ち主のみに
学生用ターミナルのWebSocketを開く際、バックエンドは3つの点を確認します。リクエストがTT Labの画面から来たものか(他のサイトが学生のCookieを利用してrootシェルを開く経路を防ぐ)、Cookieのユーザーがそのセッションの持ち主か、セッションが実行中かです。他人のセッションや存在しないセッションは同じコードで拒否し、区別させません。コンソールサブリソース自体にアクセスできるKubernetesのアイデンティティはバックエンドのサービスアカウントのみです。
5. 複数の層を1行に
| 層 | 防御する内容 | 実測 |
|---|---|---|
| 入ってくるポリシー | バックエンド以外はVMに到達できない | 実習用Pod → 8899 タイムアウト |
| 採点トークン | ポリシーに不備があった際に到達したPodがコマンドを実行できない | — |
| 出るポリシー | VMが内部ネットワーク・クラスターAPIに到達できない | 内部アドレスすべて3秒タイムアウト |
| セッションネットワークポリシー | 他のセッションのVMが自分のセッションネットワークに割り込めない | 割り込み試行10回、パケット0個 |
| RBAC + Admission Policy | バックエンドコードのミスがノードroot·デフォルトポリシー削除·ゴールデン削除に発展しない | — |
| コンソールの所有者確認 | 他人のセッションシェルを開けない | — |
6. 残された隙間
- DNSは名前を選びません。 外向きポリシーはクラスタDNSへのクエリの名前制限なしに(
*)許可します。TCPがすべて塞がれても、DNSクエリでデータを少しずつ持ち出す道は残ります。同様の問題がハンズオンポッド側でも指摘され、リポジトリのポッドポリシーはすでに狭められており、クラスタへの適用は人が行う作業として残っています。VM側はまだ広範囲です。 - 時刻同期がブロックされています。 NTP(UDP 123)を開いていないため、ゲストの時刻同期状態は「いいえ」です。仮想クロックのおかげで時刻は合っており、現在ハンズオン中に時刻同期が必要なものはありません。
- ハイパーバイザーからの脱出はこの記事の防御範囲外です。 ゲストのrootがQEMUやKVMの脆弱性によってノードに脱出するケースは、上記のどの多層防御でも防げません。このリスクは、KubeVirt·QEMU·カーネルを適時にアップデートすることでしか軽減できません。
- ポリシーが実際に適用されているかは別途確認する必要があります。 VM名前空間のマニフェストはGitOpsの外で手動で適用します(GitOpsオーバーレイが名前空間を上書きして誤った場所に作成されてしまうため)。ファイルがリポジトリにあることとポリシーが実際に適用されていることは異なるため、最も確実な確認方法はハンズオンポッドからVMの8899番ポートに実際に接続してみることです。
TTの一言。 VM内でrootであることは問題ありません。重要なのは、そのrootが外部の何にアクセスできるかです。入ってくる経路はバックエンド一つ、出て行く経路はインターネットのみ、バックエンドも必ず必要な名前だけ!そして「リポジトリにある」と「クラスタに適用されている」は違う意味なので、直接叩いて確かめてみてください。
参考
ドキュメントはすべて2026-10-16に閲覧しました。
- Kubernetesドキュメント、RBAC(
resourceNames): https://kubernetes.io/docs/reference/access-authn-authz/rbac/ - Kubernetesドキュメント、ValidatingAdmissionPolicy: https://kubernetes.io/docs/reference/access-authn-authz/validating-admission-policy/
- KubeVirtユーザーガイド、ディスクとボリューム(ephemeralの読み取り専用ベース): https://kubevirt.io/user-guide/storage/disks_and_volumes/
- TT Labのマニフェストとコード:
deploy/vm-netpolicy.yaml(入力側)、deploy/vm-egress-netpolicy.yaml(出力側)、deploy/vms/mesh-policy-guard.yaml(入場ポリシー)、deploy/vms/rbac.yaml(Role·クォータ)、backend/app/vm_agent.py(トークン確認)、backend/app/main.py(terminalWebSocketのオリジン·所有者確認) - 履歴:
docs/plans/2026-08-29-vm-labs-design.md,docs/plans/2026-09-24-multi-vm-sessions.md,docs/SECURITY-REVIEW-2026-10-14.md