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
|
@@ -4,10 +4,11 @@ require 'seccomp-tools/instruction/ld'
|
|
|
4
4
|
|
|
5
5
|
module SeccompTools
|
|
6
6
|
module Instruction
|
|
7
|
-
# Instruction ldx.
|
|
7
|
+
# Instruction ldx, same as {LD} but loads into the index register X.
|
|
8
8
|
class LDX < LD
|
|
9
|
-
#
|
|
10
|
-
# @return [
|
|
9
|
+
# Name of the register being loaded into.
|
|
10
|
+
# @return [String]
|
|
11
|
+
# The index register, +"X"+.
|
|
11
12
|
def reg
|
|
12
13
|
'X'
|
|
13
14
|
end
|
|
@@ -4,9 +4,11 @@ require 'seccomp-tools/instruction/base'
|
|
|
4
4
|
|
|
5
5
|
module SeccompTools
|
|
6
6
|
module Instruction
|
|
7
|
-
# Instruction misc.
|
|
7
|
+
# Instruction misc, copies a value between the A and X registers.
|
|
8
8
|
class MISC < Base
|
|
9
9
|
# Decompile instruction.
|
|
10
|
+
# @return [String]
|
|
11
|
+
# Either +"A = X"+ or +"X = A"+.
|
|
10
12
|
def decompile
|
|
11
13
|
case op
|
|
12
14
|
when :txa then 'A = X'
|
|
@@ -16,21 +18,21 @@ module SeccompTools
|
|
|
16
18
|
|
|
17
19
|
# See {Instruction::Base#symbolize}.
|
|
18
20
|
# @return [[:misc, (:tax, :txa)]]
|
|
21
|
+
# +:tax+ copies A into X, +:txa+ copies X into A.
|
|
19
22
|
def symbolize
|
|
20
23
|
[:misc, op]
|
|
21
24
|
end
|
|
22
25
|
|
|
23
26
|
# See {Base#branch}.
|
|
24
|
-
# @param [
|
|
25
|
-
# Current
|
|
26
|
-
# @return [Array<(Integer,
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
# @param [Symbolic::State] state
|
|
28
|
+
# Current state.
|
|
29
|
+
# @return [Array<(Integer, Symbolic::State)>]
|
|
30
|
+
# Always the next line, with the copied value recorded in the register.
|
|
31
|
+
def branch(state)
|
|
29
32
|
case op
|
|
30
|
-
when :txa then
|
|
31
|
-
when :tax then
|
|
33
|
+
when :txa then [[line + 1, state.with(a: state.x)]]
|
|
34
|
+
when :tax then [[line + 1, state.with(x: state.a)]]
|
|
32
35
|
end
|
|
33
|
-
[[line + 1, ctx]]
|
|
34
36
|
end
|
|
35
37
|
|
|
36
38
|
private
|
|
@@ -4,22 +4,29 @@ require 'seccomp-tools/instruction/base'
|
|
|
4
4
|
|
|
5
5
|
module SeccompTools
|
|
6
6
|
module Instruction
|
|
7
|
-
# Instruction ret
|
|
7
|
+
# Instruction ret, terminates the filter with an action such as +ALLOW+ or +KILL+.
|
|
8
|
+
#
|
|
9
|
+
# The action comes from either the accumulator register A or the immediate +k+.
|
|
8
10
|
class RET < Base
|
|
9
11
|
# Decompile instruction.
|
|
12
|
+
# @return [String]
|
|
13
|
+
# The return as assembly, e.g. +"return ERRNO(1)"+.
|
|
10
14
|
def decompile
|
|
11
15
|
"return #{ret_str}"
|
|
12
16
|
end
|
|
13
17
|
|
|
14
18
|
# See {Instruction::Base#symbolize}.
|
|
15
19
|
# @return [[:ret, (:a, Integer)]]
|
|
20
|
+
# +:a+ when the action is taken from the A register, otherwise the immediate action value.
|
|
16
21
|
def symbolize
|
|
17
22
|
[:ret, code & 0x18 == SRC[:a] ? :a : k]
|
|
18
23
|
end
|
|
19
24
|
|
|
20
25
|
# See {Base#branch}.
|
|
21
|
-
#
|
|
22
|
-
#
|
|
26
|
+
#
|
|
27
|
+
# Accepts and ignores any arguments, the state is irrelevant here.
|
|
28
|
+
# @return [Array]
|
|
29
|
+
# Always an empty array, a filter stops executing at a return.
|
|
23
30
|
def branch(*)
|
|
24
31
|
[]
|
|
25
32
|
end
|
|
@@ -30,9 +37,8 @@ module SeccompTools
|
|
|
30
37
|
_, type = symbolize
|
|
31
38
|
return 'A' if type == :a
|
|
32
39
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
str
|
|
40
|
+
# fall back to the raw value (still re-assemblable) for an action the kernel does not define
|
|
41
|
+
Const::BPF.action_label(type) || "0x#{type.to_s(16)}"
|
|
36
42
|
end
|
|
37
43
|
end
|
|
38
44
|
end
|
|
@@ -4,24 +4,33 @@ require 'seccomp-tools/instruction/ld'
|
|
|
4
4
|
|
|
5
5
|
module SeccompTools
|
|
6
6
|
module Instruction
|
|
7
|
-
# Instruction st.
|
|
7
|
+
# Instruction st, stores the accumulator register A into a slot of the scratch memory.
|
|
8
|
+
#
|
|
9
|
+
# {STX} inherits from this class and stores the X register instead.
|
|
8
10
|
class ST < LD
|
|
9
11
|
# Decompile instruction.
|
|
12
|
+
# @return [String]
|
|
13
|
+
# The store as assembly, e.g. +"mem[0] = A"+.
|
|
10
14
|
def decompile
|
|
11
15
|
"mem[#{k}] = #{reg}"
|
|
12
16
|
end
|
|
13
17
|
|
|
14
18
|
# See {Instruction::Base#symbolize}.
|
|
15
19
|
# @return [[:st, (:a, :x), Integer]]
|
|
20
|
+
# The source register and the index of the target memory slot.
|
|
16
21
|
def symbolize
|
|
17
22
|
[:st, reg.downcase.to_sym, k]
|
|
18
23
|
end
|
|
19
24
|
|
|
20
|
-
#
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
+
# See {Base#branch}.
|
|
26
|
+
# @param [Symbolic::State] state
|
|
27
|
+
# Current state.
|
|
28
|
+
# @return [Array<(Integer, Symbolic::State)>]
|
|
29
|
+
# Always the next line, with the register's value recorded in the scratch slot.
|
|
30
|
+
def branch(state)
|
|
31
|
+
mem = state.mem.dup
|
|
32
|
+
mem[k] = reg == 'X' ? state.x : state.a
|
|
33
|
+
[[line + 1, state.with(mem:)]]
|
|
25
34
|
end
|
|
26
35
|
end
|
|
27
36
|
end
|
|
@@ -4,10 +4,11 @@ require 'seccomp-tools/instruction/st'
|
|
|
4
4
|
|
|
5
5
|
module SeccompTools
|
|
6
6
|
module Instruction
|
|
7
|
-
# Instruction stx.
|
|
7
|
+
# Instruction stx, same as {ST} but stores the index register X.
|
|
8
8
|
class STX < ST
|
|
9
|
-
#
|
|
10
|
-
# @return [
|
|
9
|
+
# Name of the register being stored.
|
|
10
|
+
# @return [String]
|
|
11
|
+
# The index register, +"X"+.
|
|
11
12
|
def reg
|
|
12
13
|
'X'
|
|
13
14
|
end
|
data/lib/seccomp-tools/logger.rb
CHANGED
|
@@ -13,6 +13,9 @@ module SeccompTools
|
|
|
13
13
|
|
|
14
14
|
# Returns a +::Logger+ object for internal logging.
|
|
15
15
|
#
|
|
16
|
+
# The returned logger writes to +$stdout+ with a formatter that prefixes each message with its
|
|
17
|
+
# colorized severity and indents continuation lines to align with the first.
|
|
18
|
+
#
|
|
16
19
|
# @return [::Logger]
|
|
17
20
|
def logger
|
|
18
21
|
::Logger.new($stdout).tap do |log|
|
|
@@ -31,7 +34,17 @@ module SeccompTools
|
|
|
31
34
|
end
|
|
32
35
|
end
|
|
33
36
|
|
|
34
|
-
|
|
37
|
+
# @!method error(msg)
|
|
38
|
+
# Logs +msg+ at the +error+ severity.
|
|
39
|
+
# @param [String] msg
|
|
40
|
+
# The message to be logged.
|
|
41
|
+
# @return [true]
|
|
42
|
+
# @!method warn(msg)
|
|
43
|
+
# Logs +msg+ at the +warn+ severity.
|
|
44
|
+
# @param [String] msg
|
|
45
|
+
# The message to be logged.
|
|
46
|
+
# @return [true]
|
|
47
|
+
%i[error warn].each do |sym|
|
|
35
48
|
define_method(sym) do |msg|
|
|
36
49
|
logger.__send__(sym, msg)
|
|
37
50
|
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'seccomp-tools/symbolic/expr'
|
|
4
|
+
|
|
5
|
+
module SeccompTools
|
|
6
|
+
module Symbolic
|
|
7
|
+
# One fact that must hold for the executor to be on a particular path, e.g. +A == 1+ or
|
|
8
|
+
# +data[16] & 0xffff != 0+. Every conditional jump adds one {Constraint} to each branch it
|
|
9
|
+
# takes; the accumulated list is the "path condition" carried in {State#path} and reported on a
|
|
10
|
+
# {Executor::Leaf}.
|
|
11
|
+
#
|
|
12
|
+
# A constraint keeps a lone constant on the right: BPF always compares the A register against X
|
|
13
|
+
# or +k+, but A itself may hold the constant (e.g. +A = 5+ compared against a data word +tax+'d
|
|
14
|
+
# into X earlier), and every consumer reads "expression op constant". So +Constraint.new(5, :>,
|
|
15
|
+
# word)+ normalizes to +word < 5+ at construction - the two spellings are one value.
|
|
16
|
+
class Constraint
|
|
17
|
+
# The same comparison with its two sides swapped: +5 > x+ is +x < 5+. The bit tests are
|
|
18
|
+
# symmetric (+&+ commutes).
|
|
19
|
+
MIRROR = {
|
|
20
|
+
:== => :==, :!= => :!=, :> => :<, :>= => :<=, :< => :>, :<= => :>=, set: :set, unset: :unset
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
# Applies one of the constraint operators to concrete operands: the +jset+ bit tests
|
|
24
|
+
# (+:set+/+:unset+) and the Integer comparisons. Stateless - used to evaluate a pinned
|
|
25
|
+
# constraint or a candidate value, without building a {Constraint}.
|
|
26
|
+
# @param [Integer] value
|
|
27
|
+
# @param [Symbol] op
|
|
28
|
+
# @param [Integer] k
|
|
29
|
+
# @return [Boolean]
|
|
30
|
+
def self.evaluate(value, op, k)
|
|
31
|
+
case op
|
|
32
|
+
when :set then !value.nobits?(k)
|
|
33
|
+
when :unset then value.nobits?(k)
|
|
34
|
+
else value.public_send(op, k) # the comparisons are all Integer methods
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @return [Expr] The left-hand side (what is being tested).
|
|
39
|
+
attr_reader :lhs
|
|
40
|
+
# @return [Symbol] The comparison, one of +:==, :!=, :>, :>=, :<, :<=, :set, :unset+.
|
|
41
|
+
# +:set+/+:unset+ mean "some/none of these bits are set" (from a +jset+ test).
|
|
42
|
+
attr_reader :op
|
|
43
|
+
# @return [Expr] The right-hand side (what it is compared against).
|
|
44
|
+
attr_reader :rhs
|
|
45
|
+
|
|
46
|
+
# @param [Expr] lhs
|
|
47
|
+
# @param [Symbol] op
|
|
48
|
+
# @param [Expr] rhs
|
|
49
|
+
def initialize(lhs, op, rhs)
|
|
50
|
+
lhs, op, rhs = rhs, MIRROR[op], lhs if lhs.imm? && !rhs.imm? # keep the constant on the right
|
|
51
|
+
@lhs = lhs
|
|
52
|
+
@op = op
|
|
53
|
+
@rhs = rhs
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Is this a fact about one plain data word compared against a constant - about the word at
|
|
57
|
+
# +offset+, when given? These are the facts rule-based consumers can reason about, e.g. the
|
|
58
|
+
# feasibility pruning in +Symbolic::Executor+.
|
|
59
|
+
# @param [Integer?] offset
|
|
60
|
+
# @return [Boolean]
|
|
61
|
+
def plain_data_fact?(offset = nil)
|
|
62
|
+
lhs.plain_data? && rhs.imm? && (offset.nil? || lhs.offset == offset)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Is this a +word == constant+ fact - about the word at +offset+, when given?
|
|
66
|
+
# @param [Integer?] offset
|
|
67
|
+
# @return [Boolean]
|
|
68
|
+
def plain_data_eq?(offset = nil)
|
|
69
|
+
op == :== && plain_data_fact?(offset)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# A string that uniquely identifies this constraint, for both equality and hashing; the
|
|
73
|
+
# +expr op expr+ counterpart of {Expr#key}, injective for the same reason.
|
|
74
|
+
# @return [String]
|
|
75
|
+
def key
|
|
76
|
+
@key ||= "#{lhs.key}#{op}#{rhs.key}"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -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
|