capabilitiesの削減とseccomp(BPF)によるsyscallフィルタでコンテナの権限を絞る
Network namespaceとvethペアを構築する記事ではホスト・コンテナ間の通信を実装しました。今回はコンテナプロセスの権限を絞り込む2つの仕組み、capabilitiesとseccompを実装します。どちらもroot権限そのものを取り上げるのではなく、「rootであっても、コンテナの中からはこの操作はできない」という制限を後付けする仕組みです。
capabilities: rootの権限を単位ごとに削減する
Linux capabilitiesは、伝統的にroot(UID 0)にひとまとめに与えられていた特権を、CAP_SYS_ADMIN(マウント操作など多数の管理操作)やCAP_NET_ADMIN(ネットワーク設定変更)のような単位に分割したものです。プロセスごとに、必要なcapabilityだけを残して残りを剥奪できます。
capabilityの集合にはbounding set・effective set・permitted set・inheritable setの4種類がありますが、今回は「bounding set(今後も含めて取得不可能にする集合)」と「effective/permitted set(現在有効な集合)」の両方から、不要なcapabilityを外す実装にしました。許可リストはDocker/runcのデフォルト(14個)に合わせています。
const KEEP_CAPS = [_]u8{
linux.CAP.CHOWN,
linux.CAP.DAC_OVERRIDE,
linux.CAP.FOWNER,
linux.CAP.FSETID,
linux.CAP.KILL,
linux.CAP.SETGID,
linux.CAP.SETUID,
linux.CAP.SETPCAP,
linux.CAP.NET_BIND_SERVICE,
linux.CAP.NET_RAW,
linux.CAP.SYS_CHROOT,
linux.CAP.MKNOD,
linux.CAP.AUDIT_WRITE,
linux.CAP.SETFCAP,
};
const CAPABILITY_VERSION_3: u32 = 0x20080522;
fn keepMask() u64 {
var mask: u64 = 0;
for (KEEP_CAPS) |cap| mask |= @as(u64, 1) << @as(u6, @intCast(cap));
return mask;
}
pub fn dropToMinimalSet() !void {
const mask = keepMask();
var cap: u8 = 0;
while (cap <= linux.CAP.LAST_CAP) : (cap += 1) {
if (mask & (@as(u64, 1) << @as(u6, @intCast(cap))) != 0) continue;
try sys.prctl(@intFromEnum(linux.PR.CAPBSET_DROP), cap, 0, 0, 0);
}
var header = linux.cap_user_header_t{ .version = CAPABILITY_VERSION_3, .pid = 0 };
const data = [2]linux.cap_user_data_t{
.{ .effective = @truncate(mask), .permitted = @truncate(mask), .inheritable = 0 },
.{ .effective = @truncate(mask >> 32), .permitted = @truncate(mask >> 32), .inheritable = 0 },
};
try sys.setCapabilities(&header, &data[0]);
}cap_user_data_tは32ビット分のcapabilityしか表現できないため、現行の41個(0〜40番)を表すには2要素の配列が必要です(_LINUX_CAPABILITY_VERSION_3の仕様)。data[0]が0〜31番目、data[1]が32番目以降のビットに対応します。
ループの中でCAP_SETPCAP自体をbounding setから外していますが、prctl(PR_CAPBSET_DROP)が要求するのは呼び出し時点のeffective set(このループ中はまだ変更していない)なので、最後まで問題なく実行できます。実際にeffective/permitted setからCAP_SETPCAPが外れるのは、ループの後のcapset呼び出し時点です。
std.os.linuxにはcapget/capset・cap_user_header_t/cap_user_data_t・capability番号の定数(CAP)がすでに揃っていたので、linux.zig側に追加したのはprctlとcapsetの薄いラッパーだけです。
seccomp: BPFで呼び出せるsyscallを絞る
seccompは、プロセスが呼び出せるsyscallをBPF(Berkeley Packet Filter)プログラムでフィルタするカーネルの機能です。今回はmount(2)だけを明示的に拒否し、それ以外のsyscallはすべて許可する4命令のプログラムを組み立てました。
const sock_filter = extern struct {
code: u16,
jt: u8,
jf: u8,
k: u32,
};
const sock_fprog = extern struct {
len: u16,
filter: [*]const sock_filter,
};
const BPF_LD_W_ABS: u16 = 0x00 | 0x00 | 0x20; // BPF_LD | BPF_W | BPF_ABS
const BPF_JMP_JEQ_K: u16 = 0x05 | 0x10 | 0x00; // BPF_JMP | BPF_JEQ | BPF_K
const BPF_RET_K: u16 = 0x06 | 0x00; // BPF_RET | BPF_K
const EPERM: u32 = 1;
fn stmt(code: u16, k: u32) sock_filter {
return .{ .code = code, .jt = 0, .jf = 0, .k = k };
}
fn jump(code: u16, k: u32, jt: u8, jf: u8) sock_filter {
return .{ .code = code, .jt = jt, .jf = jf, .k = k };
}
pub fn blockMount() !void {
const nr_offset: u32 = @offsetOf(linux.SECCOMP.data, "nr");
const mount_nr: u32 = @intFromEnum(linux.SYS.mount);
const program = [_]sock_filter{
stmt(BPF_LD_W_ABS, nr_offset),
jump(BPF_JMP_JEQ_K, mount_nr, 0, 1),
stmt(BPF_RET_K, linux.SECCOMP.RET.ERRNO | EPERM),
stmt(BPF_RET_K, linux.SECCOMP.RET.ALLOW),
};
const prog = sock_fprog{ .len = program.len, .filter = &program };
try sys.prctl(@intFromEnum(linux.PR.SET_NO_NEW_PRIVS), 1, 0, 0, 0);
try sys.installSeccompFilter(&prog);
}4命令の内訳は、①今回呼ばれたsyscall番号(seccomp_data.nr)をロード、②mountのsyscall番号と一致するか比較して分岐、③一致していればEPERMを返して拒否、④一致していなければ許可、という流れです。std.os.linuxにはseccomp()・SECCOMP.RET(返り値の定数)・SECCOMP.data(seccomp_data構造体)まで揃っていましたが、BPF命令そのものを表すsock_filter/sock_fprogと、オペコードの定数(BPF_LD・BPF_JMP・BPF_RETなど)は無かったので自前で定義しています。
BPF_RETのオペコードは0x06ですが、実装時に一度0x05(BPF_JMPのオペコード)と書き間違えていました。この状態だと③④の命令が実際には無条件ジャンプ命令(BPF_JMP | BPF_JA | BPF_K)として解釈され、kフィールド(EPERMのエラーコードやRET.ALLOWの値)が「何命令先へジャンプするか」という意味で使われてしまいます。値そのものはプログラムの命令数(4)を大きく超えているため、カーネルのBPF検証器が範囲外ジャンプとしてseccomp(2)呼び出し自体をEINVALで拒否します。オペコード定数を0x06 | 0x00に直したところ、正しくロードできるようになりました。
capabilitiesとseccompはどちらもmountを防ぐ効果を持ちますが、意図的に重複させています。Dockerのデフォルトseccompプロファイルも、capabilitiesで防げるsyscallを含めて数十個をブロックしており、多層防御としてはこの重複が普通です。また実行順序としても、seccompのフィルタはsyscallの入り口で最初に評価されるため、実際にEPERMを返すのはcapabilitiesの権限チェックより先にseccomp側になります。
両方とも、pivotRootInto・mountProcが終わった後、execIntoの直前に適用しています。mountにはまだCAP_SYS_ADMINが必要なため、剥奪前に済ませておく必要があるためです。
動作確認
$ sudo ./zig-out/bin/zigcon run ./rootfs
...
[child] capabilities reduced to Docker-default set
[child] seccomp filter installed (mount blocked)
[child] handing over to /bin/sh
BusyBox v1.30.1 (Ubuntu 1:1.30.1-7ubuntu3.1) built-in shell (ash)
Enter 'help' for a list of built-in commands.
/ # cat /proc/self/status | grep Cap
CapInh: 0000000000000000
CapPrm: 00000000a80425fb
CapEff: 00000000a80425fb
CapBnd: 00000000a80425fb
CapAmb: 0000000000000000
/ # mount -t proc proc /proc
mount: permission denied (are you root?)
/ # ls /
bin home proc sys
/ # echo hello
helloCapEff/CapPrm/CapBndはいずれも00000000a80425fbで、KEEP_CAPSに指定した14個のcapabilityのビットだけが立った値と一致しています。mountはseccompフィルタによってEPERMで拒否され、busyboxのmountコマンドは「permission denied (are you root?)」という独自メッセージを表示します(内部的なerrnoはEPERMそのもので、rootでない場合によくあるケースとしてbusyboxが文言を付け替えています)。一方lsやechoは通常通り動作しており、mountだけがピンポイントで遮断され、他のsyscallには影響していないことも確認できました。