mirror of
https://github.com/netbirdio/gvisor.git
synced 2026-05-22 17:12:49 -07:00
BPF: Add "instrumented execution" mode.
This creates a duplicate BPF interpreter which keeps track of the
instructions and input bytes it accesses.
Having such a mode is useful for the following reasons:
- For seccomp-bpf programs that the Sentry enforces from the sandboxed
application, this allows it to check whether the program is
deterministic based solely on the syscall number. For such syscalls,
we can cache the result of executing the program, and never run the
interpreter during syscall execution. (This optimization is not yet
implemented, but this change enables it to be.)
- For verification of correctness, coverage-based fuzzing is necessary.
I plan to implement a fuzz-based test which verifies that the
optimized and unoptimized versions of the same seccomp-bpf programs
have the same behavior, and ensuring that the corpus achieves full
coverage is necessary to ensure a good degree of correctness.
However, this mode is slower than it needs to when evaluating the
program in an application syscall (which is a very hot path), so this
has to live in a separate (sadly duplicated) function. The tests are
expanded to verify the for instrumented output, but also verify that
the "fast" version (aka the previous implementation) behaves the exact
same way, so this prevents the risk that code from one will drift away
behavior-wise.
To illustrate how much slower (not to mention it does allocations):
```
│ with_binary_order_removed │
│ sec/op │
Interpreter 14.26n ± 0%
InstrumentedInterpreter 98.37n ± 3%
```
PiperOrigin-RevId: 576562506
This commit is contained in:
committed by
gVisor bot
parent
d4973670c3
commit
ecbf37d037
+280
-2
@@ -16,6 +16,8 @@ package bpf
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Possible values for ProgramError.Code.
|
||||
@@ -99,7 +101,7 @@ func (p Program) Length() int {
|
||||
|
||||
// Compile performs validation and optimization on a sequence of BPF
|
||||
// instructions before wrapping them in a Program.
|
||||
func Compile(insns []Instruction) (Program, error) {
|
||||
func Compile(insns []Instruction, optimize bool) (Program, error) {
|
||||
if len(insns) == 0 || len(insns) > MaxInstructions {
|
||||
return Program{}, Error{InvalidInstructionCount, len(insns)}
|
||||
}
|
||||
@@ -214,7 +216,10 @@ func Compile(insns []Instruction) (Program, error) {
|
||||
}
|
||||
}
|
||||
|
||||
return Program{Optimize(insns)}, nil
|
||||
if optimize {
|
||||
insns = Optimize(insns)
|
||||
}
|
||||
return Program{insns}, nil
|
||||
}
|
||||
|
||||
// machine represents the state of a BPF virtual machine.
|
||||
@@ -379,3 +384,276 @@ func Exec(p Program, in Input) (uint32, error) {
|
||||
}
|
||||
return 0, Error{InvalidEndOfProgram, pc}
|
||||
}
|
||||
|
||||
// ExecutionMetrics represents the result of executing a BPF program.
|
||||
type ExecutionMetrics struct {
|
||||
// ReturnValue is the result of the program execution.
|
||||
ReturnValue uint32
|
||||
|
||||
// Coverage maps instruction indexes to whether or not they were executed.
|
||||
// This slice has the same size as the number of instructions as the BPF
|
||||
// program that was run, so it can be used as a way to get the program size.
|
||||
// Since an instruction can never run twice in BPF, this can also be used
|
||||
// to determine how many instructions were executed.
|
||||
Coverage []bool
|
||||
|
||||
// InputAccessed maps input byte offsets to whether or not they were
|
||||
// read by the program during execution.
|
||||
InputAccessed []bool
|
||||
}
|
||||
|
||||
// String returns a human-readable view of an `Execution`.
|
||||
func (e *ExecutionMetrics) String() string {
|
||||
type intRange struct {
|
||||
from, to int
|
||||
}
|
||||
|
||||
// addRangeString formats an `intRange` and writes it to `sb`.
|
||||
addRangeString := func(sb *strings.Builder, rng intRange) {
|
||||
if rng.from == rng.to {
|
||||
sb.WriteString(strconv.Itoa(rng.from))
|
||||
} else {
|
||||
sb.WriteString(strconv.Itoa(rng.from))
|
||||
sb.WriteRune('-')
|
||||
sb.WriteString(strconv.Itoa(rng.to))
|
||||
}
|
||||
}
|
||||
|
||||
// `getRanges` takes a slice of booleans and returns ranges of all-true
|
||||
// indexes.
|
||||
getRanges := func(s []bool) []intRange {
|
||||
var ranges []intRange
|
||||
firstTrueIndex := -1
|
||||
for i, covered := range s {
|
||||
if covered {
|
||||
if firstTrueIndex == -1 {
|
||||
firstTrueIndex = i
|
||||
}
|
||||
continue
|
||||
}
|
||||
if firstTrueIndex != -1 {
|
||||
ranges = append(ranges, intRange{firstTrueIndex, i - 1})
|
||||
firstTrueIndex = -1
|
||||
}
|
||||
}
|
||||
if firstTrueIndex != -1 {
|
||||
ranges = append(ranges, intRange{firstTrueIndex, len(s) - 1})
|
||||
}
|
||||
return ranges
|
||||
}
|
||||
|
||||
// ranges returns a human-friendly representation of the
|
||||
// ranges of items in `s` that are contiguously `true`.
|
||||
ranges := func(s []bool) string {
|
||||
if len(s) == 0 {
|
||||
return "empty"
|
||||
}
|
||||
allFalse := true
|
||||
allTrue := true
|
||||
for _, v := range s {
|
||||
if v {
|
||||
allFalse = false
|
||||
} else {
|
||||
allTrue = false
|
||||
}
|
||||
}
|
||||
if allFalse {
|
||||
return "none"
|
||||
}
|
||||
if allTrue {
|
||||
return "all"
|
||||
}
|
||||
ranges := getRanges(s)
|
||||
var sb strings.Builder
|
||||
for i, rng := range ranges {
|
||||
if i != 0 {
|
||||
sb.WriteRune(',')
|
||||
}
|
||||
addRangeString(&sb, rng)
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
executedInstructions := 0
|
||||
for _, covered := range e.Coverage {
|
||||
if covered {
|
||||
executedInstructions++
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("returned %d, covered %d/%d instructions (%s), read input bytes %s (%d total input bytes)", e.ReturnValue, executedInstructions, len(e.Coverage), ranges(e.Coverage), ranges(e.InputAccessed), len(e.InputAccessed))
|
||||
}
|
||||
|
||||
// markInputRead marks the `bytesRead` bytes starting at `offset` as having
|
||||
// been read from the input. This function assumes that the offset and number
|
||||
// of bytes have already been verified as valid.
|
||||
func (e *ExecutionMetrics) markInputRead(offset uint32, bytesRead int) {
|
||||
if int(offset)+bytesRead > len(e.InputAccessed) {
|
||||
panic(fmt.Sprintf("invalid offset or number of bytes read: offset=%d bytesRead=%d len=%d", offset, bytesRead, len(e.InputAccessed)))
|
||||
}
|
||||
for i := 0; i < bytesRead; i++ {
|
||||
e.InputAccessed[int(offset)+i] = true
|
||||
}
|
||||
}
|
||||
|
||||
// InstrumentedExec executes a BPF program over the given input while
|
||||
// instrumenting it: recording memory accesses and lines executed.
|
||||
// This is slower than Exec, but should return equivalent results.
|
||||
func InstrumentedExec(p Program, in Input) (ExecutionMetrics, error) {
|
||||
ret := ExecutionMetrics{
|
||||
Coverage: make([]bool, len(p.instructions)),
|
||||
InputAccessed: make([]bool, in.Length()),
|
||||
}
|
||||
var m machine
|
||||
var pc int
|
||||
for ; pc < len(p.instructions); pc++ {
|
||||
ret.Coverage[pc] = true
|
||||
i := p.instructions[pc]
|
||||
switch i.OpCode {
|
||||
case Ld | Imm | W:
|
||||
m.A = i.K
|
||||
case Ld | Abs | W:
|
||||
val, ok := in.Load32(i.K)
|
||||
if !ok {
|
||||
return ret, Error{InvalidLoad, pc}
|
||||
}
|
||||
ret.markInputRead(i.K, 4)
|
||||
m.A = val
|
||||
case Ld | Abs | H:
|
||||
val, ok := in.Load16(i.K)
|
||||
if !ok {
|
||||
return ret, Error{InvalidLoad, pc}
|
||||
}
|
||||
ret.markInputRead(i.K, 2)
|
||||
m.A = uint32(val)
|
||||
case Ld | Abs | B:
|
||||
val, ok := in.Load8(i.K)
|
||||
if !ok {
|
||||
return ret, Error{InvalidLoad, pc}
|
||||
}
|
||||
ret.markInputRead(i.K, 1)
|
||||
m.A = uint32(val)
|
||||
case Ld | Ind | W:
|
||||
val, ok := in.Load32(m.X + i.K)
|
||||
if !ok {
|
||||
return ret, Error{InvalidLoad, pc}
|
||||
}
|
||||
ret.markInputRead(m.X+i.K, 4)
|
||||
m.A = val
|
||||
case Ld | Ind | H:
|
||||
val, ok := in.Load16(m.X + i.K)
|
||||
if !ok {
|
||||
return ret, Error{InvalidLoad, pc}
|
||||
}
|
||||
ret.markInputRead(m.X+i.K, 2)
|
||||
m.A = uint32(val)
|
||||
case Ld | Ind | B:
|
||||
val, ok := in.Load8(m.X + i.K)
|
||||
if !ok {
|
||||
return ret, Error{InvalidLoad, pc}
|
||||
}
|
||||
ret.markInputRead(m.X+i.K, 1)
|
||||
m.A = uint32(val)
|
||||
case Ld | Mem | W:
|
||||
m.A = m.M[int(i.K)]
|
||||
case Ld | Len | W:
|
||||
m.A = in.Length()
|
||||
case Ldx | Imm | W:
|
||||
m.X = i.K
|
||||
case Ldx | Mem | W:
|
||||
m.X = m.M[int(i.K)]
|
||||
case Ldx | Len | W:
|
||||
m.X = in.Length()
|
||||
case Ldx | Msh | B:
|
||||
val, ok := in.Load8(i.K)
|
||||
if !ok {
|
||||
return ret, Error{InvalidLoad, pc}
|
||||
}
|
||||
ret.markInputRead(i.K, 1)
|
||||
m.X = 4 * uint32(val&0xf)
|
||||
case St:
|
||||
m.M[int(i.K)] = m.A
|
||||
case Stx:
|
||||
m.M[int(i.K)] = m.X
|
||||
case Alu | Add | K:
|
||||
m.A += i.K
|
||||
case Alu | Add | X:
|
||||
m.A += m.X
|
||||
case Alu | Sub | K:
|
||||
m.A -= i.K
|
||||
case Alu | Sub | X:
|
||||
m.A -= m.X
|
||||
case Alu | Mul | K:
|
||||
m.A *= i.K
|
||||
case Alu | Mul | X:
|
||||
m.A *= m.X
|
||||
case Alu | Div | K:
|
||||
// K != 0 already checked by Compile.
|
||||
m.A /= i.K
|
||||
case Alu | Div | X:
|
||||
if m.X == 0 {
|
||||
return ret, Error{DivisionByZero, pc}
|
||||
}
|
||||
m.A /= m.X
|
||||
case Alu | Or | K:
|
||||
m.A |= i.K
|
||||
case Alu | Or | X:
|
||||
m.A |= m.X
|
||||
case Alu | And | K:
|
||||
m.A &= i.K
|
||||
case Alu | And | X:
|
||||
m.A &= m.X
|
||||
case Alu | Lsh | K:
|
||||
m.A <<= i.K
|
||||
case Alu | Lsh | X:
|
||||
m.A <<= m.X
|
||||
case Alu | Rsh | K:
|
||||
m.A >>= i.K
|
||||
case Alu | Rsh | X:
|
||||
m.A >>= m.X
|
||||
case Alu | Neg:
|
||||
m.A = uint32(-int32(m.A))
|
||||
case Alu | Mod | K:
|
||||
// K != 0 already checked by Compile.
|
||||
m.A %= i.K
|
||||
case Alu | Mod | X:
|
||||
if m.X == 0 {
|
||||
return ret, Error{DivisionByZero, pc}
|
||||
}
|
||||
m.A %= m.X
|
||||
case Alu | Xor | K:
|
||||
m.A ^= i.K
|
||||
case Alu | Xor | X:
|
||||
m.A ^= m.X
|
||||
case Jmp | Ja:
|
||||
pc += int(i.K)
|
||||
case Jmp | Jeq | K:
|
||||
pc += conditionalJumpOffset(i, m.A == i.K)
|
||||
case Jmp | Jeq | X:
|
||||
pc += conditionalJumpOffset(i, m.A == m.X)
|
||||
case Jmp | Jgt | K:
|
||||
pc += conditionalJumpOffset(i, m.A > i.K)
|
||||
case Jmp | Jgt | X:
|
||||
pc += conditionalJumpOffset(i, m.A > m.X)
|
||||
case Jmp | Jge | K:
|
||||
pc += conditionalJumpOffset(i, m.A >= i.K)
|
||||
case Jmp | Jge | X:
|
||||
pc += conditionalJumpOffset(i, m.A >= m.X)
|
||||
case Jmp | Jset | K:
|
||||
pc += conditionalJumpOffset(i, (m.A&i.K) != 0)
|
||||
case Jmp | Jset | X:
|
||||
pc += conditionalJumpOffset(i, (m.A&m.X) != 0)
|
||||
case Ret | K:
|
||||
ret.ReturnValue = i.K
|
||||
return ret, nil
|
||||
case Ret | A:
|
||||
ret.ReturnValue = m.A
|
||||
return ret, nil
|
||||
case Misc | Tax:
|
||||
m.A = m.X
|
||||
case Misc | Txa:
|
||||
m.X = m.A
|
||||
default:
|
||||
return ret, Error{InvalidOpcode, pc}
|
||||
}
|
||||
}
|
||||
return ret, Error{InvalidEndOfProgram, pc}
|
||||
}
|
||||
|
||||
+370
-112
File diff suppressed because it is too large
Load Diff
@@ -872,9 +872,9 @@ func TestBasic(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("BuildProgram() got error: %v", err)
|
||||
}
|
||||
p, err := bpf.Compile(instrs)
|
||||
p, err := bpf.Compile(instrs, true /* optimize */)
|
||||
if err != nil {
|
||||
t.Fatalf("bpf.Compile() got error: %v", err)
|
||||
t.Fatalf("bpf.Compile got error: %v", err)
|
||||
}
|
||||
for _, spec := range test.specs {
|
||||
got, err := bpf.Exec(p, DataAsBPFInput(&spec.data, buf))
|
||||
@@ -913,9 +913,9 @@ func TestRandom(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("buildProgram() got error: %v", err)
|
||||
}
|
||||
p, err := bpf.Compile(instrs)
|
||||
p, err := bpf.Compile(instrs, true /* optimize */)
|
||||
if err != nil {
|
||||
t.Fatalf("bpf.Compile() got error: %v", err)
|
||||
t.Fatalf("bpf.Compile got error: %v", err)
|
||||
}
|
||||
buf := make([]byte, (&linux.SeccompData{}).SizeBytes())
|
||||
for i := uint32(0); i < 200; i++ {
|
||||
|
||||
@@ -67,7 +67,7 @@ func seccomp(t *kernel.Task, mode, flags uint64, addr hostarch.Addr) error {
|
||||
for i, ins := range filter {
|
||||
bpfFilter[i] = bpf.Instruction(ins)
|
||||
}
|
||||
compiledFilter, err := bpf.Compile(bpfFilter)
|
||||
compiledFilter, err := bpf.Compile(bpfFilter, true /* optimize */)
|
||||
if err != nil {
|
||||
t.Debugf("Invalid seccomp-bpf filter: %v", err)
|
||||
return linuxerr.EINVAL
|
||||
|
||||
@@ -56,7 +56,7 @@ func BuildProgram(s *specs.LinuxSeccomp) (bpf.Program, error) {
|
||||
return bpf.Program{}, fmt.Errorf("building seccomp program: %w", err)
|
||||
}
|
||||
|
||||
program, err := bpf.Compile(instrs)
|
||||
program, err := bpf.Compile(instrs, true /* optimize */)
|
||||
if err != nil {
|
||||
return bpf.Program{}, fmt.Errorf("compiling seccomp program: %w", err)
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ func RunBench(b *testing.B, bn secbenchdef.Bench) {
|
||||
// two runs.
|
||||
// If there are no syscall sequences that will be approved, then we can
|
||||
// skip running the runner the second time altogether.
|
||||
program, err := bpf.Compile(bn.Instructions)
|
||||
program, err := bpf.Compile(bn.Instructions, true /* optimize */)
|
||||
if err != nil {
|
||||
b.Fatalf("program does not compile: %v", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user