seccomp-tools 1.6.2 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +153 -0
  3. data/README.md +263 -42
  4. data/completions/_seccomp-tools +83 -0
  5. data/completions/seccomp-tools.bash +59 -0
  6. data/completions/seccomp-tools.fish +52 -0
  7. data/ext/ptrace/ptrace.c +2 -2
  8. data/lib/seccomp-tools/asm/asm.rb +9 -4
  9. data/lib/seccomp-tools/asm/compiler.rb +32 -2
  10. data/lib/seccomp-tools/asm/sasm.tab.rb +27 -19
  11. data/lib/seccomp-tools/asm/sasm.y +15 -7
  12. data/lib/seccomp-tools/asm/scalar.rb +50 -7
  13. data/lib/seccomp-tools/asm/scanner.rb +33 -1
  14. data/lib/seccomp-tools/asm/statement.rb +14 -5
  15. data/lib/seccomp-tools/asm/token.rb +19 -1
  16. data/lib/seccomp-tools/audit/catalog.rb +66 -0
  17. data/lib/seccomp-tools/audit/checks/arch_unchecked.rb +41 -0
  18. data/lib/seccomp-tools/audit/checks/dangerous_allow.rb +34 -0
  19. data/lib/seccomp-tools/audit/checks/orw_chain.rb +41 -0
  20. data/lib/seccomp-tools/audit/checks/permissive_default.rb +29 -0
  21. data/lib/seccomp-tools/audit/checks/syscall_alt_gap.rb +44 -0
  22. data/lib/seccomp-tools/audit/checks/x32_guard.rb +46 -0
  23. data/lib/seccomp-tools/audit/checks.rb +42 -0
  24. data/lib/seccomp-tools/audit/finding.rb +25 -0
  25. data/lib/seccomp-tools/audit/policy.rb +126 -0
  26. data/lib/seccomp-tools/audit/report.rb +98 -0
  27. data/lib/seccomp-tools/audit.rb +48 -0
  28. data/lib/seccomp-tools/bpf.rb +27 -17
  29. data/lib/seccomp-tools/cli/asm.rb +6 -2
  30. data/lib/seccomp-tools/cli/audit.rb +86 -0
  31. data/lib/seccomp-tools/cli/base.rb +51 -8
  32. data/lib/seccomp-tools/cli/cli.rb +9 -3
  33. data/lib/seccomp-tools/cli/completion.rb +40 -0
  34. data/lib/seccomp-tools/cli/disasm.rb +9 -5
  35. data/lib/seccomp-tools/cli/dump.rb +37 -52
  36. data/lib/seccomp-tools/cli/dumpable.rb +79 -0
  37. data/lib/seccomp-tools/cli/emu.rb +20 -5
  38. data/lib/seccomp-tools/cli/explain.rb +51 -0
  39. data/lib/seccomp-tools/cli/filter_input.rb +130 -0
  40. data/lib/seccomp-tools/const.rb +98 -15
  41. data/lib/seccomp-tools/consts/sys_nr/riscv64.rb +332 -0
  42. data/lib/seccomp-tools/disasm/disasm.rb +30 -12
  43. data/lib/seccomp-tools/dumper.rb +65 -33
  44. data/lib/seccomp-tools/emulator.rb +40 -15
  45. data/lib/seccomp-tools/error.rb +4 -2
  46. data/lib/seccomp-tools/explain/analysis.rb +67 -0
  47. data/lib/seccomp-tools/explain/path_facts.rb +110 -0
  48. data/lib/seccomp-tools/explain/qword.rb +204 -0
  49. data/lib/seccomp-tools/explain/renderer.rb +128 -0
  50. data/lib/seccomp-tools/explain/summary.rb +218 -0
  51. data/lib/seccomp-tools/explain/verdict.rb +44 -0
  52. data/lib/seccomp-tools/explain.rb +38 -0
  53. data/lib/seccomp-tools/instruction/alu.rb +14 -9
  54. data/lib/seccomp-tools/instruction/base.rb +39 -10
  55. data/lib/seccomp-tools/instruction/jmp.rb +50 -23
  56. data/lib/seccomp-tools/instruction/ld.rb +46 -21
  57. data/lib/seccomp-tools/instruction/ldx.rb +4 -3
  58. data/lib/seccomp-tools/instruction/misc.rb +11 -9
  59. data/lib/seccomp-tools/instruction/ret.rb +12 -6
  60. data/lib/seccomp-tools/instruction/st.rb +15 -6
  61. data/lib/seccomp-tools/instruction/stx.rb +4 -3
  62. data/lib/seccomp-tools/logger.rb +14 -1
  63. data/lib/seccomp-tools/symbolic/constraint.rb +80 -0
  64. data/lib/seccomp-tools/symbolic/executor.rb +210 -0
  65. data/lib/seccomp-tools/symbolic/expr.rb +185 -0
  66. data/lib/seccomp-tools/symbolic/state.rb +82 -0
  67. data/lib/seccomp-tools/syscall.rb +72 -20
  68. data/lib/seccomp-tools/util.rb +70 -11
  69. data/lib/seccomp-tools/version.rb +1 -1
  70. data/lib/seccomp-tools.rb +10 -1
  71. metadata +36 -4
  72. data/lib/seccomp-tools/disasm/context.rb +0 -171
