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
@@ -3,20 +3,21 @@
3
3
  require 'seccomp-tools/const'
4
4
 
5
5
  module SeccompTools
6
- # For emulating seccomp.
6
+ # Runs a seccomp filter against a hypothetical syscall to find out which action it returns.
7
7
  class Emulator
8
8
  # Instantiate a {Emulator} object.
9
9
  #
10
10
  # All parameters except +instructions+ are optional. A warning is shown when uninitialized data is accessed.
11
11
  # @param [Array<Instruction::Base>] instructions
12
- # @param [Integer] sys_nr
12
+ # The filter to be emulated, as returned by +SeccompTools::Disasm.to_bpf(raw, arch).map(&:inst)+.
13
+ # @param [Integer?] sys_nr
13
14
  # Syscall number.
14
15
  # @param [Array<Integer>] args
15
- # Syscall arguments
16
- # @param [Integer] instruction_pointer
17
- # Program counter address when this syscall invoked.
16
+ # Syscall arguments.
17
+ # @param [Integer?] instruction_pointer
18
+ # Program counter address when this syscall is invoked.
18
19
  # @param [Symbol?] arch
19
- # System architecture is used when this parameter is not provided.
20
+ # Defaults to the system architecture when not provided.
20
21
  #
21
22
  # See {SeccompTools::Util.supported_archs} for list of supported architectures.
22
23
  def initialize(instructions, sys_nr: nil, args: [], instruction_pointer: nil, arch: nil)
@@ -24,16 +25,27 @@ module SeccompTools
24
25
  @sys_nr = sys_nr
25
26
  @args = args
26
27
  @ip = instruction_pointer
27
- @arch = audit(arch || Util.system_arch)
28
+ arch ||= Util.system_arch
29
+ @arch = audit(arch)
30
+ # On a big-endian architecture the high 32-bit word of a 64-bit field comes first.
31
+ @big_endian = Const::Endian.big?(arch)
28
32
  end
29
33
 
30
34
  # Run emulation!
35
+ #
36
+ # Executes the filter until it returns, then reports the final machine state.
37
+ # @yieldparam [{Symbol, Integer => Integer}] values
38
+ # If a block is given, it is invoked before each instruction with the current machine state.
31
39
  # @return [{Symbol, Integer => Integer}]
40
+ # The final state: +:ret+ is the action the filter returned, +:pc+ the line it returned from,
41
+ # +:a+ and +:x+ the registers, and Integer keys the scratch memory slots.
42
+ # @example
43
+ # insts = SeccompTools::Disasm.to_bpf(raw, :amd64).map(&:inst)
44
+ # SeccompTools::Emulator.new(insts, sys_nr: 0).run[:ret]
45
+ # #=> 2147418112 # SECCOMP_RET_ALLOW
32
46
  def run
33
47
  @values = { pc: 0, a: 0, x: 0 }
34
48
  loop do
35
- break if @values[:ret] # break when returned
36
-
37
49
  yield(@values) if block_given?
38
50
  inst = @instructions[pc]
39
51
  op, *args = inst.symbolize
@@ -46,6 +58,8 @@ module SeccompTools
46
58
  when :alu then alu(args[0], args[1]) # alu
47
59
  when :misc then misc(args[0]) # misc: txa/tax
48
60
  end
61
+ break if @values[:ret] # break when returned
62
+
49
63
  set(:pc, get(:pc) + 1) if %i[ld st alu misc].include?(op)
50
64
  end
51
65
  @values
@@ -101,6 +115,9 @@ module SeccompTools
101
115
  set(:a, (2**32) - get(:a))
102
116
  else
103
117
  src = get(:x) if src == :x
118
+ # Classic BPF aborts the whole program on division by zero.
119
+ return set(:ret, Const::BPF::ACTION[:KILL_THREAD]) if op == :/ && src.zero?
120
+
104
121
  set(:a, get(:a).__send__(op, src))
105
122
  end
106
123
  end
@@ -140,20 +157,28 @@ module SeccompTools
140
157
  end
141
158
 
142
159
  def data_of(index)
143
- raise IndexError, "Invalid index: #{index}" unless index.nobits?(3) && index.between?(0, 63)
160
+ data = Const::BPF::SeccompData
161
+ raise IndexError, "Invalid index: #{index}" unless index.nobits?(3) && index.between?(0, data::SIZE - 1)
144
162
 
145
163
  index /= 4
146
164
  case index
147
- when 0 then @sys_nr || undefined('sys_number')
148
- when 1 then @arch || undefined('arch')
149
- when 2 then (@ip & 0xffffffff) || undefined('instruction_pointer')
150
- when 3 then (@ip >> 32) || undefined('instruction_pointer')
165
+ when 0 then @sys_nr || undefined(data::NAMES[data::SYS_NUMBER])
166
+ when 1 then @arch || undefined(data::NAMES[data::ARCH])
167
+ when 2, 3 then word_of(@ip || undefined(data::NAMES[data::INSTRUCTION_POINTER]), index)
151
168
  else
