seccomp-tools 1.6.1 → 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 (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +153 -0
  3. data/LICENSE +21 -0
  4. data/README.md +267 -46
  5. data/completions/_seccomp-tools +83 -0
  6. data/completions/seccomp-tools.bash +59 -0
  7. data/completions/seccomp-tools.fish +52 -0
  8. data/ext/ptrace/ptrace.c +2 -2
  9. data/lib/seccomp-tools/asm/asm.rb +9 -4
  10. data/lib/seccomp-tools/asm/compiler.rb +39 -9
  11. data/lib/seccomp-tools/asm/sasm.tab.rb +31 -21
  12. data/lib/seccomp-tools/asm/sasm.y +15 -7
  13. data/lib/seccomp-tools/asm/scalar.rb +50 -7
  14. data/lib/seccomp-tools/asm/scanner.rb +40 -8
  15. data/lib/seccomp-tools/asm/statement.rb +14 -5
  16. data/lib/seccomp-tools/asm/token.rb +19 -1
  17. data/lib/seccomp-tools/audit/catalog.rb +66 -0
  18. data/lib/seccomp-tools/audit/checks/arch_unchecked.rb +41 -0
  19. data/lib/seccomp-tools/audit/checks/dangerous_allow.rb +34 -0
  20. data/lib/seccomp-tools/audit/checks/orw_chain.rb +41 -0
  21. data/lib/seccomp-tools/audit/checks/permissive_default.rb +29 -0
  22. data/lib/seccomp-tools/audit/checks/syscall_alt_gap.rb +44 -0
  23. data/lib/seccomp-tools/audit/checks/x32_guard.rb +46 -0
  24. data/lib/seccomp-tools/audit/checks.rb +42 -0
  25. data/lib/seccomp-tools/audit/finding.rb +25 -0
  26. data/lib/seccomp-tools/audit/policy.rb +126 -0
  27. data/lib/seccomp-tools/audit/report.rb +98 -0
  28. data/lib/seccomp-tools/audit.rb +48 -0
  29. data/lib/seccomp-tools/bpf.rb +27 -17
  30. data/lib/seccomp-tools/cli/asm.rb +7 -3
  31. data/lib/seccomp-tools/cli/audit.rb +86 -0
  32. data/lib/seccomp-tools/cli/base.rb +51 -8
  33. data/lib/seccomp-tools/cli/cli.rb +10 -6
  34. data/lib/seccomp-tools/cli/completion.rb +40 -0
  35. data/lib/seccomp-tools/cli/disasm.rb +10 -6
  36. data/lib/seccomp-tools/cli/dump.rb +37 -52
  37. data/lib/seccomp-tools/cli/dumpable.rb +79 -0
  38. data/lib/seccomp-tools/cli/emu.rb +32 -9
  39. data/lib/seccomp-tools/cli/explain.rb +51 -0
  40. data/lib/seccomp-tools/cli/filter_input.rb +130 -0
  41. data/lib/seccomp-tools/const.rb +98 -15
  42. data/lib/seccomp-tools/consts/sys_nr/amd64.rb +1 -1
  43. data/lib/seccomp-tools/consts/sys_nr/riscv64.rb +332 -0
  44. data/lib/seccomp-tools/disasm/disasm.rb +31 -13
  45. data/lib/seccomp-tools/dumper.rb +67 -35
  46. data/lib/seccomp-tools/emulator.rb +41 -16
  47. data/lib/seccomp-tools/error.rb +4 -2
  48. data/lib/seccomp-tools/explain/analysis.rb +67 -0
  49. data/lib/seccomp-tools/explain/path_facts.rb +110 -0
  50. data/lib/seccomp-tools/explain/qword.rb +204 -0
  51. data/lib/seccomp-tools/explain/renderer.rb +128 -0
  52. data/lib/seccomp-tools/explain/summary.rb +218 -0
  53. data/lib/seccomp-tools/explain/verdict.rb +44 -0
  54. data/lib/seccomp-tools/explain.rb +38 -0
  55. data/lib/seccomp-tools/instruction/alu.rb +14 -9
  56. data/lib/seccomp-tools/instruction/base.rb +39 -10
  57. data/lib/seccomp-tools/instruction/jmp.rb +50 -23
  58. data/lib/seccomp-tools/instruction/ld.rb +46 -21
  59. data/lib/seccomp-tools/instruction/ldx.rb +4 -3
  60. data/lib/seccomp-tools/instruction/misc.rb +11 -9
  61. data/lib/seccomp-tools/instruction/ret.rb +12 -6
  62. data/lib/seccomp-tools/instruction/st.rb +15 -6
  63. data/lib/seccomp-tools/instruction/stx.rb +4 -3
  64. data/lib/seccomp-tools/logger.rb +15 -2
  65. data/lib/seccomp-tools/symbolic/constraint.rb +80 -0
  66. data/lib/seccomp-tools/symbolic/executor.rb +210 -0
  67. data/lib/seccomp-tools/symbolic/expr.rb +185 -0
  68. data/lib/seccomp-tools/symbolic/state.rb +82 -0
  69. data/lib/seccomp-tools/syscall.rb +74 -22
  70. data/lib/seccomp-tools/util.rb +70 -11
  71. data/lib/seccomp-tools/version.rb +1 -1
  72. data/lib/seccomp-tools.rb +10 -1
  73. metadata +83 -11
  74. data/lib/seccomp-tools/disasm/context.rb +0 -171
@@ -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
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'seccomp-tools/symbolic/expr'
4
+
5
+ module SeccompTools
6
+ module Symbolic
7
+ # A snapshot of the classic-BPF machine at one point along one path of the walk: the two
8
+ # registers (A and X), the 16 scratch-memory slots, and the path condition (the {Constraint}s
9
+ # that must hold to have reached here). The walk fills values in as it interprets
10
+ # instructions, starting from {.initial}.
11
+ #
12
+ # A {State} is treated as immutable - stepping an instruction produces a *new* state via {#with},
13
+ # so the many paths of the walk can safely share the parts they have in common.
14
+ class State
15
+ # @return [Expr] Register A (the accumulator).
16
+ attr_reader :a
17
+ # @return [Expr] Register X (the index register).
18
+ attr_reader :x
19
+ # @return [Array<Expr>] The 16 scratch-memory slots (+mem[0..15]+).
20
+ attr_reader :mem
21
+ # @return [Array<Constraint>] The path condition accumulated so far.
22
+ attr_reader :path
23
+
24
+ # The starting state, as the kernel sets it up: both registers zero (a classic BPF program is
25
+ # guaranteed +A = X = 0+ on entry - the cBPF-to-eBPF converter clears them first), the
26
+ # scratch slots unknown, and no facts assumed. The slots stay {Expr.opaque} because nothing
27
+ # can rely on them: the kernel rejects a filter that reads a slot before writing it.
28
+ # @return [State]
29
+ def self.initial
30
+ new(a: Expr.imm(0), x: Expr.imm(0), mem: Array.new(16, Expr.opaque), path: [])
31
+ end
32
+
33
+ # @param [Expr] a
34
+ # @param [Expr] x
35
+ # @param [Array<Expr>] mem
36
+ # @param [Array<Constraint>] path
37
+ def initialize(a:, x:, mem:, path:)
38
+ @a = a
39
+ @x = x
40
+ @mem = mem
41
+ @path = path
42
+ end
43
+
44
+ # Returns a copy with the given fields replaced; unreplaced fields are shared (the state is
45
+ # immutable).
46
+ # @return [State]
47
+ def with(a: @a, x: @x, mem: @mem, path: @path)
48
+ State.new(a:, x:, mem:, path:)
49
+ end
50
+
51
+ # A string that identifies this state exactly, joined from each part's {Expr#key} /
52
+ # {Constraint#key} (the same identity, one level up). The walk uses it to skip a
53
+ # +(line, state)+ pair it has already visited, which keeps merging control-flow from blowing
54
+ # up. Both {#==} and {#hash} are decided on it; see {Expr#key} for why the key is a string.
55
+ # The +;+ / +,+ separators appear in no part's key, so the join stays injective.
56
+ # @return [String]
57
+ def key
58
+ @key ||= "#{a.key};#{x.key};#{mem.map(&:key).join(',')};#{path.map(&:key).join(',')}"
59
+ end
60
+
61
+ # The constant the data word at byte +offset+ is pinned to on this path (by an +==+ fact), or
62
+ # +nil+ when it is not pinned.
63
+ # @param [Integer] offset
64
+ # @return [Integer?]
65
+ def pinned(offset)
66
+ path.find { |c| c.plain_data_eq?(offset) }&.rhs&.val
67
+ end
68
+
69
+ # @param [State] other
70
+ # @return [Boolean]
71
+ def ==(other)
72
+ other.is_a?(State) && key == other.key
73
+ end
74
+ alias eql? ==
75
+
76
+ # @return [Integer]
77
+ def hash
78
+ key.hash
79
+ end
80
+ end
81
+ end
82
+ end
@@ -1,8 +1,10 @@
1
+ # encoding: ascii-8bit
1
2
  # frozen_string_literal: true
2
3
 
3
4
  require 'os'
4
5
 
5
6
  require 'seccomp-tools/const'
7
+ require 'seccomp-tools/util'
6
8
  require 'seccomp-tools/ptrace' if OS.linux?
7
9
 
8
10
  module SeccompTools
@@ -13,25 +15,32 @@ module SeccompTools
13
15
  amd64: { number: 120, args: [112, 104, 96, 56, 72, 44], ret: 80, SYS_prctl: 157, SYS_seccomp: 317 },
14
16
  i386: { number: 44, args: [0, 4, 8, 12, 16, 20], ret: 24, SYS_prctl: 172, SYS_seccomp: 354 },
15
17
  aarch64: { number: 64, args: [0, 8, 16, 24, 32, 40, 48], ret: 0, SYS_prctl: 167, SYS_seccomp: 277 },
18
+ riscv64: { number: 136, args: [80, 88, 96, 104, 112, 120], ret: 80, SYS_prctl: 167, SYS_seccomp: 277 },
16
19
  # Most software invokes syscalls through "svc 0", in which case the syscall number is in r1.
17
20
  # However, it's also possible to use "svc NR": this case is not handled here.
18
21
  s390x: { number: 24, args: [32, 40, 48, 56, 64, 72], ret: 32, SYS_prctl: 172, SYS_seccomp: 348 }
19
22
  }.freeze
