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,210 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
5
|
+
require 'seccomp-tools/symbolic/constraint'
|
|
6
|
+
require 'seccomp-tools/symbolic/expr'
|
|
7
|
+
require 'seccomp-tools/symbolic/state'
|
|
8
|
+
|
|
9
|
+
module SeccompTools
|
|
10
|
+
module Symbolic
|
|
11
|
+
# Symbolic executor for classic BPF (the byte-code seccomp filters are written in).
|
|
12
|
+
#
|
|
13
|
+
# A normal interpreter (see +SeccompTools::Emulator+) runs a program *once* with concrete inputs
|
|
14
|
+
# and follows the one path those inputs select. A symbolic executor instead keeps the inputs
|
|
15
|
+
# *unknown* ({Expr}) and explores **every** path through the program at once. Wherever the
|
|
16
|
+
# program branches, it walks both sides, remembering on each side the {Constraint} that made
|
|
17
|
+
# that branch taken. When a path reaches a +return+, the executor records a {Leaf}: the value
|
|
18
|
+
# returned plus the exact list of conditions that lead there. The collection of leaves is a
|
|
19
|
+
# complete, input-independent description of what the program does.
|
|
20
|
+
#
|
|
21
|
+
# The machine model is classic BPF: two registers (A and X), 16 scratch-memory slots, and a
|
|
22
|
+
# read-only input buffer addressed by byte offset. Jumps are always forward, so a single walk
|
|
23
|
+
# with a visited-set terminates and never loops.
|
|
24
|
+
#
|
|
25
|
+
# @example
|
|
26
|
+
# # instructions come from `SeccompTools::Disasm.to_bpf(raw, arch).map(&:inst)`
|
|
27
|
+
# leaves, truncated = SeccompTools::Symbolic::Executor.new(instructions).run
|
|
28
|
+
# leaves.first.ret #=> an Expr describing the returned value
|
|
29
|
+
# leaves.first.path #=> the Array<Constraint> under which it is returned
|
|
30
|
+
class Executor
|
|
31
|
+
# A reached +return+: the accumulated path condition, the value returned (an {Expr}), and the
|
|
32
|
+
# line the +return+ is on.
|
|
33
|
+
Leaf = Struct.new(:path, :ret, :line)
|
|
34
|
+
|
|
35
|
+
# Upper bound on the number of states visited, so a pathological program cannot make the walk
|
|
36
|
+
# run unboundedly. When hit, {#run} stops early and reports +truncated+.
|
|
37
|
+
STEP_CAP = 100_000
|
|
38
|
+
|
|
39
|
+
# Maps a comparison operator to the pair of {Constraint} operators implied on the taken and
|
|
40
|
+
# not-taken branches (e.g. a +>=+ test learns +>=+ if taken, +<+ if not).
|
|
41
|
+
SPLIT = {
|
|
42
|
+
:== => %i[== !=],
|
|
43
|
+
:> => %i[> <=],
|
|
44
|
+
:>= => %i[>= <],
|
|
45
|
+
:& => %i[set unset]
|
|
46
|
+
}.freeze
|
|
47
|
+
|
|
48
|
+
# @param [Array<Instruction::Base>] instructions
|
|
49
|
+
# The program to execute, as +SeccompTools::Disasm.to_bpf(raw, arch).map(&:inst)+. Only the
|
|
50
|
+
# duck-typed +#symbolize+ method is used, so any classic-BPF instruction set works.
|
|
51
|
+
def initialize(instructions)
|
|
52
|
+
@instructions = instructions
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Walks every path and returns the reachable leaves.
|
|
56
|
+
#
|
|
57
|
+
# Leaves whose path condition is self-contradictory (e.g. +A == 1+ and +A == 2+ on the same
|
|
58
|
+
# word) are dropped - a conditional jump forks both ways regardless of feasibility, so the
|
|
59
|
+
# walk can construct paths that can never happen at runtime. See {#feasible?} for exactly
|
|
60
|
+
# what can (and deliberately cannot) be proven contradictory.
|
|
61
|
+
# @return [Array(Array<Leaf>, Boolean)]
|
|
62
|
+
# The feasible leaves, and whether the walk was truncated at {STEP_CAP}.
|
|
63
|
+
def run
|
|
64
|
+
leaves, truncated = walk
|
|
65
|
+
[leaves.select { |leaf| feasible?(leaf.path) }, truncated]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
# Depth-first walk of the control-flow graph. Because jumps are always forward, every successor
|
|
71
|
+
# line is strictly greater, so the walk terminates; identical +(line, state)+ pairs are
|
|
72
|
+
# visited once so that re-merging control-flow does not explode.
|
|
73
|
+
# @return [Array(Array<Leaf>, Boolean)]
|
|
74
|
+
def walk
|
|
75
|
+
leaves = []
|
|
76
|
+
visited = Set.new
|
|
77
|
+
stack = [[0, State.initial]]
|
|
78
|
+
steps = 0
|
|
79
|
+
until stack.empty?
|
|
80
|
+
return [leaves, true] if steps >= STEP_CAP
|
|
81
|
+
|
|
82
|
+
steps += 1
|
|
83
|
+
pc, st = stack.pop
|
|
84
|
+
next if pc >= @instructions.size
|
|
85
|
+
next unless visited.add?([pc, st.key])
|
|
86
|
+
|
|
87
|
+
step(pc, st, leaves, stack)
|
|
88
|
+
end
|
|
89
|
+
[leaves, false]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Interprets one instruction symbolically, pushing the successor state(s) onto +stack+ (or
|
|
93
|
+
# appending a {Leaf} when it is a +return+).
|
|
94
|
+
def step(pc, st, leaves, stack)
|
|
95
|
+
op, *args = @instructions[pc].symbolize
|
|
96
|
+
case op
|
|
97
|
+
when :ret then leaves << Leaf.new(st.path, args[0] == :a ? st.a : Expr.imm(args[0]), pc)
|
|
98
|
+
when :ld then stack << [pc + 1, load(st, args[0], args[1])]
|
|
99
|
+
when :st then stack << [pc + 1, store(st, args[0], args[1])]
|
|
100
|
+
when :alu then stack << [pc + 1, st.with(a: st.a.apply(args[0], alu_operand(st, args[1])))]
|
|
101
|
+
when :misc then stack << [pc + 1, args[0] == :txa ? st.with(a: st.x) : st.with(x: st.a)]
|
|
102
|
+
when :jmp then stack << [pc + args[0] + 1, st]
|
|
103
|
+
when :cmp then branch_cmp(pc, st, args, stack)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The right operand of an ALU instruction: the X register, an immediate, or +nil+ for the
|
|
108
|
+
# unary +neg+ (whose symbolized operand is +nil+).
|
|
109
|
+
def alu_operand(st, src)
|
|
110
|
+
return st.x if src == :x
|
|
111
|
+
|
|
112
|
+
src && Expr.imm(src)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Loads an immediate, a scratch slot, or a data-buffer word into register A or X.
|
|
116
|
+
def load(st, dst, src)
|
|
117
|
+
val = case src[:rel]
|
|
118
|
+
when :immi then Expr.imm(src[:val])
|
|
119
|
+
when :mem then st.mem[src[:val]]
|
|
120
|
+
when :data then Expr.data(src[:val])
|
|
121
|
+
end
|
|
122
|
+
dst == :x ? st.with(x: val) : st.with(a: val)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Stores register A or X into a scratch slot.
|
|
126
|
+
def store(st, reg, idx)
|
|
127
|
+
mem = st.mem.dup
|
|
128
|
+
mem[idx] = reg == :x ? st.x : st.a
|
|
129
|
+
st.with(mem:)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Forks a conditional jump into its taken and not-taken successors, recording the {Constraint}
|
|
133
|
+
# each branch implies. A comparison between two constants (e.g. against the guaranteed-zero
|
|
134
|
+
# initial A or X) does not fork: only the branch it actually selects is walked, and no fact
|
|
135
|
+
# is recorded.
|
|
136
|
+
def branch_cmp(pc, st, args, stack)
|
|
137
|
+
op, src, jt, jf = args
|
|
138
|
+
# jt == jf: the jump is unconditional, so no fact is learned.
|
|
139
|
+
return stack << [pc + jt + 1, st] if jt == jf
|
|
140
|
+
|
|
141
|
+
rhs = src == :x ? st.x : Expr.imm(src)
|
|
142
|
+
taken, els = SPLIT[op]
|
|
143
|
+
if st.a.imm? && rhs.imm?
|
|
144
|
+
j = Constraint.evaluate(st.a.val, taken, rhs.val) ? jt : jf
|
|
145
|
+
return stack << [pc + j + 1, st]
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
stack << [pc + jt + 1, st.with(path: st.path + [Constraint.new(st.a, taken, rhs)])]
|
|
149
|
+
stack << [pc + jf + 1, st.with(path: st.path + [Constraint.new(st.a, els, rhs)])]
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Is +path+ satisfiable? Deliberately a small rule-based check, not a solver.
|
|
153
|
+
#
|
|
154
|
+
# Only facts of the shape "untransformed data word compared to a constant" are examined,
|
|
155
|
+
# grouped by which word they constrain; any other fact (a transformed word, a comparison
|
|
156
|
+
# against X, an opaque value) is assumed satisfiable. So an impossible path is never
|
|
157
|
+
# *wrongly* dropped - the cost of not understanding a fact is noise in the caller's output,
|
|
158
|
+
# never hidden behavior.
|
|
159
|
+
#
|
|
160
|
+
# That fragment is where essentially all real contradictions live. The walk forks every
|
|
161
|
+
# conditional both ways, so re-merging tests over the same word manufacture impossible
|
|
162
|
+
# paths: a syscall allowlist behind an x32 range guard yields
|
|
163
|
+
# +sys >= 0x40000000 && sys == 2+, libseccomp's binary-search dispatch yields the same
|
|
164
|
+
# equality-versus-range shapes, and sentinel tests yield +sys == 0xffffffff && sys == 2+.
|
|
165
|
+
# All of these are caught, and since the data words are independent inputs, checking
|
|
166
|
+
# word-by-word is exact for this fragment, not an approximation: a conjunction of
|
|
167
|
+
# single-word facts is satisfiable iff each word's facts are.
|
|
168
|
+
#
|
|
169
|
+
# What it cannot prove infeasible are contradictions through *derived* values:
|
|
170
|
+
# +(args[0] & 0xff) == 0x100+ (impossible by masking), wraparound arithmetic like
|
|
171
|
+
# +args[0] + 1 == 0 && args[0] == 5+, or relations between two transforms of one word
|
|
172
|
+
# (+sys >> 8 == 1 && sys < 0x100+). Deciding those in general is bit-vector SMT; a filter
|
|
173
|
+
# convoluted enough to produce them (a deliberately obfuscated challenge, not a seccomp
|
|
174
|
+
# library) calls for a real solver anyway, so rule-based pruning of that space would add
|
|
175
|
+
# complexity without making such filters readable. Their paths are kept and rendered with
|
|
176
|
+
# their full conditions instead.
|
|
177
|
+
# @param [Array<Constraint>] path
|
|
178
|
+
# @return [Boolean]
|
|
179
|
+
def feasible?(path)
|
|
180
|
+
path.select(&:plain_data_fact?)
|
|
181
|
+
.group_by { |c| c.lhs.offset }
|
|
182
|
+
.all? { |_offset, cs| cell_feasible?(cs) }
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Are the constraints on a single data word jointly satisfiable? Two different +==+ values
|
|
186
|
+
# are impossible (+A == 1 && A == 2+). A single +==+ pins the word, so every other fact -
|
|
187
|
+
# +!=+, the four bounds, and both jset forms - is simply evaluated against it
|
|
188
|
+
# (+A >= 0x40000000 && A == 2+ dies here). With no +==+, the inequalities must leave a
|
|
189
|
+
# non-empty range (+A > 10 && A < 5+); +!=+ and jset facts are ignored in that case, since
|
|
190
|
+
# ruling out a 32-bit word with them alone would take a filter no library generates.
|
|
191
|
+
def cell_feasible?(constraints)
|
|
192
|
+
eqs = constraints.select { |c| c.op == :== }.map { |c| c.rhs.val }.uniq
|
|
193
|
+
return false if eqs.size > 1
|
|
194
|
+
return constraints.all? { |c| Constraint.evaluate(eqs.first, c.op, c.rhs.val) } unless eqs.empty?
|
|
195
|
+
|
|
196
|
+
lo = 0
|
|
197
|
+
hi = 0xffffffff
|
|
198
|
+
constraints.each do |c|
|
|
199
|
+
case c.op
|
|
200
|
+
when :> then lo = [lo, c.rhs.val + 1].max
|
|
201
|
+
when :>= then lo = [lo, c.rhs.val].max
|
|
202
|
+
when :< then hi = [hi, c.rhs.val - 1].min
|
|
203
|
+
when :<= then hi = [hi, c.rhs.val].min
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
lo <= hi
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'seccomp-tools/instruction/alu'
|
|
4
|
+
|
|
5
|
+
module SeccompTools
|
|
6
|
+
# Generic symbolic execution of classic BPF, with no seccomp knowledge. Where +SeccompTools::Emulator+
|
|
7
|
+
# runs a program once with concrete inputs, the {Executor} here keeps the inputs unknown and walks
|
|
8
|
+
# *every* path, reporting each reachable +return+ together with the conditions that lead to it. The
|
|
9
|
+
# values it manipulates are the {Expr}, {Constraint}, and {State} types; see {Executor} for the
|
|
10
|
+
# full picture.
|
|
11
|
+
module Symbolic
|
|
12
|
+
# A value the executor only knows *symbolically* - that is, without picking concrete inputs.
|
|
13
|
+
#
|
|
14
|
+
# Every register and scratch slot in the {State} holds an {Expr}, which is one of:
|
|
15
|
+
# * {.imm} - a known 32-bit constant, e.g. the result of +A = 5+.
|
|
16
|
+
# * {.data} - a single word of the input data buffer, at some byte +offset+. Its value is
|
|
17
|
+
# unknown; we only remember where it was read from.
|
|
18
|
+
# * {.binop} - an arithmetic combination of two sub-expressions, e.g. +data[16] & 0xffff+ or even
|
|
19
|
+
# +data[16] & data[24]+ (two buffer words combined). This is what lets a caller later describe a
|
|
20
|
+
# branch condition faithfully.
|
|
21
|
+
# * {.unop} - a unary operation on a sub-expression; the only one BPF has is negation (+-A+).
|
|
22
|
+
# * {.opaque} - a value we cannot describe (an unsupported operation, or one whose operand is
|
|
23
|
+
# itself opaque). Nothing can be concluded about it.
|
|
24
|
+
class Expr
|
|
25
|
+
# The binary ALU operators {#apply} accepts - exactly the ones +Instruction::ALU#symbolize+
|
|
26
|
+
# can produce (its unary +neg+ is handled separately).
|
|
27
|
+
REPRESENTABLE = Instruction::ALU::OP_SYM.values.freeze
|
|
28
|
+
|
|
29
|
+
# @return [:imm, :data, :binop, :unop, :opaque] Which kind of expression this is.
|
|
30
|
+
attr_reader :kind
|
|
31
|
+
# @return [Integer?] The constant, when +kind+ is +:imm+.
|
|
32
|
+
attr_reader :val
|
|
33
|
+
# @return [Integer?] The byte offset into the input data buffer, when +kind+ is +:data+.
|
|
34
|
+
attr_reader :offset
|
|
35
|
+
# @return [Symbol?] The operator, when +kind+ is +:binop+ or +:unop+.
|
|
36
|
+
attr_reader :op
|
|
37
|
+
# @return [Expr?] The left and right operands, when +kind+ is +:binop+; a +:unop+ keeps its
|
|
38
|
+
# only operand in +lhs+.
|
|
39
|
+
attr_reader :lhs, :rhs
|
|
40
|
+
|
|
41
|
+
# A known constant.
|
|
42
|
+
# @param [Integer] val
|
|
43
|
+
# @return [Expr]
|
|
44
|
+
def self.imm(val)
|
|
45
|
+
new(:imm, val: val & 0xffffffff)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# A single word of the input data buffer.
|
|
49
|
+
# @param [Integer] offset
|
|
50
|
+
# Byte offset into the buffer.
|
|
51
|
+
# @return [Expr]
|
|
52
|
+
def self.data(offset)
|
|
53
|
+
new(:data, offset:)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# An arithmetic combination of two expressions.
|
|
57
|
+
# @param [Symbol] op
|
|
58
|
+
# @param [Expr] lhs
|
|
59
|
+
# @param [Expr] rhs
|
|
60
|
+
# @return [Expr]
|
|
61
|
+
def self.binop(op, lhs, rhs)
|
|
62
|
+
new(:binop, op:, lhs:, rhs:)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# A unary operation on one expression (its operand is kept in +lhs+).
|
|
66
|
+
# @param [Symbol] op
|
|
67
|
+
# @param [Expr] operand
|
|
68
|
+
# @return [Expr]
|
|
69
|
+
def self.unop(op, operand)
|
|
70
|
+
new(:unop, op:, lhs: operand)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# A value that cannot be described symbolically.
|
|
74
|
+
# @return [Expr]
|
|
75
|
+
def self.opaque
|
|
76
|
+
new(:opaque)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @param [:imm, :data, :binop, :unop, :opaque] kind
|
|
80
|
+
# @param [Hash] fields
|
|
81
|
+
# The kind-specific fields: +:val+, +:offset+, +:op+, +:lhs+, +:rhs+.
|
|
82
|
+
def initialize(kind, **fields)
|
|
83
|
+
@kind = kind
|
|
84
|
+
@val = fields[:val]
|
|
85
|
+
@offset = fields[:offset]
|
|
86
|
+
@op = fields[:op]
|
|
87
|
+
@lhs = fields[:lhs]
|
|
88
|
+
@rhs = fields[:rhs]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# @return [Boolean]
|
|
92
|
+
def imm?
|
|
93
|
+
kind == :imm
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Is this a bare data-buffer word (no arithmetic applied)? These are the values a caller can
|
|
97
|
+
# compare directly against a constant.
|
|
98
|
+
# @return [Boolean]
|
|
99
|
+
def plain_data?
|
|
100
|
+
kind == :data
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# @return [Boolean]
|
|
104
|
+
def opaque?
|
|
105
|
+
kind == :opaque
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Applies an ALU operation, returning the resulting {Expr}. +neg+ is unary (its operand is
|
|
109
|
+
# ignored). Two constants fold into a new constant; an operation on non-opaque operands
|
|
110
|
+
# becomes a {.binop}; an opaque operand makes the result {.opaque}.
|
|
111
|
+
# @param [Symbol] op
|
|
112
|
+
# A Ruby operator symbol as produced by +Instruction::ALU#symbolize+, or +:neg+.
|
|
113
|
+
# @param [Expr, nil] operand
|
|
114
|
+
# The right operand (+nil+ for the unary +neg+).
|
|
115
|
+
# @return [Expr]
|
|
116
|
+
# @raise [ArgumentError]
|
|
117
|
+
# When +op+ is not one of seccomp's ALU operators (+:neg+ or {REPRESENTABLE}).
|
|
118
|
+
def apply(op, operand)
|
|
119
|
+
return apply_neg if op == :neg
|
|
120
|
+
raise ArgumentError, "unsupported operator #{op}" unless REPRESENTABLE.include?(op)
|
|
121
|
+
return Expr.opaque if opaque? || operand.opaque?
|
|
122
|
+
return Expr.imm(self.class.fold(val, op, operand.val)) if imm? && operand.imm?
|
|
123
|
+
|
|
124
|
+
Expr.binop(op, self, operand)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# A string that uniquely identifies this expression, used for both equality and hashing;
|
|
128
|
+
# memoized, since an {Expr} is immutable. The operator is written as text, not left a {Symbol},
|
|
129
|
+
# because Ruby's +Symbol#hash+ maps the operators to very few values (+:==+ and +:!=+ hash
|
|
130
|
+
# alike) - a key carrying them would collapse when hashed and drag the walk's visited +Set+
|
|
131
|
+
# down to a linear scan; a string hashes over its bytes cleanly. The encoding is injective
|
|
132
|
+
# (leaves are +i<val>+ / +d<offset>+ / +o+, compounds are parenthesised, and no operator
|
|
133
|
+
# contains a leaf-start char, a digit, or the +;+ / +,+ that {State} joins with), so it is
|
|
134
|
+
# safe to compare on.
|
|
135
|
+
# @return [String]
|
|
136
|
+
def key
|
|
137
|
+
@key ||= case kind
|
|
138
|
+
when :binop then "(#{lhs.key}#{op}#{rhs.key})"
|
|
139
|
+
when :unop then "(#{op}#{lhs.key})"
|
|
140
|
+
when :imm then "i#{val}"
|
|
141
|
+
when :data then "d#{offset}"
|
|
142
|
+
else 'o'
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# @param [Expr] other
|
|
147
|
+
# @return [Boolean]
|
|
148
|
+
def ==(other)
|
|
149
|
+
other.is_a?(Expr) && key == other.key
|
|
150
|
+
end
|
|
151
|
+
alias eql? ==
|
|
152
|
+
|
|
153
|
+
# @return [Integer]
|
|
154
|
+
def hash
|
|
155
|
+
key.hash
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Folds a binary ALU operation on two constants, wrapping to 32 bits (classic BPF is 32-bit).
|
|
159
|
+
# Every {REPRESENTABLE} operator is a Ruby +Integer+ method, so it applies directly.
|
|
160
|
+
# @return [Integer]
|
|
161
|
+
def self.fold(lhs, op, rhs)
|
|
162
|
+
return 0 if op == :/ && rhs.zero? # a real BPF program is rejected at load for div-by-zero
|
|
163
|
+
# ... and for shifts of 32+ bits; short-circuit them to their masked result (everything
|
|
164
|
+
# shifted out) instead of building a needlessly huge integer.
|
|
165
|
+
return 0 if %i[<< >>].include?(op) && rhs >= 32
|
|
166
|
+
|
|
167
|
+
lhs.public_send(op, rhs) & 0xffffffff
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
private
|
|
171
|
+
|
|
172
|
+
# The unary negation +A = -A+ (two's complement, 32-bit). Negating a negation cancels - exact
|
|
173
|
+
# in 32-bit two's complement - instead of stacking into +--x+, which C would even lex as a
|
|
174
|
+
# decrement.
|
|
175
|
+
# @return [Expr]
|
|
176
|
+
def apply_neg
|
|
177
|
+
return Expr.opaque if opaque?
|
|
178
|
+
return Expr.imm(self.class.fold(0, :-, val)) if imm?
|
|
179
|
+
return lhs if kind == :unop # the only unop is :neg
|
|
180
|
+
|
|
181
|
+
Expr.unop(:neg, self)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'seccomp-tools/symbolic/expr'
|
|
4
|
+
|
|
5
|
+
module SeccompTools
|
|
6
|
+
module Symbolic
|
|
7
|
+
# A snapshot of the classic-BPF machine at one point along one path of the walk: the two
|
|
8
|
+
# registers (A and X), the 16 scratch-memory slots, and the path condition (the {Constraint}s
|
|
9
|
+
# that must hold to have reached here). The walk fills values in as it interprets
|
|
10
|
+
# instructions, starting from {.initial}.
|
|
11
|
+
#
|
|
12
|
+
# A {State} is treated as immutable - stepping an instruction produces a *new* state via {#with},
|
|
13
|
+
# so the many paths of the walk can safely share the parts they have in common.
|
|
14
|
+
class State
|
|
15
|
+
# @return [Expr] Register A (the accumulator).
|
|
16
|
+
attr_reader :a
|
|
17
|
+
# @return [Expr] Register X (the index register).
|
|
18
|
+
attr_reader :x
|
|
19
|
+
# @return [Array<Expr>] The 16 scratch-memory slots (+mem[0..15]+).
|
|
20
|
+
attr_reader :mem
|
|
21
|
+
# @return [Array<Constraint>] The path condition accumulated so far.
|
|
22
|
+
attr_reader :path
|
|
23
|
+
|
|
24
|
+
# The starting state, as the kernel sets it up: both registers zero (a classic BPF program is
|
|
25
|
+
# guaranteed +A = X = 0+ on entry - the cBPF-to-eBPF converter clears them first), the
|
|
26
|
+
# scratch slots unknown, and no facts assumed. The slots stay {Expr.opaque} because nothing
|
|
27
|
+
# can rely on them: the kernel rejects a filter that reads a slot before writing it.
|
|
28
|
+
# @return [State]
|
|
29
|
+
def self.initial
|
|
30
|
+
new(a: Expr.imm(0), x: Expr.imm(0), mem: Array.new(16, Expr.opaque), path: [])
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @param [Expr] a
|
|
34
|
+
# @param [Expr] x
|
|
35
|
+
# @param [Array<Expr>] mem
|
|
36
|
+
# @param [Array<Constraint>] path
|
|
37
|
+
def initialize(a:, x:, mem:, path:)
|
|
38
|
+
@a = a
|
|
39
|
+
@x = x
|
|
40
|
+
@mem = mem
|
|
41
|
+
@path = path
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Returns a copy with the given fields replaced; unreplaced fields are shared (the state is
|
|
45
|
+
# immutable).
|
|
46
|
+
# @return [State]
|
|
47
|
+
def with(a: @a, x: @x, mem: @mem, path: @path)
|
|
48
|
+
State.new(a:, x:, mem:, path:)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# A string that identifies this state exactly, joined from each part's {Expr#key} /
|
|
52
|
+
# {Constraint#key} (the same identity, one level up). The walk uses it to skip a
|
|
53
|
+
# +(line, state)+ pair it has already visited, which keeps merging control-flow from blowing
|
|
54
|
+
# up. Both {#==} and {#hash} are decided on it; see {Expr#key} for why the key is a string.
|
|
55
|
+
# The +;+ / +,+ separators appear in no part's key, so the join stays injective.
|
|
56
|
+
# @return [String]
|
|
57
|
+
def key
|
|
58
|
+
@key ||= "#{a.key};#{x.key};#{mem.map(&:key).join(',')};#{path.map(&:key).join(',')}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The constant the data word at byte +offset+ is pinned to on this path (by an +==+ fact), or
|
|
62
|
+
# +nil+ when it is not pinned.
|
|
63
|
+
# @param [Integer] offset
|
|
64
|
+
# @return [Integer?]
|
|
65
|
+
def pinned(offset)
|
|
66
|
+
path.find { |c| c.plain_data_eq?(offset) }&.rhs&.val
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @param [State] other
|
|
70
|
+
# @return [Boolean]
|
|
71
|
+
def ==(other)
|
|
72
|
+
other.is_a?(State) && key == other.key
|
|
73
|
+
end
|
|
74
|
+
alias eql? ==
|
|
75
|
+
|
|
76
|
+
# @return [Integer]
|
|
77
|
+
def hash
|
|
78
|
+
key.hash
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
# encoding: ascii-8bit
|
|
1
2
|
# frozen_string_literal: true
|
|
2
3
|
|
|
3
4
|
require 'os'
|
|
4
5
|
|
|
5
6
|
require 'seccomp-tools/const'
|
|
7
|
+
require 'seccomp-tools/util'
|
|
6
8
|
require 'seccomp-tools/ptrace' if OS.linux?
|
|
7
9
|
|
|
8
10
|
module SeccompTools
|
|
@@ -13,25 +15,32 @@ module SeccompTools
|
|
|
13
15
|
amd64: { number: 120, args: [112, 104, 96, 56, 72, 44], ret: 80, SYS_prctl: 157, SYS_seccomp: 317 },
|
|
14
16
|
i386: { number: 44, args: [0, 4, 8, 12, 16, 20], ret: 24, SYS_prctl: 172, SYS_seccomp: 354 },
|
|
15
17
|
aarch64: { number: 64, args: [0, 8, 16, 24, 32, 40, 48], ret: 0, SYS_prctl: 167, SYS_seccomp: 277 },
|
|
18
|
+
riscv64: { number: 136, args: [80, 88, 96, 104, 112, 120], ret: 80, SYS_prctl: 167, SYS_seccomp: 277 },
|
|
16
19
|
# Most software invokes syscalls through "svc 0", in which case the syscall number is in r1.
|
|
17
20
|
# However, it's also possible to use "svc NR": this case is not handled here.
|
|
18
21
|
s390x: { number: 24, args: [32, 40, 48, 56, 64, 72], ret: 32, SYS_prctl: 172, SYS_seccomp: 348 }
|
|
19
22
|
}.freeze
|
|
20
23
|
|
|
21
|
-
# @return [Integer]
|
|
24
|
+
# @return [Integer] Id of the traced process.
|
|
22
25
|
attr_reader :pid
|
|
23
|
-
# @return [{Symbol => Integer, Array<Integer>}]
|
|
26
|
+
# @return [{Symbol => Integer, Array<Integer>}]
|
|
27
|
+
# The {ABI} entry of this syscall's architecture.
|
|
24
28
|
attr_reader :abi
|
|
25
29
|
# @return [Integer] Syscall number.
|
|
26
30
|
attr_reader :number
|
|
27
|
-
# @return [Integer] Syscall arguments.
|
|
31
|
+
# @return [Array<Integer>] Syscall arguments, in register order.
|
|
28
32
|
attr_reader :args
|
|
29
33
|
# @return [Integer] Syscall return value.
|
|
30
34
|
attr_reader :ret
|
|
31
35
|
|
|
32
36
|
# Instantiate a {Syscall} object.
|
|
33
|
-
#
|
|
34
|
-
#
|
|
37
|
+
#
|
|
38
|
+
# Reads the syscall number, arguments and return value of +pid+ through ptrace, so the process
|
|
39
|
+
# must already be stopped and attached.
|
|
40
|
+
# @param [Integer] pid
|
|
41
|
+
# Id of the traced process.
|
|
42
|
+
# @raise [ArgumentError]
|
|
43
|
+
# If the architecture of +pid+ is not one of {ABI}'s keys.
|
|
35
44
|
def initialize(pid)
|
|
36
45
|
@pid = pid
|
|
37
46
|
raise ArgumentError, "Only supports #{ABI.keys.join(', ')}" if ABI[arch].nil?
|
|
@@ -42,38 +51,80 @@ module SeccompTools
|
|
|
42
51
|
@ret = peek(abi[:ret])
|
|
43
52
|
end
|
|
44
53
|
|
|
45
|
-
# Is this a
|
|
54
|
+
# Is this a seccomp installation syscall?
|
|
46
55
|
#
|
|
56
|
+
# Both +SECCOMP_MODE_FILTER+ and +SECCOMP_MODE_STRICT+ installations are recognized, invoked
|
|
57
|
+
# through either +seccomp+ or +prctl(PR_SET_SECCOMP, ...)+.
|
|
47
58
|
# @return [Boolean]
|
|
48
|
-
# +true+
|
|
59
|
+
# +true+ if this is a seccomp installation syscall.
|
|
49
60
|
def set_seccomp?
|
|
50
|
-
|
|
61
|
+
filter_mode? || strict_mode?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Is this a +seccomp(SECCOMP_SET_MODE_FILTER, ..)+/+prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, ..)+ syscall?
|
|
65
|
+
#
|
|
66
|
+
# @return [Boolean]
|
|
67
|
+
def filter_mode?
|
|
51
68
|
return true if number == abi[:SYS_seccomp] && args[0] == Const::BPF::SECCOMP_SET_MODE_FILTER
|
|
52
69
|
|
|
53
70
|
number == abi[:SYS_prctl] && args[0] == Const::BPF::PR_SET_SECCOMP && args[1] == Const::BPF::SECCOMP_MODE_FILTER
|
|
54
71
|
end
|
|
55
72
|
|
|
56
|
-
#
|
|
73
|
+
# Is this a +seccomp(SECCOMP_SET_MODE_STRICT, ..)+/+prctl(PR_SET_SECCOMP, SECCOMP_MODE_STRICT)+ syscall?
|
|
74
|
+
#
|
|
75
|
+
# @return [Boolean]
|
|
76
|
+
def strict_mode?
|
|
77
|
+
return true if number == abi[:SYS_seccomp] && args[0] == Const::BPF::SECCOMP_SET_MODE_STRICT
|
|
78
|
+
|
|
79
|
+
number == abi[:SYS_prctl] && args[0] == Const::BPF::PR_SET_SECCOMP && args[1] == Const::BPF::SECCOMP_MODE_STRICT
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Constructs a BPF program equivalent to what +SECCOMP_MODE_STRICT+ enforces: only read, write,
|
|
83
|
+
# exit, and sigreturn are allowed, while any other syscall kills the thread.
|
|
84
|
+
#
|
|
85
|
+
# @param [Symbol] arch
|
|
86
|
+
# Target architecture, one of {ABI}'s keys.
|
|
87
|
+
# @return [String]
|
|
88
|
+
# Raw BPF bytes.
|
|
89
|
+
def self.strict_bpf(arch)
|
|
90
|
+
# Required lazily because the assembler's scanner refers to {ABI} at load time.
|
|
91
|
+
require 'seccomp-tools/asm/asm'
|
|
92
|
+
|
|
93
|
+
# The kernel checks sigreturn on architectures that have it and rt_sigreturn on the others.
|
|
94
|
+
sigreturn = Const::Syscall.const_get(arch.to_s.upcase)[:sigreturn] ? :sigreturn : :rt_sigreturn
|
|
95
|
+
Asm.asm(<<-EOS, arch: arch)
|
|
96
|
+
A = sys_number
|
|
97
|
+
A == read ? ok : next
|
|
98
|
+
A == write ? ok : next
|
|
99
|
+
A == exit ? ok : next
|
|
100
|
+
A == #{sigreturn} ? ok : next
|
|
101
|
+
return KILL
|
|
102
|
+
ok:
|
|
103
|
+
return ALLOW
|
|
104
|
+
EOS
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Dumps the BPF of the filter being installed.
|
|
108
|
+
#
|
|
109
|
+
# Only meaningful when {#set_seccomp?} is +true+. In filter mode +args[2]+ points to a
|
|
110
|
+
# +struct sock_fprog+ in the traced process, whose BPF bytes are read out. Strict mode installs
|
|
111
|
+
# no BPF, so an equivalent filter built by {.strict_bpf} is returned instead.
|
|
57
112
|
# @return [String]
|
|
113
|
+
# The raw BPF bytes of the filter being installed.
|
|
58
114
|
def dump_bpf
|
|
115
|
+
return self.class.strict_bpf(arch) if strict_mode?
|
|
116
|
+
|
|
59
117
|
addr = args[2]
|
|
60
118
|
len = Ptrace.peekdata(pid, addr, 0) & 0xffff # len is unsigned short
|
|
61
|
-
filter = Ptrace.peekdata(pid, addr + bits / 8, 0) & ((1 << bits) - 1)
|
|
62
|
-
Array.new(len) { |i| Ptrace.peekdata(pid, filter + i * 8, 0) }.pack('Q*')
|
|
119
|
+
filter = Ptrace.peekdata(pid, addr + (bits / 8), 0) & ((1 << bits) - 1)
|
|
120
|
+
Array.new(len) { |i| Ptrace.peekdata(pid, filter + (i * 8), 0) }.pack('Q*')
|
|
63
121
|
end
|
|
64
122
|
|
|
65
|
-
#
|
|
66
|
-
#
|
|
123
|
+
# Architecture of this syscall, i.e. of the traced process.
|
|
124
|
+
# @return [Symbol?]
|
|
125
|
+
# See {SeccompTools::Util.process_arch}.
|
|
67
126
|
def arch
|
|
68
|
-
@arch ||=
|
|
69
|
-
f.pos = 18
|
|
70
|
-
{
|
|
71
|
-
"\x03\x00" => :i386,
|
|
72
|
-
"\x3e\x00" => :amd64,
|
|
73
|
-
"\xb7\x00" => :aarch64,
|
|
74
|
-
"\x00\x16" => :s390x
|
|
75
|
-
}[f.read(2)]
|
|
76
|
-
end
|
|
127
|
+
@arch ||= Util.process_arch(pid)
|
|
77
128
|
end
|
|
78
129
|
|
|
79
130
|
private
|
|
@@ -83,6 +134,7 @@ module SeccompTools
|
|
|
83
134
|
i386: 32,
|
|
84
135
|
amd64: 64,
|
|
85
136
|
aarch64: 64,
|
|
137
|
+
riscv64: 64,
|
|
86
138
|
s390x: 64
|
|
87
139
|
}[arch]
|
|
88
140
|
end
|