152
169
  val = @args[(index - 4) / 2] || undefined("args[#{(index - 4) / 2}]")
153
- (val >> (index.even? ? 0 : 32)) & 0xffffffff
170
+ word_of(val, index)
154
171
  end
155
172
  end
156
173
 
174
+ # The 32-bit word of the 64-bit value +val+ that lives at word-index +index+ of
175
+ # +seccomp_data+: the high word comes second on little-endian architectures but first on
176
+ # big-endian ones (s390x).
177
+ def word_of(val, index)
178
+ hi = index.odd? ^ @big_endian
179
+ (val >> (hi ? 32 : 0)) & 0xffffffff
180
+ end
181
+
157
182
  def undefined(var)
158
183
  raise format("Undefined Variable\n\t%04d: %s <- `%s` is undefined", pc, @instructions[pc].decompile, var)
159
184
  end
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SeccompTools
4
- # Base error class.
4
+ # Base class of all errors raised by this library.
5
+ #
6
+ # Rescue this class to catch every assembler error at once.
5
7
  class Error < StandardError
6
8
  end
7
9
 
@@ -9,7 +11,7 @@ module SeccompTools
9
11
  class UnrecognizedTokenError < Error
10
12
  end
11
13
 
12
- # Raised when a referred label is defined no where on compiling seccomp assembly.
14
+ # Raised when a referenced label is defined nowhere on compiling seccomp assembly.
13
15
  class UndefinedLabelError < Error
14
16
  end