20
23
 
21
- # @return [Integer] Process id.
24
+ # @return [Integer] Id of the traced process.
22
25
  attr_reader :pid
23
- # @return [{Symbol => Integer, Array<Integer>}] See {ABI}.
26
+ # @return [{Symbol => Integer, Array<Integer>}]
27
+ # The {ABI} entry of this syscall's architecture.
24
28
  attr_reader :abi
25
29
  # @return [Integer] Syscall number.
26
30
  attr_reader :number
27
- # @return [Integer] Syscall arguments.
31
+ # @return [Array<Integer>] Syscall arguments, in register order.
28
32
  attr_reader :args
29
33
  # @return [Integer] Syscall return value.
30
34
  attr_reader :ret
31
35
 
32
36
  # Instantiate a {Syscall} object.
33
- # @param [String] pid
34
- # Process-id.
37
+ #
38
+ # Reads the syscall number, arguments and return value of +pid+ through ptrace, so the process
39
+ # must already be stopped and attached.
40
+ # @param [Integer] pid
41
+ # Id of the traced process.
42
+ # @raise [ArgumentError]
43
+ # If the architecture of +pid+ is not one of {ABI}'s keys.
35
44
  def initialize(pid)
36
45
  @pid = pid
37
46
  raise ArgumentError, "Only supports #{ABI.keys.join(', ')}" if ABI[arch].nil?
