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/capsetcap_user_header_t/cap_user_data_t・capability番号の定数(CAP)がすでに揃っていたので、linux.zig側に追加したのはprctlcapsetの薄いラッパーだけです。

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.dataseccomp_data構造体)まで揃っていましたが、BPF命令そのものを表すsock_filter/sock_fprogと、オペコードの定数(BPF_LDBPF_JMPBPF_RETなど)は無かったので自前で定義しています。

BPF_RETのオペコードは0x06ですが、実装時に一度0x05BPF_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側になります。

両方とも、pivotRootIntomountProcが終わった後、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
hello

CapEff/CapPrm/CapBndはいずれも00000000a80425fbで、KEEP_CAPSに指定した14個のcapabilityのビットだけが立った値と一致しています。mountはseccompフィルタによってEPERMで拒否され、busyboxのmountコマンドは「permission denied (are you root?)」という独自メッセージを表示します(内部的なerrnoはEPERMそのもので、rootでない場合によくあるケースとしてbusyboxが文言を付け替えています)。一方lsechoは通常通り動作しており、mountだけがピンポイントで遮断され、他のsyscallには影響していないことも確認できました。