seccomp-notify 0.1.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.
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+ require "socket"
5
+
6
+ module Seccomp
7
+ module Notify
8
+ class TargetMemory
9
+ PAGE_SIZE = 4096
10
+
11
+ def initialize(listener, request, cache, cache_mutex = Mutex.new)
12
+ @listener = listener
13
+ @request = request
14
+ @cache = cache
15
+ @cache_mutex = cache_mutex
16
+ end
17
+
18
+ def read(address, length)
19
+ raise ArgumentError, "length must be non-negative" if length.negative?
20
+
21
+ data = memory.pread(length, address)
22
+ raise MemoryReadError, "short read from target memory" unless data.bytesize == length
23
+ raise StaleNotificationError, "notification is no longer valid" unless @request.valid?
24
+
25
+ @request.mark_pointer_read!
26
+ data
27
+ rescue Errno::EIO, Errno::EFAULT, Errno::ENOENT, Errno::EACCES => error
28
+ raise MemoryReadError, error.message
29
+ end
30
+
31
+ def read_string(address, max: PAGE_SIZE)
32
+ raise ArgumentError, "max must be positive" unless max.positive?
33
+
34
+ result = +""
35
+ while result.bytesize < max
36
+ length = [PAGE_SIZE - ((address + result.bytesize) % PAGE_SIZE), max - result.bytesize].min
37
+ chunk = read(address + result.bytesize, length)
38
+ nul = chunk.index("\0")
39
+ return result << chunk.byteslice(0, nul) if nul
40
+
41
+ result << chunk
42
+ end
43
+ raise MemoryReadError, "NUL terminator not found within #{max} bytes"
44
+ end
45
+
46
+ def read_sockaddr(address, length)
47
+ raise MemoryReadError, "sockaddr is shorter than sa_family" if length < 2
48
+
49
+ bytes = read(address, length)
50
+ case bytes.unpack1("S<")
51
+ when Socket::AF_INET
52
+ raise MemoryReadError, "short AF_INET sockaddr" if length < 16
53
+ Addrinfo.tcp(IPAddr.ntop(bytes.byteslice(4, 4)), bytes.byteslice(2, 2).unpack1("n"))
54
+ when Socket::AF_INET6
55
+ raise MemoryReadError, "short AF_INET6 sockaddr" if length < 28
56
+ address = IPAddr.ntop(bytes.byteslice(8, 16))
57
+ scope = bytes.byteslice(24, 4).unpack1("L<")
58
+ address = "#{address}%#{scope}" unless scope.zero?
59
+ Addrinfo.tcp(address, bytes.byteslice(2, 2).unpack1("n"))
60
+ when Socket::AF_UNIX
61
+ raw_path = bytes.byteslice(2..).to_s
62
+ path = raw_path.start_with?("\0") ? raw_path : raw_path.split("\0", 2).first
63
+ Addrinfo.unix(path)
64
+ else
65
+ raise MemoryReadError, "unsupported sockaddr family #{bytes.unpack1("S<")}"
66
+ end
67
+ end
68
+
69
+ private
70
+
71
+ def memory
72
+ @cache[@request.tid] || @cache_mutex.synchronize do
73
+ @cache[@request.tid] ||= File.open("/proc/#{@request.tid}/mem", File::RDONLY)
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Seccomp
4
+ module Notify
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "notify/version"
4
+ require_relative "notify/errors"
5
+
6
+ unless RUBY_PLATFORM.include?("linux")
7
+ raise Seccomp::Notify::NotSupportedError, "seccomp-notify supports Linux only"
8
+ end
9
+
10
+ require "rbconfig"
11
+ require "socket"
12
+ require_relative "notify/constants"
13
+ require_relative "notify/structs"
14
+ require_relative "notify/libc"
15
+ require_relative "notify/ioctl"
16
+ require_relative "notify/syscalls"
17
+ require_relative "notify/bpf/instruction"
18
+ require_relative "notify/bpf/program"
19
+ require_relative "notify/bpf/builder"
20
+ require_relative "notify/policy"
21
+ require_relative "notify/filter"
22
+ require_relative "notify/fd_passing"
23
+ require_relative "notify/target_memory"
24
+ require_relative "notify/request"
25
+ require_relative "notify/features"
26
+ require_relative "notify/supervisor"
27
+
28
+ module Seccomp
29
+ module Notify
30
+ class << self
31
+ def supported?
32
+ Features.supported?
33
+ end
34
+
35
+ # Detects optional kernel capabilities with isolated child probes.
36
+ # @return [Hash<Symbol, Boolean>]
37
+ def features
38
+ @features ||= Features.detect
39
+ end
40
+
41
+ # Returns kernel-defined notification structure sizes.
42
+ # @return [Hash<Symbol, Integer>]
43
+ def notif_sizes
44
+ @notif_sizes ||= Libc.notif_sizes
45
+ end
46
+
47
+ # Returns whether the supervisor-side health pipe is still open.
48
+ #
49
+ # @return [Boolean, nil] nil when the process was not started by this gem
50
+ def supervisor_alive?
51
+ reader = supervisor_health_reader
52
+ reader ? IO.select([reader], nil, nil, 0).nil? : nil
53
+ end
54
+
55
+ # Starts a filtered child and returns its unfiltered parent supervisor.
56
+ # @param policy [Policy]
57
+ # @yield runs in the filtered child
58
+ # @return [Supervisor]
59
+ def spawn(policy, recv_timeout: 10, **supervisor_options, &target)
60
+ raise ArgumentError, "target block is required" unless target
61
+ unless recv_timeout.is_a?(Numeric) && recv_timeout.real? && recv_timeout.finite? && recv_timeout.positive?
62
+ raise ArgumentError, "recv_timeout must be positive"
63
+ end
64
+
65
+ program, install_flags = prepare_supervision(policy, supervisor_options)
66
+ parent_socket, child_socket = UNIXSocket.pair
67
+ health_reader, health_writer = IO.pipe
68
+ pid = fork do
69
+ parent_socket.close
70
+ health_writer.close
71
+ keep_health_reader(health_reader)
72
+ listener = Filter.install!(program, flags: install_flags)
73
+ FdPassing.send_fd(child_socket, listener)
74
+ child_socket.close
75
+ listener.close
76
+ target.call
77
+ exit! 0
78
+ rescue Exception => error # rubocop:disable Lint/RescueException
79
+ warn("seccomp-notify target setup failed: #{error.message}")
80
+ exit! 127
81
+ end
82
+ child_socket.close
83
+ health_reader.close
84
+ listener = FdPassing.recv_fd(parent_socket, timeout: recv_timeout)
85
+ parent_socket.close
86
+ Supervisor.new(listener, target_pid: pid, target_child: true, health_writer:, **supervisor_options)
87
+ rescue StandardError
88
+ listener&.close unless listener&.closed?
89
+ parent_socket&.close unless parent_socket&.closed?
90
+ child_socket&.close unless child_socket&.closed?
91
+ health_reader&.close unless health_reader&.closed?
92
+ health_writer&.close unless health_writer&.closed?
93
+ terminate_child(pid)
94
+ raise
95
+ end
96
+
97
+ # Installs a filter in the caller and starts a detached supervisor child.
98
+ # @param policy [Policy]
99
+ # @param supervisor [Symbol] :spawn or :fork
100
+ # @return [Integer] supervisor process id
101
+ def supervise_self(policy, supervisor: :spawn, **options, &configure)
102
+ raise ArgumentError, "supervisor must be :spawn or :fork" unless %i[spawn fork].include?(supervisor)
103
+ raise ArgumentError, "supervisor: :spawn does not support Ruby handler blocks" if supervisor == :spawn && configure
104
+
105
+ program, install_flags = prepare_supervision(policy, options)
106
+ selected_features = options.fetch(:features, features)
107
+ raise NotSupportedError, "supervise_self requires CONTINUE when no handler block is given" if !configure && !selected_features[:continue]
108
+
109
+ parent_socket, supervisor_socket = UNIXSocket.pair
110
+ health_reader, health_writer = IO.pipe
111
+ target_pid = Process.pid
112
+ supervisor_pid = case supervisor
113
+ when :fork
114
+ fork_supervisor(supervisor_socket, parent_socket, health_reader, health_writer, target_pid, options, configure)
115
+ when :spawn
116
+ spawn_supervisor(supervisor_socket, parent_socket, health_writer, target_pid, options)
117
+ end
118
+
119
+ supervisor_socket.close
120
+ health_writer.close
121
+ keep_health_reader(health_reader)
122
+ listener = Filter.install!(program, flags: install_flags)
123
+ FdPassing.send_fd(parent_socket, listener)
124
+ listener.close
125
+ parent_socket.close
126
+ Process.detach(supervisor_pid)
127
+ supervisor_pid
128
+ rescue StandardError
129
+ listener&.close unless listener&.closed?
130
+ parent_socket&.close unless parent_socket&.closed?
131
+ supervisor_socket&.close unless supervisor_socket&.closed?
132
+ health_reader&.close unless health_reader&.closed?
133
+ health_writer&.close unless health_writer&.closed?
134
+ clear_health_reader(health_reader)
135
+ terminate_child(supervisor_pid)
136
+ raise
137
+ end
138
+
139
+ private
140
+
141
+ def fork_supervisor(socket, other_socket, health_reader, health_writer, target_pid, options, configure)
142
+ fork do
143
+ other_socket.close
144
+ health_reader.close
145
+ Process.setsid
146
+ listener = FdPassing.recv_fd(socket)
147
+ socket.close
148
+ instance = Supervisor.new(listener, target_pid:, target_child: false, health_writer:, **options)
149
+ configure ? configure.call(instance) : instance.on_unknown { |request| request.continue! }
150
+ instance.run
151
+ exit! 0
152
+ rescue Exception => error # rubocop:disable Lint/RescueException
153
+ warn("seccomp-notify supervisor failed: #{error.message}")
154
+ exit! 1
155
+ end
156
+ end
157
+
158
+ def spawn_supervisor(socket, other_socket, health_writer, target_pid, options)
159
+ other_socket.close_on_exec = true
160
+ socket.close_on_exec = false
161
+ health_writer.close_on_exec = false
162
+ encoded_options = Marshal.dump(options).unpack1("H*")
163
+ Process.spawn(
164
+ RbConfig.ruby,
165
+ "-I",
166
+ File.expand_path("..", __dir__),
167
+ File.expand_path("../../exe/seccomp-notify-supervisor", __dir__),
168
+ socket.fileno.to_s,
169
+ target_pid.to_s,
170
+ encoded_options,
171
+ health_writer.fileno.to_s,
172
+ socket.fileno => socket.fileno,
173
+ health_writer.fileno => health_writer.fileno,
174
+ pgroup: true
175
+ )
176
+ end
177
+
178
+ def prepare_supervision(policy, options)
179
+ Supervisor.validate_options!(options)
180
+ sizes = notif_sizes
181
+ raise NotSupportedError, "unsupported kernel notification sizes: #{sizes.inspect}" unless sizes == Supervisor::EXPECTED_SIZES
182
+
183
+ selected_features = options.fetch(:features, features)
184
+ optional_flags = selected_features[:wait_killable_recv] ? Constants::SECCOMP_FILTER_FLAG_WAIT_KILLABLE_RECV : 0
185
+ [BPF::Builder.new(policy).build, policy.flags | optional_flags]
186
+ end
187
+
188
+ def keep_health_reader(reader)
189
+ reader.close_on_exec = false
190
+ ENV["SECCOMP_NOTIFY_HEALTH_FD"] = reader.fileno.to_s
191
+ @supervisor_health_reader = reader
192
+ end
193
+
194
+ def supervisor_health_reader
195
+ return @supervisor_health_reader if defined?(@supervisor_health_reader)
196
+
197
+ fd = ENV["SECCOMP_NOTIFY_HEALTH_FD"]
198
+ @supervisor_health_reader = IO.for_fd(Integer(fd), autoclose: false) if fd
199
+ end
200
+
201
+ def clear_health_reader(reader)
202
+ return unless defined?(@supervisor_health_reader) && @supervisor_health_reader.equal?(reader)
203
+
204
+ @supervisor_health_reader = nil
205
+ ENV.delete("SECCOMP_NOTIFY_HEALTH_FD")
206
+ end
207
+
208
+ def terminate_child(pid)
209
+ return unless pid
210
+
211
+ waited = Process.waitpid(pid, Process::WNOHANG)
212
+ return if waited
213
+
214
+ Process.kill("KILL", pid)
215
+ Process.waitpid(pid)
216
+ rescue Errno::ECHILD, Errno::ESRCH
217
+ nil
218
+ end
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,65 @@
1
+ #define _GNU_SOURCE
2
+ #include <errno.h>
3
+ #include <fcntl.h>
4
+ #include <linux/audit.h>
5
+ #include <linux/filter.h>
6
+ #include <linux/seccomp.h>
7
+ #include <stddef.h>
8
+ #include <stdio.h>
9
+ #include <stdlib.h>
10
+ #include <sys/ioctl.h>
11
+ #include <sys/prctl.h>
12
+ #include <sys/socket.h>
13
+ #include <sys/syscall.h>
14
+ #include <sys/wait.h>
15
+ #include <unistd.h>
16
+
17
+ #if defined(__aarch64__)
18
+ #define EXPECTED_ARCH AUDIT_ARCH_AARCH64
19
+ #else
20
+ #define EXPECTED_ARCH AUDIT_ARCH_X86_64
21
+ #endif
22
+
23
+ static void send_fd(int socket, int fd) {
24
+ char byte = 0, control[CMSG_SPACE(sizeof(fd))];
25
+ struct iovec iov = {.iov_base = &byte, .iov_len = 1};
26
+ struct msghdr msg = {.msg_iov = &iov, .msg_iovlen = 1, .msg_control = control, .msg_controllen = sizeof(control)};
27
+ struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg);
28
+ cmsg->cmsg_level = SOL_SOCKET; cmsg->cmsg_type = SCM_RIGHTS; cmsg->cmsg_len = CMSG_LEN(sizeof(fd));
29
+ *(int *)CMSG_DATA(cmsg) = fd;
30
+ if (sendmsg(socket, &msg, 0) < 0) perror("sendmsg"), exit(1);
31
+ }
32
+
33
+ static int recv_fd(int socket) {
34
+ char byte, control[CMSG_SPACE(sizeof(int))];
35
+ struct iovec iov = {.iov_base = &byte, .iov_len = 1};
36
+ struct msghdr msg = {.msg_iov = &iov, .msg_iovlen = 1, .msg_control = control, .msg_controllen = sizeof(control)};
37
+ if (recvmsg(socket, &msg, 0) < 0) perror("recvmsg"), exit(1);
38
+ return *(int *)CMSG_DATA(CMSG_FIRSTHDR(&msg));
39
+ }
40
+
41
+ int main(int argc, char **argv) {
42
+ int sockets[2]; socketpair(AF_UNIX, SOCK_STREAM, 0, sockets);
43
+ pid_t pid = fork();
44
+ if (pid == 0) {
45
+ struct sock_filter ins[] = {
46
+ BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, arch)),
47
+ BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, EXPECTED_ARCH, 1, 0), BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS),
48
+ BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)),
49
+ BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, SYS_openat, 0, 1), BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_USER_NOTIF),
50
+ BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
51
+ };
52
+ struct sock_fprog program = {.len = sizeof(ins) / sizeof(ins[0]), .filter = ins};
53
+ prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
54
+ int listener = syscall(SYS_seccomp, SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_NEW_LISTENER, &program);
55
+ send_fd(sockets[1], listener);
56
+ int fd = syscall(SYS_openat, AT_FDCWD, "/dev/null", 0);
57
+ return (argc > 1 && argv[1][0] == 'c') ? (fd < 0) : !(fd < 0 && errno == EPERM);
58
+ }
59
+ int listener = recv_fd(sockets[0]);
60
+ struct seccomp_notif req = {0}; struct seccomp_notif_resp resp = {0};
61
+ ioctl(listener, SECCOMP_IOCTL_NOTIF_RECV, &req); resp.id = req.id;
62
+ if (argc > 1 && argv[1][0] == 'c') resp.flags = SECCOMP_USER_NOTIF_FLAG_CONTINUE; else resp.error = -EPERM;
63
+ ioctl(listener, SECCOMP_IOCTL_NOTIF_SEND, &resp);
64
+ int status; waitpid(pid, &status, 0); return WEXITSTATUS(status);
65
+ }
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Usage: tools/gen_syscall_table.rb X86_SYSCALL_64_TBL GENERIC_SYSCALL_TBL OUTPUT
5
+
6
+ def syscall_table(path, abis)
7
+ rows = File.readlines(path, chomp: true).filter_map do |line|
8
+ number, abi, name = line.split
9
+ [Integer(number), abi, name.to_sym] if number&.match?(/\A\d+\z/) && abis.include?(abi)
10
+ end
11
+ rows.group_by(&:first).to_h do |number, choices|
12
+ _number, _abi, name = choices.max_by { |_nr, abi, _name| abi == "64" ? 2 : abi == "common" ? 1 : 0 }
13
+ [name, number]
14
+ end
15
+ end
16
+
17
+ def ruby_hash(name, table)
18
+ entries = table.sort_by { |_syscall, number| number }.map { |syscall, number| " #{syscall}: #{number}" }
19
+ " #{name} = {\n#{entries.join(",\n")}\n }.freeze"
20
+ end
21
+
22
+ x86_path, generic_path, output = ARGV
23
+ abort "usage: #{$PROGRAM_NAME} X86_SYSCALL_64_TBL GENERIC_SYSCALL_TBL OUTPUT" unless output
24
+
25
+ source = <<~RUBY
26
+ # frozen_string_literal: true
27
+
28
+ # Generated by tools/gen_syscall_table.rb from Linux kernel syscall tables.
29
+ module Seccomp
30
+ module Notify
31
+ module Syscalls
32
+ #{ruby_hash("X86_64", syscall_table(x86_path, %w[common 64]))}
33
+
34
+ #{ruby_hash("AARCH64", syscall_table(generic_path, %w[common 64 time32 renameat stat64 rlimit memfd_secret]))}
35
+
36
+ TABLES = {x86_64: X86_64, aarch64: AARCH64}.freeze
37
+
38
+ module_function
39
+
40
+ def number(name, arch = Libc.architecture)
41
+ TABLES.fetch(arch).fetch(name.to_sym) do
42
+ raise InvalidPolicyError, "unknown syscall \#{name.inspect} for \#{arch}"
43
+ end
44
+ end
45
+
46
+ def name(number, arch = Libc.architecture)
47
+ reverse(arch)[number]
48
+ end
49
+
50
+ def reverse(arch)
51
+ @reverse ||= {}
52
+ @reverse[arch] ||= TABLES.fetch(arch).invert.freeze
53
+ end
54
+ end
55
+ end
56
+ end
57
+ RUBY
58
+
59
+ File.write(output, source)
metadata ADDED
@@ -0,0 +1,86 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: seccomp-notify
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Yudai Takada
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: fiddle
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ description: Builds seccomp cBPF filters and supervises Linux syscalls through SECCOMP_RET_USER_NOTIF
27
+ without native extensions.
28
+ email:
29
+ - t.yudai92@gmail.com
30
+ executables:
31
+ - seccomp-notify-supervisor
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - CHANGELOG.md
36
+ - LICENSE.txt
37
+ - README.md
38
+ - Rakefile
39
+ - examples/deny_network.rb
40
+ - examples/emulate_open.rb
41
+ - examples/strace_lite.rb
42
+ - exe/seccomp-notify-supervisor
43
+ - lib/seccomp/notify.rb
44
+ - lib/seccomp/notify/bpf/builder.rb
45
+ - lib/seccomp/notify/bpf/instruction.rb
46
+ - lib/seccomp/notify/bpf/program.rb
47
+ - lib/seccomp/notify/constants.rb
48
+ - lib/seccomp/notify/errors.rb
49
+ - lib/seccomp/notify/fd_passing.rb
50
+ - lib/seccomp/notify/features.rb
51
+ - lib/seccomp/notify/filter.rb
52
+ - lib/seccomp/notify/ioctl.rb
53
+ - lib/seccomp/notify/libc.rb
54
+ - lib/seccomp/notify/policy.rb
55
+ - lib/seccomp/notify/request.rb
56
+ - lib/seccomp/notify/structs.rb
57
+ - lib/seccomp/notify/supervisor.rb
58
+ - lib/seccomp/notify/syscalls.rb
59
+ - lib/seccomp/notify/target_memory.rb
60
+ - lib/seccomp/notify/version.rb
61
+ - spike/notify_min.c
62
+ - tools/gen_syscall_table.rb
63
+ homepage: https://rubygems.org/gems/seccomp-notify
64
+ licenses:
65
+ - MIT
66
+ metadata:
67
+ rubygems_mfa_required: 'true'
68
+ homepage_uri: https://rubygems.org/gems/seccomp-notify
69
+ rdoc_options: []
70
+ require_paths:
71
+ - lib
72
+ required_ruby_version: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: 3.1.0
77
+ required_rubygems_version: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ requirements: []
83
+ rubygems_version: 4.0.6
84
+ specification_version: 4
85
+ summary: Pure Ruby bindings for Linux seccomp user notifications
86
+ test_files: []