seccomp-tools 1.6.2 → 1.7.1
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 +167 -0
- data/README.md +280 -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 +69 -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_arg.rb +43 -5
- data/lib/seccomp-tools/consts/sys_nr/aarch64.rb +52 -1
- data/lib/seccomp-tools/consts/sys_nr/amd64.rb +53 -1
- data/lib/seccomp-tools/consts/sys_nr/i386.rb +83 -2
- data/lib/seccomp-tools/consts/sys_nr/riscv64.rb +332 -0
- data/lib/seccomp-tools/consts/sys_nr/s390x.rb +30 -1
- data/lib/seccomp-tools/disasm/disasm.rb +30 -12
- data/lib/seccomp-tools/dumper.rb +67 -36
- 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 +73 -23
- data/lib/seccomp-tools/util.rb +76 -11
- data/lib/seccomp-tools/version.rb +1 -1
- data/lib/seccomp-tools.rb +10 -1
- metadata +36 -24
- data/lib/seccomp-tools/disasm/context.rb +0 -171
|
@@ -11,9 +11,14 @@ module SeccompTools
|
|
|
11
11
|
#
|
|
12
12
|
# Maintains columns and rows to have informative error messages.
|
|
13
13
|
#
|
|
14
|
-
# Internally used by
|
|
14
|
+
# Internally used by the seccomp asm parser.
|
|
15
15
|
class Scanner
|
|
16
|
+
# @return [{Symbol => Integer}]
|
|
17
|
+
# Syscall name to number, for the architecture this scanner was created with.
|
|
16
18
|
attr_reader :syscalls
|
|
19
|
+
# @return [Symbol]
|
|
20
|
+
# The architecture this scanner was created with.
|
|
21
|
+
attr_reader :arch
|
|
17
22
|
|
|
18
23
|
# Keywords with special meanings in our assembly. Keywords are all case-insensitive.
|
|
19
24
|
KEYWORDS = %w[a x if else return mem args args_h data len sys_number arch instruction_pointer].freeze
|
|
@@ -40,14 +45,20 @@ module SeccompTools
|
|
|
40
45
|
# Supported architectures
|
|
41
46
|
ARCHES = SeccompTools::Syscall::ABI.keys.map(&:to_s)
|
|
42
47
|
|
|
48
|
+
# Instantiates a {Scanner} object.
|
|
49
|
+
#
|
|
43
50
|
# @param [String] str
|
|
51
|
+
# The assembly source to be scanned.
|
|
44
52
|
# @param [Symbol] arch
|
|
53
|
+
# Target architecture, decides which syscall names are recognized.
|
|
45
54
|
# @param [String?] filename
|
|
55
|
+
# Only used for error messages, defaults to +<inline>+.
|
|
46
56
|
# @example
|
|
47
57
|
# Scanner.new('return ALLOW', :amd64)
|
|
48
58
|
def initialize(str, arch, filename: nil)
|
|
49
59
|
@filename = filename || '<inline>'
|
|
50
60
|
@str = str
|
|
61
|
+
@arch = arch
|
|
51
62
|
@syscalls =
|
|
52
63
|
begin; Const::Syscall.const_get(arch.to_s.upcase); rescue NameError; []; end
|
|
53
64
|
@syscall_all = ARCHES.each_with_object({}) do |ar, memo|
|
|
@@ -57,7 +68,9 @@ module SeccompTools
|
|
|
57
68
|
|
|
58
69
|
# Scans the whole string and raises errors when there are unrecognized tokens.
|
|
59
70
|
# @return [self]
|
|
71
|
+
# The scanner itself, so calls can be chained.
|
|
60
72
|
# @raise [UnrecognizedTokenError]
|
|
73
|
+
# If any token could not be recognized. The message points at every such token.
|
|
61
74
|
def validate!
|
|
62
75
|
errors = validate
|
|
63
76
|
return self if errors.empty?
|
|
@@ -68,11 +81,17 @@ module SeccompTools
|
|
|
68
81
|
# Same as {#validate!} but returns the array of errors instead of raising an exception.
|
|
69
82
|
#
|
|
70
83
|
# @return [Array<Token>]
|
|
84
|
+
# The +:unknown+ tokens, empty when the source is valid.
|
|
71
85
|
def validate
|
|
72
86
|
scan.select { |t| t.sym == :unknown }
|
|
73
87
|
end
|
|
74
88
|
|
|
89
|
+
# Scans the whole string into tokens.
|
|
90
|
+
#
|
|
91
|
+
# The result is memoized, so repeated calls are free.
|
|
75
92
|
# @return [Array<Token>]
|
|
93
|
+
# All tokens in source order. Unrecognized text becomes +:unknown+ tokens rather than
|
|
94
|
+
# raising, see {#validate!}.
|
|
76
95
|
def scan
|
|
77
96
|
return @tokens if defined?(@tokens)
|
|
78
97
|
|
|
@@ -138,9 +157,22 @@ module SeccompTools
|
|
|
138
157
|
# Let tab on terminal be 4 spaces wide.
|
|
139
158
|
TAB_WIDTH = 4
|
|
140
159
|
|
|
160
|
+
# Formats an error message that points at a token in the source.
|
|
161
|
+
#
|
|
141
162
|
# @param [Token] tok
|
|
163
|
+
# The token the message refers to.
|
|
142
164
|
# @param [String] msg
|
|
165
|
+
# The message to be shown next to the source location.
|
|
143
166
|
# @return [String]
|
|
167
|
+
# The location and message, the offending source line, and a row of carets underlining
|
|
168
|
+
# the token.
|
|
169
|
+
# @example
|
|
170
|
+
# scanner.format_error(tok, 'unknown token "foo"')
|
|
171
|
+
# #=> <<-EOS
|
|
172
|
+
# # <inline>:1:1 unknown token "foo"
|
|
173
|
+
# # foo
|
|
174
|
+
# # ^^^
|
|
175
|
+
# # EOS
|
|
144
176
|
def format_error(tok, msg)
|
|
145
177
|
@lines = @str.lines unless defined?(@lines)
|
|
146
178
|
line = @lines[tok.line]
|
|
@@ -8,15 +8,21 @@ module SeccompTools
|
|
|
8
8
|
# Internally used by sasm.y.
|
|
9
9
|
# @private
|
|
10
10
|
class Statement
|
|
11
|
-
|
|
11
|
+
# @return [Symbol] Kind of this statement, one of +:alu+, +:assign+, +:if+ and +:ret+.
|
|
12
|
+
attr_reader :type
|
|
13
|
+
# @return [Object] The operands of this statement, see {#initialize}.
|
|
14
|
+
attr_reader :data
|
|
15
|
+
# @return [Array<Token>] Labels that refer to this statement.
|
|
16
|
+
attr_reader :symbols
|
|
12
17
|
|
|
13
18
|
# Instantiates a {Statement} object.
|
|
14
19
|
#
|
|
15
20
|
# @param [:alu, :assign, :if, :ret] type
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
21
|
+
# Kind of this statement.
|
|
22
|
+
# @param [Object] data
|
|
23
|
+
# The data for describing this statement. Type of +data+ varies according to the value of +type+.
|
|
24
|
+
# @param [Array<Token>] symbols
|
|
25
|
+
# Label tokens that refer to this statement.
|
|
20
26
|
def initialize(type, data, symbols)
|
|
21
27
|
@type = type
|
|
22
28
|
@data = data
|
|
@@ -24,6 +30,9 @@ module SeccompTools
|
|
|
24
30
|
end
|
|
25
31
|
|
|
26
32
|
# @param [Statement] other
|
|
33
|
+
# The statement to be compared with.
|
|
34
|
+
# @return [Boolean]
|
|
35
|
+
# +true+ only if the type, data and symbols are all equal.
|
|
27
36
|
def ==(other)
|
|
28
37
|
[type, data, symbols] == [other.type, other.data, other.symbols]
|
|
29
38
|
end
|
|
@@ -3,14 +3,30 @@
|
|
|
3
3
|
module SeccompTools
|
|
4
4
|
module Asm
|
|
5
5
|
# Records information of a token.
|
|
6
|
+
#
|
|
7
|
+
# Beyond the token itself, a {Token} remembers where it was found so that
|
|
8
|
+
# {Scanner#format_error} can point at it in the source.
|
|
6
9
|
class Token
|
|
7
|
-
|
|
10
|
+
# @return [Symbol]
|
|
11
|
+
# Type of this token, e.g. +:NEWLINE+, +:SYMBOL+, +:GOTO+, or +:unknown+ for text the
|
|
12
|
+
# scanner failed to recognize.
|
|
13
|
+
attr_reader :sym
|
|
14
|
+
# @return [String] The matched text.
|
|
15
|
+
attr_reader :str
|
|
16
|
+
# @return [Integer] Zero-based line number where this token starts.
|
|
17
|
+
attr_reader :line
|
|
18
|
+
# @return [Integer] Zero-based column number where this token starts.
|
|
19
|
+
attr_reader :col
|
|
8
20
|
|
|
9
21
|
# Instantiates a {Token} object.
|
|
10
22
|
# @param [Symbol] sym
|
|
23
|
+
# Type of this token.
|
|
11
24
|
# @param [String] str
|
|
25
|
+
# The matched text.
|
|
12
26
|
# @param [Integer] line
|
|
27
|
+
# Zero-based line number where this token starts.
|
|
13
28
|
# @param [Integer] col
|
|
29
|
+
# Zero-based column number where this token starts.
|
|
14
30
|
def initialize(sym, str, line, col)
|
|
15
31
|
@sym = sym
|
|
16
32
|
@str = str
|
|
@@ -20,7 +36,9 @@ module SeccompTools
|
|
|
20
36
|
|
|
21
37
|
# To compare with another {Token} object.
|
|
22
38
|
# @param [Token] other
|
|
39
|
+
# The token to be compared with.
|
|
23
40
|
# @return [Boolean]
|
|
41
|
+
# +true+ only if all four attributes are equal.
|
|
24
42
|
def ==(other)
|
|
25
43
|
[other.sym, other.str, other.line, other.col] == [sym, str, line, col]
|
|
26
44
|
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SeccompTools
|
|
4
|
+
class Audit
|
|
5
|
+
# The seccomp-domain knowledge the checks classify against: which syscalls are dangerous, which
|
|
6
|
+
# are equivalents of each other, and which form a file read/write chain. Entries are by syscall
|
|
7
|
+
# *name*; each check resolves them against the target architecture's own syscall table (a name a
|
|
8
|
+
# given arch lacks is simply skipped), and the +*_BY_ARCH+ maps add arch-specific knowledge on a
|
|
9
|
+
# clean extension point - no arch is hardcoded in the checks themselves.
|
|
10
|
+
module Catalog
|
|
11
|
+
# Dangerous-if-reachable syscalls => +{severity:, why:}+. Applied on every architecture. Kept to
|
|
12
|
+
# a low-noise, high-signal set: syscalls a real sandbox almost never wants. Ubiquitous-but-risky
|
|
13
|
+
# ones (+mmap+/+mprotect+ RWX, decided by the +prot+ argument) are intentionally out of v1 -
|
|
14
|
+
# flagging them needs argument-flag analysis, else every normal allowlist trips them.
|
|
15
|
+
DANGEROUS = {
|
|
16
|
+
execve: { severity: :high, why: 'arbitrary program execution' },
|
|
17
|
+
execveat: { severity: :high, why: 'arbitrary program execution' },
|
|
18
|
+
ptrace: { severity: :high, why: 'inspect/inject into other processes' },
|
|
19
|
+
process_vm_readv: { severity: :high, why: "read another process's memory" },
|
|
20
|
+
process_vm_writev: { severity: :high, why: "write another process's memory" },
|
|
21
|
+
io_uring_setup: {
|
|
22
|
+
severity: :high, why: 'reads/writes/opens as ring operations, bypassing filters on those syscalls'
|
|
23
|
+
},
|
|
24
|
+
socket: { severity: :medium, why: 'network access (exfiltration)' },
|
|
25
|
+
connect: { severity: :medium, why: 'network access (exfiltration)' }
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
# Per-arch additions to {DANGEROUS} (e.g. i386 multiplexes the socket API through +socketcall+).
|
|
29
|
+
DANGEROUS_BY_ARCH = {
|
|
30
|
+
i386: { socketcall: { severity: :medium, why: 'multiplexed socket API (exfiltration)' } }
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
# Equivalent-syscall groups: denying one member while another is allowed is a bypass gap.
|
|
34
|
+
ALT_GROUPS = {
|
|
35
|
+
exec: %i[execve execveat],
|
|
36
|
+
open: %i[open openat openat2],
|
|
37
|
+
read: %i[read readv pread64 preadv preadv2],
|
|
38
|
+
write: %i[write writev pwrite64 pwritev sendfile],
|
|
39
|
+
fork: %i[fork vfork clone clone3]
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
# Per-arch additions to {ALT_GROUPS}.
|
|
43
|
+
ALT_GROUPS_BY_ARCH = {}.freeze
|
|
44
|
+
|
|
45
|
+
# The open/read/write families whose joint availability is a file read/exfil chain.
|
|
46
|
+
ORW = {
|
|
47
|
+
open: %i[open openat openat2],
|
|
48
|
+
read: %i[read readv pread64 preadv preadv2],
|
|
49
|
+
write: %i[write writev pwrite64 pwritev sendfile]
|
|
50
|
+
}.freeze
|
|
51
|
+
|
|
52
|
+
module_function
|
|
53
|
+
|
|
54
|
+
# {DANGEROUS} plus any per-arch additions for +arch_sym+.
|
|
55
|
+
# @param [Symbol?] arch_sym
|
|
56
|
+
# @return [Hash{Symbol=>Hash}]
|
|
57
|
+
def dangerous(arch_sym)
|
|
58
|
+
DANGEROUS.merge(DANGEROUS_BY_ARCH.fetch(arch_sym, {}))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# {ALT_GROUPS} plus any per-arch additions for +arch_sym+.
|
|
62
|
+
# @param [Symbol?] arch_sym
|
|
63
|
+
# @return [Hash{Symbol=>Array<Symbol>}]
|
|
64
|
+
def alt_groups(arch_sym)
|
|
65
|
+
ALT_GROUPS.merge(ALT_GROUPS_BY_ARCH.fetch(arch_sym, {}))
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'seccomp-tools/audit/finding'
|
|
4
|
+
require 'seccomp-tools/explain/verdict'
|
|
5
|
+
|
|
6
|
+
module SeccompTools
|
|
7
|
+
class Audit
|
|
8
|
+
module Checks
|
|
9
|
+
# The filter does not validate the architecture, or lets an unlisted one reach +ALLOW+. Syscall
|
|
10
|
+
# numbers are ABI-relative, so number-only rules are dodged by invoking under another ABI.
|
|
11
|
+
module ArchUnchecked
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# @param [Explain::Analysis] analysis
|
|
15
|
+
# @return [Array<Audit::Finding>]
|
|
16
|
+
def call(analysis)
|
|
17
|
+
if analysis.arch_values.empty?
|
|
18
|
+
[finding('Architecture is never validated',
|
|
19
|
+
'The filter checks syscall numbers without ever comparing data[4] (arch). ' \
|
|
20
|
+
'Numbers mean different syscalls under another AUDIT_ARCH, so the checks can be ' \
|
|
21
|
+
'dodged by invoking through a different ABI (e.g. i386 numbering on amd64).')]
|
|
22
|
+
elsif analysis.other_leaves.any? { |l| Explain::Verdict.label(l.ret) == 'ALLOW' }
|
|
23
|
+
[finding('Unlisted architectures reach ALLOW',
|
|
24
|
+
'Some paths reach ALLOW on an architecture the filter does not explicitly ' \
|
|
25
|
+
'check, so a different-ABI call can slip past the syscall-number rules.')]
|
|
26
|
+
else
|
|
27
|
+
[]
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @!visibility private
|
|
32
|
+
def finding(title, detail)
|
|
33
|
+
Finding.new(id: 'arch-unchecked', severity: :high, arch: nil, title:, detail:,
|
|
34
|
+
syscalls: [], condition: nil,
|
|
35
|
+
remediation: 'Compare data[4] against your AUDIT_ARCH_* and KILL every ' \
|
|
36
|
+
'architecture you do not explicitly handle.')
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'seccomp-tools/audit/catalog'
|
|
4
|
+
require 'seccomp-tools/audit/finding'
|
|
5
|
+
|
|
6
|
+
module SeccompTools
|
|
7
|
+
class Audit
|
|
8
|
+
module Checks
|
|
9
|
+
# A syscall a sandbox almost never wants ({Catalog#dangerous}) is reachable as +ALLOW+. One
|
|
10
|
+
# finding per syscall, carrying the argument condition (if any) under which it is allowed.
|
|
11
|
+
module DangerousAllow
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# @param [Audit::Policy] policy
|
|
15
|
+
# @return [Array<Audit::Finding>]
|
|
16
|
+
def call(policy)
|
|
17
|
+
Catalog.dangerous(policy.arch_sym).filter_map do |name, meta|
|
|
18
|
+
nr = policy.number(name)
|
|
19
|
+
next unless nr && policy.reachable_as_allow?(nr)
|
|
20
|
+
|
|
21
|
+
cond = policy.condition_for(nr)
|
|
22
|
+
Finding.new(
|
|
23
|
+
id: 'dangerous-allow', severity: meta[:severity], arch: policy.arch_name,
|
|
24
|
+
title: "#{name} is allowed",
|
|
25
|
+
detail: "#{name} reaches ALLOW - #{meta[:why]}.",
|
|
26
|
+
syscalls: [name.to_s], condition: cond,
|
|
27
|
+
remediation: "Block #{name} unless the program genuinely needs it."
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'seccomp-tools/audit/catalog'
|
|
4
|
+
require 'seccomp-tools/audit/finding'
|
|
5
|
+
|
|
6
|
+
module SeccompTools
|
|
7
|
+
class Audit
|
|
8
|
+
module Checks
|
|
9
|
+
# An open-family, a read-family and a write/send-family syscall are all reachable as +ALLOW+ -
|
|
10
|
+
# the classic "open the flag, read it, write it out" chain.
|
|
11
|
+
module OrwChain
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# @param [Audit::Policy] policy
|
|
15
|
+
# @return [Array<Audit::Finding>]
|
|
16
|
+
def call(policy)
|
|
17
|
+
o = first_allowed(policy, Catalog::ORW[:open])
|
|
18
|
+
r = first_allowed(policy, Catalog::ORW[:read])
|
|
19
|
+
w = first_allowed(policy, Catalog::ORW[:write])
|
|
20
|
+
return [] unless o && r && w
|
|
21
|
+
|
|
22
|
+
[Finding.new(
|
|
23
|
+
id: 'orw-chain', severity: :high, arch: policy.arch_name,
|
|
24
|
+
title: 'A file can be opened and its contents copied out',
|
|
25
|
+
# The syscall names are painted wherever they appear, so keep them out of prose that
|
|
26
|
+
# merely describes the actions - only name one where the syscall itself is meant.
|
|
27
|
+
detail: "#{o}, #{r} and #{w} all reach ALLOW, so the contents of an arbitrary file " \
|
|
28
|
+
'(e.g. the flag) can be copied straight back out.',
|
|
29
|
+
syscalls: [o, r, w].map(&:to_s), condition: nil,
|
|
30
|
+
remediation: 'Deny the open-family syscalls unless the program genuinely needs arbitrary files.'
|
|
31
|
+
)]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @!visibility private
|
|
35
|
+
def first_allowed(policy, names)
|
|
36
|
+
names.find { |n| (nr = policy.number(n)) && policy.reachable_as_allow?(nr) }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'seccomp-tools/audit/finding'
|
|
4
|
+
|
|
5
|
+
module SeccompTools
|
|
6
|
+
class Audit
|
|
7
|
+
module Checks
|
|
8
|
+
# The catch-all action is +ALLOW+: a denylist, bypassable by anything the author forgot.
|
|
9
|
+
module PermissiveDefault
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# @param [Audit::Policy] policy
|
|
13
|
+
# @return [Array<Audit::Finding>]
|
|
14
|
+
def call(policy)
|
|
15
|
+
return [] unless policy.default_label == 'ALLOW'
|
|
16
|
+
|
|
17
|
+
[Finding.new(
|
|
18
|
+
id: 'permissive-default', severity: :high, arch: policy.arch_name,
|
|
19
|
+
title: 'Default action is ALLOW (denylist)',
|
|
20
|
+
detail: 'Any syscall the filter does not explicitly block is allowed; a denylist is ' \
|
|
21
|
+
'bypassable by any syscall the author overlooked.',
|
|
22
|
+
syscalls: [], condition: nil,
|
|
23
|
+
remediation: 'Use an allowlist: default to KILL/ERRNO and permit only the needed syscalls.'
|
|
24
|
+
)]
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'seccomp-tools/audit/catalog'
|
|
4
|
+
require 'seccomp-tools/audit/finding'
|
|
5
|
+
|
|
6
|
+
module SeccompTools
|
|
7
|
+
class Audit
|
|
8
|
+
module Checks
|
|
9
|
+
# An equivalent-syscall gap: a group member is singled out for denial while a sibling with the
|
|
10
|
+
# same capability still reaches +ALLOW+ (blocked +execve+ but not +execveat+, +open+ but not
|
|
11
|
+
# +openat+, +fork+ but not +clone+).
|
|
12
|
+
module SyscallAltGap
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# @param [Audit::Policy] policy
|
|
16
|
+
# @return [Array<Audit::Finding>]
|
|
17
|
+
def call(policy)
|
|
18
|
+
Catalog.alt_groups(policy.arch_sym).filter_map do |group, names|
|
|
19
|
+
present = names.filter_map { |n| [n, policy.number(n)] if policy.number(n) }
|
|
20
|
+
denied = present.select { |_n, nr| policy.explicitly_denied?(nr) }.map(&:first)
|
|
21
|
+
allowed = present.select { |_n, nr| policy.reachable_as_allow?(nr) }.map(&:first)
|
|
22
|
+
next if denied.empty? || allowed.empty?
|
|
23
|
+
|
|
24
|
+
finding(policy, group, denied, allowed)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @!visibility private
|
|
29
|
+
def finding(policy, group, denied, allowed)
|
|
30
|
+
Finding.new(
|
|
31
|
+
id: 'syscall-alt-gap', severity: %i[exec open].include?(group) ? :high : :medium,
|
|
32
|
+
arch: policy.arch_name,
|
|
33
|
+
title: "#{denied.join('/')} blocked but #{allowed.join('/')} allowed",
|
|
34
|
+
detail: "#{denied.join(', ')} denied, but the equivalent #{allowed.join(', ')} reaches " \
|
|
35
|
+
'ALLOW - same capability, different syscall number.',
|
|
36
|
+
# Both sides: the finding is about the pair, and the detail says which is which.
|
|
37
|
+
syscalls: (denied + allowed).map(&:to_s), condition: nil,
|
|
38
|
+
remediation: "Deny every equivalent in the group: also block #{allowed.join(', ')}."
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'seccomp-tools/audit/finding'
|
|
4
|
+
|
|
5
|
+
module SeccompTools
|
|
6
|
+
class Audit
|
|
7
|
+
module Checks
|
|
8
|
+
# amd64's x32 ABI quirk: x32 syscalls share +AUDIT_ARCH_X86_64+ but number as +nr | 0x40000000+.
|
|
9
|
+
# Without a +sys_number >= 0x40000000+ (or +jset 0x40000000+) guard, a syscall blocked by its
|
|
10
|
+
# native number is still reachable through its x32 number. Registered only for amd64, so no
|
|
11
|
+
# other architecture is ever mis-flagged.
|
|
12
|
+
module X32Guard
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# @param [Audit::Policy] policy
|
|
16
|
+
# @return [Array<Audit::Finding>]
|
|
17
|
+
def call(policy)
|
|
18
|
+
sharp = policy.table.filter_map do |name, nr|
|
|
19
|
+
next if name.to_s.start_with?('x32_')
|
|
20
|
+
|
|
21
|
+
x = policy.number(:"x32_#{name}")
|
|
22
|
+
name if x && policy.reachable_as_allow?(x) && !policy.reachable_as_allow?(nr)
|
|
23
|
+
end
|
|
24
|
+
return [] if sharp.empty?
|
|
25
|
+
|
|
26
|
+
[finding(policy, sharp)]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @!visibility private
|
|
30
|
+
def finding(policy, sharp)
|
|
31
|
+
shown = sharp.first(8).map(&:to_s)
|
|
32
|
+
more = sharp.size > shown.size ? ", ... (+#{sharp.size - shown.size} more)" : ''
|
|
33
|
+
Finding.new(
|
|
34
|
+
id: 'x32-guard', severity: :high, arch: policy.arch_name,
|
|
35
|
+
title: 'x32 ABI is not guarded',
|
|
36
|
+
detail: 'Syscalls blocked by their native number are reachable via their x32 number ' \
|
|
37
|
+
"(nr | 0x40000000): #{shown.join(', ')}#{more}.",
|
|
38
|
+
syscalls: shown, condition: nil,
|
|
39
|
+
remediation: 'After the arch check, KILL when sys_number >= 0x40000000 ' \
|
|
40
|
+
'(or jset 0x40000000).'
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'seccomp-tools/audit/checks/arch_unchecked'
|
|
4
|
+
require 'seccomp-tools/audit/checks/dangerous_allow'
|
|
5
|
+
require 'seccomp-tools/audit/checks/orw_chain'
|
|
6
|
+
require 'seccomp-tools/audit/checks/permissive_default'
|
|
7
|
+
require 'seccomp-tools/audit/checks/syscall_alt_gap'
|
|
8
|
+
require 'seccomp-tools/audit/checks/x32_guard'
|
|
9
|
+
|
|
10
|
+
module SeccompTools
|
|
11
|
+
class Audit
|
|
12
|
+
# The registries the {Audit} engine runs. A check is anything answering +call+ and returning
|
|
13
|
+
# {Finding}s; which registry it is listed in decides both how often it runs and what it is
|
|
14
|
+
# handed, so adding one is a single entry here rather than a change to the engine.
|
|
15
|
+
#
|
|
16
|
+
# * {FILTER} - asked once about the whole filter, and handed the {Explain::Analysis}. For
|
|
17
|
+
# questions no single architecture can answer, such as whether the filter checks the
|
|
18
|
+
# architecture at all.
|
|
19
|
+
# * {SECTION} / {SECTION_BY_ARCH} - asked once per architecture, and handed that architecture's
|
|
20
|
+
# {Policy}. {SECTION_BY_ARCH} keeps a quirk confined to the architecture that has it, so it is
|
|
21
|
+
# a one-line entry rather than a branch inside a check.
|
|
22
|
+
module Checks
|
|
23
|
+
# Whole-filter checks; each takes the {Explain::Analysis}.
|
|
24
|
+
FILTER = [ArchUnchecked].freeze
|
|
25
|
+
|
|
26
|
+
# Per-architecture checks that apply everywhere; each takes a {Policy}.
|
|
27
|
+
SECTION = [PermissiveDefault, SyscallAltGap, OrwChain, DangerousAllow].freeze
|
|
28
|
+
|
|
29
|
+
# Per-architecture checks that apply only to the keyed architecture (amd64's x32 is the exemplar).
|
|
30
|
+
SECTION_BY_ARCH = { amd64: [X32Guard] }.freeze
|
|
31
|
+
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
# The per-architecture checks to run for +arch_sym+: the common ones plus any it alone has.
|
|
35
|
+
# @param [Symbol?] arch_sym
|
|
36
|
+
# @return [Array]
|
|
37
|
+
def section_checks(arch_sym)
|
|
38
|
+
SECTION + SECTION_BY_ARCH.fetch(arch_sym, [])
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SeccompTools
|
|
4
|
+
class Audit
|
|
5
|
+
# Severities, most to least severe. Drives ordering and (for +human+ output) coloring.
|
|
6
|
+
SEVERITIES = %i[high medium low].freeze
|
|
7
|
+
|
|
8
|
+
# One weakness reported by a {Check}: a stable +id+, a +severity+, a human +title+/+detail+, the
|
|
9
|
+
# architecture it applies to, the syscalls involved, an optional argument +condition+ under which
|
|
10
|
+
# it holds, and a one-line +remediation+.
|
|
11
|
+
Finding = Struct.new(:id, :severity, :title, :detail, :arch, :syscalls, :condition, :remediation,
|
|
12
|
+
keyword_init: true) do
|
|
13
|
+
# Sort key: by severity, then id, then the affected syscalls.
|
|
14
|
+
# @return [Array]
|
|
15
|
+
def rank
|
|
16
|
+
[SEVERITIES.index(severity) || SEVERITIES.size, id, Array(syscalls).join(',')]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @return [Hash] JSON-ready shape.
|
|
20
|
+
def to_h
|
|
21
|
+
{ id:, severity:, title:, detail:, arch:, syscalls: Array(syscalls), condition:, remediation: }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'seccomp-tools/const'
|
|
4
|
+
require 'seccomp-tools/explain/qword'
|
|
5
|
+
require 'seccomp-tools/explain/renderer'
|
|
6
|
+
require 'seccomp-tools/explain/verdict'
|
|
7
|
+
require 'seccomp-tools/symbolic/constraint'
|
|
8
|
+
|
|
9
|
+
module SeccompTools
|
|
10
|
+
class Audit
|
|
11
|
+
# One architecture's view of a filter: given a syscall number, which actions can an attacker
|
|
12
|
+
# reach? Built from that arch's section leaves (already arch-filtered by {Explain::Analysis}).
|
|
13
|
+
#
|
|
14
|
+
# A leaf is reachable for syscall +nr+ when every plain +sys_number+ fact on its path is satisfied
|
|
15
|
+
# by +nr+ - this one predicate covers +==+, ranges, and +jset+ bit-tests (so it sees an x32 guard
|
|
16
|
+
# written either way). Argument and opaque facts are *not* consulted: arguments are
|
|
17
|
+
# attacker-controlled and the executor already dropped self-contradictory paths, so a surviving
|
|
18
|
+
# leaf is reachable under some argument choice. The stance is deliberately conservative - "could an
|
|
19
|
+
# attacker reach this action?".
|
|
20
|
+
class Policy
|
|
21
|
+
SYS = Const::BPF::SeccompData::SYS_NUMBER
|
|
22
|
+
|
|
23
|
+
# @return [Integer?] The +AUDIT_ARCH+ value (+nil+ when the filter never branches on +arch+).
|
|
24
|
+
attr_reader :arch_val
|
|
25
|
+
# @return [Symbol?] The architecture symbol whose syscall names apply (+nil+ when unknown).
|
|
26
|
+
attr_reader :arch_sym
|
|
27
|
+
# @return [Object] Display title for this section (arch symbol or +0x... (unknown)+).
|
|
28
|
+
attr_reader :title
|
|
29
|
+
# @return [Array<Symbolic::Executor::Leaf>] This section's leaves.
|
|
30
|
+
attr_reader :leaves
|
|
31
|
+
|
|
32
|
+
# @param [Explain::Analysis] analysis
|
|
33
|
+
# @param [Array] section One +[arch_val, arch_sym, title, leaves]+ entry from {Explain::Analysis#sections}.
|
|
34
|
+
def initialize(analysis, section)
|
|
35
|
+
@analysis = analysis
|
|
36
|
+
@arch_val, @arch_sym, @title, @leaves = section
|
|
37
|
+
@fusion = @arch_sym && Explain::QwordFusion.new(@arch_sym)
|
|
38
|
+
@renderer = @fusion && Explain::Renderer.new(@fusion)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# A display name for this section's architecture (+"amd64"+, or +"0x... (unknown)"+).
|
|
42
|
+
# @return [String]
|
|
43
|
+
def arch_name
|
|
44
|
+
(@arch_sym || @title).to_s
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# This arch's +name => number+ table, or +nil+ when the section has no known architecture.
|
|
48
|
+
#
|
|
49
|
+
# +@arch_sym+ is always +nil+ or a supported architecture (the CLI rejects an undetectable host
|
|
50
|
+
# before we get here), so the table lookup never fails.
|
|
51
|
+
# @return [Hash{Symbol=>Integer}?]
|
|
52
|
+
def table
|
|
53
|
+
return @table if defined?(@table)
|
|
54
|
+
|
|
55
|
+
@table = @arch_sym && Const::Syscall.const_get(@arch_sym.upcase)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The number of syscall +name+ on this arch, or +nil+ if the arch lacks it.
|
|
59
|
+
# @param [Symbol] name
|
|
60
|
+
# @return [Integer?]
|
|
61
|
+
def number(name)
|
|
62
|
+
table && table[name]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The action of the catch-all (default) path, e.g. +"ALLOW"+ / +"ERRNO(5)"+.
|
|
66
|
+
# @return [String?]
|
|
67
|
+
def default_label
|
|
68
|
+
@analysis.default_label(@leaves)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The distinct actions reachable for syscall number +nr+.
|
|
72
|
+
# @param [Integer] nr
|
|
73
|
+
# @return [Array<String>]
|
|
74
|
+
def reachable_actions(nr)
|
|
75
|
+
reachable_leaves(nr).map { |l| Explain::Verdict.label(l.ret) }.uniq
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Can syscall +nr+ reach +ALLOW+?
|
|
79
|
+
# @param [Integer] nr
|
|
80
|
+
# @return [Boolean]
|
|
81
|
+
def reachable_as_allow?(nr)
|
|
82
|
+
reachable_leaves(nr).any? { |l| Explain::Verdict.label(l.ret) == 'ALLOW' }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Was syscall +nr+ singled out for denial (a rule pins +sys_number == nr+ to a non-+ALLOW+
|
|
86
|
+
# action), as opposed to merely being absent from an allowlist? Distinguishes a real
|
|
87
|
+
# "blocked one equivalent but not another" gap from allowlist omissions.
|
|
88
|
+
# @param [Integer] nr
|
|
89
|
+
# @return [Boolean]
|
|
90
|
+
def explicitly_denied?(nr)
|
|
91
|
+
!reachable_as_allow?(nr) &&
|
|
92
|
+
@leaves.any? { |l| @analysis.facts(l).sys_eq == nr && Explain::Verdict.label(l.ret) != 'ALLOW' }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The argument condition under which +nr+ reaches +action+, rendered like +explain+
|
|
96
|
+
# (+"filename == 0x..."+), or +nil+ when it is unconditional or the arch is unknown.
|
|
97
|
+
# @param [Integer] nr
|
|
98
|
+
# @param [String] action
|
|
99
|
+
# @return [String?]
|
|
100
|
+
def condition_for(nr, action = 'ALLOW')
|
|
101
|
+
return nil unless @renderer
|
|
102
|
+
|
|
103
|
+
ls = reachable_leaves(nr).select { |l| Explain::Verdict.label(l.ret) == action }
|
|
104
|
+
conds = @fusion.merge_or(ls.map { |l| @analysis.facts(l).residual })
|
|
105
|
+
.map { |list| @renderer.conjunction(@fusion.fold(list), name_of(nr)) }.uniq
|
|
106
|
+
conds.include?('') ? nil : conds.join(' or ')
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def name_of(nr)
|
|
112
|
+
table && table.invert[nr]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def reachable_leaves(nr)
|
|
116
|
+
@leaves.select { |l| sys_satisfied?(l, nr) }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def sys_satisfied?(leaf, nr)
|
|
120
|
+
leaf.path.all? do |c|
|
|
121
|
+
!c.plain_data_fact?(SYS) || Symbolic::Constraint.evaluate(nr, c.op, c.rhs.val)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|