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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +153 -0
- data/README.md +263 -42
- 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 +32 -2
- data/lib/seccomp-tools/asm/sasm.tab.rb +27 -19
- 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 +33 -1
- 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 +6 -2
- 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 +9 -3
- data/lib/seccomp-tools/cli/completion.rb +40 -0
- data/lib/seccomp-tools/cli/disasm.rb +9 -5
- 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 +20 -5
- 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/riscv64.rb +332 -0
- data/lib/seccomp-tools/disasm/disasm.rb +30 -12
- data/lib/seccomp-tools/dumper.rb +65 -33
- data/lib/seccomp-tools/emulator.rb +40 -15
- 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 +14 -1
- 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 +72 -20
- data/lib/seccomp-tools/util.rb +70 -11
- data/lib/seccomp-tools/version.rb +1 -1
- data/lib/seccomp-tools.rb +10 -1
- metadata +36 -4
- data/lib/seccomp-tools/disasm/context.rb +0 -171
|
@@ -3,20 +3,21 @@
|
|
|
3
3
|
require 'seccomp-tools/const'
|
|
4
4
|
|
|
5
5
|
module SeccompTools
|
|
6
|
-
#
|
|
6
|
+
# Runs a seccomp filter against a hypothetical syscall to find out which action it returns.
|
|
7
7
|
class Emulator
|
|
8
8
|
# Instantiate a {Emulator} object.
|
|
9
9
|
#
|
|
10
10
|
# All parameters except +instructions+ are optional. A warning is shown when uninitialized data is accessed.
|
|
11
11
|
# @param [Array<Instruction::Base>] instructions
|
|
12
|
-
#
|
|
12
|
+
# The filter to be emulated, as returned by +SeccompTools::Disasm.to_bpf(raw, arch).map(&:inst)+.
|
|
13
|
+
# @param [Integer?] sys_nr
|
|
13
14
|
# Syscall number.
|
|
14
15
|
# @param [Array<Integer>] args
|
|
15
|
-
# Syscall arguments
|
|
16
|
-
# @param [Integer] instruction_pointer
|
|
17
|
-
# Program counter address when this syscall invoked.
|
|
16
|
+
# Syscall arguments.
|
|
17
|
+
# @param [Integer?] instruction_pointer
|
|
18
|
+
# Program counter address when this syscall is invoked.
|
|
18
19
|
# @param [Symbol?] arch
|
|
19
|
-
#
|
|
20
|
+
# Defaults to the system architecture when not provided.
|
|
20
21
|
#
|
|
21
22
|
# See {SeccompTools::Util.supported_archs} for list of supported architectures.
|
|
22
23
|
def initialize(instructions, sys_nr: nil, args: [], instruction_pointer: nil, arch: nil)
|
|
@@ -24,16 +25,27 @@ module SeccompTools
|
|
|
24
25
|
@sys_nr = sys_nr
|
|
25
26
|
@args = args
|
|
26
27
|
@ip = instruction_pointer
|
|
27
|
-
|
|
28
|
+
arch ||= Util.system_arch
|
|
29
|
+
@arch = audit(arch)
|
|
30
|
+
# On a big-endian architecture the high 32-bit word of a 64-bit field comes first.
|
|
31
|
+
@big_endian = Const::Endian.big?(arch)
|
|
28
32
|
end
|
|
29
33
|
|
|
30
34
|
# Run emulation!
|
|
35
|
+
#
|
|
36
|
+
# Executes the filter until it returns, then reports the final machine state.
|
|
37
|
+
# @yieldparam [{Symbol, Integer => Integer}] values
|
|
38
|
+
# If a block is given, it is invoked before each instruction with the current machine state.
|
|
31
39
|
# @return [{Symbol, Integer => Integer}]
|
|
40
|
+
# The final state: +:ret+ is the action the filter returned, +:pc+ the line it returned from,
|
|
41
|
+
# +:a+ and +:x+ the registers, and Integer keys the scratch memory slots.
|
|
42
|
+
# @example
|
|
43
|
+
# insts = SeccompTools::Disasm.to_bpf(raw, :amd64).map(&:inst)
|
|
44
|
+
# SeccompTools::Emulator.new(insts, sys_nr: 0).run[:ret]
|
|
45
|
+
# #=> 2147418112 # SECCOMP_RET_ALLOW
|
|
32
46
|
def run
|
|
33
47
|
@values = { pc: 0, a: 0, x: 0 }
|
|
34
48
|
loop do
|
|
35
|
-
break if @values[:ret] # break when returned
|
|
36
|
-
|
|
37
49
|
yield(@values) if block_given?
|
|
38
50
|
inst = @instructions[pc]
|
|
39
51
|
op, *args = inst.symbolize
|
|
@@ -46,6 +58,8 @@ module SeccompTools
|
|
|
46
58
|
when :alu then alu(args[0], args[1]) # alu
|
|
47
59
|
when :misc then misc(args[0]) # misc: txa/tax
|
|
48
60
|
end
|
|
61
|
+
break if @values[:ret] # break when returned
|
|
62
|
+
|
|
49
63
|
set(:pc, get(:pc) + 1) if %i[ld st alu misc].include?(op)
|
|
50
64
|
end
|
|
51
65
|
@values
|
|
@@ -101,6 +115,9 @@ module SeccompTools
|
|
|
101
115
|
set(:a, (2**32) - get(:a))
|
|
102
116
|
else
|
|
103
117
|
src = get(:x) if src == :x
|
|
118
|
+
# Classic BPF aborts the whole program on division by zero.
|
|
119
|
+
return set(:ret, Const::BPF::ACTION[:KILL_THREAD]) if op == :/ && src.zero?
|
|
120
|
+
|
|
104
121
|
set(:a, get(:a).__send__(op, src))
|
|
105
122
|
end
|
|
106
123
|
end
|
|
@@ -140,20 +157,28 @@ module SeccompTools
|
|
|
140
157
|
end
|
|
141
158
|
|
|
142
159
|
def data_of(index)
|
|
143
|
-
|
|
160
|
+
data = Const::BPF::SeccompData
|
|
161
|
+
raise IndexError, "Invalid index: #{index}" unless index.nobits?(3) && index.between?(0, data::SIZE - 1)
|
|
144
162
|
|
|
145
163
|
index /= 4
|
|
146
164
|
case index
|
|
147
|
-
when 0 then @sys_nr || undefined(
|
|
148
|
-
when 1 then @arch || undefined(
|
|
149
|
-
when 2 then (@ip
|
|
150
|
-
when 3 then (@ip >> 32) || undefined('instruction_pointer')
|
|
165
|
+
when 0 then @sys_nr || undefined(data::NAMES[data::SYS_NUMBER])
|
|
166
|
+
when 1 then @arch || undefined(data::NAMES[data::ARCH])
|
|
167
|
+
when 2, 3 then word_of(@ip || undefined(data::NAMES[data::INSTRUCTION_POINTER]), index)
|
|
151
168
|
else
|
|
152
169
|
val = @args[(index - 4) / 2] || undefined("args[#{(index - 4) / 2}]")
|
|
153
|
-
(val
|
|
170
|
+
word_of(val, index)
|
|
154
171
|
end
|
|
155
172
|
end
|
|
156
173
|
|
|
174
|
+
# The 32-bit word of the 64-bit value +val+ that lives at word-index +index+ of
|
|
175
|
+
# +seccomp_data+: the high word comes second on little-endian architectures but first on
|
|
176
|
+
# big-endian ones (s390x).
|
|
177
|
+
def word_of(val, index)
|
|
178
|
+
hi = index.odd? ^ @big_endian
|
|
179
|
+
(val >> (hi ? 32 : 0)) & 0xffffffff
|
|
180
|
+
end
|
|
181
|
+
|
|
157
182
|
def undefined(var)
|
|
158
183
|
raise format("Undefined Variable\n\t%04d: %s <- `%s` is undefined", pc, @instructions[pc].decompile, var)
|
|
159
184
|
end
|
data/lib/seccomp-tools/error.rb
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module SeccompTools
|
|
4
|
-
# Base
|
|
4
|
+
# Base class of all errors raised by this library.
|
|
5
|
+
#
|
|
6
|
+
# Rescue this class to catch every assembler error at once.
|
|
5
7
|
class Error < StandardError
|
|
6
8
|
end
|
|
7
9
|
|
|
@@ -9,7 +11,7 @@ module SeccompTools
|
|
|
9
11
|
class UnrecognizedTokenError < Error
|
|
10
12
|
end
|
|
11
13
|
|
|
12
|
-
# Raised when a
|
|
14
|
+
# Raised when a referenced label is defined nowhere on compiling seccomp assembly.
|
|
13
15
|
class UndefinedLabelError < Error
|
|
14
16
|
end
|
|
15
17
|
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'seccomp-tools/const'
|
|
4
|
+
require 'seccomp-tools/explain/path_facts'
|
|
5
|
+
require 'seccomp-tools/explain/verdict'
|
|
6
|
+
|
|
7
|
+
module SeccompTools
|
|
8
|
+
class Explain
|
|
9
|
+
# What a walk of one filter amounts to: the reachable returns, each one's facts read once, and
|
|
10
|
+
# the split into the architectures the filter distinguishes.
|
|
11
|
+
#
|
|
12
|
+
# Everything that reads a walk needs the same groundwork - {Summary} to render a per-architecture
|
|
13
|
+
# policy, {Audit} to assess each architecture's reachable syscalls - so it is derived here once
|
|
14
|
+
# rather than in each of them.
|
|
15
|
+
class Analysis
|
|
16
|
+
# @param [Array<Symbolic::Executor::Leaf>] leaves
|
|
17
|
+
def initialize(leaves)
|
|
18
|
+
@leaves = leaves
|
|
19
|
+
@facts = Hash.new { |h, leaf| h[leaf] = PathFacts.new(leaf.path) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# The {PathFacts} of +leaf+, computed once and shared across all consumers.
|
|
23
|
+
# @param [Symbolic::Executor::Leaf] leaf
|
|
24
|
+
# @return [PathFacts]
|
|
25
|
+
def facts(leaf)
|
|
26
|
+
@facts[leaf]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The distinct architecture values (+AUDIT_ARCH_*+) the filter explicitly branches on.
|
|
30
|
+
# @return [Array<Integer>]
|
|
31
|
+
def arch_values
|
|
32
|
+
@arch_values ||= @leaves.filter_map { |l| facts(l).arch_eq }.uniq
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# One entry per architecture section: its +AUDIT_ARCH+ value (+nil+ when the filter never
|
|
36
|
+
# branches on +arch+), the architecture symbol whose syscall names apply (+nil+ when the checked
|
|
37
|
+
# value is not one seccomp-tools knows), a display title, and the leaves reachable on it.
|
|
38
|
+
# @param [Symbol] declared_arch
|
|
39
|
+
# The architecture assumed when the filter itself does not branch on +arch+.
|
|
40
|
+
# @return [Array<Array(Integer?, Symbol?, Object, Array<Symbolic::Executor::Leaf>)>]
|
|
41
|
+
def sections(declared_arch)
|
|
42
|
+
vals = arch_values
|
|
43
|
+
return [[nil, declared_arch, declared_arch, @leaves]] if vals.empty?
|
|
44
|
+
|
|
45
|
+
vals.map do |v|
|
|
46
|
+
sym = Const::Audit.arch_symbol(v)
|
|
47
|
+
[v, sym, sym || format('0x%x (unknown)', v), @leaves.select { |l| facts(l).arch_consistent?(v) }]
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Leaves reachable when +arch+ is none of the explicitly-checked values.
|
|
52
|
+
# @return [Array<Symbolic::Executor::Leaf>]
|
|
53
|
+
def other_leaves
|
|
54
|
+
@leaves.reject { |l| facts(l).arch_eq }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The catch-all action of +leaves+: the verdict of a leaf that matches no syscall, no range and
|
|
58
|
+
# no arguments (or the first leaf, if none is a pure catch-all), or +nil+ when +leaves+ is empty.
|
|
59
|
+
# @param [Array<Symbolic::Executor::Leaf>] leaves
|
|
60
|
+
# @return [String?]
|
|
61
|
+
def default_label(leaves)
|
|
62
|
+
catch_all = leaves.find { |l| facts(l).catch_all? }
|
|
63
|
+
(catch_all || leaves.first)&.then { |l| Verdict.label(l.ret) }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'seccomp-tools/const'
|
|
4
|
+
require 'seccomp-tools/symbolic/constraint'
|
|
5
|
+
|
|
6
|
+
module SeccompTools
|
|
7
|
+
class Explain
|
|
8
|
+
# The seccomp reading of one leaf's path condition: which syscall number it pins or bounds,
|
|
9
|
+
# which architecture value it pins, and which facts remain for the rule's +when+ clause. The
|
|
10
|
+
# path is immutable, so every query is derived once, eagerly.
|
|
11
|
+
class PathFacts
|
|
12
|
+
SYS = Const::BPF::SeccompData::SYS_NUMBER
|
|
13
|
+
ARCH = Const::BPF::SeccompData::ARCH
|
|
14
|
+
# Largest 32-bit value, the upper end of an unconstrained syscall-number range.
|
|
15
|
+
U32_MAX = 0xffffffff
|
|
16
|
+
|
|
17
|
+
# The syscall number the path pins with +==+, or +nil+.
|
|
18
|
+
# @return [Integer?]
|
|
19
|
+
attr_reader :sys_eq
|
|
20
|
+
# The architecture value the path pins with +==+, or +nil+.
|
|
21
|
+
# @return [Integer?]
|
|
22
|
+
attr_reader :arch_eq
|
|
23
|
+
# The +[lo, hi]+ range (inclusive; +hi+ is +nil+ when unbounded) all bound facts restrict the
|
|
24
|
+
# syscall number to, or +nil+ when there is no lower bound. An upper bound alone does not
|
|
25
|
+
# make a range rule: it is the complement of one (e.g. the +sys < 0x40000000+ side of an x32
|
|
26
|
+
# guard) and reads naturally as part of the default bucket.
|
|
27
|
+
# @return [Array(Integer, Integer?)?]
|
|
28
|
+
attr_reader :sys_range
|
|
29
|
+
# Constraints not already conveyed by the syscall-number / architecture presentation.
|
|
30
|
+
#
|
|
31
|
+
# Consumed (dropped): +==+, +!=+ and range facts on +sys_number+ - the named/ranged buckets
|
|
32
|
+
# and the "any other syscall" default wording express them; +==+/+!=+ facts on +arch+ - the
|
|
33
|
+
# per-architecture sections and the "any other" fall-through express them; and any non-+==+
|
|
34
|
+
# fact on a word that some +==+ on the same path already pins (it is then redundant - a
|
|
35
|
+
# contradicting combination would have been pruned as infeasible).
|
|
36
|
+
#
|
|
37
|
+
# Everything else is kept so a kernel-valid check is never silently dropped: bit-tests on an
|
|
38
|
+
# unpinned +sys_number+ (e.g. an odd/even dispatch), bit-tests or ranges on +arch+ (e.g.
|
|
39
|
+
# testing the +__AUDIT_ARCH_64BIT+ flag instead of pinning one value), and any comparison
|
|
40
|
+
# against a register rather than a constant.
|
|
41
|
+
# @return [Array<Symbolic::Constraint>]
|
|
42
|
+
attr_reader :residual
|
|
43
|
+
|
|
44
|
+
# @param [Array<Symbolic::Constraint>] path
|
|
45
|
+
def initialize(path)
|
|
46
|
+
@path = path
|
|
47
|
+
@sys_eq = eq(SYS)
|
|
48
|
+
@arch_eq = eq(ARCH)
|
|
49
|
+
@sys_range = compute_sys_range
|
|
50
|
+
@residual = compute_residual
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Is the path consistent with the architecture being +val+? Every constant arch fact is
|
|
54
|
+
# evaluated against +val+.
|
|
55
|
+
# @param [Integer] val
|
|
56
|
+
# @return [Boolean]
|
|
57
|
+
def arch_consistent?(val)
|
|
58
|
+
@path.all? do |c|
|
|
59
|
+
next true unless c.plain_data_fact?(ARCH)
|
|
60
|
+
|
|
61
|
+
Symbolic::Constraint.evaluate(val, c.op, c.rhs.val)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Does the path match no syscall, no range, and no arguments - i.e. describe the filter's
|
|
66
|
+
# catch-all behavior?
|
|
67
|
+
# @return [Boolean]
|
|
68
|
+
def catch_all?
|
|
69
|
+
sys_eq.nil? && sys_range.nil? && residual.empty?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
# The value of the single +data[offset] == k+ fact, if any.
|
|
75
|
+
def eq(offset)
|
|
76
|
+
@path.find { |c| c.plain_data_eq?(offset) }&.rhs&.val
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def compute_sys_range
|
|
80
|
+
lo = nil
|
|
81
|
+
hi = nil
|
|
82
|
+
@path.each do |c|
|
|
83
|
+
next unless c.plain_data_fact?(SYS)
|
|
84
|
+
|
|
85
|
+
case c.op
|
|
86
|
+
when :> then lo = [lo || 0, c.rhs.val + 1].max
|
|
87
|
+
when :>= then lo = [lo || 0, c.rhs.val].max
|
|
88
|
+
when :< then hi = [hi || U32_MAX, c.rhs.val - 1].min
|
|
89
|
+
when :<= then hi = [hi || U32_MAX, c.rhs.val].min
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
lo && [lo, hi]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def compute_residual
|
|
96
|
+
pinned = @path.filter_map { |c| c.lhs.offset if c.plain_data_eq? }
|
|
97
|
+
@path.reject do |c|
|
|
98
|
+
next false unless c.plain_data_fact?
|
|
99
|
+
|
|
100
|
+
redundant = c.op != :== && pinned.include?(c.lhs.offset)
|
|
101
|
+
case c.lhs.offset
|
|
102
|
+
when SYS then redundant || !%i[set unset].include?(c.op)
|
|
103
|
+
when ARCH then redundant || %i[== !=].include?(c.op)
|
|
104
|
+
else redundant
|
|
105
|
+
end
|
|
106
|
+
end.uniq(&:key)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'seccomp-tools/const'
|
|
4
|
+
|
|
5
|
+
module SeccompTools
|
|
6
|
+
class Explain
|
|
7
|
+
# A 64-bit fact reassembled from 32-bit word checks (see {QwordFusion}); +base+ is the field's
|
|
8
|
+
# byte offset in +seccomp_data+.
|
|
9
|
+
Qword = Struct.new(:base, :op, :val) do
|
|
10
|
+
# Mirrors {Symbolic::Constraint#key} (a string) so fused condition lists can still be compared;
|
|
11
|
+
# the +q+ prefix keeps it distinct from any constraint key, which starts with an operand char.
|
|
12
|
+
def key
|
|
13
|
+
"q#{base},#{op},#{val}"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# A {Qword} is a whole-field fact, never a single plain-word one, so it matches neither
|
|
17
|
+
# predicate. Defining them lets the fusion code treat a mixed list uniformly, without an
|
|
18
|
+
# +is_a?+ guard on every access.
|
|
19
|
+
def plain_data_fact?(_offset = nil) = false
|
|
20
|
+
alias_method :plain_data_eq?, :plain_data_fact?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# A 64-bit field of +seccomp_data+ (+instruction_pointer+ or an argument) is two 32-bit words,
|
|
24
|
+
# and filters check them separately. This class fuses such word facts back into 64-bit ones
|
|
25
|
+
# ({Qword}), both within one path condition ({#fold}) and across the sibling or-branches
|
|
26
|
+
# libseccomp compiles a 64-bit range comparison into ({#merge_or}).
|
|
27
|
+
class QwordFusion
|
|
28
|
+
# Byte offsets of the 64-bit fields whose two 32-bit words this class fuses.
|
|
29
|
+
BASES = Const::BPF::SeccompData::QWORD_BASES
|
|
30
|
+
# How the extra facts of two sibling or-branches fuse into one 64-bit comparison: one branch
|
|
31
|
+
# holds a strict high-word fact +hi <hi_op> H+ (+hi_op+ is +>+, +<+ or +!=+, normalized by
|
|
32
|
+
# {#strict}) and the other +hi == H && lo <lo_op> L+; keyed by +[hi_op, lo_op]+, they are
|
|
33
|
+
# exactly +field <fused op> (H << 32 | L)+. This is the shape libseccomp compiles
|
|
34
|
+
# SCMP_CMP_GT/GE/LT/LE/NE argument comparisons into.
|
|
35
|
+
OR_MERGE = {
|
|
36
|
+
%i[> >] => :>, %i[> >=] => :>=, %i[< <] => :<, %i[< <=] => :<=, %i[!= !=] => :!=
|
|
37
|
+
}.freeze
|
|
38
|
+
|
|
39
|
+
# @param [Symbol] arch
|
|
40
|
+
# Decides the word order: on a big-endian architecture the high 32-bit word of a 64-bit
|
|
41
|
+
# field comes first.
|
|
42
|
+
def initialize(arch)
|
|
43
|
+
@hi_first = Const::Endian.big?(arch)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The byte offset of the low 32-bit word of the 64-bit field at +base+.
|
|
47
|
+
def lo_off(base)
|
|
48
|
+
@hi_first ? base + 4 : base
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The byte offset of the high 32-bit word of the 64-bit field at +base+.
|
|
52
|
+
def hi_off(base)
|
|
53
|
+
@hi_first ? base : base + 4
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The byte offset of the 64-bit field that the word at +offset+ belongs to.
|
|
57
|
+
def base_of(offset)
|
|
58
|
+
offset - (offset % 8)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Fuses the 32-bit word facts of one path condition into whole-field {Qword}s; facts that do
|
|
62
|
+
# not form a fusable pair pass through unchanged.
|
|
63
|
+
# @param [Array<Symbolic::Constraint, Qword>] constraints
|
|
64
|
+
# @return [Array<Symbolic::Constraint, Qword>]
|
|
65
|
+
# @example Both halves pinned by == (little-endian: args[0] lo word @16, hi @20)
|
|
66
|
+
# fold([ data[20] == 0x1, data[16] == 0x2 ])
|
|
67
|
+
# #=> [ Qword(base: 16, op: :==, val: 0x100000002) ]
|
|
68
|
+
# @example A zero high word with a low-word bound
|
|
69
|
+
# fold([ data[20] == 0x0, data[16] < 0x1000 ])
|
|
70
|
+
# #=> [ Qword(base: 16, op: :<, val: 0x1000) ]
|
|
71
|
+
def fold(constraints)
|
|
72
|
+
plan = qword_plan(constraints)
|
|
73
|
+
constraints.filter_map do |c|
|
|
74
|
+
action = plan[c]
|
|
75
|
+
next if action == :drop
|
|
76
|
+
|
|
77
|
+
action || c
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Fuses sibling or-branches that together express one 64-bit comparison, repeatedly until
|
|
82
|
+
# nothing fuses; branches that do not pair up are returned untouched. See {#fuse_pair} for
|
|
83
|
+
# the shape of a fusable pair.
|
|
84
|
+
# @param [Array<Array<Symbolic::Constraint, Qword>>] lists
|
|
85
|
+
# The condition lists of one rule's or-branches.
|
|
86
|
+
# @return [Array<Array<Symbolic::Constraint, Qword>>]
|
|
87
|
+
# @example The two match branches of a 64-bit +args[0] > 0x200000500+ (little-endian: lo @16, hi @20)
|
|
88
|
+
# merge_or([ [ data[20] > 2 ],
|
|
89
|
+
# [ data[20] == 2, data[16] > 0x500 ] ])
|
|
90
|
+
# #=> [ [ Qword(base: 16, op: :>, val: 0x200000500) ] ]
|
|
91
|
+
def merge_or(lists)
|
|
92
|
+
lists = lists.dup # own a mutable copy; the caller's array is left untouched
|
|
93
|
+
loop { break unless merge_one_pair!(lists) }
|
|
94
|
+
lists
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
# Maps each constraint to what {#fold} should do with it: a {Qword} to replace it with, or
|
|
100
|
+
# +:drop+ to remove it. A constraint absent from the plan is kept as-is.
|
|
101
|
+
def qword_plan(constraints)
|
|
102
|
+
eqs = constraints.select(&:plain_data_eq?).group_by { |c| c.lhs.offset }.transform_values(&:first)
|
|
103
|
+
plan = {}
|
|
104
|
+
BASES.each do |base|
|
|
105
|
+
hi = eqs[hi_off(base)]
|
|
106
|
+
next unless hi
|
|
107
|
+
|
|
108
|
+
if (lo = eqs[lo_off(base)])
|
|
109
|
+
plan[lo] = Qword.new(base, :==, (hi.rhs.val << 32) | lo.rhs.val)
|
|
110
|
+
elsif hi.rhs.val.zero? && (lo = lo_bound(constraints, base))
|
|
111
|
+
plan[lo] = Qword.new(base, lo.op, lo.rhs.val)
|
|
112
|
+
else
|
|
113
|
+
next
|
|
114
|
+
end
|
|
115
|
+
plan[hi] = :drop
|
|
116
|
+
end
|
|
117
|
+
plan
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# The +lo < L+ / +lo <= L+ fact on the low word of the field at +base+, if any.
|
|
121
|
+
def lo_bound(constraints, base)
|
|
122
|
+
constraints.find { |c| c.plain_data_fact?(lo_off(base)) && %i[< <=].include?(c.op) }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Fuses the first fusable pair of +lists+ in place - dropping the two branches and putting the
|
|
126
|
+
# fused one at the earlier slot - and returns it, or +nil+ when no pair fused.
|
|
127
|
+
def merge_one_pair!(lists)
|
|
128
|
+
lists.each_with_index do |a, i|
|
|
129
|
+
lists.each_with_index do |b, j|
|
|
130
|
+
next if i == j
|
|
131
|
+
|
|
132
|
+
fused = fuse_pair(a, b)
|
|
133
|
+
next unless fused
|
|
134
|
+
|
|
135
|
+
lo, hi = [i, j].minmax
|
|
136
|
+
lists[lo] = fused
|
|
137
|
+
lists.delete_at(hi)
|
|
138
|
+
return fused
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
nil
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Fuses two sibling or-branches into one 64-bit fact. It applies when +a+'s only extra fact
|
|
145
|
+
# (vs +b+) is a strict high-word fact +hi <op> H+, and +b+'s two extras are +hi == H+ plus a
|
|
146
|
+
# bound on the matching low word: that pair in +b+ is then replaced by the fused {Qword} (see
|
|
147
|
+
# {OR_MERGE}).
|
|
148
|
+
# @param [Array<Symbolic::Constraint, Qword>] a
|
|
149
|
+
# One or-branch's condition list.
|
|
150
|
+
# @param [Array<Symbolic::Constraint, Qword>] b
|
|
151
|
+
# The sibling branch's condition list.
|
|
152
|
+
# @return [Array<Symbolic::Constraint, Qword>, nil]
|
|
153
|
+
# +b+ with its +hi == H+ / +lo <op> L+ pair collapsed into one {Qword}, or +nil+ when the
|
|
154
|
+
# two lists do not have the fusable shape.
|
|
155
|
+
# @example Fusing the two match paths of a 64-bit +args[0] > 0x200000500+ (little-endian: lo word @16, hi @20)
|
|
156
|
+
# a = [ data[20] > 2 ] # hi > H
|
|
157
|
+
# b = [ data[20] == 2, data[16] > 0x500 ] # hi == H && lo > L
|
|
158
|
+
# fuse_pair(a, b) #=> [ Qword(base: 16, op: :>, val: 0x200000500) ]
|
|
159
|
+
def fuse_pair(a, b)
|
|
160
|
+
only_a = minus(a, b)
|
|
161
|
+
return unless only_a.size == 1
|
|
162
|
+
|
|
163
|
+
hi = only_a.first
|
|
164
|
+
base = hi_field_base(hi)
|
|
165
|
+
return unless base
|
|
166
|
+
|
|
167
|
+
hi_op, hi_val = strict(hi.op, hi.rhs.val)
|
|
168
|
+
only_b = minus(b, a)
|
|
169
|
+
eq = only_b.find { |c| c.plain_data_eq?(hi.lhs.offset) && c.rhs.val == hi_val }
|
|
170
|
+
lo = only_b.find { |c| c.plain_data_fact?(lo_off(base)) }
|
|
171
|
+
return unless only_b.size == 2 && eq && lo && (op = OR_MERGE[[hi_op, lo.op]])
|
|
172
|
+
|
|
173
|
+
b.map { |c| c.equal?(eq) ? Qword.new(base, op, (hi_val << 32) | lo.rhs.val) : c }.reject { |c| c.equal?(lo) }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# The field base when +hi+ is a constant comparison on the high word of a 64-bit field;
|
|
177
|
+
# +nil+ otherwise.
|
|
178
|
+
def hi_field_base(hi)
|
|
179
|
+
return unless hi.plain_data_fact?
|
|
180
|
+
|
|
181
|
+
base = base_of(hi.lhs.offset)
|
|
182
|
+
base if BASES.include?(base) && hi.lhs.offset == hi_off(base)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# +>= v+ and +> v-1+ are the same test; normalize the high-word comparison to the strict form
|
|
186
|
+
# so {OR_MERGE} needs only one spelling. At the 32-bit boundary +val+ can step out of range
|
|
187
|
+
# (+>= 0+ to +> -1+, +<= 0xffffffff+ to +< 0x100000000+), but that is safe: the result is only
|
|
188
|
+
# matched against a data-word +==+ constant, which is masked to +0..0xffffffff+ and so can
|
|
189
|
+
# never equal it, and those boundary comparisons are always-true anyway (nothing to fuse).
|
|
190
|
+
def strict(op, val)
|
|
191
|
+
case op
|
|
192
|
+
when :>= then [:>, val - 1]
|
|
193
|
+
when :<= then [:<, val + 1]
|
|
194
|
+
else [op, val]
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# The constraints in +a+ whose fact does not also appear in +b+.
|
|
199
|
+
def minus(a, b)
|
|
200
|
+
a.reject { |c| b.any? { |d| d.key == c.key } }
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'seccomp-tools/const'
|
|
4
|
+
require 'seccomp-tools/explain/qword'
|
|
5
|
+
require 'seccomp-tools/util'
|
|
6
|
+
|
|
7
|
+
module SeccompTools
|
|
8
|
+
class Explain
|
|
9
|
+
# Renders path-condition facts ({Symbolic::Constraint}s and {Qword}s) as C-like conditions,
|
|
10
|
+
# naming the +seccomp_data+ fields and parenthesizing exactly where the expression would
|
|
11
|
+
# otherwise be misread.
|
|
12
|
+
class Renderer
|
|
13
|
+
# C-like operator precedence (higher binds tighter), used to parenthesize a rendered condition
|
|
14
|
+
# exactly where it would otherwise be misread - notably that +==+ binds tighter than the
|
|
15
|
+
# bitwise operators, so +a & b == c+ must be shown as +(a & b) == c+.
|
|
16
|
+
PREC = {
|
|
17
|
+
:* => 12, :/ => 12, :+ => 11, :- => 11, :<< => 10, :>> => 10,
|
|
18
|
+
:< => 9, :<= => 9, :> => 9, :>= => 9, :== => 8, :!= => 8,
|
|
19
|
+
:& => 7, :^ => 6, :| => 5
|
|
20
|
+
}.freeze
|
|
21
|
+
# Unary negation binds tighter than any binary operator.
|
|
22
|
+
UNARY_PREC = 13
|
|
23
|
+
# The comparison operators. When one is the parent, operands are parenthesized by precedence
|
|
24
|
+
# alone (so +a & b == c+ becomes +(a & b) == c+ but +a >> b == c+ stays put), never by the
|
|
25
|
+
# extra readability rule in {#clarity_wrap?}.
|
|
26
|
+
COMPARISON = %i[== != < <= > >=].freeze
|
|
27
|
+
# The +seccomp_data+ field layout the rendered names come from.
|
|
28
|
+
DATA = Const::BPF::SeccompData
|
|
29
|
+
|
|
30
|
+
# @param [QwordFusion] fusion
|
|
31
|
+
# Supplies the endian-correct word offsets of the 64-bit fields, for naming their halves.
|
|
32
|
+
def initialize(fusion)
|
|
33
|
+
@fusion = fusion
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Renders a conjunction of facts, e.g. +"fd == 0x1 && (flags & 0xf) < 0x5"+.
|
|
37
|
+
# @param [Array<Symbolic::Constraint, Qword>] constraints
|
|
38
|
+
# @param [Symbol?] sys
|
|
39
|
+
# The syscall the facts belong to, if pinned - names the arguments.
|
|
40
|
+
# @return [String]
|
|
41
|
+
def conjunction(constraints, sys)
|
|
42
|
+
constraints.map do |c|
|
|
43
|
+
if c.is_a?(Qword)
|
|
44
|
+
"#{data_name(@fusion.lo_off(c.base), sys)} #{c.op} 0x#{c.val.to_s(16)}"
|
|
45
|
+
else
|
|
46
|
+
constraint(c, sys)
|
|
47
|
+
end
|
|
48
|
+
end.join(' && ')
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def constraint(c, sys)
|
|
54
|
+
return "(#{binop(:&, c.lhs, c.rhs, sys)}) != 0" if c.op == :set
|
|
55
|
+
return "(#{binop(:&, c.lhs, c.rhs, sys)}) == 0" if c.op == :unset
|
|
56
|
+
|
|
57
|
+
prec = PREC[c.op]
|
|
58
|
+
"#{operand(c.lhs, c.op, prec, sys)} #{c.op} #{operand(c.rhs, c.op, prec, sys)}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Renders an expression without any outer parentheses; each caller wraps it via {#operand}.
|
|
62
|
+
def expr(e, sys)
|
|
63
|
+
return '<opaque>' if e.opaque?
|
|
64
|
+
return "0x#{e.val.to_s(16)}" if e.imm?
|
|
65
|
+
return data_name(e.offset, sys) if e.plain_data?
|
|
66
|
+
return "-#{operand(e.lhs, :neg, UNARY_PREC, sys)}" if e.kind == :unop
|
|
67
|
+
|
|
68
|
+
binop(e.op, e.lhs, e.rhs, sys)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Renders +lhs op rhs+. Operators are left-associative, so the left operand shares +op+'s
|
|
72
|
+
# precedence while the right needs one higher (an equal-precedence right subtree is wrapped).
|
|
73
|
+
def binop(op, lhs, rhs, sys)
|
|
74
|
+
prec = PREC[op]
|
|
75
|
+
left = operand(lhs, op, prec, sys)
|
|
76
|
+
right = rhs.imm? && %i[<< >>].include?(op) ? rhs.val.to_s : operand(rhs, op, prec + 1, sys)
|
|
77
|
+
"#{left} #{op} #{right}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Renders +child+ as an operand of +parent_op+, parenthesizing it when precedence requires it
|
|
81
|
+
# (+child+ binds looser than +min_prec+) or when {#clarity_wrap?} judges the grouping too easy
|
|
82
|
+
# to misread.
|
|
83
|
+
def operand(child, parent_op, min_prec, sys)
|
|
84
|
+
s = expr(child, sys)
|
|
85
|
+
return s unless child.kind == :binop
|
|
86
|
+
|
|
87
|
+
PREC[child.op] < min_prec || clarity_wrap?(parent_op, child.op) ? "(#{s})" : s
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Should a +child_op+ nested under +parent_op+ be parenthesized purely for readability (beyond
|
|
91
|
+
# what precedence requires)? Yes when they sit at *different* precedence levels - mixing
|
|
92
|
+
# families like +a & (b + c)+ or +(a + b) << c+ is easy to misjudge. The exceptions, where the
|
|
93
|
+
# grouping is universally understood, are: any comparison parent (+a & b == c+ is already made
|
|
94
|
+
# unambiguous by wrapping the looser +&+), a same-level pair (+a + b - c+, +a ^ b ^ c+), and
|
|
95
|
+
# multiplication/division directly inside addition/subtraction (+a + b * c+).
|
|
96
|
+
def clarity_wrap?(parent_op, child_op)
|
|
97
|
+
return false if COMPARISON.include?(parent_op)
|
|
98
|
+
return false if PREC[parent_op] == PREC[child_op]
|
|
99
|
+
return false if PREC[child_op] == PREC[:*] && PREC[parent_op] == PREC[:+]
|
|
100
|
+
|
|
101
|
+
true
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def data_name(offset, sys)
|
|
105
|
+
case offset
|
|
106
|
+
when DATA::SYS_NUMBER, DATA::ARCH then DATA::NAMES[offset] # the scalar fields
|
|
107
|
+
else qword_word_name(offset, sys) # endian-split fields (instruction_pointer, args)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Names one 32-bit word of a 64-bit field, appending +>> 32+ for the high word - which is the
|
|
112
|
+
# second word on little-endian architectures but the first on big-endian ones.
|
|
113
|
+
def qword_word_name(offset, sys)
|
|
114
|
+
base = @fusion.base_of(offset)
|
|
115
|
+
return "data[#{offset}]" unless DATA::QWORD_BASES.include?(base)
|
|
116
|
+
|
|
117
|
+
name = if base == DATA::INSTRUCTION_POINTER
|
|
118
|
+
DATA::NAMES[base]
|
|
119
|
+
else
|
|
120
|
+
idx = (base - DATA::ARGS) / 8
|
|
121
|
+
names = sys && Const::SYS_ARG[sys]
|
|
122
|
+
Util.colorize((names && names[idx]) || "args[#{idx}]", t: :args)
|
|
123
|
+
end
|
|
124
|
+
offset == @fusion.hi_off(base) ? "#{name} >> 32" : name
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|