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
@@ -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
@@ -3,9 +3,15 @@
3
3
  require 'seccomp-tools/const'
4
4
 
5
5
  module SeccompTools
6
- # For instructions' class.
6
+ # Classes of BPF instructions, one per opcode class.
7
+ #
8
+ # Each instruction wraps a {SeccompTools::BPF} and knows how to render itself as assembly
9
+ # (+decompile+), as tokens ({Base#symbolize}), and how it moves the disassembler's state
10
+ # forward ({Base#branch}).
7
11
  module Instruction
8
12
  # Base class of instructions.
13
+ #
14
+ # Subclasses must implement {#branch} and {#symbolize}.
9
15
  class Base
10
16
  include SeccompTools::Const::BPF
11
17
 
@@ -25,29 +31,52 @@ module SeccompTools
25
31
  end
26
32
 
27
33
  # Returns the possible branches after executing this instruction.
28
- # @param [Context] _context
29
- # Current context.
30
- # @return [Array<(Integer, Context)>]
34
+ #
35
+ # Each branch is the line number to be executed next, paired with the {Symbolic::State} that
36
+ # reaching that line implies. Non-jump instructions have exactly one branch, the following
37
+ # line.
38
+ # @param [Symbolic::State] _state
39
+ # Current state.
40
+ # @return [Array<(Integer, Symbolic::State)>]
41
+ # Pairs of the next line number and the state at that line.
42
+ # @raise [NotImplementedError]
43
+ # Always, subclasses must override this method.
31
44
  # @example
32
45
  # # For ALU, LD, LDX, ST, STX
33
46
  # inst.line #=> 10
34
- # inst.branch(ctx)
35
- # #=> [[11, ctx]]
36
- def branch(_context); raise NotImplmentedError
47
+ # inst.branch(state)
48
+ # #=> [[11, state]]
49
+ def branch(_state); raise NotImplementedError
37
50
  end
38
51
 
39
- # Return tokens stand for this instruction.
52
+ # Returns tokens that represent this instruction.
40
53
  # @return [Array<Symbol, Integer>]
54
+ # The instruction as a tuple, the exact shape depends on the instruction class.
55
+ # @raise [NotImplementedError]
56
+ # Always, subclasses must override this method.
41
57
  # @example
42
58
  # ret_a.symbolize #=> [:ret, :a]
43
59
  # ret_k.symbolize #=> [:ret, 0x7fff0000]
44
60
  # jeq.symbolize #=> [:cmp, :==, 0, 0, 1]
45
- def symbolize; raise NotImplmentedError
61
+ def symbolize; raise NotImplementedError
46
62
  end
47
63
 
48
64
  private
49
65
 
50
- %i(code jt jf k arch line contexts show_arg_infer?).each do |sym|
66
+ # The architecture this instruction's operands should be read as: the one the filter has
67
+ # branched on (+arch == AUDIT_ARCH_*+) when the reaching states pin a single value, else +nil+
68
+ # so callers fall back to the declared {#arch}. Lets syscall/argument names stay correct even
69
+ # when the filter is disassembled under a different +--arch+ than it targets.
70
+ # @return [Symbol?]
71
+ def infer_arch
72
+ arches = states.map { |st| st.pinned(Const::BPF::SeccompData::ARCH) }.uniq
73
+ return nil unless arches.size == 1 && !arches.first.nil?
74
+
75
+ Const::Audit.arch_symbol(arches.first)
76
+ end
77
+
78
+ # Delegate the accessors of the wrapped {SeccompTools::BPF} so subclasses can use them directly.
79
+ %i(code jt jf k arch line states show_arg_infer?).each do |sym|
51
80
  define_method(sym) do
52
81
  @bpf.__send__(sym)
53
82
  end
@@ -5,9 +5,13 @@ require 'seccomp-tools/instruction/base'
5
5
 
6
6
  module SeccompTools
7
7
  module Instruction
8
- # Instruction jmp.
8
+ # Instruction jmp, an unconditional jump or a comparison of A against X or an immediate.
9
+ #
10
+ # Jumps are always forward, +jt+ and +jf+ being offsets relative to the following line.
9
11
  class JMP < Base
10
12
  # Decompile instruction.
13
+ # @return [String]
14
+ # The jump as assembly, e.g. +"if (A == read) goto 0003"+.
11
15
  def decompile
12
16
  return goto(k) if jop == :none
13
17
  # if jt == 0 && jf == 0 => no-op # should not happen
@@ -23,6 +27,8 @@ module SeccompTools
23
27
 
24
28
  # See {Instruction::Base#symbolize}.
25
29
  # @return [[:cmp, Symbol, (:x, Integer), Integer, Integer], [:jmp, Integer]]
30
+ # +[:jmp, offset]+ for an unconditional jump, otherwise +[:cmp, operator, right operand,
31
+ # jt, jf]+.
26
32
  def symbolize
27
33
  return [:jmp, k] if jop == :none
28
34
 