@@ -4,10 +4,11 @@ require 'seccomp-tools/instruction/ld'
4
4
 
5
5
  module SeccompTools
6
6
  module Instruction
7
- # Instruction ldx.
7
+ # Instruction ldx, same as {LD} but loads into the index register X.
8
8
  class LDX < LD
9
- # Index register.
10
- # @return ['X']
9
+ # Name of the register being loaded into.
10
+ # @return [String]
11
+ # The index register, +"X"+.
11
12
  def reg
12
13
  'X'
13
14
  end
@@ -4,9 +4,11 @@ require 'seccomp-tools/instruction/base'
4
4
 
5
5
  module SeccompTools
6
6
  module Instruction
7
- # Instruction misc.
7
+ # Instruction misc, copies a value between the A and X registers.
8
8
  class MISC < Base
9
9
  # Decompile instruction.
10
+ # @return [String]
11
+ # Either +"A = X"+ or +"X = A"+.
10
12
  def decompile
11
13
  case op
12
14
  when :txa then 'A = X'
@@ -16,21 +18,21 @@ module SeccompTools
16
18
 
17
19
  # See {Instruction::Base#symbolize}.
18
20
  # @return [[:misc, (:tax, :txa)]]
21
+ # +:tax+ copies A into X, +:txa+ copies X into A.
19
22
  def symbolize
20
23
  [:misc, op]
21
24
  end
22
25
 
23
26
  # See {Base#branch}.
24
- # @param [Context] context
25
- # Current context.
26
- # @return [Array<(Integer, Context)>]
27
- def branch(context)
28
- ctx = context.dup
27
+ # @param [Symbolic::State] state
28
+ # Current state.
29
+ # @return [Array<(Integer, Symbolic::State)>]
30
+ # Always the next line, with the copied value recorded in the register.
31
+ def branch(state)
29
32
  case op
30
- when :txa then ctx['A'] = ctx['X']
31
- when :tax then ctx['X'] = ctx['A']
33
+ when :txa then [[line + 1, state.with(a: state.x)]]
34
+ when :tax then [[line + 1, state.with(x: state.a)]]
32
35
  end
33
- [[line + 1, ctx]]
34
36
  end
35
37
 
36
38
  private
@@ -4,22 +4,29 @@ require 'seccomp-tools/instruction/base'
4
4
 
5
5
  module SeccompTools
6
6
  module Instruction
7
- # Instruction ret.
7
+ # Instruction ret, terminates the filter with an action such as +ALLOW+ or +KILL+.
8
+ #
9
+ # The action comes from either the accumulator register A or the immediate +k+.
8
10
  class RET < Base
9
11
  # Decompile instruction.
12
+ # @return [String]
13
+ # The return as assembly, e.g. +"return ERRNO(1)"+.
10
14
  def decompile
11
15
  "return #{ret_str}"
12
16
  end
13
17
 
14
18
  # See {Instruction::Base#symbolize}.
15
19
  # @return [[:ret, (:a, Integer)]]
20
+ # +:a+ when the action is taken from the A register, otherwise the immediate action value.
16
21
  def symbolize
17
22
  [:ret, code & 0x18 == SRC[:a] ? :a : k]
18
23
  end
19
24
 
20
25
  # See {Base#branch}.
21
- # @return [[]]
22
- # Always return an empty array.
26
+ #
27
+ # Accepts and ignores any arguments, the state is irrelevant here.
28
+ # @return [Array]
29
+ # Always an empty array, a filter stops executing at a return.
23
30
  def branch(*)
24
31
  []
25
32
  end
@@ -30,9 +37,8 @@ module SeccompTools
30
37
  _, type = symbolize
31
38
  return 'A' if type == :a
32
39
 
