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,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'seccomp-tools/const'
4
+
5
+ module SeccompTools
6
+ class Explain
7
+ # A 64-bit fact reassembled from 32-bit word checks (see {QwordFusion}); +base+ is the field's
8
+ # byte offset in +seccomp_data+.
9
+ Qword = Struct.new(:base, :op, :val) do
10
+ # Mirrors {Symbolic::Constraint#key} (a string) so fused condition lists can still be compared;
11
+ # the +q+ prefix keeps it distinct from any constraint key, which starts with an operand char.
12
+ def key
13
+ "q#{base},#{op},#{val}"
14
+ end
15
+
16
+ # A {Qword} is a whole-field fact, never a single plain-word one, so it matches neither
17
+ # predicate. Defining them lets the fusion code treat a mixed list uniformly, without an
18
+ # +is_a?+ guard on every access.
19
+ def plain_data_fact?(_offset = nil) = false
20
+ alias_method :plain_data_eq?, :plain_data_fact?
21
+ end
22
+
23
+ # A 64-bit field of +seccomp_data+ (+instruction_pointer+ or an argument) is two 32-bit words,
24
+ # and filters check them separately. This class fuses such word facts back into 64-bit ones
25
+ # ({Qword}), both within one path condition ({#fold}) and across the sibling or-branches
26
+ # libseccomp compiles a 64-bit range comparison into ({#merge_or}).
27
+ class QwordFusion
28
+ # Byte offsets of the 64-bit fields whose two 32-bit words this class fuses.
29
+ BASES = Const::BPF::SeccompData::QWORD_BASES
30
+ # How the extra facts of two sibling or-branches fuse into one 64-bit comparison: one branch
31
+ # holds a strict high-word fact +hi <hi_op> H+ (+hi_op+ is +>+, +<+ or +!=+, normalized by
32
+ # {#strict}) and the other +hi == H && lo <lo_op> L+; keyed by +[hi_op, lo_op]+, they are
33
+ # exactly +field <fused op> (H << 32 | L)+. This is the shape libseccomp compiles
34
+ # SCMP_CMP_GT/GE/LT/LE/NE argument comparisons into.
35
+ OR_MERGE = {
36
+ %i[> >] => :>, %i[> >=] => :>=, %i[< <] => :<, %i[< <=] => :<=, %i[!= !=] => :!=
37
+ }.freeze
38
+
39
+ # @param [Symbol] arch
40
+ # Decides the word order: on a big-endian architecture the high 32-bit word of a 64-bit
41
+ # field comes first.
42
+ def initialize(arch)
43
+ @hi_first = Const::Endian.big?(arch)
44
+ end
45
+
46
+ # The byte offset of the low 32-bit word of the 64-bit field at +base+.
47
+ def lo_off(base)
48
+ @hi_first ? base + 4 : base
49
+ end
50
+
51
+ # The byte offset of the high 32-bit word of the 64-bit field at +base+.
52
+ def hi_off(base)
53
+ @hi_first ? base : base + 4
54
+ end
55
+
56
+ # The byte offset of the 64-bit field that the word at +offset+ belongs to.
57
+ def base_of(offset)
58
+ offset - (offset % 8)
59
+ end
60
+
61
+ # Fuses the 32-bit word facts of one path condition into whole-field {Qword}s; facts that do
62
+ # not form a fusable pair pass through unchanged.
63
+ # @param [Array<Symbolic::Constraint, Qword>] constraints
64
+ # @return [Array<Symbolic::Constraint, Qword>]
65
+ # @example Both halves pinned by == (little-endian: args[0] lo word @16, hi @20)
66
+ # fold([ data[20] == 0x1, data[16] == 0x2 ])
67
+ # #=> [ Qword(base: 16, op: :==, val: 0x100000002) ]
68
+ # @example A zero high word with a low-word bound
69
+ # fold([ data[20] == 0x0, data[16] < 0x1000 ])
70
+ # #=> [ Qword(base: 16, op: :<, val: 0x1000) ]
71
+ def fold(constraints)
72
+ plan = qword_plan(constraints)
73
+ constraints.filter_map do |c|
74
+ action = plan[c]
75
+ next if action == :drop
76
+
77
+ action || c
78
+ end
79
+ end
80
+
81
+ # Fuses sibling or-branches that together express one 64-bit comparison, repeatedly until
82
+ # nothing fuses; branches that do not pair up are returned untouched. See {#fuse_pair} for
83
+ # the shape of a fusable pair.
84
+ # @param [Array<Array<Symbolic::Constraint, Qword>>] lists
85
+ # The condition lists of one rule's or-branches.
86
+ # @return [Array<Array<Symbolic::Constraint, Qword>>]
87
+ # @example The two match branches of a 64-bit +args[0] > 0x200000500+ (little-endian: lo @16, hi @20)
88
+ # merge_or([ [ data[20] > 2 ],
89
+ # [ data[20] == 2, data[16] > 0x500 ] ])
90
+ # #=> [ [ Qword(base: 16, op: :>, val: 0x200000500) ] ]
91
+ def merge_or(lists)
92
+ lists = lists.dup # own a mutable copy; the caller's array is left untouched
93
+ loop { break unless merge_one_pair!(lists) }
94
+ lists
95
+ end
96
+
97
+ private
98
+
99
+ # Maps each constraint to what {#fold} should do with it: a {Qword} to replace it with, or
100
+ # +:drop+ to remove it. A constraint absent from the plan is kept as-is.
101
+ def qword_plan(constraints)
102
+ eqs = constraints.select(&:plain_data_eq?).group_by { |c| c.lhs.offset }.transform_values(&:first)
103
+ plan = {}
104
+ BASES.each do |base|
105
+ hi = eqs[hi_off(base)]
106
+ next unless hi
107
+
108
+ if (lo = eqs[lo_off(base)])
109
+ plan[lo] = Qword.new(base, :==, (hi.rhs.val << 32) | lo.rhs.val)
110
+ elsif hi.rhs.val.zero? && (lo = lo_bound(constraints, base))
111
+ plan[lo] = Qword.new(base, lo.op, lo.rhs.val)
112
+ else
113
+ next
114
+ end
115
+ plan[hi] = :drop
116
+ end
117
+ plan
118
+ end
119
+
120
+ # The +lo < L+ / +lo <= L+ fact on the low word of the field at +base+, if any.
121
+ def lo_bound(constraints, base)
122
+ constraints.find { |c| c.plain_data_fact?(lo_off(base)) && %i[< <=].include?(c.op) }
123
+ end
124
+
125
+ # Fuses the first fusable pair of +lists+ in place - dropping the two branches and putting the
126
+ # fused one at the earlier slot - and returns it, or +nil+ when no pair fused.
127
+ def merge_one_pair!(lists)
128
+ lists.each_with_index do |a, i|
129
+ lists.each_with_index do |b, j|
130
+ next if i == j
131
+
132
+ fused = fuse_pair(a, b)
133
+ next unless fused
134
+
135
+ lo, hi = [i, j].minmax
136
+ lists[lo] = fused
137
+ lists.delete_at(hi)
138
+ return fused
139
+ end
140
+ end
141
+ nil
142
+ end
143
+
144
+ # Fuses two sibling or-branches into one 64-bit fact. It applies when +a+'s only extra fact
145
+ # (vs +b+) is a strict high-word fact +hi <op> H+, and +b+'s two extras are +hi == H+ plus a
146
+ # bound on the matching low word: that pair in +b+ is then replaced by the fused {Qword} (see
147
+ # {OR_MERGE}).
148
+ # @param [Array<Symbolic::Constraint, Qword>] a
149
+ # One or-branch's condition list.
150
+ # @param [Array<Symbolic::Constraint, Qword>] b
151
+ # The sibling branch's condition list.
152
+ # @return [Array<Symbolic::Constraint, Qword>, nil]
153
+ # +b+ with its +hi == H+ / +lo <op> L+ pair collapsed into one {Qword}, or +nil+ when the
154
+ # two lists do not have the fusable shape.
155
+ # @example Fusing the two match paths of a 64-bit +args[0] > 0x200000500+ (little-endian: lo word @16, hi @20)
156
+ # a = [ data[20] > 2 ] # hi > H
157
+ # b = [ data[20] == 2, data[16] > 0x500 ] # hi == H && lo > L
158
+ # fuse_pair(a, b) #=> [ Qword(base: 16, op: :>, val: 0x200000500) ]
159
+ def fuse_pair(a, b)
160
+ only_a = minus(a, b)
161
+ return unless only_a.size == 1
162
+
163
+ hi = only_a.first
164
+ base = hi_field_base(hi)
165
+ return unless base
166
+
167
+ hi_op, hi_val = strict(hi.op, hi.rhs.val)
168
+ only_b = minus(b, a)
169
+ eq = only_b.find { |c| c.plain_data_eq?(hi.lhs.offset) && c.rhs.val == hi_val }
170
+ lo = only_b.find { |c| c.plain_data_fact?(lo_off(base)) }
171
+ return unless only_b.size == 2 && eq && lo && (op = OR_MERGE[[hi_op, lo.op]])
172
+
173
+ b.map { |c| c.equal?(eq) ? Qword.new(base, op, (hi_val << 32) | lo.rhs.val) : c }.reject { |c| c.equal?(lo) }
174
+ end
175
+
176
+ # The field base when +hi+ is a constant comparison on the high word of a 64-bit field;
177
+ # +nil+ otherwise.
178
+ def hi_field_base(hi)
179
+ return unless hi.plain_data_fact?
180
+
181
+ base = base_of(hi.lhs.offset)
182
+ base if BASES.include?(base) && hi.lhs.offset == hi_off(base)
183
+ end
184
+
185
+ # +>= v+ and +> v-1+ are the same test; normalize the high-word comparison to the strict form
186
+ # so {OR_MERGE} needs only one spelling. At the 32-bit boundary +val+ can step out of range
187
+ # (+>= 0+ to +> -1+, +<= 0xffffffff+ to +< 0x100000000+), but that is safe: the result is only
188
+ # matched against a data-word +==+ constant, which is masked to +0..0xffffffff+ and so can
189
+ # never equal it, and those boundary comparisons are always-true anyway (nothing to fuse).
190
+ def strict(op, val)
191
+ case op
192
+ when :>= then [:>, val - 1]
193
+ when :<= then [:<, val + 1]
194
+ else [op, val]
195
+ end
196
+ end
197
+
198
+ # The constraints in +a+ whose fact does not also appear in +b+.
199
+ def minus(a, b)
200
+ a.reject { |c| b.any? { |d| d.key == c.key } }
201
+ end
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'seccomp-tools/const'
4
+ require 'seccomp-tools/explain/qword'
5
+ require 'seccomp-tools/util'
6
+
7
+ module SeccompTools
8
+ class Explain
9
+ # Renders path-condition facts ({Symbolic::Constraint}s and {Qword}s) as C-like conditions,
10
+ # naming the +seccomp_data+ fields and parenthesizing exactly where the expression would
11
+ # otherwise be misread.
12
+ class Renderer
13
+ # C-like operator precedence (higher binds tighter), used to parenthesize a rendered condition
14
+ # exactly where it would otherwise be misread - notably that +==+ binds tighter than the
15
+ # bitwise operators, so +a & b == c+ must be shown as +(a & b) == c+.
16
+ PREC = {
17
+ :* => 12, :/ => 12, :+ => 11, :- => 11, :<< => 10, :>> => 10,
18
+ :< => 9, :<= => 9, :> => 9, :>= => 9, :== => 8, :!= => 8,
19
+ :& => 7, :^ => 6, :| => 5
20
+ }.freeze
21
+ # Unary negation binds tighter than any binary operator.
22
+ UNARY_PREC = 13
23
+ # The comparison operators. When one is the parent, operands are parenthesized by precedence
24
+ # alone (so +a & b == c+ becomes +(a & b) == c+ but +a >> b == c+ stays put), never by the
25
+ # extra readability rule in {#clarity_wrap?}.
26
+ COMPARISON = %i[== != < <= > >=].freeze
27
+ # The +seccomp_data+ field layout the rendered names come from.
28
+ DATA = Const::BPF::SeccompData
29
+
30
+ # @param [QwordFusion] fusion
31
+ # Supplies the endian-correct word offsets of the 64-bit fields, for naming their halves.
32
+ def initialize(fusion)
33
+ @fusion = fusion
34
+ end
35
+
36
+ # Renders a conjunction of facts, e.g. +"fd == 0x1 && (flags & 0xf) < 0x5"+.
37
+ # @param [Array<Symbolic::Constraint, Qword>] constraints
38
+ # @param [Symbol?] sys
39
+ # The syscall the facts belong to, if pinned - names the arguments.
40
+ # @return [String]
41
+ def conjunction(constraints, sys)
42
+ constraints.map do |c|
43
+ if c.is_a?(Qword)
44
+ "#{data_name(@fusion.lo_off(c.base), sys)} #{c.op} 0x#{c.val.to_s(16)}"
45
+ else
46
+ constraint(c, sys)
47
+ end
48
+ end.join(' && ')
49
+ end
50
+
51
+ private
52
+
53
+ def constraint(c, sys)
54
+ return "(#{binop(:&, c.lhs, c.rhs, sys)}) != 0" if c.op == :set
55
+ return "(#{binop(:&, c.lhs, c.rhs, sys)}) == 0" if c.op == :unset
56
+
57
+ prec = PREC[c.op]
58
+ "#{operand(c.lhs, c.op, prec, sys)} #{c.op} #{operand(c.rhs, c.op, prec, sys)}"
59
+ end
60
+
61
+ # Renders an expression without any outer parentheses; each caller wraps it via {#operand}.
62
+ def expr(e, sys)
63
+ return '<opaque>' if e.opaque?
64
+ return "0x#{e.val.to_s(16)}" if e.imm?
65
+ return data_name(e.offset, sys) if e.plain_data?
66
+ return "-#{operand(e.lhs, :neg, UNARY_PREC, sys)}" if e.kind == :unop
67
+
68
+ binop(e.op, e.lhs, e.rhs, sys)
69
+ end
70
+
71
+ # Renders +lhs op rhs+. Operators are left-associative, so the left operand shares +op+'s
72
+ # precedence while the right needs one higher (an equal-precedence right subtree is wrapped).
73
+ def binop(op, lhs, rhs, sys)
74
+ prec = PREC[op]
75
+ left = operand(lhs, op, prec, sys)
76
+ right = rhs.imm? && %i[<< >>].include?(op) ? rhs.val.to_s : operand(rhs, op, prec + 1, sys)
77
+ "#{left} #{op} #{right}"
78
+ end
79
+
80
+ # Renders +child+ as an operand of +parent_op+, parenthesizing it when precedence requires it
81
+ # (+child+ binds looser than +min_prec+) or when {#clarity_wrap?} judges the grouping too easy
82
+ # to misread.
83
+ def operand(child, parent_op, min_prec, sys)
84
+ s = expr(child, sys)
85
+ return s unless child.kind == :binop
86
+
87
+ PREC[child.op] < min_prec || clarity_wrap?(parent_op, child.op) ? "(#{s})" : s
88
+ end
89
+
90
+ # Should a +child_op+ nested under +parent_op+ be parenthesized purely for readability (beyond
91
+ # what precedence requires)? Yes when they sit at *different* precedence levels - mixing
92
+ # families like +a & (b + c)+ or +(a + b) << c+ is easy to misjudge. The exceptions, where the
93
+ # grouping is universally understood, are: any comparison parent (+a & b == c+ is already made
94
+ # unambiguous by wrapping the looser +&+), a same-level pair (+a + b - c+, +a ^ b ^ c+), and
95
+ # multiplication/division directly inside addition/subtraction (+a + b * c+).
96
+ def clarity_wrap?(parent_op, child_op)
97
+ return false if COMPARISON.include?(parent_op)
98
+ return false if PREC[parent_op] == PREC[child_op]
99
+ return false if PREC[child_op] == PREC[:*] && PREC[parent_op] == PREC[:+]
100
+
101
+ true
102
+ end
103
+
104
+ def data_name(offset, sys)
105
+ case offset
106
+ when DATA::SYS_NUMBER, DATA::ARCH then DATA::NAMES[offset] # the scalar fields
107
+ else qword_word_name(offset, sys) # endian-split fields (instruction_pointer, args)
108
+ end
109
+ end
110
+
111
+ # Names one 32-bit word of a 64-bit field, appending +>> 32+ for the high word - which is the
112
+ # second word on little-endian architectures but the first on big-endian ones.
113
+ def qword_word_name(offset, sys)
114
+ base = @fusion.base_of(offset)
115
+ return "data[#{offset}]" unless DATA::QWORD_BASES.include?(base)
116
+
117
+ name = if base == DATA::INSTRUCTION_POINTER
118
+ DATA::NAMES[base]
119
+ else
120
+ idx = (base - DATA::ARGS) / 8
121
+ names = sys && Const::SYS_ARG[sys]
122
+ Util.colorize((names && names[idx]) || "args[#{idx}]", t: :args)
123
+ end
124
+ offset == @fusion.hi_off(base) ? "#{name} >> 32" : name
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'seccomp-tools/const'
4
+ require 'seccomp-tools/explain/analysis'
5
+ require 'seccomp-tools/explain/qword'
6
+ require 'seccomp-tools/explain/renderer'
7
+ require 'seccomp-tools/explain/verdict'
8
+ require 'seccomp-tools/util'
9
+
10
+ module SeccompTools
11
+ class Explain
12
+ # Turns the raw {Symbolic::Executor::Leaf}s collected by the walk into a human-readable policy,
13
+ # grouped by architecture and then by action (+ALLOW+, +KILL+, +ERRNO(n)+, ...).
14
+ #
15
+ # This class owns the presentation: sections, buckets and the default rule. It reads each
16
+ # leaf's path through {PathFacts}, decodes the returned action with {Verdict}, reassembles
17
+ # 64-bit word checks with {QwordFusion}, and stringifies conditions with {Renderer}.
18
+ class Summary
19
+ # Display name of the syscall-number field, for the range subjects.
20
+ SYS_NAME = Const::BPF::SeccompData::NAMES.fetch(Const::BPF::SeccompData::SYS_NUMBER)
21
+ # The x32 ABI bit (+__X32_SYSCALL_BIT+); a lower-bound-only range at exactly this value is the
22
+ # conventional x32 guard, worth annotating.
23
+ X32_SYSCALL_BIT = 0x40000000
24
+ # Widest a wrapped bucket line may get, in columns.
25
+ WRAP_WIDTH = 72
26
+
27
+ # @param [Array<Symbolic::Executor::Leaf>] leaves
28
+ # @param [Symbol] arch
29
+ # The filter's declared architecture, used when the filter itself does not branch on +arch+.
30
+ # @param [String?] source
31
+ # Label shown in the header.
32
+ # @param [Boolean] truncated
33
+ # Whether the walk hit {Symbolic::Executor::STEP_CAP}.
34
+ def initialize(leaves, arch:, source: nil, truncated: false)
35
+ @arch = arch
36
+ @source = source
37
+ @truncated = truncated
38
+ @fusion = QwordFusion.new(arch)
39
+ @renderer = Renderer.new(@fusion)
40
+ @analysis = Analysis.new(leaves)
41
+ end
42
+
43
+ # Renders the policy.
44
+ # @return [String]
45
+ def to_s
46
+ out = +''
47
+ out << "Seccomp policy for #{@source}\n" if @source
48
+ out << "WARNING: analysis truncated (filter too large); results may be incomplete.\n" if @truncated
49
+ @analysis.sections(@arch).each do |_arch_val, arch_sym, title, leaves|
50
+ out << "\n" << render_section(title, section_buckets(arch_sym, leaves))
51
+ end
52
+ out << render_other_arches
53
+ out
54
+ end
55
+
56
+ private
57
+
58
+ # The {PathFacts} of +leaf+, computed once (shared with {Analysis}).
59
+ def facts(leaf)
60
+ @analysis.facts(leaf)
61
+ end
62
+
63
+ # Renders what happens on the architectures the filter does not explicitly check for. Usually
64
+ # those paths just fall to one action and a one-liner suffices; when they carry rules of their
65
+ # own, a full section is rendered so the rules are not silently dropped.
66
+ def render_other_arches
67
+ return '' if @analysis.arch_values.empty?
68
+
69
+ leaves = @analysis.other_leaves
70
+ default = @analysis.default_label(leaves)
71
+ return '' unless default
72
+
73
+ buckets = rule_buckets(nil, leaves, default)
74
+ return "\nOther architectures: #{default}\n" if buckets.empty?
75
+
76
+ add_default(buckets, default)
77
+ "\n#{render_section('<any other>', buckets)}"
78
+ end
79
+
80
+ # The action buckets of one section: its non-default rules plus the default rule. +arch_sym+
81
+ # names syscalls/arguments; +nil+ (architecture unknown) leaves them numeric.
82
+ def section_buckets(arch_sym, leaves)
83
+ default = @analysis.default_label(leaves)
84
+ buckets = rule_buckets(arch_sym, leaves, default)
85
+ add_default(buckets, default)
86
+ buckets
87
+ end
88
+
89
+ # Renders one architecture section from its prebuilt +buckets+.
90
+ def render_section(title, buckets)
91
+ out = "Architecture: #{Util.colorize(title, t: :arch)}\n"
92
+ return out << "\n (no return reached; filter runs off the end)\n" if buckets.empty?
93
+
94
+ sorted_buckets(buckets).each { |label, b| out << render_bucket(label, b) }
95
+ out
96
+ end
97
+
98
+ # Buckets the non-default rules of a section by action label. Every leaf falls into exactly
99
+ # one bucket source: it pins a syscall number, restricts a range of numbers, checks arguments
100
+ # only, or is the catch-all (rendered by {#add_default}).
101
+ def rule_buckets(arch_sym, leaves, default)
102
+ named, rest = leaves.partition { |l| facts(l).sys_eq }
103
+ ranged, rest = rest.partition { |l| facts(l).sys_range }
104
+ conditional, = rest.partition { |l| !facts(l).residual.empty? }
105
+
106
+ buckets = {}
107
+ add_named(buckets, arch_sym, named, default)
108
+ add_ranges(buckets, ranged)
109
+ add_conditional(buckets, conditional, default)
110
+ buckets
111
+ end
112
+
113
+ # Explicitly matched syscalls (+A == nr+), grouped by number then verdict.
114
+ def add_named(buckets, arch_sym, leaves, default)
115
+ leaves.group_by { |l| facts(l).sys_eq }.sort_by(&:first).each do |nr, group|
116
+ sys = syscall_name(arch_sym, nr)
117
+ name = Util.colorize(sys ? sys.to_s : "0x#{nr.to_s(16)}", t: :syscall)
118
+ group.group_by { |l| Verdict.label(l.ret) }.each do |label, ls|
119
+ next if label == default # falls through to the default action
120
+
121
+ conds = merged_conds(ls, sys)
122
+ plain = conds.include?('') # some path reaches this verdict with no extra condition
123
+ entry = plain ? name : "#{name} when #{conds.join(' or ')}"
124
+ add(buckets, label, entry, simple: plain)
125
+ end
126
+ end
127
+ end
128
+
129
+ # Fall-through rules that restrict a range of syscall numbers, e.g. the x32 ABI guard,
130
+ # together with whatever else those paths check. A range whose action is the default is still
131
+ # shown when unconditional (the explicit guard is worth surfacing), and its conditional
132
+ # variants are shown too so no check is silently dropped.
133
+ def add_ranges(buckets, leaves)
134
+ leaves.group_by { |l| facts(l).sys_range }.each do |(lo, hi), group|
135
+ range = "#{SYS_NAME} >= 0x#{lo.to_s(16)}"
136
+ range << " && #{SYS_NAME} <= 0x#{hi.to_s(16)}" if hi
137
+ group.group_by { |l| Verdict.label(l.ret) }.each do |label, ls|
138
+ conds = merged_conds(ls, nil)
139
+ entry = conds.include?('') ? range.dup : "#{range} when #{conds.join(' or ')}"
140
+ entry << ' (x32 ABI)' if x32?(lo, hi)
141
+ add(buckets, label, entry, simple: false)
142
+ end
143
+ end
144
+ end
145
+
146
+ # Fall-through rules that inspect arguments (or a transformed syscall number) without pinning a
147
+ # specific syscall. Kept so such checks are never silently dropped.
148
+ def add_conditional(buckets, leaves, default)
149
+ leaves.group_by { |l| Verdict.label(l.ret) }.each do |label, ls|
150
+ next if label == default
151
+
152
+ conds = merged_conds(ls, nil)
153
+ add(buckets, label, "any syscall when #{conds.join(' or ')}", simple: false)
154
+ end
155
+ end
156
+
157
+ # The rendered or-branch conditions of the leaves +ls+, deduplicated, with 64-bit word checks
158
+ # fused back into whole-field facts (see {QwordFusion}).
159
+ def merged_conds(ls, sys)
160
+ @fusion.merge_or(ls.map { |l| facts(l).residual })
161
+ .map { |list| @renderer.conjunction(@fusion.fold(list), sys) }.uniq
162
+ end
163
+
164
+ def add_default(buckets, default)
165
+ return unless default
166
+
167
+ # "other" only makes sense when some syscall was singled out; otherwise the default is the
168
+ # whole policy.
169
+ text = buckets.empty? ? '<default> (any syscall)' : '<default> (any other syscall)'
170
+ add(buckets, default, text, simple: false)
171
+ end
172
+
173
+ def add(buckets, label, text, simple:)
174
+ b = buckets[label] ||= { simple: [], complex: [] }
175
+ (simple ? b[:simple] : b[:complex]) << text
176
+ end
177
+
178
+ def sorted_buckets(buckets)
179
+ buckets.sort_by { |label, _b| Verdict.rank(label) }
180
+ end
181
+
182
+ def render_bucket(label, bucket)
183
+ out = "\n #{label}:\n"
184
+ wrap(bucket[:simple]).each { |line| out << " #{line}\n" }
185
+ bucket[:complex].each { |line| out << " #{line}\n" }
186
+ out
187
+ end
188
+
189
+ # Wraps a list of short tokens into comma-separated lines no wider than 72 columns.
190
+ def wrap(tokens)
191
+ return [] if tokens.empty?
192
+
193
+ lines = []
194
+ line = +''
195
+ tokens.each do |tok|
196
+ piece = line.empty? ? tok : ", #{tok}"
197
+ if !line.empty? && line.size + piece.size > WRAP_WIDTH
198
+ lines << line
199
+ line = +tok
200
+ else
201
+ line << piece
202
+ end
203
+ end
204
+ lines << line
205
+ end
206
+
207
+ def x32?(lo, hi)
208
+ lo == X32_SYSCALL_BIT && hi.nil?
209
+ end
210
+
211
+ def syscall_name(arch_sym, nr)
212
+ arch_sym && Const::Syscall.const_get(arch_sym.upcase).invert[nr]
213
+ rescue NameError
214
+ nil
215
+ end
216
+ end
217
+ end
218
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'seccomp-tools/const'
4
+
5
+ module SeccompTools
6
+ class Explain
7
+ # Decodes the value a filter path returns into the action it stands for: the label a policy
8
+ # shows (+ALLOW+, +ERRNO(5)+, ...) and where that action sorts among the buckets.
9
+ module Verdict
10
+ # Buckets are printed in this order; unlisted actions sort last.
11
+ ORDER = %i[ALLOW USER_NOTIF LOG TRACE TRAP ERRNO KILL KILL_PROCESS UNKNOWN].freeze
12
+
13
+ module_function
14
+
15
+ # The label for the value +ret+ a leaf returns, including the +SECCOMP_RET_DATA+ part when
16
+ # it is meaningful for the action.
17
+ # @param [Symbolic::Expr] ret
18
+ # @return [String]
19
+ def label(ret)
20
+ return 'UNKNOWN' unless ret.imm?
21
+
22
+ # An unrecognized action value loads fine; the kernel treats it as KILL_PROCESS
23
+ # (KILL_THREAD before Linux 4.14). See seccomp(2).
24
+ Const::BPF.action_label(ret.val) || format('KILL_PROCESS (unknown action 0x%x)', ret.val)
25
+ end
26
+
27
+ # Where the bucket labeled +label+ sorts: by its action's position in {ORDER}, then
28
+ # alphabetically. Works on any label {.label} produces, data and annotations included.
29
+ # @param [String] label
30
+ # @return [Array(Integer, String)]
31
+ def rank(label)
32
+ [ORDER.index(action_of(label)) || ORDER.size, label]
33
+ end
34
+
35
+ # The bare action symbol of a label, recovered by stripping any +(data)+ / + (annotation)+
36
+ # suffix {.label} appended. Safe because no action name itself contains +" ("+.
37
+ # @param [String] label
38
+ # @return [Symbol]
39
+ def action_of(label)
40
+ label.sub(/\s*\(.*/, '').to_sym
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'seccomp-tools/explain/summary'
4
+ require 'seccomp-tools/symbolic/executor'
5
+
6
+ module SeccompTools
7
+ # Analyzes a whole seccomp filter across all execution paths and summarizes it as a per-action
8
+ # policy: which syscalls end in +ALLOW+, +KILL+, +ERRNO(n)+, etc., and under what argument
9
+ # constraints.
10
+ #
11
+ # It runs the generic {Symbolic::Executor} over the filter to collect every reachable +return+
12
+ # together with the path condition that leads to it, then hands those leaves to {Summary}, which
13
+ # interprets them with seccomp semantics (syscall numbers, architectures, actions, ...).
14
+ #
15
+ # @example
16
+ # insts = SeccompTools::Disasm.to_bpf(raw, :amd64).map(&:inst)
17
+ # puts SeccompTools::Explain.new(insts, arch: :amd64).summarize
18
+ class Explain
19
+ # @param [Array<Instruction::Base>] instructions
20
+ # The filter, as +SeccompTools::Disasm.to_bpf(raw, arch).map(&:inst)+.
21
+ # @param [Symbol] arch
22
+ # The architecture the filter is written for, used for syscall/argument names.
23
+ # @param [String?] source
24
+ # A label for the filter (e.g. a filename) shown in the summary header.
25
+ def initialize(instructions, arch:, source: nil)
26
+ @instructions = instructions
27
+ @arch = arch
28
+ @source = source
29
+ end
30
+
31
+ # Walks the filter and returns a printable {Summary}.
32
+ # @return [Summary]
33
+ def summarize
34
+ leaves, truncated = Symbolic::Executor.new(@instructions).run
35
+ Summary.new(leaves, arch: @arch, source: @source, truncated:)
36
+ end
37
+ end
38
+ end
@@ -4,7 +4,9 @@ require 'seccomp-tools/instruction/base'
4
4
 
5
5
  module SeccompTools
6
6
  module Instruction
7
- # Instruction alu.
7
+ # Instruction alu, performs an arithmetic or bitwise operation on the accumulator register A.
8
+ #
9
+ # The right operand is either the X register or the immediate +k+.
8
10
  class ALU < Base
9
11
  # Mapping from name to operator.
10
12
  OP_SYM = {
@@ -17,10 +19,12 @@ module SeccompTools
17
19
  lsh: :<<,
18
20
  rsh: :>>,
19
21
  # neg: :-, # should not be invoked
20
- # mod: :%, # unsupported
22
+ # mod: :%, # the kernel rejects BPF_MOD (see Const::BPF::OP), so it never reaches here
21
23
  xor: :^
22
24
  }.freeze
23
25
  # Decompile instruction.
26
+ # @return [String]
27
+ # The operation as assembly, e.g. +"A &= 0x7fff"+.
24
28
  def decompile
25
29
  return 'A = -A' if op == :neg
26
30
 
@@ -29,6 +33,8 @@ module SeccompTools
29
33
 
30
34
  # See {Instruction::Base#symbolize}.
31
35
  # @return [[:alu, Symbol, (:x, Integer, nil)]]
36
+ # The operator and its right operand, which is +:x+ for the X register, an Integer for an
37
+ # immediate, or +nil+ for the unary +neg+.
32
38
  def symbolize
33
39
  return [:alu, :neg, nil] if op == :neg
34
40
 
@@ -36,13 +42,12 @@ module SeccompTools
36
42
  end
37
43
 
38
44
  # See {Base#branch}.
39
- # @param [Context] context
40
- # Current context.
41
- # @return [Array<(Integer, Context)>]
42
- def branch(context)
43
- ctx = context.dup
44
- ctx[:a] = Disasm::Context::Value.new
45
- [[line + 1, ctx]]
45
+ # @param [Symbolic::State] state
46
+ # Current state.
47
+ # @return [Array<(Integer, Symbolic::State)>]
48
+ # Always the next line, with A marked as no longer tracked.
49
+ def branch(state)
50
+ [[line + 1, state.with(a: Symbolic::Expr.opaque)]]
46
51
  end
47
52
 
48
53
  private