@@ -30,19 +36,49 @@ module SeccompTools
30
36
  end
31
37
 
32
38
  # See {Base#branch}.
33
- # @param [Context] context
34
- # Current context.
35
- # @return [Array<(Integer, Context)>]
36
- def branch(context)
37
- return [[at(k), context]] if jop == :none
38
- return [[at(jt), context]] if jt == jf
39
- return [[at(jt), context.dup.eql!(src)], [at(jf), context]] if jop == :==
40
-
41
- [[at(jt), context], [at(jf), context]]
39
+ #
40
+ # Unlike the other instructions, a conditional jump has two possible successors. On the
41
+ # taken branch of an equality test the state is narrowed, recording that A is known to equal
42
+ # the compared value.
43
+ # @param [Symbolic::State] state
44
+ # Current state.
45
+ # @return [Array<(Integer, Symbolic::State)>]
46
+ # One pair for an unconditional jump, two otherwise.
47
+ # @example
48
+ # # 0000: if (A == 0) goto 0002 else goto 0003
49
+ # jeq.branch(state) #=> [[2, narrowed_state], [3, state]]
50
+ def branch(state)
51
+ return [[at(k), state]] if jop == :none
52
+ return [[at(jt), state]] if jt == jf
53
+ return [[at(jt), narrow(state)], [at(jf), state]] if jop == :==
54
+
55
+ [[at(jt), state], [at(jf), state]]
42
56
  end
43
57
 
44
58
  private
45
59
 
60
+ # The taken branch of +A == src+ learns +A == value+. When A holds a plain data word and the
61
+ # compared side is (or resolves to) a constant, that pins the word - recorded as a
62
+ # {Symbolic::Constraint} on the path. Anything else leaves the state unchanged.
63
+ # @param [Symbolic::State] state
64
+ # @return [Symbolic::State]
65
+ def narrow(state)
66
+ return state unless state.a.plain_data?
67
+
68
+ rhs = src == :x ? resolve(state, state.x) : Symbolic::Expr.imm(k)
69
+ return state unless rhs.imm?
70
+
71
+ state.with(path: state.path + [Symbolic::Constraint.new(state.a, :==, rhs)])
72
+ end
73
+
74
+ # Replaces a data-word +expr+ with the constant it is pinned to on +state+'s path, if any;
75
+ # otherwise returns it unchanged.
76
+ def resolve(state, expr)
77
+ return expr unless expr.plain_data?
78
+
79
+ state.path.find { |c| c.plain_data_eq?(expr.offset) }&.rhs || expr
80
+ end
81
+
46
82
  def jop
47
83
  case Const::BPF::JMP.invert[code & 0x70]
48
84
  when :ja then :none
@@ -57,15 +93,15 @@ module SeccompTools
57
93
  def src_str
58
94
  return 'X' if src == :x
59
95
 
60
- # if A in all contexts are same
61
- a = contexts.map(&:a).uniq
96
+ # only when A holds the same data word across every reaching state
97
+ a = states.map(&:a).uniq
62
98
  return k.to_s if a.size != 1
63
99
 
64
100
  a = a[0]
65
- return k.to_s unless a.data?
101
+ return k.to_s unless a.plain_data?
66
102
 
67
103
  hex = "0x#{k.to_s(16)}"
68
- case a.val
104
+ case a.offset
69
105
  # interpret as syscalls only if it's an equality test
70
106
  when 0 then Util.colorize(jop == :== ? sysname_by_k || hex : hex, t: :syscall)
71
107
  when 4 then Util.colorize(Const::Audit::ARCH.invert[k] || hex, t: :arch)
@@ -81,15 +117,6 @@ module SeccompTools
81
117
  a == arch ? name : "#{a}.#{name}"
82
118
  end
83
119
 
84
- # Infers the architecture from context.
85
- # @return [Symbol?]
86
- def infer_arch
87
- arches = contexts.map { |ctx| ctx.known_data[4] }.uniq
88
- return nil unless arches.size == 1 && !arches.first.nil?
89
-
90
- Const::Audit::ARCH_NAME.invert[Const::Audit::ARCH.invert[arches.first]]
91
- end
92
-
93
120
  def src
94
121
  SRC.invert[code & 8] == :x ? :x : k
95
122
  end
@@ -6,9 +6,14 @@ require 'seccomp-tools/util'
6
6
 
7
7
  module SeccompTools
8
8
  module Instruction
9
- # Instruction ld.
9
+ # Instruction ld, loads a value into the accumulator register A.
10
+ #
11
+ # The value can be an immediate, a word of +struct seccomp_data+, or a slot of the scratch
12
+ # memory. {LDX} inherits from this class and targets the X register instead.
10
13
  class LD < Base
11
14
  # Decompile instruction.
15
+ # @return [String]
16
+ # The assignment as assembly, e.g. +"A = sys_number"+.
12
17
  def decompile
13
18
  ret = "#{reg} = "
14
19
  _, _reg, type = symbolize
