seccomp-notify 0.1.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4348459a8a4722dfdc17dcf0acf21b0e7802f6b9b5fe80e1821d9cb1964d7802
4
- data.tar.gz: 3d8776d6b56396439b30b17897eb862dde5fbe8c591ce3e58d62a1c928c90e72
3
+ metadata.gz: 1338763ff6ac458c1224576b7763524b57034de1b29423acc2dde48268012dd1
4
+ data.tar.gz: 03dc39b44de12c7bb1401edc303097eb02b389c511e040589c2293cb840d2c41
5
5
  SHA512:
6
- metadata.gz: b3c6841b95077468dea06d50250d91f660386cf21e33240bcf270b07b7f7efabe6e890a0ee1e6ca22d6492d9cb6837b1cd25954c4eb93d195c77a4ce025221ac
7
- data.tar.gz: 63d5fdf903dc3541c9ded5eb90deb66de7ea4171304b375f6228aae690462816cd5880eef05de9d27df95bb843fb84c573e89b6ffc718cee50c2e693827ff731
6
+ metadata.gz: 9c99782a7d1c9bf43d3069aa1f742f1c3f5e33b40a47014c6e5a3d4803062b8d8239e2df8a915583d056a4441ed438deebde1ad1cc8af9e1a1bdd8b5f95e333f
7
+ data.tar.gz: dd0f9b82f5749bdeb933e6e56c616619f150fadbb9742582f03ef32ff8d8401e7732dff4070b7a0860abcd6bb42ed537047cda119fa314891c99ab51af0bbd4e
data/CHANGELOG.md CHANGED
@@ -5,6 +5,20 @@ All notable changes to seccomp-notify are documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and versions follow [Semantic Versioning](https://semver.org/).
7
7
 
8
+ ## v0.3.0 - 2026-08-24
9
+
10
+ - Kill the issuing thread group without treating a thread ID as a process ID.
11
+ - Keep the listener transfer descriptor reserved so later `sendmsg` calls cannot bypass policy.
12
+ - Reject x32 syscalls, conflicting policy actions, and invalid emulated return values.
13
+ - Preserve the protocol-neutral type of decoded socket addresses.
14
+ - Marshal ioctl and seccomp buffers through native memory for compacting-GC safety.
15
+ - Reduce runtime feature detection from four isolated child probes to two and synchronize supervisor shutdown state.
16
+
17
+ ## v0.2.0 - 2026-08-22
18
+
19
+ - Stop supervising a non-child target once it becomes a zombie.
20
+ - Read target memory from the current address space after `exec` and normalize EOF failures.
21
+
8
22
  ## v0.1.0 - 2026-08-22
9
23
 
10
24
  - Initial release
data/README.md CHANGED
@@ -1,82 +1,171 @@
1
- # seccomp-notify
2
-
3
- Pure Ruby plumbing for Linux [`SECCOMP_RET_USER_NOTIF`](https://www.kernel.org/doc/html/latest/userspace-api/seccomp_filter.html). It builds cBPF filters, transfers listener file descriptors, supervises notifications, reads target memory, and injects file descriptors without a native extension.
1
+ <h1 align="center">seccomp-notify</h1>
2
+
3
+ <p align="center">
4
+ <strong>Pure Ruby bindings for Linux seccomp user notifications</strong>
5
+ </p>
6
+
7
+ <p align="center">
8
+ <a href="https://rubygems.org/gems/seccomp-notify"><img src="https://img.shields.io/gem/v/seccomp-notify.svg?colorB=319e8c" alt="Gem Version"></a>
9
+ <a href="https://rubygems.org/gems/seccomp-notify"><img src="https://img.shields.io/gem/dt/seccomp-notify.svg" alt="Downloads"></a>
10
+ <img src="https://img.shields.io/badge/ruby-%3E%3D%203.1-ruby.svg" alt="Ruby Version">
11
+ <img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License">
12
+ </p>
13
+
14
+ <p align="center">
15
+ <a href="#features">Features</a> ·
16
+ <a href="#installation">Installation</a> ·
17
+ <a href="#quick-start">Quick Start</a> ·
18
+ <a href="https://ydah.github.io/seccomp-notify/">API Docs</a> ·
19
+ <a href="#responses">Responses</a> ·
20
+ <a href="#security">Security</a> ·
21
+ <a href="#development">Development</a>
22
+ </p>
23
+
24
+ ---
25
+
26
+ `seccomp-notify` provides the plumbing for Linux
27
+ [`SECCOMP_RET_USER_NOTIF`](https://www.kernel.org/doc/html/latest/userspace-api/seccomp_filter.html).
28
+ It builds cBPF filters, transfers listener file descriptors, supervises syscall notifications,
29
+ reads target memory, and injects file descriptors without a native extension.
4
30
 
5
31
  > [!WARNING]
6
- > This gem is a mechanism, not a security policy or a complete sandbox. Never inspect a pointer argument and then use `continue!` as a security decision: another target thread can change that memory between inspection and syscall execution (TOCTOU). Reject the syscall or emulate it instead.
32
+ > This gem is a mechanism, not a security policy or a complete sandbox. Never inspect a pointer
33
+ > argument and then use `continue!` as a security decision: another target thread can change that
34
+ > memory between inspection and syscall execution (TOCTOU). Reject or emulate the syscall instead.
35
+
36
+ ## Features
37
+
38
+ - Pure Ruby cBPF filter generation and seccomp installation
39
+ - Parent supervisor with a filtered child through `Seccomp::Notify.spawn`
40
+ - Existing-process supervision through `Seccomp::Notify.supervise_self`
41
+ - Syscall handlers with errno, emulation, continuation, and process termination responses
42
+ - Target memory helpers for strings, bytes, and socket addresses
43
+ - File descriptor injection with atomic and fallback ADDFD paths
44
+ - Runtime detection for optional kernel features
45
+ - Handler timeouts, concurrent workers, and supervisor health checks
46
+ - Linux x86_64 and aarch64 support
7
47
 
8
- Seccomp cannot intercept vDSO calls, I/O submitted through an existing `io_uring`, operations on already-open file descriptors, or indirect shared-memory effects. `io_uring_setup` is denied by default. If the supervisor dies, blocked notification syscalls return `ENOSYS`.
48
+ ## Installation
9
49
 
10
- `fcntl` and `sendmsg` must remain allowed because Ruby uses them to wrap and transfer the listener fd; policies that try to notify or deny them are rejected.
50
+ Add the gem to your Gemfile:
11
51
 
12
- ## Requirements
52
+ ```ruby
53
+ gem "seccomp-notify"
54
+ ```
55
+
56
+ Then install:
57
+
58
+ ```sh
59
+ bundle install
60
+ ```
61
+
62
+ ### Requirements
13
63
 
14
64
  - Linux on x86_64 or aarch64
15
65
  - Ruby 3.1 or newer
16
- - Linux 5.0 or newer; ADDFD needs 5.9 or newer
66
+ - Linux 5.0 or newer; ADDFD requires Linux 5.9 or newer
17
67
  - `CONFIG_SECCOMP_FILTER=y`
18
68
 
19
- Container runtimes may block `seccomp(2)` with their own profile. For Docker development, use an isolated test container with:
69
+ Container runtimes may block `seccomp(2)` with their own profile. For Docker development, use an
70
+ isolated test container with seccomp disabled:
20
71
 
21
72
  ```sh
22
73
  docker run --rm --security-opt seccomp=unconfined -v "$PWD:/app" -w /app ruby:3.4 bundle exec rake
23
74
  ```
24
75
 
25
- ## Installation
26
-
27
- ```ruby
28
- gem "seccomp-notify"
29
- ```
30
-
31
- ## Usage
76
+ ## Quick Start
32
77
 
33
- The recommended shape keeps the unfiltered supervisor in the parent and installs the filter in a child:
78
+ Keep the unfiltered supervisor in the parent and install the filter in a child:
34
79
 
35
80
  ```ruby
36
81
  require "seccomp/notify"
37
82
 
38
- open_syscalls = RUBY_PLATFORM.include?("x86_64") ? %i[open openat] : %i[openat]
39
83
  policy = Seccomp::Notify::Policy.new do
40
- notify(*open_syscalls, :connect)
84
+ notify :connect, :sendto
41
85
  end
42
86
 
43
87
  supervisor = Seccomp::Notify.spawn(policy) do
44
- exec("bundle", "install")
88
+ exec("curl", "https://example.com")
45
89
  end
46
90
 
47
- open_handler = proc do |request|
48
- path_argument = request.syscall == :open ? 0 : 1
49
- path = request.read_string(request.args[path_argument])
50
- if path == "/etc/shadow"
51
- request.error!(Errno::EACCES)
52
- else
53
- request.continue!(unsafe: true)
54
- end
55
- end
56
- open_syscalls.each { |syscall| supervisor.on(syscall, &open_handler) }
57
-
58
91
  supervisor.on(:connect) { |request| request.error!(Errno::ENETUNREACH) }
92
+ supervisor.on(:sendto) { |request| request.error!(Errno::ENETUNREACH) }
93
+
59
94
  status = supervisor.run
60
95
  ```
61
96
 
62
- `request.pid` is an alias for `request.tid`: the kernel reports the thread ID that issued the syscall, not the process ID.
97
+ `request.pid` is an alias for `request.tid`: the kernel reports the thread ID that issued the
98
+ syscall, not the process ID.
99
+
100
+ ### Supervise the current process
101
+
102
+ `supervise_self(policy, supervisor: :fork)` accepts a block that configures the child supervisor.
103
+ The safer `:spawn` form starts a clean Ruby VM and supports the default pass-through handler only.
104
+ Both forms detach the supervisor child, so broad `Process.wait` calls in the target can still
105
+ observe related lifecycle effects. The default pass-through handler requires `continue!` and Linux
106
+ 5.5 or newer; handler-based supervision works on Linux 5.0 or newer.
107
+
108
+ ## Responses
109
+
110
+ | Method | Behavior | Requirement |
111
+ |--------|----------|-------------|
112
+ | `allow!(value = 0)` | Emulate a successful return value without running the syscall | Linux 5.0+ |
113
+ | `error!(Errno::EPERM)` | Return an errno | Linux 5.0+ |
114
+ | `continue!` | Run the original syscall | Linux 5.5+ |
115
+ | `add_fd!(io)` | Inject a file descriptor and return its number | Linux 5.9+ |
116
+ | `kill!` | Terminate the issuing process | — |
117
+
118
+ Use `Seccomp::Notify.features` to inspect runtime support. Set
119
+ `SECCOMP_NOTIFY_DISABLE_FEATURES=addfd,continue` to force feature fallbacks in tests.
120
+
121
+ Targets started by this gem can call `Seccomp::Notify.supervisor_alive?` to poll the health pipe.
122
+ If the target calls `exec`, the pipe descriptor is inherited and published as
123
+ `SECCOMP_NOTIFY_HEALTH_FD`.
63
124
 
64
- `supervise_self(policy, supervisor: :fork)` accepts a block that configures the child supervisor. The safer `:spawn` form starts a clean Ruby VM and therefore supports only the default pass-through handler. Both forms detach the supervisor child, so broad `Process.wait` calls in the target can still observe related lifecycle effects.
125
+ ## How It Works
65
126
 
66
- ## Responses and features
127
+ 1. The parent creates a Unix socket pair and forks the target.
128
+ 2. The target installs a cBPF filter and sends its listener descriptor to the parent with
129
+ `SCM_RIGHTS`.
130
+ 3. The parent receives syscall notifications and dispatches them to registered handlers.
131
+ 4. Each handler returns an errno, emulated value, continuation, or injected descriptor.
67
132
 
68
- - `allow!(value = 0)` emulates a successful return value without executing the syscall.
69
- - `error!(Errno::EPERM)` returns an errno.
70
- - `continue!` executes the original syscall when supported.
71
- - `add_fd!(io)` injects a file descriptor when supported.
72
- - `kill!` terminates the issuing task.
133
+ ## Security
73
134
 
74
- Use `Seccomp::Notify.features` to inspect runtime support. Set `SECCOMP_NOTIFY_DISABLE_FEATURES=addfd,continue` to force feature fallbacks in tests.
135
+ - Pointer data may change after inspection. Never use it to justify `continue!`.
136
+ - vDSO calls, existing file descriptors, and indirect shared-memory effects are outside seccomp's
137
+ control.
138
+ - I/O submitted through an existing `io_uring` does not pass through the filter;
139
+ `io_uring_setup` is denied by default.
140
+ - If the supervisor dies, blocked notification syscalls return `ENOSYS`.
141
+ - `fcntl` must remain allowed while Ruby wraps the listener descriptor, so policies that notify or
142
+ deny it are rejected. `sendmsg` may be filtered; only listener transfer on its reserved descriptor
143
+ is allowed, and that descriptor remains open across `exec` to prevent fd-number reuse.
75
144
 
76
- Targets started by this gem can call `Seccomp::Notify.supervisor_alive?` to poll the health pipe. If the target `exec`s, the pipe fd is inherited and published as `SECCOMP_NOTIFY_HEALTH_FD`.
145
+ ## Examples
146
+
147
+ - [`examples/deny_network.rb`](examples/deny_network.rb) rejects outbound network syscalls.
148
+ - [`examples/emulate_open.rb`](examples/emulate_open.rb) replaces `/dev/urandom` with an injected
149
+ file descriptor.
150
+ - [`examples/strace_lite.rb`](examples/strace_lite.rb) logs selected syscall notifications.
77
151
 
78
152
  ## Development
79
153
 
80
- Run `bundle exec rake`. Linux runs include real seccomp integration tests; other systems run only portable filter and layout tests. `spike/notify_min.c` is the reference C round trip, and `tools/gen_syscall_table.rb` regenerates architecture tables from Linux kernel headers.
154
+ ```sh
155
+ bundle install
156
+ bundle exec rake
157
+ ```
158
+
159
+ Linux runs include real seccomp integration tests; other systems run portable filter and layout
160
+ tests only. `spike/notify_min.c` is the reference C round trip, and
161
+ `tools/gen_syscall_table.rb` regenerates architecture tables from Linux kernel syscall tables.
162
+
163
+ ## Contributing
164
+
165
+ Bug reports and pull requests are welcome at https://github.com/ydah/seccomp-notify.
166
+
167
+ Releases before 1.0 may change the API.
168
+
169
+ ## License
81
170
 
82
- Releases before 1.0 may change the API. The project is available under the MIT License.
171
+ Released under the [MIT License](LICENSE.txt).
@@ -4,31 +4,45 @@ module Seccomp
4
4
  module Notify
5
5
  module BPF
6
6
  class Builder
7
- LINEAR_LIMIT = 64
7
+ LINEAR_LIMIT = 4
8
8
  AUDIT_ARCHES = {x86_64: Constants::AUDIT_ARCH_X86_64, aarch64: Constants::AUDIT_ARCH_AARCH64}.freeze
9
9
 
10
- def initialize(policy, arch: Libc.architecture)
10
+ def initialize(policy, arch: Libc.architecture, transfer_fd: nil)
11
11
  raise NotSupportedError, "unsupported architecture: #{arch}" unless AUDIT_ARCHES.key?(arch)
12
+ raise ArgumentError, "transfer_fd must be a non-negative integer" if transfer_fd && (!transfer_fd.is_a?(Integer) || transfer_fd.negative?)
12
13
 
13
14
  @policy = policy
14
15
  @arch = arch
16
+ @transfer_fd = transfer_fd
15
17
  end
16
18
 
17
19
  def build
18
20
  decisions = @policy.decisions(@arch)
21
+ if @transfer_fd.nil? && @policy.default_action != Constants::SECCOMP_RET_ALLOW && !@policy.notified?(:sendmsg)
22
+ decisions[Syscalls.number(:sendmsg, @arch)] = Constants::SECCOMP_RET_ALLOW
23
+ end
19
24
  body = decisions.length <= LINEAR_LIMIT ? linear(decisions) : tree(decisions.sort)
20
- Program.new(prologue + body)
25
+ conditions = @policy.conditional_notifications(@arch)
26
+ bootstrap_first = @transfer_fd && conditions.any? { |number, _argument, _mask| number == Syscalls.number(:sendmsg, @arch) }
27
+ conditional = conditional_notifications(conditions, decisions)
28
+ bootstrap = bootstrap_first ? transfer_allowance + conditional : conditional + transfer_allowance
29
+ Program.new(prologue + bootstrap + body)
21
30
  end
22
31
 
23
32
  private
24
33
 
25
34
  def prologue
26
- [
35
+ instructions = [
27
36
  ins(Constants::BPF_LD_W_ABS, 0, 0, Constants::OFF_ARCH),
28
37
  ins(Constants::BPF_JMP_JEQ_K, 1, 0, AUDIT_ARCHES.fetch(@arch)),
29
38
  ins(Constants::BPF_RET_K, 0, 0, Constants::SECCOMP_RET_KILL_PROCESS),
30
39
  ins(Constants::BPF_LD_W_ABS, 0, 0, Constants::OFF_NR)
31
40
  ]
41
+ if @arch == :x86_64
42
+ instructions << ins(Constants::BPF_JMP_JGE_K, 0, 1, Constants::X32_SYSCALL_BIT)
43
+ instructions << ins(Constants::BPF_RET_K, 0, 0, Constants::SECCOMP_RET_KILL_PROCESS)
44
+ end
45
+ instructions
32
46
  end
33
47
 
34
48
  def linear(decisions)
@@ -37,6 +51,42 @@ module Seccomp
37
51
  end << ins(Constants::BPF_RET_K, 0, 0, @policy.default_action)
38
52
  end
39
53
 
54
+ def transfer_allowance
55
+ return [] unless @transfer_fd
56
+
57
+ conditional(Syscalls.number(:sendmsg, @arch), 0, @transfer_fd, Constants::BPF_JMP_JEQ_K)
58
+ end
59
+
60
+ def conditional_notifications(conditions, decisions)
61
+ last_condition = {}
62
+ conditions.each_with_index { |(number, _argument, _mask), index| last_condition[number] = index }
63
+ conditions.each_with_index.flat_map do |(number, argument, mask), index|
64
+ fallback = decisions.fetch(number, @policy.default_action) if last_condition.fetch(number) == index
65
+ conditional(number, argument, mask, Constants::BPF_JMP_JSET_K, fallback:)
66
+ end
67
+ end
68
+
69
+ def conditional(number, argument, value, comparison, fallback: nil)
70
+ if fallback
71
+ return [
72
+ ins(Constants::BPF_JMP_JEQ_K, 0, 4, number),
73
+ ins(Constants::BPF_LD_W_ABS, 0, 0, Constants::OFF_ARGS + argument * 8),
74
+ ins(comparison, 0, 1, value),
75
+ ins(Constants::BPF_RET_K, 0, 0, Constants::SECCOMP_RET_USER_NOTIF),
76
+ ins(Constants::BPF_RET_K, 0, 0, fallback),
77
+ ins(Constants::BPF_LD_W_ABS, 0, 0, Constants::OFF_NR)
78
+ ]
79
+ end
80
+
81
+ [
82
+ ins(Constants::BPF_JMP_JEQ_K, 0, 3, number),
83
+ ins(Constants::BPF_LD_W_ABS, 0, 0, Constants::OFF_ARGS + argument * 8),
84
+ ins(comparison, 0, 1, value),
85
+ ins(Constants::BPF_RET_K, 0, 0, comparison == Constants::BPF_JMP_JEQ_K ? Constants::SECCOMP_RET_ALLOW : Constants::SECCOMP_RET_USER_NOTIF),
86
+ ins(Constants::BPF_LD_W_ABS, 0, 0, Constants::OFF_NR)
87
+ ]
88
+ end
89
+
40
90
  def tree(decisions)
41
91
  return [ins(Constants::BPF_RET_K, 0, 0, @policy.default_action)] if decisions.empty?
42
92
  return linear(decisions) if decisions.length <= 4
@@ -32,13 +32,17 @@ module Seccomp
32
32
  AUDIT_ARCH_X86_64 = 0xc000_003e
33
33
  AUDIT_ARCH_AARCH64 = 0xc000_00b7
34
34
  AUDIT_ARCH_I386 = 0x4000_0003
35
+ X32_SYSCALL_BIT = 0x4000_0000
35
36
  SYS_SECCOMP = {x86_64: 317, aarch64: 277}.freeze
37
+ SYS_TGKILL = {x86_64: 234, aarch64: 131}.freeze
36
38
 
37
39
  PR_SET_NO_NEW_PRIVS = 38
40
+ PR_SET_PTRACER = 0x5961_6d61
38
41
  BPF_LD_W_ABS = 0x20
39
42
  BPF_JMP_JA = 0x05
40
43
  BPF_JMP_JEQ_K = 0x15
41
44
  BPF_JMP_JGE_K = 0x35
45
+ BPF_JMP_JSET_K = 0x45
42
46
  BPF_RET_K = 0x06
43
47
  BPF_MAXINSNS = 4096
44
48
  MAX_ERRNO = 4095
@@ -9,7 +9,7 @@ module Seccomp
9
9
  module_function
10
10
 
11
11
  def send_fd(socket, io)
12
- socket.sendmsg("\0", 0, nil, Socket::AncillaryData.unix_rights(io))
12
+ socket.send_io(io)
13
13
  end
14
14
 
15
15
  def recv_fd(socket, timeout: 10)
@@ -12,9 +12,7 @@ module Seccomp
12
12
  result = {user_notif: supported?, continue: false, addfd: false, addfd_send: false, wait_killable_recv: false}
13
13
  return result.freeze unless result[:user_notif]
14
14
 
15
- result[:continue] = probe_response(Constants::SECCOMP_USER_NOTIF_FLAG_CONTINUE)
16
- result[:addfd] = probe_addfd(0)
17
- result[:addfd_send] = probe_addfd(Constants::SECCOMP_ADDFD_FLAG_SEND)
15
+ result.update(probe_responses)
18
16
  result[:wait_killable_recv] = probe_install(Constants::SECCOMP_FILTER_FLAG_WAIT_KILLABLE_RECV)
19
17
  disabled.each { |name| result[name.to_sym] = false if result.key?(name.to_sym) }
20
18
  result[:addfd_send] = false unless result[:addfd]
@@ -28,21 +26,36 @@ module Seccomp
28
26
  false
29
27
  end
30
28
 
31
- def probe_response(flags)
32
- probe do |listener, request|
33
- response = [request.unpack1("Q<"), 0, 0, flags].pack(Structs::RESPONSE_FORMAT)
34
- Ioctl.call(listener, Ioctl::NOTIF_SEND, response)
35
- end
36
- end
37
-
38
- def probe_addfd(flags)
29
+ def probe_responses
30
+ parent, child = UNIXSocket.pair
31
+ listener = nil
32
+ pid = nil
39
33
  File.open("/dev/null") do |source|
40
- probe do |listener, request|
41
- id = request.unpack1("Q<")
42
- injected = Ioctl.call(listener, Ioctl::NOTIF_ADDFD, [id, flags, source.fileno, 0, 0].pack(Structs::ADDFD_FORMAT))
43
- Ioctl.call(listener, Ioctl::NOTIF_SEND, [id, injected, 0, 0].pack(Structs::RESPONSE_FORMAT)) if flags.zero?
34
+ pid = fork do
35
+ parent.close
36
+ listener = Filter.install!(BPF::Builder.new(Policy.new { notify :getpid }).build)
37
+ FdPassing.send_fd(child, listener)
38
+ 3.times { Libc::SYSCALL.call(Syscalls.number(:getpid), 0, 0, 0) }
39
+ exit! 0
40
+ rescue StandardError
41
+ exit! 1
44
42
  end
43
+ child.close
44
+ listener = FdPassing.recv_fd(parent, timeout: 2)
45
+ result = {
46
+ continue: probe_response(listener, Constants::SECCOMP_USER_NOTIF_FLAG_CONTINUE),
47
+ addfd: probe_addfd(listener, source, 0),
48
+ addfd_send: probe_addfd(listener, source, Constants::SECCOMP_ADDFD_FLAG_SEND)
49
+ }
50
+ return result if wait_for_probe(pid).success?
45
51
  end
52
+ {continue: false, addfd: false, addfd_send: false}
53
+ rescue SystemCallError, EOFError, Timeout::Error
54
+ terminate_probe(pid)
55
+ {continue: false, addfd: false, addfd_send: false}
56
+ ensure
57
+ listener&.close unless listener&.closed?
58
+ parent&.close unless parent&.closed?
46
59
  end
47
60
 
48
61
  def probe_install(flags)
@@ -67,30 +80,31 @@ module Seccomp
67
80
  parent&.close unless parent&.closed?
68
81
  end
69
82
 
70
- def probe
71
- parent, child = UNIXSocket.pair
72
- pid = fork do
73
- parent.close
74
- listener = Filter.install!(BPF::Builder.new(Policy.new { notify :getpid }).build)
75
- FdPassing.send_fd(child, listener)
76
- Libc::SYSCALL.call(Syscalls.number(:getpid), 0, 0, 0)
77
- exit! 0
78
- rescue StandardError
79
- exit! 1
80
- end
81
- child.close
82
- listener = FdPassing.recv_fd(parent, timeout: 2)
83
+ def probe_response(listener, flags)
83
84
  request = "\0" * Structs::NOTIF_SIZE
84
85
  Ioctl.call(listener, Ioctl::NOTIF_RECV, request)
85
- yield listener, request
86
- status = wait_for_probe(pid)
87
- status.success?
88
- rescue SystemCallError, EOFError, Timeout::Error
89
- terminate_probe(pid)
86
+ id = request.unpack1("Q<")
87
+ Ioctl.call(listener, Ioctl::NOTIF_SEND, [id, 0, 0, flags].pack(Structs::RESPONSE_FORMAT))
88
+ true
89
+ rescue SystemCallError
90
+ respond_to_probe(listener, id)
90
91
  false
91
- ensure
92
- listener&.close unless listener&.closed?
93
- parent&.close unless parent&.closed?
92
+ end
93
+
94
+ def probe_addfd(listener, source, flags)
95
+ request = "\0" * Structs::NOTIF_SIZE
96
+ Ioctl.call(listener, Ioctl::NOTIF_RECV, request)
97
+ id = request.unpack1("Q<")
98
+ injected = Ioctl.call(listener, Ioctl::NOTIF_ADDFD, [id, flags, source.fileno, 0, 0].pack(Structs::ADDFD_FORMAT))
99
+ respond_to_probe(listener, id, injected) if flags.zero?
100
+ true
101
+ rescue SystemCallError
102
+ respond_to_probe(listener, id)
103
+ false
104
+ end
105
+
106
+ def respond_to_probe(listener, id, value = 0)
107
+ Ioctl.call(listener, Ioctl::NOTIF_SEND, [id, value, 0, 0].pack(Structs::RESPONSE_FORMAT)) if id
94
108
  end
95
109
 
96
110
  def wait_for_probe(pid)
@@ -5,7 +5,7 @@ module Seccomp
5
5
  module Filter
6
6
  module_function
7
7
 
8
- def install!(program, flags: 0)
8
+ def install!(program, flags: 0, raw: false)
9
9
  unless Libc.prctl(Constants::PR_SET_NO_NEW_PRIVS, 1).zero?
10
10
  raise SystemCallError.new("prctl(PR_SET_NO_NEW_PRIVS)", Fiddle.last_error)
11
11
  end
@@ -14,7 +14,7 @@ module Seccomp
14
14
  fd = Libc.seccomp(Constants::SECCOMP_SET_MODE_FILTER, filter_flags, program.to_sock_fprog)
15
15
  raise SystemCallError.new("seccomp(SET_MODE_FILTER)", Fiddle.last_error) if fd.negative?
16
16
 
17
- IO.for_fd(fd, "r")
17
+ raw ? fd : IO.for_fd(fd, "r")
18
18
  end
19
19
  end
20
20
  end
@@ -20,8 +20,7 @@ module Seccomp
20
20
  IOCTL = Fiddle::Function.new(
21
21
  HANDLE["ioctl"],
22
22
  [Fiddle::TYPE_INT, Fiddle::TYPE_LONG, Fiddle::TYPE_VOIDP],
23
- Fiddle::TYPE_INT,
24
- need_gvl: true
23
+ Fiddle::TYPE_INT
25
24
  )
26
25
  POLL = Fiddle::Function.new(
27
26
  HANDLE["poll"],
@@ -47,20 +46,29 @@ module Seccomp
47
46
  end
48
47
 
49
48
  def seccomp(operation, flags, buffer = 0)
50
- pointer = buffer.is_a?(String) ? Fiddle::Pointer[buffer] : buffer
51
- SYSCALL.call(Constants::SYS_SECCOMP.fetch(architecture), operation, flags, pointer.to_i)
49
+ with_pointer(buffer) do |pointer|
50
+ SYSCALL.call(Constants::SYS_SECCOMP.fetch(architecture), operation, flags, pointer.to_i)
51
+ end
52
+ end
53
+
54
+ def tgkill(tgid, tid, signal)
55
+ result = SYSCALL.call(Constants::SYS_TGKILL.fetch(architecture), tgid, tid, signal)
56
+ raise SystemCallError.new("tgkill", Fiddle.last_error) if result.negative?
57
+
58
+ result
52
59
  end
53
60
 
54
61
  def ioctl(fd, request, buffer)
55
- IOCTL.call(fd, request, Fiddle::Pointer[buffer])
62
+ with_pointer(buffer) { |pointer| IOCTL.call(fd, request, pointer) }
56
63
  end
57
64
 
58
65
  def poll_hup?(fd)
59
66
  buffer = [fd, 1, 0].pack("l<s<s<")
60
- result = POLL.call(Fiddle::Pointer[buffer], 1, 0)
67
+ result = with_pointer(buffer) { |pointer| POLL.call(pointer, 1, 0) }
61
68
  raise SystemCallError.new("poll", Fiddle.last_error) if result.negative?
62
69
 
63
- (buffer.unpack1("@6S<") & 0x10).positive?
70
+ revents = buffer.unpack1("@6S<")
71
+ (revents & 0x10).positive? && (revents & 0x01).zero?
64
72
  end
65
73
 
66
74
  def notif_sizes
@@ -71,6 +79,15 @@ module Seccomp
71
79
  notif, resp, data = buffer.unpack("S<S<S<")
72
80
  {notif: notif, resp: resp, data: data}.freeze
73
81
  end
82
+
83
+ def with_pointer(buffer)
84
+ return yield buffer unless buffer.is_a?(String)
85
+
86
+ pointer = Fiddle::Pointer.malloc(buffer.bytesize)
87
+ pointer[0, buffer.bytesize] = buffer
88
+ yield(pointer).tap { buffer.replace(pointer[0, buffer.bytesize]) }
89
+ end
90
+ private_class_method :with_pointer
74
91
  end
75
92
  end
76
93
  end
@@ -3,7 +3,7 @@
3
3
  module Seccomp
4
4
  module Notify
5
5
  class Policy
6
- BOOTSTRAP_SYSCALLS = %i[fcntl sendmsg].freeze
6
+ BOOTSTRAP_SYSCALLS = %i[fcntl].freeze
7
7
  ALLOWED_FLAGS = Constants::SECCOMP_FILTER_FLAG_LOG |
8
8
  Constants::SECCOMP_FILTER_FLAG_SPEC_ALLOW |
9
9
  Constants::SECCOMP_FILTER_FLAG_WAIT_KILLABLE_RECV
@@ -21,6 +21,7 @@ module Seccomp
21
21
 
22
22
  @flags = flags
23
23
  @notifications = []
24
+ @conditional_notifications = []
24
25
  @denials = {}
25
26
  deny_io_uring! if deny_io_uring
26
27
  instance_eval(&block) if block
@@ -34,6 +35,14 @@ module Seccomp
34
35
  @notifications.concat(names.map(&:to_sym))
35
36
  end
36
37
 
38
+ # Notifies only when one of the low 32 bits in a syscall argument is set.
39
+ def notify_if(name, argument:, mask:)
40
+ raise ArgumentError, "argument must be between 0 and 5" unless (0..5).cover?(argument)
41
+ raise ArgumentError, "mask must be between 1 and 0xffffffff" unless mask.is_a?(Integer) && (1..0xffff_ffff).cover?(mask)
42
+
43
+ @conditional_notifications << [name.to_sym, argument, mask]
44
+ end
45
+
37
46
  # Rejects syscalls in the kernel without notifying the supervisor.
38
47
  # @param names [Array<Symbol>]
39
48
  # @param errno [Class, Integer] an Errno class or numeric errno
@@ -69,9 +78,18 @@ module Seccomp
69
78
  @notifications.include?(name.to_sym)
70
79
  end
71
80
 
81
+ def conditional_notifications(arch)
82
+ @conditional_notifications.filter_map do |name, argument, mask|
83
+ [Syscalls.number(name, arch), argument, mask] unless notified?(name) || @denials.key?(name)
84
+ end
85
+ end
86
+
72
87
  private
73
88
 
74
89
  def validate!
90
+ conflict = (@notifications + @conditional_notifications.map(&:first)).find { |name| @denials.key?(name) }
91
+ raise InvalidPolicyError, "#{conflict} cannot be both notified and denied" if conflict
92
+
75
93
  invalid_bootstrap = BOOTSTRAP_SYSCALLS.find { |name| notified?(name) || @denials.key?(name) }
76
94
  if invalid_bootstrap
77
95
  raise InvalidPolicyError, "#{invalid_bootstrap} must be allowed while transferring the listener fd"
@@ -79,6 +79,10 @@ module Seccomp
79
79
  # Emulates a successful syscall return.
80
80
  # @return [void]
81
81
  def allow!(value = 0)
82
+ unless value.is_a?(Integer) && (-(1 << 63)...(1 << 63)).cover?(value) && !(-Constants::MAX_ERRNO..-1).cover?(value)
83
+ raise ArgumentError, "value must be a signed 64-bit integer outside the errno range"
84
+ end
85
+
82
86
  respond!(value:, error: 0, flags: 0)
83
87
  end
84
88
 
@@ -130,14 +134,20 @@ module Seccomp
130
134
 
131
135
  def kill!(signal = "KILL")
132
136
  ensure_unresponded!
133
- Process.kill(signal, @tid)
134
- @responded = true
137
+ signal = Signal.list.fetch(signal.to_s.delete_prefix("SIG")) { raise ArgumentError, "unknown signal: #{signal.inspect}" } unless signal.is_a?(Integer)
138
+ tgid = Integer(File.read("/proc/#{@tid}/status")[/^Tgid:\s+(\d+)/, 1])
139
+ Libc.tgkill(tgid, @tid, signal)
140
+ error!
135
141
  end
136
142
 
137
143
  def mark_pointer_read!
138
144
  @pointer_read = true
139
145
  end
140
146
 
147
+ def close
148
+ @memory.close
149
+ end
150
+
141
151
  private
142
152
 
143
153
  def validate_id(request, buffer)
@@ -53,9 +53,8 @@ module Seccomp
53
53
  @handlers = {}
54
54
  @unknown_handler = ->(request) { request.error!(@default_errno) }
55
55
  @error_handler = ->(error, _request) { warn("seccomp-notify supervisor: #{error.full_message(highlight: false, order: :top)}") }
56
- @memory_cache = {}
57
- @memory_cache_mutex = Mutex.new
58
56
  @receive_mutex = Mutex.new
57
+ @state_mutex = Mutex.new
59
58
  @stop = false
60
59
  @listener_closed = false
61
60
  end
@@ -97,19 +96,18 @@ module Seccomp
97
96
  def run
98
97
  workers = Array.new(@concurrency) { Thread.new { event_loop } }
99
98
  status = wait_for_target
100
- wait_until_listener_closes if @target_pid && !@stop
101
- @stop = true
99
+ wait_until_listener_closes if @target_pid && !stopped?
100
+ stop
102
101
  workers.each(&:join)
103
102
  status
104
103
  ensure
105
- @stop = true
106
- @memory_cache.each_value { |io| io.close unless io.closed? }
104
+ stop
107
105
  @listener.close unless @listener.closed?
108
106
  @health_writer&.close unless @health_writer&.closed?
109
107
  end
110
108
 
111
109
  def stop
112
- @stop = true
110
+ @state_mutex.synchronize { @stop = true }
113
111
  end
114
112
 
115
113
  private
@@ -118,7 +116,7 @@ module Seccomp
118
116
  return wait_until_listener_closes unless @target_pid
119
117
 
120
118
  loop do
121
- return if @stop
119
+ return if stopped?
122
120
 
123
121
  waited = Process.waitpid2(@target_pid, Process::WNOHANG)
124
122
  return waited.last if waited
@@ -132,7 +130,8 @@ module Seccomp
132
130
 
133
131
  def wait_for_non_child
134
132
  loop do
135
- return if @stop
133
+ return if stopped?
134
+ return if target_zombie?
136
135
 
137
136
  Process.kill(0, @target_pid)
138
137
  sleep(@poll_interval)
@@ -141,17 +140,23 @@ module Seccomp
141
140
  nil
142
141
  end
143
142
 
143
+ def target_zombie?
144
+ File.read("/proc/#{@target_pid}/stat").match?(/\A\d+ \(.*\) [ZX] /)
145
+ rescue Errno::ENOENT
146
+ true
147
+ end
148
+
144
149
  def wait_until_listener_closes
145
- sleep(@poll_interval) until @stop || @listener_closed
150
+ sleep(@poll_interval) until stopped? || listener_closed?
146
151
  end
147
152
 
148
153
  def event_loop
149
- process_one until @stop || @listener_closed
154
+ process_one until stopped? || listener_closed?
150
155
  rescue IOError, Errno::EBADF, Errno::ENOTCONN
151
- @stop = true
156
+ stop
152
157
  rescue StandardError => error
153
158
  warn("seccomp-notify worker failed: #{error.message}")
154
- @stop = true
159
+ stop
155
160
  end
156
161
 
157
162
  def process_one
@@ -161,13 +166,20 @@ module Seccomp
161
166
  dispatch(request)
162
167
  rescue Errno::EINTR
163
168
  retry
169
+ rescue Errno::EAGAIN
170
+ mark_listener_closed if Libc.poll_hup?(@listener.fileno)
171
+ nil
164
172
  rescue Errno::ENOENT
165
- @listener_closed = true if Libc.poll_hup?(@listener.fileno)
173
+ mark_listener_closed if Libc.poll_hup?(@listener.fileno)
166
174
  nil
167
175
  end
168
176
 
169
177
  def receive_request
170
178
  return unless IO.select([@listener], nil, nil, @poll_interval)
179
+ if Libc.poll_hup?(@listener.fileno)
180
+ mark_listener_closed
181
+ return
182
+ end
171
183
 
172
184
  buffer = "\0" * @sizes[:notif]
173
185
  Ioctl.call(@listener, Ioctl::NOTIF_RECV, buffer)
@@ -175,8 +187,8 @@ module Seccomp
175
187
  buffer,
176
188
  listener: @listener,
177
189
  sizes: @sizes,
178
- memory_cache: @memory_cache,
179
- memory_cache_mutex: @memory_cache_mutex,
190
+ memory_cache: nil,
191
+ memory_cache_mutex: nil,
180
192
  features: @features
181
193
  )
182
194
  end
@@ -191,7 +203,23 @@ module Seccomp
191
203
  warn("seccomp-notify error callback failed: #{callback_error.message}")
192
204
  end
193
205
  ensure
194
- request&.error!(@default_errno) unless request&.responded?
206
+ begin
207
+ request&.error!(@default_errno) unless request&.responded?
208
+ ensure
209
+ request&.close
210
+ end
211
+ end
212
+
213
+ def stopped?
214
+ @state_mutex.synchronize { @stop }
215
+ end
216
+
217
+ def listener_closed?
218
+ @state_mutex.synchronize { @listener_closed }
219
+ end
220
+
221
+ def mark_listener_closed
222
+ @state_mutex.synchronize { @listener_closed = true }
195
223
  end
196
224
  end
197
225
  end
@@ -1,18 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "ipaddr"
4
3
  require "socket"
4
+ require "ipaddr"
5
5
 
6
6
  module Seccomp
7
7
  module Notify
8
8
  class TargetMemory
9
9
  PAGE_SIZE = 4096
10
+ SOCKADDR_STORAGE_SIZE = 128
10
11
 
11
- def initialize(listener, request, cache, cache_mutex = Mutex.new)
12
+ def initialize(listener, request, _cache = nil, _cache_mutex = nil)
12
13
  @listener = listener
13
14
  @request = request
14
- @cache = cache
15
- @cache_mutex = cache_mutex
15
+ @memory = nil
16
+ end
17
+
18
+ def close
19
+ @memory&.close unless @memory&.closed?
16
20
  end
17
21
 
18
22
  def read(address, length)
@@ -24,7 +28,7 @@ module Seccomp
24
28
 
25
29
  @request.mark_pointer_read!
26
30
  data
27
- rescue Errno::EIO, Errno::EFAULT, Errno::ENOENT, Errno::EACCES => error
31
+ rescue EOFError, Errno::EIO, Errno::EFAULT, Errno::ENOENT, Errno::EACCES => error
28
32
  raise MemoryReadError, error.message
29
33
  end
30
34
 
@@ -45,18 +49,22 @@ module Seccomp
45
49
 
46
50
  def read_sockaddr(address, length)
47
51
  raise MemoryReadError, "sockaddr is shorter than sa_family" if length < 2
52
+ raise MemoryReadError, "sockaddr is longer than sockaddr_storage" if length > SOCKADDR_STORAGE_SIZE
48
53
 
49
54
  bytes = read(address, length)
50
55
  case bytes.unpack1("S<")
51
56
  when Socket::AF_INET
52
57
  raise MemoryReadError, "short AF_INET sockaddr" if length < 16
53
- Addrinfo.tcp(IPAddr.ntop(bytes.byteslice(4, 4)), bytes.byteslice(2, 2).unpack1("n"))
58
+ port = bytes.unpack1("@2n")
59
+ host = IPAddr.ntop(bytes.byteslice(4, 4))
60
+ Addrinfo.new(Socket.sockaddr_in(port, host), Socket::AF_INET, 0, 0)
54
61
  when Socket::AF_INET6
55
62
  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"))
63
+ port = bytes.unpack1("@2n")
64
+ host = IPAddr.ntop(bytes.byteslice(8, 16))
65
+ scope = bytes.unpack1("@24L<")
66
+ host = "#{host}%#{scope}" unless scope.zero?
67
+ Addrinfo.new(Socket.sockaddr_in(port, host), Socket::AF_INET6, 0, 0)
60
68
  when Socket::AF_UNIX
61
69
  raw_path = bytes.byteslice(2..).to_s
62
70
  path = raw_path.start_with?("\0") ? raw_path : raw_path.split("\0", 2).first
@@ -69,9 +77,7 @@ module Seccomp
69
77
  private
70
78
 
71
79
  def memory
72
- @cache[@request.tid] || @cache_mutex.synchronize do
73
- @cache[@request.tid] ||= File.open("/proc/#{@request.tid}/mem", File::RDONLY)
74
- end
80
+ @memory ||= File.open("/proc/#{@request.tid}/mem", File::RDONLY)
75
81
  end
76
82
  end
77
83
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Seccomp
4
4
  module Notify
5
- VERSION = "0.1.0"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
@@ -62,16 +62,17 @@ module Seccomp
62
62
  raise ArgumentError, "recv_timeout must be positive"
63
63
  end
64
64
 
65
- program, install_flags = prepare_supervision(policy, supervisor_options)
66
65
  parent_socket, child_socket = UNIXSocket.pair
66
+ program, install_flags = prepare_supervision(policy, supervisor_options, transfer_fd: child_socket.fileno)
67
67
  health_reader, health_writer = IO.pipe
68
68
  pid = fork do
69
69
  parent_socket.close
70
70
  health_writer.close
71
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
72
+ listener_fd = Filter.install!(program, flags: install_flags, raw: true)
73
+ FdPassing.send_fd(child_socket, listener_fd)
74
+ keep_transfer_socket(child_socket)
75
+ listener = IO.for_fd(listener_fd, "r")
75
76
  listener.close
76
77
  target.call
77
78
  exit! 0
@@ -102,11 +103,11 @@ module Seccomp
102
103
  raise ArgumentError, "supervisor must be :spawn or :fork" unless %i[spawn fork].include?(supervisor)
103
104
  raise ArgumentError, "supervisor: :spawn does not support Ruby handler blocks" if supervisor == :spawn && configure
104
105
 
105
- program, install_flags = prepare_supervision(policy, options)
106
106
  selected_features = options.fetch(:features, features)
107
107
  raise NotSupportedError, "supervise_self requires CONTINUE when no handler block is given" if !configure && !selected_features[:continue]
108
108
 
109
109
  parent_socket, supervisor_socket = UNIXSocket.pair
110
+ program, install_flags = prepare_supervision(policy, options, transfer_fd: parent_socket.fileno)
110
111
  health_reader, health_writer = IO.pipe
111
112
  target_pid = Process.pid
112
113
  supervisor_pid = case supervisor
@@ -119,10 +120,11 @@ module Seccomp
119
120
  supervisor_socket.close
120
121
  health_writer.close
121
122
  keep_health_reader(health_reader)
122
- listener = Filter.install!(program, flags: install_flags)
123
- FdPassing.send_fd(parent_socket, listener)
123
+ listener_fd = Filter.install!(program, flags: install_flags, raw: true)
124
+ FdPassing.send_fd(parent_socket, listener_fd)
125
+ keep_transfer_socket(parent_socket)
126
+ listener = IO.for_fd(listener_fd, "r")
124
127
  listener.close
125
- parent_socket.close
126
128
  Process.detach(supervisor_pid)
127
129
  supervisor_pid
128
130
  rescue StandardError
@@ -175,14 +177,14 @@ module Seccomp
175
177
  )
176
178
  end
177
179
 
178
- def prepare_supervision(policy, options)
180
+ def prepare_supervision(policy, options, transfer_fd: nil)
179
181
  Supervisor.validate_options!(options)
180
182
  sizes = notif_sizes
181
183
  raise NotSupportedError, "unsupported kernel notification sizes: #{sizes.inspect}" unless sizes == Supervisor::EXPECTED_SIZES
182
184
 
183
185
  selected_features = options.fetch(:features, features)
184
186
  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]
187
+ [BPF::Builder.new(policy, transfer_fd:).build, policy.flags | optional_flags]
186
188
  end
187
189
 
188
190
  def keep_health_reader(reader)
@@ -191,6 +193,11 @@ module Seccomp
191
193
  @supervisor_health_reader = reader
192
194
  end
193
195
 
196
+ def keep_transfer_socket(socket)
197
+ socket.close_on_exec = false
198
+ @listener_transfer_socket = socket
199
+ end
200
+
194
201
  def supervisor_health_reader
195
202
  return @supervisor_health_reader if defined?(@supervisor_health_reader)
196
203
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: seccomp-notify
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yudai Takada
@@ -60,12 +60,12 @@ files:
60
60
  - lib/seccomp/notify/version.rb
61
61
  - spike/notify_min.c
62
62
  - tools/gen_syscall_table.rb
63
- homepage: https://rubygems.org/gems/seccomp-notify
63
+ homepage: https://ydah.github.io/seccomp-notify/
64
64
  licenses:
65
65
  - MIT
66
66
  metadata:
67
67
  rubygems_mfa_required: 'true'
68
- homepage_uri: https://rubygems.org/gems/seccomp-notify
68
+ homepage_uri: https://ydah.github.io/seccomp-notify/
69
69
  rdoc_options: []
70
70
  require_paths:
71
71
  - lib
@@ -80,7 +80,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
80
80
  - !ruby/object:Gem::Version
81
81
  version: '0'
82
82
  requirements: []
83
- rubygems_version: 4.0.6
83
+ rubygems_version: 4.0.19
84
84
  specification_version: 4
85
85
  summary: Pure Ruby bindings for Linux seccomp user notifications
86
86
  test_files: []