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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +153 -0
- data/LICENSE +21 -0
- data/README.md +267 -46
- data/completions/_seccomp-tools +83 -0
- data/completions/seccomp-tools.bash +59 -0
- data/completions/seccomp-tools.fish +52 -0
- data/ext/ptrace/ptrace.c +2 -2
- data/lib/seccomp-tools/asm/asm.rb +9 -4
- data/lib/seccomp-tools/asm/compiler.rb +39 -9
- data/lib/seccomp-tools/asm/sasm.tab.rb +31 -21
- data/lib/seccomp-tools/asm/sasm.y +15 -7
- data/lib/seccomp-tools/asm/scalar.rb +50 -7
- data/lib/seccomp-tools/asm/scanner.rb +40 -8
- data/lib/seccomp-tools/asm/statement.rb +14 -5
- data/lib/seccomp-tools/asm/token.rb +19 -1
- data/lib/seccomp-tools/audit/catalog.rb +66 -0
- data/lib/seccomp-tools/audit/checks/arch_unchecked.rb +41 -0
- data/lib/seccomp-tools/audit/checks/dangerous_allow.rb +34 -0
- data/lib/seccomp-tools/audit/checks/orw_chain.rb +41 -0
- data/lib/seccomp-tools/audit/checks/permissive_default.rb +29 -0
- data/lib/seccomp-tools/audit/checks/syscall_alt_gap.rb +44 -0
- data/lib/seccomp-tools/audit/checks/x32_guard.rb +46 -0
- data/lib/seccomp-tools/audit/checks.rb +42 -0
- data/lib/seccomp-tools/audit/finding.rb +25 -0
- data/lib/seccomp-tools/audit/policy.rb +126 -0
- data/lib/seccomp-tools/audit/report.rb +98 -0
- data/lib/seccomp-tools/audit.rb +48 -0
- data/lib/seccomp-tools/bpf.rb +27 -17
- data/lib/seccomp-tools/cli/asm.rb +7 -3
- data/lib/seccomp-tools/cli/audit.rb +86 -0
- data/lib/seccomp-tools/cli/base.rb +51 -8
- data/lib/seccomp-tools/cli/cli.rb +10 -6
- data/lib/seccomp-tools/cli/completion.rb +40 -0
- data/lib/seccomp-tools/cli/disasm.rb +10 -6
- data/lib/seccomp-tools/cli/dump.rb +37 -52
- data/lib/seccomp-tools/cli/dumpable.rb +79 -0
- data/lib/seccomp-tools/cli/emu.rb +32 -9
- data/lib/seccomp-tools/cli/explain.rb +51 -0
- data/lib/seccomp-tools/cli/filter_input.rb +130 -0
- data/lib/seccomp-tools/const.rb +98 -15
- data/lib/seccomp-tools/consts/sys_nr/amd64.rb +1 -1
- data/lib/seccomp-tools/consts/sys_nr/riscv64.rb +332 -0
- data/lib/seccomp-tools/disasm/disasm.rb +31 -13
- data/lib/seccomp-tools/dumper.rb +67 -35
- data/lib/seccomp-tools/emulator.rb +41 -16
- data/lib/seccomp-tools/error.rb +4 -2
- data/lib/seccomp-tools/explain/analysis.rb +67 -0
- data/lib/seccomp-tools/explain/path_facts.rb +110 -0
- data/lib/seccomp-tools/explain/qword.rb +204 -0
- data/lib/seccomp-tools/explain/renderer.rb +128 -0
- data/lib/seccomp-tools/explain/summary.rb +218 -0
- data/lib/seccomp-tools/explain/verdict.rb +44 -0
- data/lib/seccomp-tools/explain.rb +38 -0
- data/lib/seccomp-tools/instruction/alu.rb +14 -9
- data/lib/seccomp-tools/instruction/base.rb +39 -10
- data/lib/seccomp-tools/instruction/jmp.rb +50 -23
- data/lib/seccomp-tools/instruction/ld.rb +46 -21
- data/lib/seccomp-tools/instruction/ldx.rb +4 -3
- data/lib/seccomp-tools/instruction/misc.rb +11 -9
- data/lib/seccomp-tools/instruction/ret.rb +12 -6
- data/lib/seccomp-tools/instruction/st.rb +15 -6
- data/lib/seccomp-tools/instruction/stx.rb +4 -3
- data/lib/seccomp-tools/logger.rb +15 -2
- data/lib/seccomp-tools/symbolic/constraint.rb +80 -0
- data/lib/seccomp-tools/symbolic/executor.rb +210 -0
- data/lib/seccomp-tools/symbolic/expr.rb +185 -0
- data/lib/seccomp-tools/symbolic/state.rb +82 -0
- data/lib/seccomp-tools/syscall.rb +74 -22
- data/lib/seccomp-tools/util.rb +70 -11
- data/lib/seccomp-tools/version.rb +1 -1
- data/lib/seccomp-tools.rb +10 -1
- metadata +83 -11
- 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: :%, #
|
|
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 [
|
|
40
|
-
# Current
|
|
41
|
-
# @return [Array<(Integer,
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|