seccomp-tools 1.6.1 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +153 -0
- data/LICENSE +21 -0
- data/README.md +267 -46
- data/completions/_seccomp-tools +83 -0
- data/completions/seccomp-tools.bash +59 -0
- data/completions/seccomp-tools.fish +52 -0
- data/ext/ptrace/ptrace.c +2 -2
- data/lib/seccomp-tools/asm/asm.rb +9 -4
- data/lib/seccomp-tools/asm/compiler.rb +39 -9
- data/lib/seccomp-tools/asm/sasm.tab.rb +31 -21
- data/lib/seccomp-tools/asm/sasm.y +15 -7
- data/lib/seccomp-tools/asm/scalar.rb +50 -7
- data/lib/seccomp-tools/asm/scanner.rb +40 -8
- data/lib/seccomp-tools/asm/statement.rb +14 -5
- data/lib/seccomp-tools/asm/token.rb +19 -1
- data/lib/seccomp-tools/audit/catalog.rb +66 -0
- data/lib/seccomp-tools/audit/checks/arch_unchecked.rb +41 -0
- data/lib/seccomp-tools/audit/checks/dangerous_allow.rb +34 -0
- data/lib/seccomp-tools/audit/checks/orw_chain.rb +41 -0
- data/lib/seccomp-tools/audit/checks/permissive_default.rb +29 -0
- data/lib/seccomp-tools/audit/checks/syscall_alt_gap.rb +44 -0
- data/lib/seccomp-tools/audit/checks/x32_guard.rb +46 -0
- data/lib/seccomp-tools/audit/checks.rb +42 -0
- data/lib/seccomp-tools/audit/finding.rb +25 -0
- data/lib/seccomp-tools/audit/policy.rb +126 -0
- data/lib/seccomp-tools/audit/report.rb +98 -0
- data/lib/seccomp-tools/audit.rb +48 -0
- data/lib/seccomp-tools/bpf.rb +27 -17
- data/lib/seccomp-tools/cli/asm.rb +7 -3
- data/lib/seccomp-tools/cli/audit.rb +86 -0
- data/lib/seccomp-tools/cli/base.rb +51 -8
- data/lib/seccomp-tools/cli/cli.rb +10 -6
- data/lib/seccomp-tools/cli/completion.rb +40 -0
- data/lib/seccomp-tools/cli/disasm.rb +10 -6
- data/lib/seccomp-tools/cli/dump.rb +37 -52
- data/lib/seccomp-tools/cli/dumpable.rb +79 -0
- data/lib/seccomp-tools/cli/emu.rb +32 -9
- data/lib/seccomp-tools/cli/explain.rb +51 -0
- data/lib/seccomp-tools/cli/filter_input.rb +130 -0
- data/lib/seccomp-tools/const.rb +98 -15
- data/lib/seccomp-tools/consts/sys_nr/amd64.rb +1 -1
- data/lib/seccomp-tools/consts/sys_nr/riscv64.rb +332 -0
- data/lib/seccomp-tools/disasm/disasm.rb +31 -13
- data/lib/seccomp-tools/dumper.rb +67 -35
- data/lib/seccomp-tools/emulator.rb +41 -16
- data/lib/seccomp-tools/error.rb +4 -2
- data/lib/seccomp-tools/explain/analysis.rb +67 -0
- data/lib/seccomp-tools/explain/path_facts.rb +110 -0
- data/lib/seccomp-tools/explain/qword.rb +204 -0
- data/lib/seccomp-tools/explain/renderer.rb +128 -0
- data/lib/seccomp-tools/explain/summary.rb +218 -0
- data/lib/seccomp-tools/explain/verdict.rb +44 -0
- data/lib/seccomp-tools/explain.rb +38 -0
- data/lib/seccomp-tools/instruction/alu.rb +14 -9
- data/lib/seccomp-tools/instruction/base.rb +39 -10
- data/lib/seccomp-tools/instruction/jmp.rb +50 -23
- data/lib/seccomp-tools/instruction/ld.rb +46 -21
- data/lib/seccomp-tools/instruction/ldx.rb +4 -3
- data/lib/seccomp-tools/instruction/misc.rb +11 -9
- data/lib/seccomp-tools/instruction/ret.rb +12 -6
- data/lib/seccomp-tools/instruction/st.rb +15 -6
- data/lib/seccomp-tools/instruction/stx.rb +4 -3
- data/lib/seccomp-tools/logger.rb +15 -2
- data/lib/seccomp-tools/symbolic/constraint.rb +80 -0
- data/lib/seccomp-tools/symbolic/executor.rb +210 -0
- data/lib/seccomp-tools/symbolic/expr.rb +185 -0
- data/lib/seccomp-tools/symbolic/state.rb +82 -0
- data/lib/seccomp-tools/syscall.rb +74 -22
- data/lib/seccomp-tools/util.rb +70 -11
- data/lib/seccomp-tools/version.rb +1 -1
- data/lib/seccomp-tools.rb +10 -1
- metadata +83 -11
- data/lib/seccomp-tools/disasm/context.rb +0 -171
|
@@ -0,0 +1,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
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'seccomp-tools/audit/finding'
|
|
4
|
+
require 'seccomp-tools/util'
|
|
5
|
+
|
|
6
|
+
module SeccompTools
|
|
7
|
+
class Audit
|
|
8
|
+
# The findings for one filter, rendered either as a human report or a JSON-ready hash.
|
|
9
|
+
class Report
|
|
10
|
+
# Severity => {Util.colorize} theme for the +[SEVERITY]+ tag.
|
|
11
|
+
SEVERITY_THEME = { high: :error, medium: :warn, low: :info }.freeze
|
|
12
|
+
|
|
13
|
+
# Widest a rendered line may get, so a finding reads without wrapping on a standard terminal.
|
|
14
|
+
WIDTH = 120
|
|
15
|
+
|
|
16
|
+
# @param [String?] source Label for the audited filter.
|
|
17
|
+
# @param [Array<String>] arches The architectures covered.
|
|
18
|
+
# @param [Array<Finding>] findings
|
|
19
|
+
# @param [Boolean] truncated Whether the symbolic walk was cut short.
|
|
20
|
+
def initialize(source:, arches:, findings:, truncated:)
|
|
21
|
+
@source = source
|
|
22
|
+
@arches = arches
|
|
23
|
+
@findings = findings.sort_by(&:rank)
|
|
24
|
+
@truncated = truncated
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @return [Array<Finding>]
|
|
28
|
+
attr_reader :findings
|
|
29
|
+
# @return [Array<String>] The architectures covered.
|
|
30
|
+
attr_reader :arches
|
|
31
|
+
|
|
32
|
+
# The human report.
|
|
33
|
+
# @return [String]
|
|
34
|
+
def to_s
|
|
35
|
+
out = +''
|
|
36
|
+
out << "Seccomp audit of #{@source}\n" if @source
|
|
37
|
+
out << "Architectures: #{@arches.join(', ')}\n" unless @arches.empty?
|
|
38
|
+
# A truncated walk can only hide weaknesses, so it qualifies the whole report rather than
|
|
39
|
+
# being a finding of its own.
|
|
40
|
+
out << "WARNING: analysis truncated (filter too large); results may be incomplete.\n" if @truncated
|
|
41
|
+
return out << "\nNo weaknesses found.\n" if @findings.empty?
|
|
42
|
+
|
|
43
|
+
@findings.each { |f| out << render(f) }
|
|
44
|
+
out
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [Hash] JSON-ready shape for one filter.
|
|
48
|
+
def to_h
|
|
49
|
+
{ source: @source, arches: @arches, truncated: @truncated, findings: @findings.map(&:to_h) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def render(finding)
|
|
55
|
+
tag = Util.colorize("[#{finding.severity.to_s.upcase}]", t: SEVERITY_THEME[finding.severity])
|
|
56
|
+
arch = finding.arch ? " (#{Util.colorize(finding.arch, t: :arch)})" : ''
|
|
57
|
+
names = finding.syscalls
|
|
58
|
+
out = "\n#{tag} #{highlight(finding.title, names)}#{arch}\n"
|
|
59
|
+
out << paragraph(finding.detail, names, ' ', ' ')
|
|
60
|
+
out << paragraph(finding.condition, names, ' when: ', ' ') if finding.condition
|
|
61
|
+
out << paragraph(finding.remediation, names, ' fix: ', ' ') if finding.remediation
|
|
62
|
+
out
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# +text+ wrapped to {WIDTH} columns, its first line prefixed with +first+ and the rest with
|
|
66
|
+
# +hang+ so they read as one block. Wrapping is measured before coloring, so the invisible
|
|
67
|
+
# escape codes never count towards the width.
|
|
68
|
+
# @return [String]
|
|
69
|
+
def paragraph(text, names, first, hang)
|
|
70
|
+
lines = []
|
|
71
|
+
indent = first
|
|
72
|
+
line = nil
|
|
73
|
+
text.to_s.split(/\s+/).each do |word|
|
|
74
|
+
if line.nil?
|
|
75
|
+
line = word
|
|
76
|
+
elsif indent.size + line.size + 1 + word.size <= WIDTH
|
|
77
|
+
line = "#{line} #{word}"
|
|
78
|
+
else
|
|
79
|
+
lines << (indent + line)
|
|
80
|
+
indent = hang
|
|
81
|
+
line = word
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
lines << (indent + line) if line
|
|
85
|
+
lines.map { |l| "#{highlight(l, names)}\n" }.join
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Paints the syscall names a finding is about wherever they appear in +text+, in the same color
|
|
89
|
+
# disasm gives them. Whole words only, so +read+ leaves +process_vm_readv+ alone.
|
|
90
|
+
# @return [String]
|
|
91
|
+
def highlight(text, names)
|
|
92
|
+
Array(names).uniq.reduce(text) do |painted, name|
|
|
93
|
+
painted.gsub(/\b#{Regexp.escape(name)}\b/) { Util.colorize(name, t: :syscall) }
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'seccomp-tools/audit/checks'
|
|
4
|
+
require 'seccomp-tools/audit/policy'
|
|
5
|
+
require 'seccomp-tools/audit/report'
|
|
6
|
+
require 'seccomp-tools/explain/analysis'
|
|
7
|
+
require 'seccomp-tools/symbolic/executor'
|
|
8
|
+
|
|
9
|
+
module SeccompTools
|
|
10
|
+
# Assesses a seccomp filter for weaknesses / escape routes and reports them as a {Audit::Report}.
|
|
11
|
+
#
|
|
12
|
+
# It runs the generic {Symbolic::Executor} over the filter (the same walk {Explain} uses), splits
|
|
13
|
+
# the leaves per architecture with {Explain::Analysis}, and runs each {Checks} rule against the
|
|
14
|
+
# per-arch {Policy}. Every supported architecture is assessed; architecture-specific quirks (e.g.
|
|
15
|
+
# amd64's x32 ABI) are registered per arch in {Checks}, never special-cased inline.
|
|
16
|
+
#
|
|
17
|
+
# @example
|
|
18
|
+
# insts = SeccompTools::Disasm.to_bpf(raw, :amd64).map(&:inst)
|
|
19
|
+
# puts SeccompTools::Audit.new(insts, arch: :amd64, source: 'a.out').audit
|
|
20
|
+
class Audit
|
|
21
|
+
# @param [Array<Instruction::Base>] instructions
|
|
22
|
+
# The filter, as +SeccompTools::Disasm.to_bpf(raw, arch).map(&:inst)+.
|
|
23
|
+
# @param [Symbol] arch
|
|
24
|
+
# The architecture the filter is written for, used when it does not itself branch on +arch+.
|
|
25
|
+
# @param [String?] source
|
|
26
|
+
# A label for the filter (e.g. a filename) shown in the report.
|
|
27
|
+
def initialize(instructions, arch:, source: nil)
|
|
28
|
+
@instructions = instructions
|
|
29
|
+
@arch = arch
|
|
30
|
+
@source = source
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Walks the filter, runs every check, and returns the {Report}.
|
|
34
|
+
# @return [Report]
|
|
35
|
+
def audit
|
|
36
|
+
leaves, truncated = Symbolic::Executor.new(@instructions).run
|
|
37
|
+
analysis = Explain::Analysis.new(leaves)
|
|
38
|
+
policies = analysis.sections(@arch).map { |section| Policy.new(analysis, section) }
|
|
39
|
+
|
|
40
|
+
findings = Checks::FILTER.flat_map { |check| check.call(analysis) }
|
|
41
|
+
policies.each do |policy|
|
|
42
|
+
Checks.section_checks(policy.arch_sym).each { |check| findings.concat(check.call(policy)) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
Report.new(source: @source, arches: policies.map(&:arch_name), findings:, truncated:)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
data/lib/seccomp-tools/bpf.rb
CHANGED
|
@@ -7,7 +7,10 @@ require 'seccomp-tools/const'
|
|
|
7
7
|
require 'seccomp-tools/instruction/instruction'
|
|
8
8
|
|
|
9
9
|
module SeccompTools
|
|
10
|
-
#
|
|
10
|
+
# One BPF instruction, i.e. a +struct sock_filter+.
|
|
11
|
+
#
|
|
12
|
+
# Beyond the four fields of the C struct, a {BPF} also carries the architecture it belongs to and
|
|
13
|
+
# its line number, which together allow it to be disassembled into readable assembly.
|
|
11
14
|
class BPF
|
|
12
15
|
# @return [Integer] Line number.
|
|
13
16
|
attr_reader :line
|
|
@@ -21,12 +24,13 @@ module SeccompTools
|
|
|
21
24
|
attr_reader :k
|
|
22
25
|
# @return [Symbol] Architecture.
|
|
23
26
|
attr_reader :arch
|
|
24
|
-
# @return [Set<
|
|
25
|
-
attr_accessor :
|
|
27
|
+
# @return [Set<SeccompTools::Symbolic::State>] Possible states before this instruction.
|
|
28
|
+
attr_accessor :states
|
|
26
29
|
|
|
27
30
|
# Instantiate a {BPF} object.
|
|
28
|
-
# @param [String] raw
|
|
29
|
-
# One +struct sock_filter
|
|
31
|
+
# @param [String, {Symbol => Integer}] raw
|
|
32
|
+
# One +struct sock_filter+, either as 8 raw bytes or as a hash of the +:code+, +:jt+, +:jf+
|
|
33
|
+
# and +:k+ fields.
|
|
30
34
|
# @param [Symbol] arch
|
|
31
35
|
# Architecture, for showing constant names in decompile.
|
|
32
36
|
# @param [Integer] line
|
|
@@ -47,7 +51,7 @@ module SeccompTools
|
|
|
47
51
|
end
|
|
48
52
|
@arch = arch
|
|
49
53
|
@line = line
|
|
50
|
-
@
|
|
54
|
+
@states = Set.new
|
|
51
55
|
@disasm_setting = {
|
|
52
56
|
code: true,
|
|
53
57
|
arg_infer: true
|
|
@@ -56,8 +60,11 @@ module SeccompTools
|
|
|
56
60
|
|
|
57
61
|
# Pretty display the disassemble result.
|
|
58
62
|
# @param [{Symbol => Boolean}] options
|
|
59
|
-
#
|
|
63
|
+
# Display settings, merged into the current ones. Supports +:code+, whether to show the raw
|
|
64
|
+
# +code+, +jt+, +jf+ and +k+ fields, and +:arg_infer+, whether to annotate the line with the
|
|
65
|
+
# inferred syscall argument.
|
|
60
66
|
# @return [String]
|
|
67
|
+
# One line of disassembly, without a trailing newline.
|
|
61
68
|
def disasm(**options)
|
|
62
69
|
@disasm_setting.merge!(options)
|
|
63
70
|
if show_code?
|
|
@@ -69,12 +76,14 @@ module SeccompTools
|
|
|
69
76
|
end
|
|
70
77
|
end
|
|
71
78
|
|
|
72
|
-
# Whether
|
|
79
|
+
# Whether the raw +code+, +jt+, +jf+, +k+ fields need to be dumped.
|
|
80
|
+
# @return [Boolean]
|
|
73
81
|
def show_code?
|
|
74
82
|
@disasm_setting[:code]
|
|
75
83
|
end
|
|
76
84
|
|
|
77
|
-
# Whether
|
|
85
|
+
# Whether the syscall argument names need to be inferred.
|
|
86
|
+
# @return [Boolean]
|
|
78
87
|
def show_arg_infer?
|
|
79
88
|
@disasm_setting[:arg_infer]
|
|
80
89
|
end
|
|
@@ -88,8 +97,8 @@ module SeccompTools
|
|
|
88
97
|
end
|
|
89
98
|
|
|
90
99
|
# Command according to +code+.
|
|
91
|
-
# @return [Symbol]
|
|
92
|
-
# See {Const::BPF::COMMAND} for list of commands.
|
|
100
|
+
# @return [Symbol?]
|
|
101
|
+
# See {Const::BPF::COMMAND} for the list of commands, +nil+ if +code+ is invalid.
|
|
93
102
|
def command
|
|
94
103
|
Const::BPF::COMMAND.invert[code & 7]
|
|
95
104
|
end
|
|
@@ -101,15 +110,16 @@ module SeccompTools
|
|
|
101
110
|
inst.decompile
|
|
102
111
|
end
|
|
103
112
|
|
|
104
|
-
#
|
|
105
|
-
#
|
|
113
|
+
# Yields every branch that may be taken after executing this instruction.
|
|
114
|
+
# @param [SeccompTools::Symbolic::State] state
|
|
115
|
+
# Current state.
|
|
106
116
|
# @yieldparam [Integer] pc
|
|
107
117
|
# Program counter after this instruction.
|
|
108
|
-
# @yieldparam [
|
|
109
|
-
#
|
|
118
|
+
# @yieldparam [SeccompTools::Symbolic::State] st
|
|
119
|
+
# State after this instruction.
|
|
110
120
|
# @return [void]
|
|
111
|
-
def branch(
|
|
112
|
-
inst.branch(
|
|
121
|
+
def branch(state, &)
|
|
122
|
+
inst.branch(state).each(&)
|
|
113
123
|
end
|
|
114
124
|
|
|
115
125
|
# Corresponding instruction object.
|
|
@@ -10,8 +10,11 @@ module SeccompTools
|
|
|
10
10
|
# Summary of this command.
|
|
11
11
|
SUMMARY = 'Seccomp bpf assembler.'
|
|
12
12
|
# Usage of this command.
|
|
13
|
-
USAGE = "asm - #{SUMMARY}\n\nUsage: seccomp-tools asm IN_FILE [options]"
|
|
13
|
+
USAGE = "asm - #{SUMMARY}\n\nUsage: seccomp-tools asm IN_FILE [options]".freeze
|
|
14
14
|
|
|
15
|
+
# Instantiate an {Asm} object, defaulting the output format to +:inspect+.
|
|
16
|
+
#
|
|
17
|
+
# Takes the same arguments as {Base#initialize}.
|
|
15
18
|
def initialize(*)
|
|
16
19
|
super
|
|
17
20
|
option[:format] = :inspect
|
|
@@ -19,10 +22,11 @@ module SeccompTools
|
|
|
19
22
|
|
|
20
23
|
# Define option parser.
|
|
21
24
|
# @return [OptionParser]
|
|
25
|
+
# The parser of this command's options.
|
|
22
26
|
def parser
|
|
23
27
|
@parser ||= OptionParser.new do |opt|
|
|
24
28
|
opt.banner = usage
|
|
25
|
-
opt.on('-o', '--output FILE', '
|
|
29
|
+
opt.on('-o', '--output FILE', 'Write output to FILE instead of stdout.') do |o|
|
|
26
30
|
option[:ofile] = o
|
|
27
31
|
end
|
|
28
32
|
|
|
@@ -36,7 +40,7 @@ module SeccompTools
|
|
|
36
40
|
end
|
|
37
41
|
end
|
|
38
42
|
|
|
39
|
-
#
|
|
43
|
+
# Assembles the input file and writes the result in the requested format.
|
|
40
44
|
# @return [void]
|
|
41
45
|
def handle
|
|
42
46
|
return unless super
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
require 'seccomp-tools/audit'
|
|
6
|
+
require 'seccomp-tools/cli/base'
|
|
7
|
+
require 'seccomp-tools/cli/filter_input'
|
|
8
|
+
require 'seccomp-tools/disasm/disasm'
|
|
9
|
+
require 'seccomp-tools/logger'
|
|
10
|
+
|
|
11
|
+
module SeccompTools
|
|
12
|
+
module CLI
|
|
13
|
+
# Handle 'audit' command.
|
|
14
|
+
class Audit < Base
|
|
15
|
+
include FilterInput
|
|
16
|
+
|
|
17
|
+
# Summary of this command.
|
|
18
|
+
SUMMARY = 'Assess a seccomp filter for weaknesses and escape routes.'
|
|
19
|
+
# Usage of this command.
|
|
20
|
+
USAGE = "audit - #{SUMMARY}\n\nUsage: seccomp-tools audit [options] [BPF_FILE|EXEC]".freeze
|
|
21
|
+
|
|
22
|
+
# Instantiate an {Audit} object.
|
|
23
|
+
#
|
|
24
|
+
# Takes the same arguments as {Base#initialize}.
|
|
25
|
+
def initialize(*)
|
|
26
|
+
super
|
|
27
|
+
option[:format] = :human
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Define option parser.
|
|
31
|
+
# @return [OptionParser]
|
|
32
|
+
# The parser of this command's options.
|
|
33
|
+
def parser
|
|
34
|
+
@parser ||= OptionParser.new do |opt|
|
|
35
|
+
opt.banner = usage
|
|
36
|
+
option_filter_source(opt, 'audit')
|
|
37
|
+
option_arch(opt, 'With an executable or --pid the architecture is auto-detected instead.')
|
|
38
|
+
|
|
39
|
+
opt.on('-f', '--format FORMAT', %i[human json], 'Output format, one of <human|json>.',
|
|
40
|
+
'Default: human') do |f|
|
|
41
|
+
option[:format] = f
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Reads the filter(s) from a BPF file, an executable, or an existing process, then reports the
|
|
47
|
+
# weaknesses of each.
|
|
48
|
+
# @return [void]
|
|
49
|
+
def handle
|
|
50
|
+
return unless super
|
|
51
|
+
|
|
52
|
+
filters = collect_filters
|
|
53
|
+
return if filters.empty?
|
|
54
|
+
|
|
55
|
+
option[:format] == :json ? emit_json(filters) : emit_human(filters)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# Prints each filter's report, warning first when several filters stack.
|
|
61
|
+
def emit_human(filters)
|
|
62
|
+
if filters.size > 1
|
|
63
|
+
Logger.warn("#{filters.size} filters are installed; they stack, so a syscall must pass every one " \
|
|
64
|
+
'(most restrictive wins). Each is audited separately below.')
|
|
65
|
+
end
|
|
66
|
+
each_report(filters) { |report| output { report.to_s } }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Prints one JSON document describing every stacked filter.
|
|
70
|
+
def emit_json(filters)
|
|
71
|
+
reports = []
|
|
72
|
+
each_report(filters) { |report| reports << report.to_h }
|
|
73
|
+
output { "#{JSON.pretty_generate(stacked_filters: filters.size, reports:)}\n" }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Yields the {Audit::Report} of each filter, labelling stacked filters like +explain+ does.
|
|
77
|
+
def each_report(filters)
|
|
78
|
+
filters.each_with_index do |(raw, arch, source), idx|
|
|
79
|
+
label = filters.size > 1 ? "#{source} (filter ##{idx})" : source
|
|
80
|
+
insts = SeccompTools::Disasm.to_bpf(raw, arch).map(&:inst)
|
|
81
|
+
yield SeccompTools::Audit.new(insts, arch:, source: label).audit
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -2,13 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
require 'optparse'
|
|
4
4
|
|
|
5
|
+
require 'seccomp-tools/logger'
|
|
5
6
|
require 'seccomp-tools/util'
|
|
6
7
|
|
|
7
8
|
module SeccompTools
|
|
8
9
|
module CLI
|
|
9
10
|
# Base class for handlers.
|
|
11
|
+
#
|
|
12
|
+
# Each subcommand is a subclass that defines a +USAGE+ constant and a +parser+, and overrides
|
|
13
|
+
# {#handle} to do its work.
|
|
10
14
|
class Base
|
|
11
|
-
# @return [{Symbol => Object}]
|
|
15
|
+
# @return [{Symbol => Object}]
|
|
16
|
+
# Options parsed from the command line. Common keys are +:arch+, +:ifile+, +:ofile+,
|
|
17
|
+
# +:format+, +:limit+, +:pid+ and +:verbose+, depending on the subcommand.
|
|
12
18
|
attr_reader :option
|
|
13
19
|
# @return [Array<String>] Arguments array.
|
|
14
20
|
attr_reader :argv
|
|
@@ -23,14 +29,29 @@ module SeccompTools
|
|
|
23
29
|
|
|
24
30
|
private
|
|
25
31
|
|
|
26
|
-
#
|
|
32
|
+
# Parses the common options, and shows the help message when asked to.
|
|
27
33
|
# @return [Boolean]
|
|
28
|
-
# For
|
|
34
|
+
# For descendants to check whether they need to continue. +false+ when the help message
|
|
35
|
+
# was shown and there is nothing left to do.
|
|
29
36
|
def handle
|
|
30
|
-
return CLI.show(parser.help) if argv.empty? || %w[-h --help].
|
|
37
|
+
return CLI.show(parser.help) if argv.empty? || %w[-h --help].intersect?(argv)
|
|
31
38
|
|
|
32
39
|
parser.parse!(argv)
|
|
33
|
-
|
|
40
|
+
|
|
41
|
+
# Fill in the architecture from the host when --arch was not given. A command that offers
|
|
42
|
+
# --arch needs a concrete architecture to name syscalls; if the host CPU is one seccomp-tools
|
|
43
|
+
# does not recognize, auto-detection cannot supply one, so fail with a clear message instead
|
|
44
|
+
# of letting :unknown reach a syscall-table lookup and raise deep down.
|
|
45
|
+
return true unless option[:arch].nil?
|
|
46
|
+
|
|
47
|
+
arch = Util.system_arch
|
|
48
|
+
if arch == :unknown && @arch_option_offered
|
|
49
|
+
Logger.error('Could not detect the host architecture; specify one with ' \
|
|
50
|
+
"--arch <#{Util.supported_archs.join('|')}>.")
|
|
51
|
+
return false
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
option[:arch] = arch
|
|
34
55
|
true
|
|
35
56
|
end
|
|
36
57
|
|
|
@@ -43,6 +64,9 @@ module SeccompTools
|
|
|
43
64
|
end
|
|
44
65
|
|
|
45
66
|
# Write data to stdout or file(s).
|
|
67
|
+
#
|
|
68
|
+
# When +option[:ofile]+ is set, each call writes to a new file named after it with an
|
|
69
|
+
# incrementing serial number, see {#file_of}. Colors are disabled while writing to a file.
|
|
46
70
|
# @yieldreturn [String]
|
|
47
71
|
# The data to be written.
|
|
48
72
|
# @return [void]
|
|
@@ -65,7 +89,7 @@ module SeccompTools
|
|
|
65
89
|
# @param [String] file
|
|
66
90
|
# Filename.
|
|
67
91
|
# @param [Integer] serial
|
|
68
|
-
#
|
|
92
|
+
# Serial number, starts from zero.
|
|
69
93
|
# @return [String]
|
|
70
94
|
# Result filename.
|
|
71
95
|
# @example
|
|
@@ -89,11 +113,30 @@ module SeccompTools
|
|
|
89
113
|
self.class.const_get(:USAGE)
|
|
90
114
|
end
|
|
91
115
|
|
|
92
|
-
|
|
116
|
+
# Warns about positional arguments left over in {#argv} after the command has taken what it
|
|
117
|
+
# needs, e.g. an exec given together with +-c+, or anything after +--pid+. The command still
|
|
118
|
+
# proceeds.
|
|
119
|
+
# @return [void]
|
|
120
|
+
def warn_ignored_arguments
|
|
121
|
+
return if argv.empty?
|
|
122
|
+
|
|
123
|
+
Logger.warn("ignoring unused argument#{'s' if argv.size > 1}: #{argv.join(' ')}")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Registers the common +--arch+ option on +opt+.
|
|
127
|
+
#
|
|
128
|
+
# @param [OptionParser] opt
|
|
129
|
+
# The parser to add the option to.
|
|
130
|
+
# @param [Array<String>] extra_desc
|
|
131
|
+
# Extra description lines appended after the default ones, for command-specific guidance.
|
|
132
|
+
# @return [void]
|
|
133
|
+
def option_arch(opt, *extra_desc)
|
|
134
|
+
@arch_option_offered = true
|
|
93
135
|
supported = Util.supported_archs
|
|
94
136
|
opt.on('-a', '--arch ARCH', supported, 'Specify architecture.',
|
|
95
137
|
"Supported architectures are <#{supported.join('|')}>.",
|
|
96
|
-
|
|
138
|
+
'Default: auto-detected from the host machine.',
|
|
139
|
+
'Set it when the filter targets an architecture other than the host.', *extra_desc) do |a|
|
|
97
140
|
option[:arch] = a
|
|
98
141
|
end
|
|
99
142
|
end
|