@@ -42,38 +51,80 @@ module SeccompTools
42
51
  @ret = peek(abi[:ret])
43
52
  end
44
53
 
45
- # Is this a +seccomp(SECCOMP_MODE_FILTER, addr)+/+prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, addr)+ syscall?
54
+ # Is this a seccomp installation syscall?
46
55
  #
56
+ # Both +SECCOMP_MODE_FILTER+ and +SECCOMP_MODE_STRICT+ installations are recognized, invoked
57
+ # through either +seccomp+ or +prctl(PR_SET_SECCOMP, ...)+.
47
58
  # @return [Boolean]
48
- # +true+ for is a seccomp installation syscall.
59
+ # +true+ if this is a seccomp installation syscall.
49
60
  def set_seccomp?
50
- # TODO: handle SECCOMP_MODE_SET_STRICT / SECCOMP_MODE_STRICT
61
+ filter_mode? || strict_mode?
62
+ end
63
+
64
+ # Is this a +seccomp(SECCOMP_SET_MODE_FILTER, ..)+/+prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, ..)+ syscall?
65
+ #
66
+ # @return [Boolean]
67
+ def filter_mode?
51
68
  return true if number == abi[:SYS_seccomp] && args[0] == Const::BPF::SECCOMP_SET_MODE_FILTER
