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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +82 -0
- data/Rakefile +8 -0
- data/examples/deny_network.rb +11 -0
- data/examples/emulate_open.rb +19 -0
- data/examples/strace_lite.rb +14 -0
- data/exe/seccomp-notify-supervisor +15 -0
- data/lib/seccomp/notify/bpf/builder.rb +61 -0
- data/lib/seccomp/notify/bpf/instruction.rb +13 -0
- data/lib/seccomp/notify/bpf/program.rb +30 -0
- data/lib/seccomp/notify/constants.rb +62 -0
- data/lib/seccomp/notify/errors.rb +13 -0
- data/lib/seccomp/notify/fd_passing.rb +28 -0
- data/lib/seccomp/notify/features.rb +106 -0
- data/lib/seccomp/notify/filter.rb +21 -0
- data/lib/seccomp/notify/ioctl.rb +34 -0
- data/lib/seccomp/notify/libc.rb +76 -0
- data/lib/seccomp/notify/policy.rb +84 -0
- data/lib/seccomp/notify/request.rb +165 -0
- data/lib/seccomp/notify/structs.rb +21 -0
- data/lib/seccomp/notify/supervisor.rb +198 -0
- data/lib/seccomp/notify/syscalls.rb +747 -0
- data/lib/seccomp/notify/target_memory.rb +78 -0
- data/lib/seccomp/notify/version.rb +7 -0
- data/lib/seccomp/notify.rb +221 -0
- data/spike/notify_min.c +65 -0
- data/tools/gen_syscall_table.rb +59 -0
- metadata +86 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 4348459a8a4722dfdc17dcf0acf21b0e7802f6b9b5fe80e1821d9cb1964d7802
|
|
4
|
+
data.tar.gz: 3d8776d6b56396439b30b17897eb862dde5fbe8c591ce3e58d62a1c928c90e72
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: b3c6841b95077468dea06d50250d91f660386cf21e33240bcf270b07b7f7efabe6e890a0ee1e6ca22d6492d9cb6837b1cd25954c4eb93d195c77a4ce025221ac
|
|
7
|
+
data.tar.gz: 63d5fdf903dc3541c9ded5eb90deb66de7ea4171304b375f6228aae690462816cd5880eef05de9d27df95bb843fb84c573e89b6ffc718cee50c2e693827ff731
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to seccomp-notify are documented here.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and versions follow [Semantic Versioning](https://semver.org/).
|
|
7
|
+
|
|
8
|
+
## v0.1.0 - 2026-08-22
|
|
9
|
+
|
|
10
|
+
- Initial release
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yudai Takada
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
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.
|
|
4
|
+
|
|
5
|
+
> [!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.
|
|
7
|
+
|
|
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`.
|
|
9
|
+
|
|
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.
|
|
11
|
+
|
|
12
|
+
## Requirements
|
|
13
|
+
|
|
14
|
+
- Linux on x86_64 or aarch64
|
|
15
|
+
- Ruby 3.1 or newer
|
|
16
|
+
- Linux 5.0 or newer; ADDFD needs 5.9 or newer
|
|
17
|
+
- `CONFIG_SECCOMP_FILTER=y`
|
|
18
|
+
|
|
19
|
+
Container runtimes may block `seccomp(2)` with their own profile. For Docker development, use an isolated test container with:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
docker run --rm --security-opt seccomp=unconfined -v "$PWD:/app" -w /app ruby:3.4 bundle exec rake
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
gem "seccomp-notify"
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
The recommended shape keeps the unfiltered supervisor in the parent and installs the filter in a child:
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
require "seccomp/notify"
|
|
37
|
+
|
|
38
|
+
open_syscalls = RUBY_PLATFORM.include?("x86_64") ? %i[open openat] : %i[openat]
|
|
39
|
+
policy = Seccomp::Notify::Policy.new do
|
|
40
|
+
notify(*open_syscalls, :connect)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
supervisor = Seccomp::Notify.spawn(policy) do
|
|
44
|
+
exec("bundle", "install")
|
|
45
|
+
end
|
|
46
|
+
|
|
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
|
+
supervisor.on(:connect) { |request| request.error!(Errno::ENETUNREACH) }
|
|
59
|
+
status = supervisor.run
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`request.pid` is an alias for `request.tid`: the kernel reports the thread ID that issued the syscall, not the process ID.
|
|
63
|
+
|
|
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.
|
|
65
|
+
|
|
66
|
+
## Responses and features
|
|
67
|
+
|
|
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.
|
|
73
|
+
|
|
74
|
+
Use `Seccomp::Notify.features` to inspect runtime support. Set `SECCOMP_NOTIFY_DISABLE_FEATURES=addfd,continue` to force feature fallbacks in tests.
|
|
75
|
+
|
|
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`.
|
|
77
|
+
|
|
78
|
+
## Development
|
|
79
|
+
|
|
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.
|
|
81
|
+
|
|
82
|
+
Releases before 1.0 may change the API. The project is available under the MIT License.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "seccomp/notify"
|
|
4
|
+
|
|
5
|
+
abort "usage: #{$PROGRAM_NAME} COMMAND [ARG...]" if ARGV.empty?
|
|
6
|
+
|
|
7
|
+
policy = Seccomp::Notify::Policy.new { notify :connect, :sendto }
|
|
8
|
+
supervisor = Seccomp::Notify.spawn(policy) { exec(*ARGV) }
|
|
9
|
+
supervisor.on(:connect) { |request| request.error!(Errno::ENETUNREACH) }
|
|
10
|
+
supervisor.on(:sendto) { |request| request.error!(Errno::ENETUNREACH) }
|
|
11
|
+
exit(supervisor.run.exitstatus)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "seccomp/notify"
|
|
4
|
+
|
|
5
|
+
abort "usage: #{$PROGRAM_NAME} REPLACEMENT COMMAND [ARG...]" if ARGV.length < 2
|
|
6
|
+
replacement = File.expand_path(ARGV.shift)
|
|
7
|
+
open_syscalls = RUBY_PLATFORM.include?("x86_64") ? %i[open openat] : %i[openat]
|
|
8
|
+
policy = Seccomp::Notify::Policy.new { notify(*open_syscalls) }
|
|
9
|
+
supervisor = Seccomp::Notify.spawn(policy) { exec(*ARGV) }
|
|
10
|
+
handler = proc do |request|
|
|
11
|
+
path_argument = request.syscall == :open ? 0 : 1
|
|
12
|
+
if request.read_string(request.args[path_argument]) == "/dev/urandom"
|
|
13
|
+
File.open(replacement) { |file| request.add_fd!(file) }
|
|
14
|
+
else
|
|
15
|
+
request.continue!(unsafe: true)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
open_syscalls.each { |syscall| supervisor.on(syscall, &handler) }
|
|
19
|
+
exit(supervisor.run.exitstatus)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "seccomp/notify"
|
|
4
|
+
|
|
5
|
+
abort "usage: #{$PROGRAM_NAME} COMMAND [ARG...]" if ARGV.empty?
|
|
6
|
+
|
|
7
|
+
open_syscalls = RUBY_PLATFORM.include?("x86_64") ? %i[open openat] : %i[openat]
|
|
8
|
+
policy = Seccomp::Notify::Policy.new { notify(*open_syscalls, :connect, :execve) }
|
|
9
|
+
supervisor = Seccomp::Notify.spawn(policy) { exec(*ARGV) }
|
|
10
|
+
supervisor.on_unknown do |request|
|
|
11
|
+
warn "#{request.tid} #{request.syscall}(#{request.args.map { |arg| "0x#{arg.to_s(16)}" }.join(", ")})"
|
|
12
|
+
request.continue!
|
|
13
|
+
end
|
|
14
|
+
exit(supervisor.run.exitstatus)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "socket"
|
|
5
|
+
require "seccomp/notify"
|
|
6
|
+
|
|
7
|
+
socket = UNIXSocket.for_fd(Integer(ARGV.fetch(0)))
|
|
8
|
+
target_pid = Integer(ARGV.fetch(1))
|
|
9
|
+
options = Marshal.load([ARGV.fetch(2)].pack("H*")) # rubocop:disable Security/MarshalLoad
|
|
10
|
+
health_writer = IO.for_fd(Integer(ARGV.fetch(3)))
|
|
11
|
+
listener = Seccomp::Notify::FdPassing.recv_fd(socket)
|
|
12
|
+
socket.close
|
|
13
|
+
supervisor = Seccomp::Notify::Supervisor.new(listener, target_pid:, target_child: false, health_writer:, **options)
|
|
14
|
+
supervisor.on_unknown { |request| request.continue! }
|
|
15
|
+
supervisor.run
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Seccomp
|
|
4
|
+
module Notify
|
|
5
|
+
module BPF
|
|
6
|
+
class Builder
|
|
7
|
+
LINEAR_LIMIT = 64
|
|
8
|
+
AUDIT_ARCHES = {x86_64: Constants::AUDIT_ARCH_X86_64, aarch64: Constants::AUDIT_ARCH_AARCH64}.freeze
|
|
9
|
+
|
|
10
|
+
def initialize(policy, arch: Libc.architecture)
|
|
11
|
+
raise NotSupportedError, "unsupported architecture: #{arch}" unless AUDIT_ARCHES.key?(arch)
|
|
12
|
+
|
|
13
|
+
@policy = policy
|
|
14
|
+
@arch = arch
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def build
|
|
18
|
+
decisions = @policy.decisions(@arch)
|
|
19
|
+
body = decisions.length <= LINEAR_LIMIT ? linear(decisions) : tree(decisions.sort)
|
|
20
|
+
Program.new(prologue + body)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def prologue
|
|
26
|
+
[
|
|
27
|
+
ins(Constants::BPF_LD_W_ABS, 0, 0, Constants::OFF_ARCH),
|
|
28
|
+
ins(Constants::BPF_JMP_JEQ_K, 1, 0, AUDIT_ARCHES.fetch(@arch)),
|
|
29
|
+
ins(Constants::BPF_RET_K, 0, 0, Constants::SECCOMP_RET_KILL_PROCESS),
|
|
30
|
+
ins(Constants::BPF_LD_W_ABS, 0, 0, Constants::OFF_NR)
|
|
31
|
+
]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def linear(decisions)
|
|
35
|
+
decisions.flat_map do |number, action|
|
|
36
|
+
[ins(Constants::BPF_JMP_JEQ_K, 0, 1, number), ins(Constants::BPF_RET_K, 0, 0, action)]
|
|
37
|
+
end << ins(Constants::BPF_RET_K, 0, 0, @policy.default_action)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def tree(decisions)
|
|
41
|
+
return [ins(Constants::BPF_RET_K, 0, 0, @policy.default_action)] if decisions.empty?
|
|
42
|
+
return linear(decisions) if decisions.length <= 4
|
|
43
|
+
|
|
44
|
+
middle = decisions.length / 2
|
|
45
|
+
left = tree(decisions[...middle])
|
|
46
|
+
right = tree(decisions[middle..])
|
|
47
|
+
pivot = decisions[middle][0]
|
|
48
|
+
[
|
|
49
|
+
ins(Constants::BPF_JMP_JGE_K, 0, 1, pivot),
|
|
50
|
+
ins(Constants::BPF_JMP_JA, 0, 0, 1),
|
|
51
|
+
ins(Constants::BPF_JMP_JA, 0, 0, right.length)
|
|
52
|
+
] + right + left
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def ins(code, jt, jf, k)
|
|
56
|
+
Instruction.new(code:, jt:, jf:, k:)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fiddle"
|
|
4
|
+
|
|
5
|
+
module Seccomp
|
|
6
|
+
module Notify
|
|
7
|
+
module BPF
|
|
8
|
+
class Program
|
|
9
|
+
attr_reader :instructions
|
|
10
|
+
|
|
11
|
+
def initialize(instructions)
|
|
12
|
+
@instructions = instructions.freeze
|
|
13
|
+
raise FilterTooLargeError, "BPF program exceeds #{Constants::BPF_MAXINSNS} instructions" if instructions.length > Constants::BPF_MAXINSNS
|
|
14
|
+
|
|
15
|
+
@binary = instructions.map(&:to_binary).join
|
|
16
|
+
@pointer = Fiddle::Pointer[@binary]
|
|
17
|
+
@sock_fprog = [instructions.length, @pointer.to_i].pack(Structs::PROGRAM_FORMAT)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def to_binary
|
|
21
|
+
@binary
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def to_sock_fprog
|
|
25
|
+
@sock_fprog
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Seccomp
|
|
4
|
+
module Notify
|
|
5
|
+
module Constants
|
|
6
|
+
SECCOMP_SET_MODE_STRICT = 0
|
|
7
|
+
SECCOMP_SET_MODE_FILTER = 1
|
|
8
|
+
SECCOMP_GET_ACTION_AVAIL = 2
|
|
9
|
+
SECCOMP_GET_NOTIF_SIZES = 3
|
|
10
|
+
|
|
11
|
+
SECCOMP_FILTER_FLAG_TSYNC = 1 << 0
|
|
12
|
+
SECCOMP_FILTER_FLAG_LOG = 1 << 1
|
|
13
|
+
SECCOMP_FILTER_FLAG_SPEC_ALLOW = 1 << 2
|
|
14
|
+
SECCOMP_FILTER_FLAG_NEW_LISTENER = 1 << 3
|
|
15
|
+
SECCOMP_FILTER_FLAG_TSYNC_ESRCH = 1 << 4
|
|
16
|
+
SECCOMP_FILTER_FLAG_WAIT_KILLABLE_RECV = 1 << 5
|
|
17
|
+
|
|
18
|
+
SECCOMP_RET_KILL_PROCESS = 0x8000_0000
|
|
19
|
+
SECCOMP_RET_KILL_THREAD = 0x0000_0000
|
|
20
|
+
SECCOMP_RET_TRAP = 0x0003_0000
|
|
21
|
+
SECCOMP_RET_ERRNO = 0x0005_0000
|
|
22
|
+
SECCOMP_RET_USER_NOTIF = 0x7fc0_0000
|
|
23
|
+
SECCOMP_RET_TRACE = 0x7ff0_0000
|
|
24
|
+
SECCOMP_RET_LOG = 0x7ffc_0000
|
|
25
|
+
SECCOMP_RET_ALLOW = 0x7fff_0000
|
|
26
|
+
|
|
27
|
+
SECCOMP_USER_NOTIF_FLAG_CONTINUE = 1 << 0
|
|
28
|
+
SECCOMP_ADDFD_FLAG_SETFD = 1 << 0
|
|
29
|
+
SECCOMP_ADDFD_FLAG_SEND = 1 << 1
|
|
30
|
+
O_CLOEXEC = 0x80000
|
|
31
|
+
|
|
32
|
+
AUDIT_ARCH_X86_64 = 0xc000_003e
|
|
33
|
+
AUDIT_ARCH_AARCH64 = 0xc000_00b7
|
|
34
|
+
AUDIT_ARCH_I386 = 0x4000_0003
|
|
35
|
+
SYS_SECCOMP = {x86_64: 317, aarch64: 277}.freeze
|
|
36
|
+
|
|
37
|
+
PR_SET_NO_NEW_PRIVS = 38
|
|
38
|
+
BPF_LD_W_ABS = 0x20
|
|
39
|
+
BPF_JMP_JA = 0x05
|
|
40
|
+
BPF_JMP_JEQ_K = 0x15
|
|
41
|
+
BPF_JMP_JGE_K = 0x35
|
|
42
|
+
BPF_RET_K = 0x06
|
|
43
|
+
BPF_MAXINSNS = 4096
|
|
44
|
+
MAX_ERRNO = 4095
|
|
45
|
+
OFF_NR = 0
|
|
46
|
+
OFF_ARCH = 4
|
|
47
|
+
OFF_IP = 8
|
|
48
|
+
OFF_ARGS = 16
|
|
49
|
+
|
|
50
|
+
module_function
|
|
51
|
+
|
|
52
|
+
def errno_number(value)
|
|
53
|
+
number = value.is_a?(Module) ? value.const_get(:Errno, false) : value
|
|
54
|
+
return number if number.is_a?(Integer) && (1..MAX_ERRNO).cover?(number)
|
|
55
|
+
|
|
56
|
+
raise ArgumentError, "errno must be between 1 and #{MAX_ERRNO}"
|
|
57
|
+
rescue NameError
|
|
58
|
+
raise ArgumentError, "errno must be between 1 and #{MAX_ERRNO}"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Seccomp
|
|
4
|
+
module Notify
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
class NotSupportedError < Error; end
|
|
7
|
+
class InvalidPolicyError < Error; end
|
|
8
|
+
class FilterTooLargeError < Error; end
|
|
9
|
+
class AlreadyRespondedError < Error; end
|
|
10
|
+
class StaleNotificationError < Error; end
|
|
11
|
+
class MemoryReadError < Error; end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "timeout"
|
|
5
|
+
|
|
6
|
+
module Seccomp
|
|
7
|
+
module Notify
|
|
8
|
+
module FdPassing
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def send_fd(socket, io)
|
|
12
|
+
socket.sendmsg("\0", 0, nil, Socket::AncillaryData.unix_rights(io))
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def recv_fd(socket, timeout: 10)
|
|
16
|
+
raise Timeout::Error, "timed out waiting for listener fd" unless IO.select([socket], nil, nil, timeout)
|
|
17
|
+
|
|
18
|
+
_message, _address, _flags, control = socket.recvmsg(1, 0, 64, scm_rights: true)
|
|
19
|
+
raise EOFError, "message did not contain a file descriptor" unless control
|
|
20
|
+
|
|
21
|
+
io = control.unix_rights.first
|
|
22
|
+
raise EOFError, "message did not contain a file descriptor" unless io
|
|
23
|
+
|
|
24
|
+
io
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "timeout"
|
|
4
|
+
|
|
5
|
+
module Seccomp
|
|
6
|
+
module Notify
|
|
7
|
+
module Features
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def detect
|
|
11
|
+
disabled = ENV.fetch("SECCOMP_NOTIFY_DISABLE_FEATURES", "").split(",").map(&:strip)
|
|
12
|
+
result = {user_notif: supported?, continue: false, addfd: false, addfd_send: false, wait_killable_recv: false}
|
|
13
|
+
return result.freeze unless result[:user_notif]
|
|
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)
|
|
18
|
+
result[:wait_killable_recv] = probe_install(Constants::SECCOMP_FILTER_FLAG_WAIT_KILLABLE_RECV)
|
|
19
|
+
disabled.each { |name| result[name.to_sym] = false if result.key?(name.to_sym) }
|
|
20
|
+
result[:addfd_send] = false unless result[:addfd]
|
|
21
|
+
result.freeze
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def supported?
|
|
25
|
+
Libc.notif_sizes
|
|
26
|
+
true
|
|
27
|
+
rescue SystemCallError, NotSupportedError
|
|
28
|
+
false
|
|
29
|
+
end
|
|
30
|
+
|
|
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)
|
|
39
|
+
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?
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def probe_install(flags)
|
|
49
|
+
parent, child = UNIXSocket.pair
|
|
50
|
+
pid = fork do
|
|
51
|
+
parent.close
|
|
52
|
+
listener = Filter.install!(BPF::Builder.new(Policy.new { notify :getpid }).build, flags:)
|
|
53
|
+
FdPassing.send_fd(child, listener)
|
|
54
|
+
exit! 0
|
|
55
|
+
rescue SystemCallError
|
|
56
|
+
exit! 1
|
|
57
|
+
end
|
|
58
|
+
child.close
|
|
59
|
+
listener = FdPassing.recv_fd(parent, timeout: 2)
|
|
60
|
+
listener.close
|
|
61
|
+
status = wait_for_probe(pid)
|
|
62
|
+
status.success?
|
|
63
|
+
rescue EOFError, Timeout::Error
|
|
64
|
+
terminate_probe(pid)
|
|
65
|
+
false
|
|
66
|
+
ensure
|
|
67
|
+
parent&.close unless parent&.closed?
|
|
68
|
+
end
|
|
69
|
+
|
|
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
|
+
request = "\0" * Structs::NOTIF_SIZE
|
|
84
|
+
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)
|
|
90
|
+
false
|
|
91
|
+
ensure
|
|
92
|
+
listener&.close unless listener&.closed?
|
|
93
|
+
parent&.close unless parent&.closed?
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def wait_for_probe(pid)
|
|
97
|
+
Timeout.timeout(2) { Process.waitpid2(pid).last }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def terminate_probe(pid)
|
|
101
|
+
Process.kill("KILL", pid) rescue nil
|
|
102
|
+
Process.waitpid(pid) rescue nil
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Seccomp
|
|
4
|
+
module Notify
|
|
5
|
+
module Filter
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def install!(program, flags: 0)
|
|
9
|
+
unless Libc.prctl(Constants::PR_SET_NO_NEW_PRIVS, 1).zero?
|
|
10
|
+
raise SystemCallError.new("prctl(PR_SET_NO_NEW_PRIVS)", Fiddle.last_error)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
filter_flags = flags | Constants::SECCOMP_FILTER_FLAG_NEW_LISTENER
|
|
14
|
+
fd = Libc.seccomp(Constants::SECCOMP_SET_MODE_FILTER, filter_flags, program.to_sock_fprog)
|
|
15
|
+
raise SystemCallError.new("seccomp(SET_MODE_FILTER)", Fiddle.last_error) if fd.negative?
|
|
16
|
+
|
|
17
|
+
IO.for_fd(fd, "r")
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fiddle"
|
|
4
|
+
|
|
5
|
+
module Seccomp
|
|
6
|
+
module Notify
|
|
7
|
+
module Ioctl
|
|
8
|
+
NONE = 0
|
|
9
|
+
WRITE = 1
|
|
10
|
+
READ = 2
|
|
11
|
+
MAGIC = "!".ord
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def ioc(dir, type, nr, size)
|
|
16
|
+
(dir << 30) | (size << 16) | (type << 8) | nr
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
NOTIF_RECV = ioc(READ | WRITE, MAGIC, 0, 80)
|
|
20
|
+
NOTIF_SEND = ioc(READ | WRITE, MAGIC, 1, 24)
|
|
21
|
+
NOTIF_ID_VALID = ioc(WRITE, MAGIC, 2, 8)
|
|
22
|
+
NOTIF_ID_VALID_OLD = ioc(READ, MAGIC, 2, 8)
|
|
23
|
+
NOTIF_ADDFD = ioc(WRITE, MAGIC, 3, 24)
|
|
24
|
+
NOTIF_SET_FLAGS = ioc(WRITE, MAGIC, 4, 8)
|
|
25
|
+
|
|
26
|
+
def call(io, request, buffer)
|
|
27
|
+
result = Libc.ioctl(io.fileno, request, buffer)
|
|
28
|
+
return result unless result.negative?
|
|
29
|
+
|
|
30
|
+
raise SystemCallError.new("ioctl(0x#{request.to_s(16)})", Fiddle.last_error)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fiddle"
|
|
4
|
+
|
|
5
|
+
module Seccomp
|
|
6
|
+
module Notify
|
|
7
|
+
module Libc
|
|
8
|
+
HANDLE = Fiddle.dlopen(nil)
|
|
9
|
+
SYSCALL = Fiddle::Function.new(
|
|
10
|
+
HANDLE["syscall"],
|
|
11
|
+
[Fiddle::TYPE_LONG] * 4,
|
|
12
|
+
Fiddle::TYPE_LONG,
|
|
13
|
+
need_gvl: true
|
|
14
|
+
)
|
|
15
|
+
PRCTL = Fiddle::Function.new(
|
|
16
|
+
HANDLE["prctl"],
|
|
17
|
+
[Fiddle::TYPE_INT] * 5,
|
|
18
|
+
Fiddle::TYPE_INT
|
|
19
|
+
)
|
|
20
|
+
IOCTL = Fiddle::Function.new(
|
|
21
|
+
HANDLE["ioctl"],
|
|
22
|
+
[Fiddle::TYPE_INT, Fiddle::TYPE_LONG, Fiddle::TYPE_VOIDP],
|
|
23
|
+
Fiddle::TYPE_INT,
|
|
24
|
+
need_gvl: true
|
|
25
|
+
)
|
|
26
|
+
POLL = Fiddle::Function.new(
|
|
27
|
+
HANDLE["poll"],
|
|
28
|
+
[Fiddle::TYPE_VOIDP, Fiddle::TYPE_LONG, Fiddle::TYPE_INT],
|
|
29
|
+
Fiddle::TYPE_INT
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
def architecture
|
|
35
|
+
@architecture ||= case RUBY_PLATFORM
|
|
36
|
+
when /x86_64/
|
|
37
|
+
:x86_64
|
|
38
|
+
when /aarch64|arm64/
|
|
39
|
+
:aarch64
|
|
40
|
+
else
|
|
41
|
+
raise NotSupportedError, "unsupported architecture: #{RUBY_PLATFORM}"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def prctl(option, arg2 = 0, arg3 = 0, arg4 = 0, arg5 = 0)
|
|
46
|
+
PRCTL.call(option, arg2, arg3, arg4, arg5)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
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)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def ioctl(fd, request, buffer)
|
|
55
|
+
IOCTL.call(fd, request, Fiddle::Pointer[buffer])
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def poll_hup?(fd)
|
|
59
|
+
buffer = [fd, 1, 0].pack("l<s<s<")
|
|
60
|
+
result = POLL.call(Fiddle::Pointer[buffer], 1, 0)
|
|
61
|
+
raise SystemCallError.new("poll", Fiddle.last_error) if result.negative?
|
|
62
|
+
|
|
63
|
+
(buffer.unpack1("@6S<") & 0x10).positive?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def notif_sizes
|
|
67
|
+
buffer = "\0" * 6
|
|
68
|
+
result = seccomp(Constants::SECCOMP_GET_NOTIF_SIZES, 0, buffer)
|
|
69
|
+
raise SystemCallError.new("seccomp(GET_NOTIF_SIZES)", Fiddle.last_error) if result.negative?
|
|
70
|
+
|
|
71
|
+
notif, resp, data = buffer.unpack("S<S<S<")
|
|
72
|
+
{notif: notif, resp: resp, data: data}.freeze
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|