From d652a099305c8da763c55b42c1112c5d363307c8 Mon Sep 17 00:00:00 2001 From: Muhammad Falak R Wani Date: Tue, 10 Nov 2026 15:11:42 +0530 Subject: [PATCH] devices: drop cilium/ebpf/asm dependency The device filter only uses a handful of single-width eBPF instructions (word loads, 32-bit ALU operations, JNE and exit). Encode them directly instead of using cilium/ebpf/asm, and point each conditional jump at the next block by offset rather than by label. The generated bytecode does not change. cilium/ebpf/asm imports crypto/sha1 for Instructions.Tag, which is not used here. It is the only package in the runc binary that imports a crypto package. Remove endian.go as well: it was only needed because asm's Marshal does not accept binary.NativeEndian. The tests now decode the generated program with cilium/ebpf/asm, in both byte orders, and compare it with the existing expected listings, which are unchanged. cilium/ebpf is now only a test dependency. Signed-off-by: Muhammad Falak R Wani --- devices/devicefilter.go | 74 ++++++++++++++++-------------------- devices/devicefilter_test.go | 57 ++++++++++++++++++++++++++- devices/ebpf_linux.go | 51 +++++++++++++++++++------ devices/endian.go | 20 ---------- 4 files changed, 127 insertions(+), 75 deletions(-) delete mode 100644 devices/endian.go diff --git a/devices/devicefilter.go b/devices/devicefilter.go index aafa0d0..3048a20 100644 --- a/devices/devicefilter.go +++ b/devices/devicefilter.go @@ -10,9 +10,7 @@ import ( "errors" "fmt" "math" - "strconv" - "github.com/cilium/ebpf/asm" devices "github.com/opencontainers/cgroups/devices/config" "golang.org/x/sys/unix" ) @@ -23,7 +21,7 @@ const ( ) // deviceFilter returns eBPF device filter program and its license string. -func deviceFilter(rules []*devices.Rule) (asm.Instructions, string, error) { +func deviceFilter(rules []*devices.Rule) (bpfInstructions, string, error) { // Generate the minimum ruleset for the device rules we are given. While we // don't care about minimum transitions in cgroupv2, using the emulator // gives us a guarantee that the behaviour of devices filtering is the same @@ -68,9 +66,9 @@ func deviceFilter(rules []*devices.Rule) (asm.Instructions, string, error) { } type program struct { - insts asm.Instructions + insts bpfInstructions defaultAllow bool - blockID int + finalized bool } func (p *program) init() { @@ -82,22 +80,22 @@ func (p *program) init() { */ // R2 <- type (lower 16 bit of u32 access_type at R1[0]) p.insts = append(p.insts, - asm.LoadMem(asm.R2, asm.R1, 0, asm.Word), - asm.And.Imm32(asm.R2, 0xFFFF)) + bpfInstruction{code: unix.BPF_LDX | unix.BPF_MEM | unix.BPF_W, dst: 2, src: 1}, + bpfInstruction{code: unix.BPF_ALU | unix.BPF_AND | unix.BPF_K, dst: 2, imm: 0xFFFF}) // R3 <- access (upper 16 bit of u32 access_type at R1[0]) p.insts = append(p.insts, - asm.LoadMem(asm.R3, asm.R1, 0, asm.Word), + bpfInstruction{code: unix.BPF_LDX | unix.BPF_MEM | unix.BPF_W, dst: 3, src: 1}, // RSh: bitwise shift right - asm.RSh.Imm32(asm.R3, 16)) + bpfInstruction{code: unix.BPF_ALU | unix.BPF_RSH | unix.BPF_K, dst: 3, imm: 16}) // R4 <- major (u32 major at R1[4]) p.insts = append(p.insts, - asm.LoadMem(asm.R4, asm.R1, 4, asm.Word)) + bpfInstruction{code: unix.BPF_LDX | unix.BPF_MEM | unix.BPF_W, dst: 4, src: 1, offset: 4}) // R5 <- minor (u32 minor at R1[8]) p.insts = append(p.insts, - asm.LoadMem(asm.R5, asm.R1, 8, asm.Word)) + bpfInstruction{code: unix.BPF_LDX | unix.BPF_MEM | unix.BPF_W, dst: 5, src: 1, offset: 8}) } // appendRule rule converts an OCI rule to the relevant eBPF block and adds it @@ -105,7 +103,7 @@ func (p *program) init() { // called with a "clean" rule list (generated by devices.Emulator.Rules() -- // with any "a" rules removed). func (p *program) appendRule(rule *devices.Rule) error { - if p.blockID < 0 { + if p.finalized { return errors.New("the program is finalized") } @@ -143,65 +141,57 @@ func (p *program) appendRule(rule *devices.Rule) error { // If the access is rwm, skip the check. hasAccess := bpfAccess != (unix.BPF_DEVCG_ACC_READ | unix.BPF_DEVCG_ACC_WRITE | unix.BPF_DEVCG_ACC_MKNOD) - var ( - blockSym = "block-" + strconv.Itoa(p.blockID) - nextBlockSym = "block-" + strconv.Itoa(p.blockID+1) - prevBlockLastIdx = len(p.insts) - 1 - ) + first := len(p.insts) p.insts = append(p.insts, // if (R2 != bpfType) goto next - asm.JNE.Imm(asm.R2, bpfType, nextBlockSym), + bpfInstruction{code: unix.BPF_JMP | unix.BPF_JNE | unix.BPF_K, dst: 2, imm: bpfType}, ) if hasAccess { p.insts = append(p.insts, // if (R3 & bpfAccess != R3 /* use R1 as a temp var */) goto next - asm.Mov.Reg32(asm.R1, asm.R3), - asm.And.Imm32(asm.R1, bpfAccess), - asm.JNE.Reg(asm.R1, asm.R3, nextBlockSym), + bpfInstruction{code: unix.BPF_ALU | unix.BPF_MOV | unix.BPF_X, dst: 1, src: 3}, + bpfInstruction{code: unix.BPF_ALU | unix.BPF_AND | unix.BPF_K, dst: 1, imm: bpfAccess}, + bpfInstruction{code: unix.BPF_JMP | unix.BPF_JNE | unix.BPF_X, dst: 1, src: 3}, ) } if hasMajor { p.insts = append(p.insts, // if (R4 != major) goto next - asm.JNE.Imm(asm.R4, int32(rule.Major), nextBlockSym), + bpfInstruction{code: unix.BPF_JMP | unix.BPF_JNE | unix.BPF_K, dst: 4, imm: int32(rule.Major)}, ) } if hasMinor { p.insts = append(p.insts, // if (R5 != minor) goto next - asm.JNE.Imm(asm.R5, int32(rule.Minor), nextBlockSym), + bpfInstruction{code: unix.BPF_JMP | unix.BPF_JNE | unix.BPF_K, dst: 5, imm: int32(rule.Minor)}, ) } p.insts = append(p.insts, acceptBlock(rule.Allow)...) - // set blockSym to the first instruction we added in this iteration - p.insts[prevBlockLastIdx+1] = p.insts[prevBlockLastIdx+1].WithSymbol(blockSym) - p.blockID++ + // All conditional jumps skip to the next rule. A rule has at most eight + // instructions, so these offsets always fit in an int16. + for i := first; i < len(p.insts); i++ { + switch p.insts[i].code { + case unix.BPF_JMP | unix.BPF_JNE | unix.BPF_K, unix.BPF_JMP | unix.BPF_JNE | unix.BPF_X: + p.insts[i].offset = int16(len(p.insts) - i - 1) + } + } return nil } -func (p *program) finalize() asm.Instructions { - var v int32 - if p.defaultAllow { - v = 1 - } - blockSym := "block-" + strconv.Itoa(p.blockID) - p.insts = append(p.insts, - // R0 <- v - asm.Mov.Imm32(asm.R0, v).WithSymbol(blockSym), - asm.Return(), - ) - p.blockID = -1 +func (p *program) finalize() bpfInstructions { + p.insts = append(p.insts, acceptBlock(p.defaultAllow)...) + p.finalized = true return p.insts } -func acceptBlock(accept bool) asm.Instructions { +func acceptBlock(accept bool) bpfInstructions { var v int32 if accept { v = 1 } - return []asm.Instruction{ + return bpfInstructions{ // R0 <- v - asm.Mov.Imm32(asm.R0, v), - asm.Return(), + {code: unix.BPF_ALU | unix.BPF_MOV | unix.BPF_K, dst: 0, imm: v}, + {code: unix.BPF_JMP | unix.BPF_EXIT}, } } diff --git a/devices/devicefilter_test.go b/devices/devicefilter_test.go index 6df6af2..508fbdf 100644 --- a/devices/devicefilter_test.go +++ b/devices/devicefilter_test.go @@ -1,9 +1,14 @@ package devices import ( + "bytes" + "encoding/binary" + "slices" + "strconv" "strings" "testing" + "github.com/cilium/ebpf/asm" devices "github.com/opencontainers/cgroups/devices/config" ) @@ -19,12 +24,62 @@ func hash(s, comm string) string { return strings.Join(res, "\n") } +// disassemble decodes the program with cilium/ebpf/asm and labels it the way +// deviceFilter lays it out: a block starts right after the instructions added +// by init and at every jump target, and each jump refers to the block it +// lands on. A jump with a wrong offset lands elsewhere and shows up as a +// misplaced label in the listing. +func disassemble(t testing.TB, insts bpfInstructions, bo binary.ByteOrder) string { + t.Helper() + var decoded asm.Instructions + r := bytes.NewReader(insts.marshal(bo)) + for r.Len() > 0 { + var ins asm.Instruction + if err := ins.Unmarshal(r, bo, "linux"); err != nil { + t.Fatal(err) + } + decoded = append(decoded, ins) + } + + var p program + p.init() + starts := []int{len(p.insts)} + for i, ins := range decoded { + if ins.OpCode.JumpOp() == asm.JNE { + starts = append(starts, i+1+int(ins.Offset)) + } + } + slices.Sort(starts) + starts = slices.Compact(starts) + label := func(i int) string { + return "block-" + strconv.Itoa(slices.Index(starts, i)) + } + for i, ins := range decoded { + if ins.OpCode.JumpOp() == asm.JNE { + target := i + 1 + int(ins.Offset) + // Print the jump the way the assembler shows a jump to a label. + ins.Offset = -1 + decoded[i] = ins.WithReference(label(target)) + } + } + for _, i := range starts { + if i >= len(decoded) { + t.Fatalf("jump to instruction %d of a %d-instruction program", i, len(decoded)) + } + decoded[i] = decoded[i].WithSymbol(label(i)) + } + return decoded.String() +} + func testDeviceFilter(t testing.TB, devices []*devices.Rule, expectedStr string) { insts, _, err := deviceFilter(devices) if err != nil { t.Fatalf("%s: %v (devices: %+v)", t.Name(), err, devices) } - s := insts.String() + s := disassemble(t, insts, binary.LittleEndian) + if be := disassemble(t, insts, binary.BigEndian); be != s { + t.Fatalf("big-endian encoding decodes to:\n%s\nlittle-endian encoding decodes to:\n%s", be, s) + } if expectedStr != "" { hashed := hash(s, "//") expectedHashed := hash(expectedStr, "//") diff --git a/devices/ebpf_linux.go b/devices/ebpf_linux.go index 9c47ecd..01a9d6b 100644 --- a/devices/ebpf_linux.go +++ b/devices/ebpf_linux.go @@ -2,6 +2,7 @@ package devices import ( "bytes" + "encoding/binary" "errors" "fmt" "os" @@ -9,7 +10,6 @@ import ( "sync" "unsafe" - "github.com/cilium/ebpf/asm" "github.com/sirupsen/logrus" "golang.org/x/sys/unix" ) @@ -18,6 +18,37 @@ import ( // after being interrupted by a signal (EAGAIN). const bpfMaxRetries = 30 +const bpfInstructionSize = 8 + +// bpfInstruction holds a single-width eBPF instruction. Device filters do not +// need wide immediates, relocations, or any of the other assembler features. +type bpfInstruction struct { + code uint8 + dst, src uint8 + offset int16 + imm int32 +} + +type bpfInstructions []bpfInstruction + +func (insns bpfInstructions) marshal(bo binary.ByteOrder) []byte { + data := make([]byte, len(insns)*bpfInstructionSize) + littleEndian := bo.Uint16([]byte{1, 0}) == 1 + for i, ins := range insns { + raw := data[i*bpfInstructionSize : (i+1)*bpfInstructionSize] + raw[0] = ins.code + // The register bitfields reverse order on big-endian architectures. + if littleEndian { + raw[1] = ins.src<<4 | ins.dst + } else { + raw[1] = ins.dst<<4 | ins.src + } + bo.PutUint16(raw[2:4], uint16(ins.offset)) + bo.PutUint32(raw[4:8], uint32(ins.imm)) + } + return data +} + func bpf(cmd uintptr, attr unsafe.Pointer, size uintptr) (uintptr, error) { // Prevent the Go profiler from repeatedly interrupting the verifier. if cmd == unix.BPF_PROG_LOAD || cmd == unix.BPF_PROG_RUN { @@ -65,12 +96,8 @@ func bpfFD(cmd uintptr, attr unsafe.Pointer, size uintptr) (int, error) { // // It is roughly equivalent to [github.com/cilium/ebpf/internal/sys.ProgLoad], // and the "retry with verifier log" is taken from [github.com/cilium/ebpf.NewProgram]. -func bpfProgLoad(insns asm.Instructions, license string) (int, error) { - buf := bytes.NewBuffer(make([]byte, 0, insns.Size())) - if err := insns.Marshal(buf, nativeEndian); err != nil { - return -1, err - } - insnsBytes := buf.Bytes() +func bpfProgLoad(insns bpfInstructions, license string) (int, error) { + insnsBytes := insns.marshal(binary.NativeEndian) licensePtr, err := unix.BytePtrFromString(license) if err != nil { @@ -90,7 +117,7 @@ func bpfProgLoad(insns asm.Instructions, license string) (int, error) { logBuf uint64 // pointer }{ progType: unix.BPF_PROG_TYPE_CGROUP_DEVICE, - insnCnt: uint32(len(insnsBytes) / asm.InstructionSize), + insnCnt: uint32(len(insns)), insns: uint64(uintptr(unsafe.Pointer(&insnsBytes[0]))), license: uint64(uintptr(unsafe.Pointer(licensePtr))), } @@ -290,9 +317,9 @@ var ( // https://github.com/cilium/ebpf/blob/v0.6.0/link/syscalls.go. func haveBpfProgReplace() bool { haveBpfProgReplaceOnce.Do(func() { - progFd, err := bpfProgLoad(asm.Instructions{ - asm.Mov.Imm(asm.R0, 0), - asm.Return(), + progFd, err := bpfProgLoad(bpfInstructions{ + {code: unix.BPF_ALU64 | unix.BPF_MOV | unix.BPF_K, dst: 0, imm: 0}, + {code: unix.BPF_JMP | unix.BPF_EXIT}, }, "MIT") if err != nil { logrus.Warnf("checking for BPF_F_REPLACE support: bpfProgLoad failed: %v", err) @@ -337,7 +364,7 @@ func haveBpfProgReplace() bool { // Requires the system to be running in cgroup2 unified-mode with kernel >= 4.15 . // // https://github.com/torvalds/linux/commit/ebc614f687369f9df99828572b1d85a7c2de3d92 -func loadAttachCgroupDeviceFilter(insts asm.Instructions, license string, dirFd int) error { +func loadAttachCgroupDeviceFilter(insts bpfInstructions, license string, dirFd int) error { // Increase `ulimit -l` limit to avoid BPF_PROG_LOAD error (#2167). // This limit is not inherited into the container. memlockLimit := &unix.Rlimit{ diff --git a/devices/endian.go b/devices/endian.go deleted file mode 100644 index 23c9cb1..0000000 --- a/devices/endian.go +++ /dev/null @@ -1,20 +0,0 @@ -package devices - -import ( - "encoding/binary" -) - -// nativeEndian is used as a workaround for cilium/ebpf/asm, -// which does not accept binary.NativeEndian. -var nativeEndian binary.ByteOrder = func() binary.ByteOrder { - buf := [2]byte{} - binary.NativeEndian.PutUint16(buf[:], 0xABCD) - switch buf { - case [2]byte{0xCD, 0xAB}: - return binary.LittleEndian - case [2]byte{0xAB, 0xCD}: - return binary.BigEndian - default: - panic("could not determine native endianness") - } -}()