@@ -18,26 +23,35 @@ module SeccompTools
18
23
  ret + seccomp_data_str
19
24
  end
20
25
 
21
- # @return [void]
26
+ # See {Instruction::Base#symbolize}.
27
+ # @return [[:ld, Symbol, {rel: Symbol, val: Integer}]]
28
+ # The target register and the value being loaded, whose +:rel+ is one of +:immi+, +:mem+
29
+ # or +:data+.
22
30
  def symbolize
23
31
  type = load_val
24
32
  [:ld, reg.downcase.to_sym, type]
25
33
  end
26
34
 
27
- # Accumulator register.
28
- # @return ['A']
35
+ # Name of the register being loaded into.
36
+ # @return [String]
37
+ # The accumulator register, +"A"+.
29
38
  def reg
30
39
  'A'
31
40
  end
32
41
 
33
42
  # See {Base#branch}.
34
- # @param [Context] context
35
- # Current context.
36
- # @return [Array<(Integer, Context)>]
37
- def branch(context)
38
- ctx = context.dup
39
- ctx.load(reg, **load_val)
40
- [[line + 1, ctx]]
43
+ # @param [Symbolic::State] state
44
+ # Current state.
45
+ # @return [Array<(Integer, Symbolic::State)>]
46
+ # Always the next line, with the loaded value recorded in the register.
47
+ def branch(state)
48
+ v = load_val
49
+ val = case v[:rel]
50
+ when :immi then Symbolic::Expr.imm(v[:val])
51
+ when :mem then state.mem[v[:val]]
52
+ when :data then Symbolic::Expr.data(v[:val])
53
+ end
54
+ [[line + 1, reg == 'X' ? state.with(x: val) : state.with(a: val)]]
41
55
  end
42
56
 
43
57
  private
@@ -64,33 +78,44 @@ module SeccompTools
64
78
  # __u64 args[6];
65
79
  # };
66
80
  def seccomp_data_str
81
+ data = Const::BPF::SeccompData
67
82
  case k
68
- when 0 then 'sys_number'
69
- when 4 then 'arch'
70
- when 8 then 'instruction_pointer'
71
- when 12 then 'instruction_pointer >> 32'
83
+ when data::SYS_NUMBER, data::ARCH then data::NAMES[k]
84
+ when data::INSTRUCTION_POINTER, data::INSTRUCTION_POINTER + 4
85
+ ip = data::NAMES[data::INSTRUCTION_POINTER]
86
+ hi_word?(k) ? "#{ip} >> 32" : ip
72
87
  else
73
- idx = Array.new(12) { |i| (i * 4) + 16 }.index(k)
88
+ idx = (data::ARGS...data::SIZE).step(4).to_a.index(k)
74
89
  return 'INVALID' if idx.nil?
75
90
 
76
91
  args_name(idx)
77
92
  end
78
93
  end
79
94
 
95
+ # Is the 32-bit word at byte offset +k+ the high half of its 64-bit +seccomp_data+ field?
96
+ # The high word comes second on little-endian architectures but first on big-endian ones
97
+ # (s390x); see +arch_arg_offset_hi+ in libseccomp.
98
+ def hi_word?(k)
99
+ (k % 8 == 4) ^ Const::Endian.big?(arch)
100
+ end
101
+
80
102
  def args_name(idx)
81
- default = idx.even? ? "args[#{idx / 2}]" : "args[#{idx / 2}] >> 32"
103
+ hi = hi_word?((idx * 4) + Const::BPF::SeccompData::ARGS)
104
+ default = hi ? "args[#{idx / 2}] >> 32" : "args[#{idx / 2}]"
82
105
  return default unless show_arg_infer?
83
106
 
84
- sys_nrs = contexts.map { |ctx| ctx.known_data[0] }.uniq
107
+ sys_nrs = states.map { |st| st.pinned(Const::BPF::SeccompData::SYS_NUMBER) }.uniq
85
108
  return default if sys_nrs.size != 1 || sys_nrs.first.nil?
86
109
 
87
- sys = Const::Syscall.const_get(arch.upcase.to_sym).invert[sys_nrs.first]
110
+ a = infer_arch || arch
111
+ sys = Const::Syscall.const_get(a.upcase.to_sym).invert[sys_nrs.first]
88
112
  args = Const::SYS_ARG[sys]
89
113
  return default if args.nil? || args[idx / 2].nil? # function prototype doesn't have that argument
90
114
 
91
- comment = "# #{sys}(#{args.join(', ')})"
115
+ name = a == arch ? sys : "#{a}.#{sys}"
116
+ comment = "# #{name}(#{args.join(', ')})"
92
117
  arg_name = Util.colorize(args[idx / 2], t: :args)
93
- "#{idx.even? ? arg_name : "#{arg_name} >> 32"} #{Util.colorize(comment, t: :gray)}"
118
+ "#{hi ? "#{arg_name} >> 32" : arg_name} #{Util.colorize(comment, t: :gray)}"
94
119
  end
95
120
  end
96
121
  end