15
17
 
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'seccomp-tools/const'
4
+ require 'seccomp-tools/explain/path_facts'
5
+ require 'seccomp-tools/explain/verdict'
6
+
7
+ module SeccompTools
8
+ class Explain
9
+ # What a walk of one filter amounts to: the reachable returns, each one's facts read once, and
10
+ # the split into the architectures the filter distinguishes.
11
+ #
12
+ # Everything that reads a walk needs the same groundwork - {Summary} to render a per-architecture
13
+ # policy, {Audit} to assess each architecture's reachable syscalls - so it is derived here once
14
+ # rather than in each of them.
15
+ class Analysis
16
+ # @param [Array<Symbolic::Executor::Leaf>] leaves
17
+ def initialize(leaves)
18
+ @leaves = leaves
19
+ @facts = Hash.new { |h, leaf| h[leaf] = PathFacts.new(leaf.path) }
20
+ end
21
+
22
+ # The {PathFacts} of +leaf+, computed once and shared across all consumers.
23
+ # @param [Symbolic::Executor::Leaf] leaf
24
+ # @return [PathFacts]
25
+ def facts(leaf)
26
+ @facts[leaf]
27
+ end
28
+
29
+ # The distinct architecture values (+AUDIT_ARCH_*+) the filter explicitly branches on.
30
+ # @return [Array<Integer>]
31
+ def arch_values
32
+ @arch_values ||= @leaves.filter_map { |l| facts(l).arch_eq }.uniq
33
+ end
34
+
35
+ # One entry per architecture section: its +AUDIT_ARCH+ value (+nil+ when the filter never
36
+ # branches on +arch+), the architecture symbol whose syscall names apply (+nil+ when the checked
37
+ # value is not one seccomp-tools knows), a display title, and the leaves reachable on it.
38
+ # @param [Symbol] declared_arch
39
+ # The architecture assumed when the filter itself does not branch on +arch+.
40
+ # @return [Array<Array(Integer?, Symbol?, Object, Array<Symbolic::Executor::Leaf>)>]
41
+ def sections(declared_arch)
42
+ vals = arch_values
43
+ return [[nil, declared_arch, declared_arch, @leaves]] if vals.empty?
44
+
45
+ vals.map do |v|
46
+ sym = Const::Audit.arch_symbol(v)
47
+ [v, sym, sym || format('0x%x (unknown)', v), @leaves.select { |l| facts(l).arch_consistent?(v) }]
48
+ end
49
+ end
50
+
51
+ # Leaves reachable when +arch+ is none of the explicitly-checked values.
52
+ # @return [Array<Symbolic::Executor::Leaf>]
53
+ def other_leaves
54
+ @leaves.reject { |l| facts(l).arch_eq }
55
+ end
56
+
57
+ # The catch-all action of +leaves+: the verdict of a leaf that matches no syscall, no range and
58
+ # no arguments (or the first leaf, if none is a pure catch-all), or +nil+ when +leaves+ is empty.
59
+ # @param [Array<Symbolic::Executor::Leaf>] leaves
60
+ # @return [String?]
61
+ def default_label(leaves)
62
+ catch_all = leaves.find { |l| facts(l).catch_all? }
63
+ (catch_all || leaves.first)&.then { |l| Verdict.label(l.ret) }
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'seccomp-tools/const'
4
+ require 'seccomp-tools/symbolic/constraint'
5
+
6
+ module SeccompTools
7
+ class Explain
8
+ # The seccomp reading of one leaf's path condition: which syscall number it pins or bounds,
9
+ # which architecture value it pins, and which facts remain for the rule's +when+ clause. The
10
+ # path is immutable, so every query is derived once, eagerly.
11
+ class PathFacts
12
+ SYS = Const::BPF::SeccompData::SYS_NUMBER
13
+ ARCH = Const::BPF::SeccompData::ARCH
14
+ # Largest 32-bit value, the upper end of an unconstrained syscall-number range.
15
+ U32_MAX = 0xffffffff
16
+
17
+ # The syscall number the path pins with +==+, or +nil+.
18
+ # @return [Integer?]
19
+ attr_reader :sys_eq
20
+ # The architecture value the path pins with +==+, or +nil+.
21
+ # @return [Integer?]
22
+ attr_reader :arch_eq
23
+ # The +[lo, hi]+ range (inclusive; +hi+ is +nil+ when unbounded) all bound facts restrict the
24
+ # syscall number to, or +nil+ when there is no lower bound. An upper bound alone does not
25
+ # make a range rule: it is the complement of one (e.g. the +sys < 0x40000000+ side of an x32
26
+ # guard) and reads naturally as part of the default bucket.
27
+ # @return [Array(Integer, Integer?)?]
28
+ attr_reader :sys_range
29
+ # Constraints not already conveyed by the syscall-number / architecture presentation.
30
+ #
31
+ # Consumed (dropped): +==+, +!=+ and range facts on +sys_number+ - the named/ranged buckets
32
+ # and the "any other syscall" default wording express them; +==+/+!=+ facts on +arch+ - the
33
+ # per-architecture sections and the "any other" fall-through express them; and any non-+==+
34
+ # fact on a word that some +==+ on the same path already pins (it is then redundant - a
35
+ # contradicting combination would have been pruned as infeasible).
36
+ #
37
+ # Everything else is kept so a kernel-valid check is never silently dropped: bit-tests on an
38
+ # unpinned +sys_number+ (e.g. an odd/even dispatch), bit-tests or ranges on +arch+ (e.g.
39
+ # testing the +__AUDIT_ARCH_64BIT+ flag instead of pinning one value), and any comparison
40
+ # against a register rather than a constant.
41
+ # @return [Array<Symbolic::Constraint>]
42
+ attr_reader :residual
43
+
44
+ # @param [Array<Symbolic::Constraint>] path
45
+ def initialize(path)
46
+ @path = path
47
+ @sys_eq = eq(SYS)
48
+ @arch_eq = eq(ARCH)
49
+ @sys_range = compute_sys_range
50
+ @residual = compute_residual
51
+ end
52
+
53
+ # Is the path consistent with the architecture being +val+? Every constant arch fact is
54
+ # evaluated against +val+.
55
+ # @param [Integer] val
56
+ # @return [Boolean]
57
+ def arch_consistent?(val)
58
+ @path.all? do |c|
59
+ next true unless c.plain_data_fact?(ARCH)
60
+
61
+ Symbolic::Constraint.evaluate(val, c.op, c.rhs.val)
62
+ end
63
+ end
64
+
65
+ # Does the path match no syscall, no range, and no arguments - i.e. describe the filter's
66
+ # catch-all behavior?
67
+ # @return [Boolean]
68
+ def catch_all?
69
+ sys_eq.nil? && sys_range.nil? && residual.empty?
70
+ end
71
+
72
+ private
73
+
74
+ # The value of the single +data[offset] == k+ fact, if any.
75
+ def eq(offset)
76
+ @path.find { |c| c.plain_data_eq?(offset) }&.rhs&.val
77
+ end
78
+
79
+ def compute_sys_range
80
+ lo = nil
81
+ hi = nil
82
+ @path.each do |c|
83
+ next unless c.plain_data_fact?(SYS)
84
+
85
+ case c.op
86
+ when :> then lo = [lo || 0, c.rhs.val + 1].max
87
+ when :>= then lo = [lo || 0, c.rhs.val].max
88
+ when :< then hi = [hi || U32_MAX, c.rhs.val - 1].min
89
+ when :<= then hi = [hi || U32_MAX, c.rhs.val].min
90
+ end
91
+ end
92
+ lo && [lo, hi]
93
+ end
94
+
95
+ def compute_residual
96
+ pinned = @path.filter_map { |c| c.lhs.offset if c.plain_data_eq? }
97
+ @path.reject do |c|
98
+ next false unless c.plain_data_fact?
99
+
100
+ redundant = c.op != :== && pinned.include?(c.lhs.offset)
101
+ case c.lhs.offset
102
+ when SYS then redundant || !%i[set unset].include?(c.op)
103
+ when ARCH then redundant || %i[== !=].include?(c.op)
104
+ else redundant
105
+ end
106
+ end.uniq(&:key)
107
+ end
108
+ end
109
+ end
110
+ end
@@ -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