52
69
 
53
70
  number == abi[:SYS_prctl] && args[0] == Const::BPF::PR_SET_SECCOMP && args[1] == Const::BPF::SECCOMP_MODE_FILTER
54
71
  end
55
72
 
56
- # Dump bpf byte from +args[2]+.
73
+ # Is this a +seccomp(SECCOMP_SET_MODE_STRICT, ..)+/+prctl(PR_SET_SECCOMP, SECCOMP_MODE_STRICT)+ syscall?
74
+ #
75
+ # @return [Boolean]
76
+ def strict_mode?
77
+ return true if number == abi[:SYS_seccomp] && args[0] == Const::BPF::SECCOMP_SET_MODE_STRICT
78
+
79
+ number == abi[:SYS_prctl] && args[0] == Const::BPF::PR_SET_SECCOMP && args[1] == Const::BPF::SECCOMP_MODE_STRICT
80
+ end
81
+
82
+ # Constructs a BPF program equivalent to what +SECCOMP_MODE_STRICT+ enforces: only read, write,
83
+ # exit, and sigreturn are allowed, while any other syscall kills the thread.
84
+ #
85
+ # @param [Symbol] arch
86
+ # Target architecture, one of {ABI}'s keys.
87
+ # @return [String]
88
+ # Raw BPF bytes.
89
+ def self.strict_bpf(arch)
90
+ # Required lazily because the assembler's scanner refers to {ABI} at load time.
91
+ require 'seccomp-tools/asm/asm'
92
+
93
+ # The kernel checks sigreturn on architectures that have it and rt_sigreturn on the others.
94
+ sigreturn = Const::Syscall.const_get(arch.to_s.upcase)[:sigreturn] ? :sigreturn : :rt_sigreturn
95
+ Asm.asm(<<-EOS, arch: arch)
96
+ A = sys_number
97
+ A == read ? ok : next
98
+ A == write ? ok : next
99
+ A == exit ? ok : next
100
+ A == #{sigreturn} ? ok : next
101
+ return KILL
102
+ ok:
103
+ return ALLOW
104
+ EOS
105
+ end
106
+
107
+ # Dumps the BPF of the filter being installed.
108
+ #
109
+ # Only meaningful when {#set_seccomp?} is +true+. In filter mode +args[2]+ points to a
110
+ # +struct sock_fprog+ in the traced process, whose BPF bytes are read out. Strict mode installs
111
+ # no BPF, so an equivalent filter built by {.strict_bpf} is returned instead.
57
112
  # @return [String]
113
+ # The raw BPF bytes of the filter being installed.
58
114
  def dump_bpf
115
+ return self.class.strict_bpf(arch) if strict_mode?
116
+
59
117
  addr = args[2]
60
118
  len = Ptrace.peekdata(pid, addr, 0) & 0xffff # len is unsigned short
61
- filter = Ptrace.peekdata(pid, addr + bits / 8, 0) & ((1 << bits) - 1)
62
- Array.new(len) { |i| Ptrace.peekdata(pid, filter + i * 8, 0) }.pack('Q*')
119
+ filter = Ptrace.peekdata(pid, addr + (bits / 8), 0) & ((1 << bits) - 1)
120
+ Array.new(len) { |i| Ptrace.peekdata(pid, filter + (i * 8), 0) }.pack('Q*')
63
121
  end
64
122
 
65
- # @return [Symbol]
66
- # Architecture of this syscall.
123
+ # Architecture of this syscall, i.e. of the traced process.
124
+ # @return [Symbol?]
125
+ # See {SeccompTools::Util.process_arch}.
67
126
  def arch
68
- @arch ||= File.open("/proc/#{pid}/exe", 'rb') do |f|
69
- f.pos = 18
70
- {
71
- "\x03\x00" => :i386,
72
- "\x3e\x00" => :amd64,
73
- "\xb7\x00" => :aarch64,
74
- "\x00\x16" => :s390x
75
- }[f.read(2)]
76
- end
127
+ @arch ||= Util.process_arch(pid)
77
128
  end
78
129
 
79
130
  private
@@ -83,6 +134,7 @@ module SeccompTools
83
134
  i386: 32,
84
135
  amd64: 64,
85
136
  aarch64: 64,
137
+ riscv64: 64,
86
138
  s390x: 64
87
139
  }[arch]
88
140
  end