33
- str = ACTION.invert[type & SECCOMP_RET_ACTION_FULL].to_s
34
- str += "(#{type & SECCOMP_RET_DATA})" if str == 'ERRNO'
35
- str
40
+ # fall back to the raw value (still re-assemblable) for an action the kernel does not define
41
+ Const::BPF.action_label(type) || "0x#{type.to_s(16)}"
36
42
  end
37
43
  end
38
44
  end
@@ -4,24 +4,33 @@ require 'seccomp-tools/instruction/ld'
4
4
 
5
5
  module SeccompTools
6
6
  module Instruction
7
- # Instruction st.
7
+ # Instruction st, stores the accumulator register A into a slot of the scratch memory.
8
+ #
9
+ # {STX} inherits from this class and stores the X register instead.
8
10
  class ST < LD
9
11
  # Decompile instruction.
12
+ # @return [String]
13
+ # The store as assembly, e.g. +"mem[0] = A"+.
10
14
  def decompile
11
15
  "mem[#{k}] = #{reg}"
12
16
  end
13
17
 
14
18
  # See {Instruction::Base#symbolize}.
15
19
  # @return [[:st, (:a, :x), Integer]]
20
+ # The source register and the index of the target memory slot.
16
21
  def symbolize
17
22
  [:st, reg.downcase.to_sym, k]
18
23
  end
19
24
 
20
- # @return [Array<(Integer, Context)>]
21
- def branch(context)
22
- ctx = context.dup
23
- ctx.store(k, reg)
24
- [[line + 1, ctx]]
25
+ # See {Base#branch}.
26
+ # @param [Symbolic::State] state
27
+ # Current state.
28
+ # @return [Array<(Integer, Symbolic::State)>]
29
+ # Always the next line, with the register's value recorded in the scratch slot.
30
+ def branch(state)
31
+ mem = state.mem.dup
32
+ mem[k] = reg == 'X' ? state.x : state.a
33
+ [[line + 1, state.with(mem:)]]
25
34
  end
26
35
  end
27
36
  end
@@ -4,10 +4,11 @@ require 'seccomp-tools/instruction/st'
4
4
 
5
5
  module SeccompTools
6
6
  module Instruction
7
- # Instruction stx.
7
+ # Instruction stx, same as {ST} but stores the index register X.
8
8
  class STX < ST
9
- # Index register.
10
- # @return ['X']
9
+ # Name of the register being stored.
10
+ # @return [String]
11
+ # The index register, +"X"+.
11
12
  def reg
12
13
  'X'
13
14
  end
@@ -13,6 +13,9 @@ module SeccompTools
13
13
 
14
14
  # Returns a +::Logger+ object for internal logging.
15
15
  #
16
+ # The returned logger writes to +$stdout+ with a formatter that prefixes each message with its
17
+ # colorized severity and indents continuation lines to align with the first.
18
+ #
16
19
  # @return [::Logger]
17
20
  def logger
18
21
  ::Logger.new($stdout).tap do |log|
@@ -31,7 +34,17 @@ module SeccompTools
31
34
  end
32
35
  end
33
36
 
34
- %i[error].each do |sym|
37
+ # @!method error(msg)
38
+ # Logs +msg+ at the +error+ severity.
39
+ # @param [String] msg
40
+ # The message to be logged.
41
+ # @return [true]
42
+ # @!method warn(msg)
43
+ # Logs +msg+ at the +warn+ severity.
44
+ # @param [String] msg
45
+ # The message to be logged.
46
+ # @return [true]
47
+ %i[error warn].each do |sym|
35
48
  define_method(sym) do |msg|
36
49
  logger.__send__(sym, msg)
37
50
  end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'seccomp-tools/symbolic/expr'
4
+
5
+ module SeccompTools
6
+ module Symbolic
7
+ # One fact that must hold for the executor to be on a particular path, e.g. +A == 1+ or
8
+ # +data[16] & 0xffff != 0+. Every conditional jump adds one {Constraint} to each branch it
9
+ # takes; the accumulated list is the "path condition" carried in {State#path} and reported on a
10
+ # {Executor::Leaf}.
11
+ #
12
+ # A constraint keeps a lone constant on the right: BPF always compares the A register against X
13
+ # or +k+, but A itself may hold the constant (e.g. +A = 5+ compared against a data word +tax+'d
14
+ # into X earlier), and every consumer reads "expression op constant". So +Constraint.new(5, :>,
15
+ # word)+ normalizes to +word < 5+ at construction - the two spellings are one value.
16
+ class Constraint
17
+ # The same comparison with its two sides swapped: +5 > x+ is +x < 5+. The bit tests are
18
+ # symmetric (+&+ commutes).
19
+ MIRROR = {
20
+ :== => :==, :!= => :!=, :> => :<, :>= => :<=, :< => :>, :<= => :>=, set: :set, unset: :unset
21
+ }.freeze
22
+
23
+ # Applies one of the constraint operators to concrete operands: the +jset+ bit tests
24
+ # (+:set+/+:unset+) and the Integer comparisons. Stateless - used to evaluate a pinned
25
+ # constraint or a candidate value, without building a {Constraint}.
26
+ # @param [Integer] value
27
+ # @param [Symbol] op
28
+ # @param [Integer] k
29
+ # @return [Boolean]
30
+ def self.evaluate(value, op, k)
31
+ case op
32
+ when :set then !value.nobits?(k)
33
+ when :unset then value.nobits?(k)
34
+ else value.public_send(op, k) # the comparisons are all Integer methods
35
+ end
36
+ end
37
+
38
+ # @return [Expr] The left-hand side (what is being tested).
39
+ attr_reader :lhs
40
+ # @return [Symbol] The comparison, one of +:==, :!=, :>, :>=, :<, :<=, :set, :unset+.
41
+ # +:set+/+:unset+ mean "some/none of these bits are set" (from a +jset+ test).
42
+ attr_reader :op
43
+ # @return [Expr] The right-hand side (what it is compared against).
44
+ attr_reader :rhs
45
+
46
+ # @param [Expr] lhs
47
+ # @param [Symbol] op
48
+ # @param [Expr] rhs
49
+ def initialize(lhs, op, rhs)
50
+ lhs, op, rhs = rhs, MIRROR[op], lhs if lhs.imm? && !rhs.imm? # keep the constant on the right
51
+ @lhs = lhs
52
+ @op = op
53
+ @rhs = rhs
54
+ end
55
+
56
+ # Is this a fact about one plain data word compared against a constant - about the word at
57
+ # +offset+, when given? These are the facts rule-based consumers can reason about, e.g. the
58
+ # feasibility pruning in +Symbolic::Executor+.
59
+ # @param [Integer?] offset
60
+ # @return [Boolean]
61
+ def plain_data_fact?(offset = nil)
62
+ lhs.plain_data? && rhs.imm? && (offset.nil? || lhs.offset == offset)
63
+ end
64
+
65
+ # Is this a +word == constant+ fact - about the word at +offset+, when given?
66
+ # @param [Integer?] offset
67
+ # @return [Boolean]
68
+ def plain_data_eq?(offset = nil)
69
+ op == :== && plain_data_fact?(offset)
70
+ end
71
+
72
+ # A string that uniquely identifies this constraint, for both equality and hashing; the
73
+ # +expr op expr+ counterpart of {Expr#key}, injective for the same reason.
74
+ # @return [String]
75
+ def key
76
+ @key ||= "#{lhs.key}#{op}#{rhs.key}"
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ require 'seccomp-tools/symbolic/constraint'
6
+ require 'seccomp-tools/symbolic/expr'
7
+ require 'seccomp-tools/symbolic/state'
8
+
9
+ module SeccompTools
10
+ module Symbolic
11
+ # Symbolic executor for classic BPF (the byte-code seccomp filters are written in).
12
+ #
13
+ # A normal interpreter (see +SeccompTools::Emulator+) runs a program *once* with concrete inputs
14
+ # and follows the one path those inputs select. A symbolic executor instead keeps the inputs
15
+ # *unknown* ({Expr}) and explores **every** path through the program at once. Wherever the
16
+ # program branches, it walks both sides, remembering on each side the {Constraint} that made
17
+ # that branch taken. When a path reaches a +return+, the executor records a {Leaf}: the value
18
+ # returned plus the exact list of conditions that lead there. The collection of leaves is a
19
+ # complete, input-independent description of what the program does.
20
+ #
21
+ # The machine model is classic BPF: two registers (A and X), 16 scratch-memory slots, and a
22
+ # read-only input buffer addressed by byte offset. Jumps are always forward, so a single walk
23
+ # with a visited-set terminates and never loops.
24
+ #
25
+ # @example
26
+ # # instructions come from `SeccompTools::Disasm.to_bpf(raw, arch).map(&:inst)`
27
+ # leaves, truncated = SeccompTools::Symbolic::Executor.new(instructions).run
28
+ # leaves.first.ret #=> an Expr describing the returned value
29
+ # leaves.first.path #=> the Array<Constraint> under which it is returned
30
+ class Executor
31
+ # A reached +return+: the accumulated path condition, the value returned (an {Expr}), and the
32
+ # line the +return+ is on.
33
+ Leaf = Struct.new(:path, :ret, :line)
34
+
35
+ # Upper bound on the number of states visited, so a pathological program cannot make the walk
36
+ # run unboundedly. When hit, {#run} stops early and reports +truncated+.
37
+ STEP_CAP = 100_000
38
+
39
+ # Maps a comparison operator to the pair of {Constraint} operators implied on the taken and
40
+ # not-taken branches (e.g. a +>=+ test learns +>=+ if taken, +<+ if not).
41
+ SPLIT = {
42
+ :== => %i[== !=],
43
+ :> => %i[> <=],
44
+ :>= => %i[>= <],
45
+ :& => %i[set unset]
46
+ }.freeze
47
+
48
+ # @param [Array<Instruction::Base>] instructions
49
+ # The program to execute, as +SeccompTools::Disasm.to_bpf(raw, arch).map(&:inst)+. Only the
50
+ # duck-typed +#symbolize+ method is used, so any classic-BPF instruction set works.
51
+ def initialize(instructions)
52
+ @instructions = instructions
53
+ end
54
+
55
+ # Walks every path and returns the reachable leaves.
56
+ #
57
+ # Leaves whose path condition is self-contradictory (e.g. +A == 1+ and +A == 2+ on the same
58
+ # word) are dropped - a conditional jump forks both ways regardless of feasibility, so the
59
+ # walk can construct paths that can never happen at runtime. See {#feasible?} for exactly
60
+ # what can (and deliberately cannot) be proven contradictory.
61
+ # @return [Array(Array<Leaf>, Boolean)]
62
+ # The feasible leaves, and whether the walk was truncated at {STEP_CAP}.
63
+ def run
64
+ leaves, truncated = walk
65
+ [leaves.select { |leaf| feasible?(leaf.path) }, truncated]
66
+ end
67
+
68
+ private
69
+
70
+ # Depth-first walk of the control-flow graph. Because jumps are always forward, every successor
71
+ # line is strictly greater, so the walk terminates; identical +(line, state)+ pairs are
72
+ # visited once so that re-merging control-flow does not explode.
73
+ # @return [Array(Array<Leaf>, Boolean)]
74
+ def walk
75
+ leaves = []
76
+ visited = Set.new
77
+ stack = [[0, State.initial]]
78
+ steps = 0
79
+ until stack.empty?
80
+ return [leaves, true] if steps >= STEP_CAP
81
+
82
+ steps += 1
83
+ pc, st = stack.pop
84
+ next if pc >= @instructions.size
85
+ next unless visited.add?([pc, st.key])
86
+
87
+ step(pc, st, leaves, stack)
88
+ end
89
+ [leaves, false]
90
+ end
91
+
92
+ # Interprets one instruction symbolically, pushing the successor state(s) onto +stack+ (or
93
+ # appending a {Leaf} when it is a +return+).
94
+ def step(pc, st, leaves, stack)
95
+ op, *args = @instructions[pc].symbolize
96
+ case op
97
+ when :ret then leaves << Leaf.new(st.path, args[0] == :a ? st.a : Expr.imm(args[0]), pc)
98
+ when :ld then stack << [pc + 1, load(st, args[0], args[1])]
99
+ when :st then stack << [pc + 1, store(st, args[0], args[1])]
100
+ when :alu then stack << [pc + 1, st.with(a: st.a.apply(args[0], alu_operand(st, args[1])))]
101
+ when :misc then stack << [pc + 1, args[0] == :txa ? st.with(a: st.x) : st.with(x: st.a)]
102
+ when :jmp then stack << [pc + args[0] + 1, st]
103
+ when :cmp then branch_cmp(pc, st, args, stack)
104
+ end
105
+ end
106
+
107
+ # The right operand of an ALU instruction: the X register, an immediate, or +nil+ for the
108
+ # unary +neg+ (whose symbolized operand is +nil+).
109
+ def alu_operand(st, src)
110
+ return st.x if src == :x
111
+
112
+ src && Expr.imm(src)
113
+ end
114
+
115
+ # Loads an immediate, a scratch slot, or a data-buffer word into register A or X.
116
+ def load(st, dst, src)
117
+ val = case src[:rel]
118
+ when :immi then Expr.imm(src[:val])
119
+ when :mem then st.mem[src[:val]]
120
+ when :data then Expr.data(src[:val])
121
+ end
122
+ dst == :x ? st.with(x: val) : st.with(a: val)
123
+ end
124
+
125
+ # Stores register A or X into a scratch slot.
126
+ def store(st, reg, idx)
127
+ mem = st.mem.dup
128
+ mem[idx] = reg == :x ? st.x : st.a
129
+ st.with(mem:)
130
+ end
131
+
132
+ # Forks a conditional jump into its taken and not-taken successors, recording the {Constraint}
133
+ # each branch implies. A comparison between two constants (e.g. against the guaranteed-zero
134
+ # initial A or X) does not fork: only the branch it actually selects is walked, and no fact
135
+ # is recorded.
136
+ def branch_cmp(pc, st, args, stack)
137
+ op, src, jt, jf = args
138
+ # jt == jf: the jump is unconditional, so no fact is learned.
139
+ return stack << [pc + jt + 1, st] if jt == jf
140
+
141
+ rhs = src == :x ? st.x : Expr.imm(src)
142
+ taken, els = SPLIT[op]
143
+ if st.a.imm? && rhs.imm?
144
+ j = Constraint.evaluate(st.a.val, taken, rhs.val) ? jt : jf
145
+ return stack << [pc + j + 1, st]
146
+ end
147
+
148
+ stack << [pc + jt + 1, st.with(path: st.path + [Constraint.new(st.a, taken, rhs)])]
149
+ stack << [pc + jf + 1, st.with(path: st.path + [Constraint.new(st.a, els, rhs)])]
150
+ end
151
+
152
+ # Is +path+ satisfiable? Deliberately a small rule-based check, not a solver.
153
+ #
154
+ # Only facts of the shape "untransformed data word compared to a constant" are examined,
155
+ # grouped by which word they constrain; any other fact (a transformed word, a comparison
156
+ # against X, an opaque value) is assumed satisfiable. So an impossible path is never
157
+ # *wrongly* dropped - the cost of not understanding a fact is noise in the caller's output,
158
+ # never hidden behavior.
159
+ #
160
+ # That fragment is where essentially all real contradictions live. The walk forks every
161
+ # conditional both ways, so re-merging tests over the same word manufacture impossible
162
+ # paths: a syscall allowlist behind an x32 range guard yields
163
+ # +sys >= 0x40000000 && sys == 2+, libseccomp's binary-search dispatch yields the same
164
+ # equality-versus-range shapes, and sentinel tests yield +sys == 0xffffffff && sys == 2+.
165
+ # All of these are caught, and since the data words are independent inputs, checking
166
+ # word-by-word is exact for this fragment, not an approximation: a conjunction of
167
+ # single-word facts is satisfiable iff each word's facts are.
168
+ #
169
+ # What it cannot prove infeasible are contradictions through *derived* values:
170
+ # +(args[0] & 0xff) == 0x100+ (impossible by masking), wraparound arithmetic like
171
+ # +args[0] + 1 == 0 && args[0] == 5+, or relations between two transforms of one word
172
+ # (+sys >> 8 == 1 && sys < 0x100+). Deciding those in general is bit-vector SMT; a filter
173
+ # convoluted enough to produce them (a deliberately obfuscated challenge, not a seccomp
174
+ # library) calls for a real solver anyway, so rule-based pruning of that space would add
175
+ # complexity without making such filters readable. Their paths are kept and rendered with
176
+ # their full conditions instead.
177
+ # @param [Array<Constraint>] path
178
+ # @return [Boolean]
179
+ def feasible?(path)
180
+ path.select(&:plain_data_fact?)
181
+ .group_by { |c| c.lhs.offset }
182
+ .all? { |_offset, cs| cell_feasible?(cs) }
183
+ end
184
+
185
+ # Are the constraints on a single data word jointly satisfiable? Two different +==+ values
186
+ # are impossible (+A == 1 && A == 2+). A single +==+ pins the word, so every other fact -
187
+ # +!=+, the four bounds, and both jset forms - is simply evaluated against it
188
+ # (+A >= 0x40000000 && A == 2+ dies here). With no +==+, the inequalities must leave a
189
+ # non-empty range (+A > 10 && A < 5+); +!=+ and jset facts are ignored in that case, since
190
+ # ruling out a 32-bit word with them alone would take a filter no library generates.
191
+ def cell_feasible?(constraints)
192
+ eqs = constraints.select { |c| c.op == :== }.map { |c| c.rhs.val }.uniq
193
+ return false if eqs.size > 1
194
+ return constraints.all? { |c| Constraint.evaluate(eqs.first, c.op, c.rhs.val) } unless eqs.empty?
195
+
196
+ lo = 0
197
+ hi = 0xffffffff
198
+ constraints.each do |c|
199
+ case c.op
200
+ when :> then lo = [lo, c.rhs.val + 1].max
201
+ when :>= then lo = [lo, c.rhs.val].max
202
+ when :< then hi = [hi, c.rhs.val - 1].min
203
+ when :<= then hi = [hi, c.rhs.val].min
204
+ end
205
+ end
206
+ lo <= hi
207
+ end
208
+ end
209
+ end
210
+ end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'seccomp-tools/instruction/alu'
4
+
5
+ module SeccompTools
6
+ # Generic symbolic execution of classic BPF, with no seccomp knowledge. Where +SeccompTools::Emulator+
7
+ # runs a program once with concrete inputs, the {Executor} here keeps the inputs unknown and walks
8
+ # *every* path, reporting each reachable +return+ together with the conditions that lead to it. The
9
+ # values it manipulates are the {Expr}, {Constraint}, and {State} types; see {Executor} for the
10
+ # full picture.
11
+ module Symbolic
12
+ # A value the executor only knows *symbolically* - that is, without picking concrete inputs.
13
+ #
14
+ # Every register and scratch slot in the {State} holds an {Expr}, which is one of:
15
+ # * {.imm} - a known 32-bit constant, e.g. the result of +A = 5+.
16
+ # * {.data} - a single word of the input data buffer, at some byte +offset+. Its value is
17
+ # unknown; we only remember where it was read from.
18
+ # * {.binop} - an arithmetic combination of two sub-expressions, e.g. +data[16] & 0xffff+ or even
19
+ # +data[16] & data[24]+ (two buffer words combined). This is what lets a caller later describe a
20
+ # branch condition faithfully.
21
+ # * {.unop} - a unary operation on a sub-expression; the only one BPF has is negation (+-A+).
22
+ # * {.opaque} - a value we cannot describe (an unsupported operation, or one whose operand is
23
+ # itself opaque). Nothing can be concluded about it.
24
+ class Expr
25
+ # The binary ALU operators {#apply} accepts - exactly the ones +Instruction::ALU#symbolize+
26
+ # can produce (its unary +neg+ is handled separately).
27
+ REPRESENTABLE = Instruction::ALU::OP_SYM.values.freeze
28
+
29
+ # @return [:imm, :data, :binop, :unop, :opaque] Which kind of expression this is.
30
+ attr_reader :kind
31
+ # @return [Integer?] The constant, when +kind+ is +:imm+.
32
+ attr_reader :val
33
+ # @return [Integer?] The byte offset into the input data buffer, when +kind+ is +:data+.
34
+ attr_reader :offset
35
+ # @return [Symbol?] The operator, when +kind+ is +:binop+ or +:unop+.
36
+ attr_reader :op
37
+ # @return [Expr?] The left and right operands, when +kind+ is +:binop+; a +:unop+ keeps its
38
+ # only operand in +lhs+.
39
+ attr_reader :lhs, :rhs
40
+
41
+ # A known constant.
42
+ # @param [Integer] val
43
+ # @return [Expr]
44
+ def self.imm(val)
45
+ new(:imm, val: val & 0xffffffff)
46
+ end
47
+
48
+ # A single word of the input data buffer.
49
+ # @param [Integer] offset
50
+ # Byte offset into the buffer.
51
+ # @return [Expr]
52
+ def self.data(offset)
53
+ new(:data, offset:)
54
+ end
55
+
56
+ # An arithmetic combination of two expressions.
57
+ # @param [Symbol] op
58
+ # @param [Expr] lhs
59
+ # @param [Expr] rhs
60
+ # @return [Expr]
61
+ def self.binop(op, lhs, rhs)
62
+ new(:binop, op:, lhs:, rhs:)
63
+ end
64
+
65
+ # A unary operation on one expression (its operand is kept in +lhs+).
66
+ # @param [Symbol] op
67
+ # @param [Expr] operand
68
+ # @return [Expr]
69
+ def self.unop(op, operand)
70
+ new(:unop, op:, lhs: operand)
71
+ end
72
+
73
+ # A value that cannot be described symbolically.
74
+ # @return [Expr]
75
+ def self.opaque
76
+ new(:opaque)
77
+ end
78
+
79
+ # @param [:imm, :data, :binop, :unop, :opaque] kind
80
+ # @param [Hash] fields
81
+ # The kind-specific fields: +:val+, +:offset+, +:op+, +:lhs+, +:rhs+.
82
+ def initialize(kind, **fields)
83
+ @kind = kind
84
+ @val = fields[:val]
85
+ @offset = fields[:offset]
86
+ @op = fields[:op]
87
+ @lhs = fields[:lhs]
88
+ @rhs = fields[:rhs]
89
+ end
90
+
91
+ # @return [Boolean]
92
+ def imm?
93
+ kind == :imm
94
+ end
95
+
96
+ # Is this a bare data-buffer word (no arithmetic applied)? These are the values a caller can
97
+ # compare directly against a constant.
98
+ # @return [Boolean]
99
+ def plain_data?
100
+ kind == :data
101
+ end
102
+
103
+ # @return [Boolean]
104
+ def opaque?
105
+ kind == :opaque
106
+ end
107
+
108
+ # Applies an ALU operation, returning the resulting {Expr}. +neg+ is unary (its operand is
109
+ # ignored). Two constants fold into a new constant; an operation on non-opaque operands
110
+ # becomes a {.binop}; an opaque operand makes the result {.opaque}.
111
+ # @param [Symbol] op
112
+ # A Ruby operator symbol as produced by +Instruction::ALU#symbolize+, or +:neg+.
113
+ # @param [Expr, nil] operand
114
+ # The right operand (+nil+ for the unary +neg+).
115
+ # @return [Expr]
116
+ # @raise [ArgumentError]
117
+ # When +op+ is not one of seccomp's ALU operators (+:neg+ or {REPRESENTABLE}).
118
+ def apply(op, operand)
119
+ return apply_neg if op == :neg
120
+ raise ArgumentError, "unsupported operator #{op}" unless REPRESENTABLE.include?(op)
121
+ return Expr.opaque if opaque? || operand.opaque?
122
+ return Expr.imm(self.class.fold(val, op, operand.val)) if imm? && operand.imm?
123
+
124
+ Expr.binop(op, self, operand)
125
+ end
126
+
127
+ # A string that uniquely identifies this expression, used for both equality and hashing;
128
+ # memoized, since an {Expr} is immutable. The operator is written as text, not left a {Symbol},
129
+ # because Ruby's +Symbol#hash+ maps the operators to very few values (+:==+ and +:!=+ hash
130
+ # alike) - a key carrying them would collapse when hashed and drag the walk's visited +Set+
131
+ # down to a linear scan; a string hashes over its bytes cleanly. The encoding is injective
132
+ # (leaves are +i<val>+ / +d<offset>+ / +o+, compounds are parenthesised, and no operator
133
+ # contains a leaf-start char, a digit, or the +;+ / +,+ that {State} joins with), so it is
134
+ # safe to compare on.
135
+ # @return [String]
136
+ def key
137
+ @key ||= case kind
138
+ when :binop then "(#{lhs.key}#{op}#{rhs.key})"
139
+ when :unop then "(#{op}#{lhs.key})"
140
+ when :imm then "i#{val}"
141
+ when :data then "d#{offset}"
142
+ else 'o'
143
+ end
144
+ end
145
+
146
+ # @param [Expr] other
147
+ # @return [Boolean]
148
+ def ==(other)
149
+ other.is_a?(Expr) && key == other.key
150
+ end
151
+ alias eql? ==
152
+
153
+ # @return [Integer]
154
+ def hash
155
+ key.hash
156
+ end
157
+
158
+ # Folds a binary ALU operation on two constants, wrapping to 32 bits (classic BPF is 32-bit).
159
+ # Every {REPRESENTABLE} operator is a Ruby +Integer+ method, so it applies directly.
160
+ # @return [Integer]
161
+ def self.fold(lhs, op, rhs)
162
+ return 0 if op == :/ && rhs.zero? # a real BPF program is rejected at load for div-by-zero
163
+ # ... and for shifts of 32+ bits; short-circuit them to their masked result (everything
164
+ # shifted out) instead of building a needlessly huge integer.
165
+ return 0 if %i[<< >>].include?(op) && rhs >= 32
166
+
167
+ lhs.public_send(op, rhs) & 0xffffffff
168
+ end
169
+
170
+ private
171
+
172
+ # The unary negation +A = -A+ (two's complement, 32-bit). Negating a negation cancels - exact
173
+ # in 32-bit two's complement - instead of stacking into +--x+, which C would even lex as a
174
+ # decrement.
175
+ # @return [Expr]
176
+ def apply_neg
177
+ return Expr.opaque if opaque?
178
+ return Expr.imm(self.class.fold(0, :-, val)) if imm?
179
+ return lhs if kind == :unop # the only unop is :neg
180
+
181
+ Expr.unop(:neg, self)
182
+ end
183
+ end
184
+ end
185
+ end