rptrace 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,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+
5
+ module Rptrace
6
+ # Architecture-specific register layouts and binary helpers.
7
+ module CStructs
8
+ # Host pointer width in bytes.
9
+ POINTER_SIZE = begin
10
+ [0].pack("J").bytesize
11
+ rescue StandardError
12
+ 8
13
+ end
14
+ # Pointer pack format for host pointer size.
15
+ POINTER_PACK = POINTER_SIZE == 8 ? "Q<" : "L<"
16
+ # Register word width in bytes.
17
+ WORD_SIZE = 8
18
+ # Register word bitmask.
19
+ WORD_MASK = (1 << (WORD_SIZE * 8)) - 1
20
+ # Register word unpack/pack format.
21
+ PACK_FORMAT = "Q<"
22
+ # seccomp metadata struct pack format.
23
+ SECCOMP_METADATA_FORMAT = "Q<Q<"
24
+ # seccomp metadata struct size.
25
+ SECCOMP_METADATA_SIZE = 16
26
+ # BPF instruction size in seccomp filter dump.
27
+ SECCOMP_FILTER_INSN_SIZE = 8
28
+
29
+ # x86_64 user_regs_struct register names.
30
+ X86_64_REGS = %i[
31
+ r15 r14 r13 r12 rbp rbx r11 r10 r9 r8
32
+ rax rcx rdx rsi rdi orig_rax rip cs eflags
33
+ rsp ss fs_base gs_base ds es fs gs
34
+ ].freeze
35
+
36
+ # aarch64 register names returned by NT_PRSTATUS regset.
37
+ AARCH64_REGS = (0..30).map { |i| :"x#{i}" }.push(:sp, :pc, :pstate).freeze
38
+
39
+ module_function
40
+
41
+ # @return [Symbol] :x86_64 or :aarch64
42
+ def arch
43
+ @arch ||= detect_arch
44
+ end
45
+
46
+ # @param arch [Symbol]
47
+ # @return [Array<Symbol>]
48
+ def reg_names(arch: self.arch)
49
+ case arch
50
+ when :x86_64 then X86_64_REGS
51
+ when :aarch64 then AARCH64_REGS
52
+ else
53
+ raise UnsupportedArchError, "Unsupported architecture: #{arch}"
54
+ end
55
+ end
56
+
57
+ # @param arch [Symbol]
58
+ # @return [Integer]
59
+ def regs_size(arch: self.arch)
60
+ reg_names(arch: arch).size * 8
61
+ end
62
+
63
+ # @param source [String, Fiddle::Pointer]
64
+ # @param arch [Symbol]
65
+ # @return [Hash<Symbol, Integer>]
66
+ def decode_regs(source, arch: self.arch)
67
+ bytes = source.is_a?(String) ? source : source[0, regs_size(arch: arch)]
68
+ values = bytes.unpack("#{PACK_FORMAT}*")
69
+ names = reg_names(arch: arch)
70
+
71
+ names.each_with_index.each_with_object({}) do |(name, index), decoded|
72
+ decoded[name] = values.fetch(index, 0)
73
+ end
74
+ end
75
+
76
+ # @param regs [Hash<Symbol, Integer>]
77
+ # @param arch [Symbol]
78
+ # @return [String] packed binary register bytes
79
+ def encode_regs(regs, arch: self.arch)
80
+ names = reg_names(arch: arch)
81
+ values = names.map { |name| Integer(regs.fetch(name, 0)) & WORD_MASK }
82
+ values.pack("#{PACK_FORMAT}*")
83
+ end
84
+
85
+ # @param base [Integer]
86
+ # @param length [Integer]
87
+ # @return [String] packed iovec
88
+ def pack_iovec(base:, length:)
89
+ [Integer(base), Integer(length)].pack("#{POINTER_PACK}#{POINTER_PACK}")
90
+ end
91
+
92
+ # @param bytes [String]
93
+ # @return [Hash<Symbol, Integer>]
94
+ def unpack_iovec(bytes)
95
+ base, length = bytes.unpack("#{POINTER_PACK}#{POINTER_PACK}")
96
+ { base: base, length: length }
97
+ end
98
+
99
+ # @return [Integer]
100
+ def seccomp_metadata_size
101
+ SECCOMP_METADATA_SIZE
102
+ end
103
+
104
+ # @param filter_off [Integer]
105
+ # @param flags [Integer]
106
+ # @return [String]
107
+ def pack_seccomp_metadata(filter_off:, flags: 0)
108
+ [Integer(filter_off), Integer(flags)].pack(SECCOMP_METADATA_FORMAT)
109
+ end
110
+
111
+ # @param bytes [String]
112
+ # @return [Hash<Symbol, Integer>]
113
+ def unpack_seccomp_metadata(bytes)
114
+ filter_off, flags = bytes.unpack(SECCOMP_METADATA_FORMAT)
115
+ { filter_off: filter_off, flags: flags }
116
+ end
117
+
118
+ # @param bytes [String]
119
+ # @return [Array<Hash<Symbol, Integer>>]
120
+ def decode_seccomp_filter(bytes)
121
+ blob = bytes.to_s.b
122
+ raise ArgumentError, "seccomp filter bytes must align to #{SECCOMP_FILTER_INSN_SIZE}" unless (blob.bytesize % SECCOMP_FILTER_INSN_SIZE).zero?
123
+
124
+ instructions = []
125
+ blob.bytes.each_slice(SECCOMP_FILTER_INSN_SIZE) do |insn|
126
+ code, jt, jf, k = insn.pack("C*").unpack("S<CCL<")
127
+ instructions << { code: code, jt: jt, jf: jf, k: k }
128
+ end
129
+ instructions
130
+ end
131
+
132
+ # @return [Symbol]
133
+ # @raise [Rptrace::UnsupportedArchError]
134
+ def detect_arch
135
+ host_cpu = RbConfig::CONFIG.fetch("host_cpu", "")
136
+
137
+ case host_cpu
138
+ when /x86_64|amd64/
139
+ :x86_64
140
+ when /aarch64|arm64/
141
+ :aarch64
142
+ else
143
+ raise UnsupportedArchError, "Unsupported architecture: #{host_cpu}"
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rptrace
4
+ # Linux ptrace and waitpid-related constants.
5
+ module Constants
6
+ NT_PRSTATUS = 1
7
+
8
+ PTRACE_TRACEME = 0
9
+ PTRACE_PEEKTEXT = 1
10
+ PTRACE_PEEKDATA = 2
11
+ PTRACE_PEEKUSER = 3
12
+ PTRACE_POKETEXT = 4
13
+ PTRACE_POKEDATA = 5
14
+ PTRACE_POKEUSER = 6
15
+ PTRACE_CONT = 7
16
+ PTRACE_KILL = 8
17
+ PTRACE_SINGLESTEP = 9
18
+ PTRACE_GETREGS = 12
19
+ PTRACE_SETREGS = 13
20
+ PTRACE_GETFPREGS = 14
21
+ PTRACE_SETFPREGS = 15
22
+ PTRACE_ATTACH = 16
23
+ PTRACE_DETACH = 17
24
+ PTRACE_GETFPXREGS = 18
25
+ PTRACE_SETFPXREGS = 19
26
+ PTRACE_SYSCALL = 24
27
+ PTRACE_SETOPTIONS = 0x4200
28
+ PTRACE_GETEVENTMSG = 0x4201
29
+ PTRACE_GETSIGINFO = 0x4202
30
+ PTRACE_SETSIGINFO = 0x4203
31
+ PTRACE_GETREGSET = 0x4204
32
+ PTRACE_SETREGSET = 0x4205
33
+ PTRACE_SEIZE = 0x4206
34
+ PTRACE_INTERRUPT = 0x4207
35
+ PTRACE_LISTEN = 0x4208
36
+ PTRACE_PEEKSIGINFO = 0x4209
37
+ PTRACE_GETSIGMASK = 0x420A
38
+ PTRACE_SETSIGMASK = 0x420B
39
+ PTRACE_SECCOMP_GET_FILTER = 0x420C
40
+ PTRACE_SECCOMP_GET_METADATA = 0x420D
41
+ PTRACE_GET_SYSCALL_INFO = 0x420E
42
+
43
+ PTRACE_O_TRACESYSGOOD = 0x00000001
44
+ PTRACE_O_TRACEFORK = 0x00000002
45
+ PTRACE_O_TRACEVFORK = 0x00000004
46
+ PTRACE_O_TRACECLONE = 0x00000008
47
+ PTRACE_O_TRACEEXEC = 0x00000010
48
+ PTRACE_O_TRACEVFORKDONE = 0x00000020
49
+ PTRACE_O_TRACEEXIT = 0x00000040
50
+ PTRACE_O_TRACESECCOMP = 0x00000080
51
+ PTRACE_O_EXITKILL = 0x00100000
52
+ PTRACE_O_SUSPEND_SECCOMP = 0x00200000
53
+
54
+ PTRACE_EVENT_FORK = 1
55
+ PTRACE_EVENT_VFORK = 2
56
+ PTRACE_EVENT_CLONE = 3
57
+ PTRACE_EVENT_EXEC = 4
58
+ PTRACE_EVENT_VFORK_DONE = 5
59
+ PTRACE_EVENT_EXIT = 6
60
+ PTRACE_EVENT_SECCOMP = 7
61
+ PTRACE_EVENT_STOP = 128
62
+
63
+ SECCOMP_FILTER_FLAG_TSYNC = 1
64
+ SECCOMP_FILTER_FLAG_LOG = 2
65
+ SECCOMP_FILTER_FLAG_SPEC_ALLOW = 4
66
+ SECCOMP_FILTER_FLAG_NEW_LISTENER = 8
67
+ SECCOMP_FILTER_FLAG_TSYNC_ESRCH = 16
68
+
69
+ WNOHANG = 1
70
+ WUNTRACED = 2
71
+ WCONTINUED = 8
72
+ WALL = 0x40000000
73
+ end
74
+
75
+ # Helpers mirroring wait status macros from sys/wait.h.
76
+ module WaitStatus
77
+ module_function
78
+
79
+ # @param status [Integer]
80
+ # @return [Boolean]
81
+ def exited?(status)
82
+ (status & 0x7F).zero?
83
+ end
84
+
85
+ # @param status [Integer]
86
+ # @return [Boolean]
87
+ def signaled?(status)
88
+ signal = status & 0x7F
89
+ !signal.zero? && signal != 0x7F
90
+ end
91
+
92
+ # @param status [Integer]
93
+ # @return [Boolean]
94
+ def stopped?(status)
95
+ (status & 0xFF) == 0x7F
96
+ end
97
+
98
+ # @param status [Integer]
99
+ # @return [Boolean]
100
+ def continued?(status)
101
+ status == 0xFFFF
102
+ end
103
+
104
+ # @param status [Integer]
105
+ # @return [Integer]
106
+ def exit_status(status)
107
+ (status >> 8) & 0xFF
108
+ end
109
+
110
+ # @param status [Integer]
111
+ # @return [Integer]
112
+ def term_signal(status)
113
+ status & 0x7F
114
+ end
115
+
116
+ # @param status [Integer]
117
+ # @return [Integer]
118
+ def stop_signal(status)
119
+ (status >> 8) & 0xFF
120
+ end
121
+
122
+ # @param status [Integer]
123
+ # @return [Boolean]
124
+ def core_dumped?(status)
125
+ signaled?(status) && (status & 0x80).positive?
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rptrace
4
+ class << self
5
+ # Default option set to follow fork/clone descendants.
6
+ FOLLOW_CHILD_TRACE_OPTIONS = Tracee::DEFAULT_TRACE_OPTIONS |
7
+ Constants::PTRACE_O_TRACECLONE |
8
+ Constants::PTRACE_O_TRACEFORK |
9
+ Constants::PTRACE_O_TRACEVFORK
10
+
11
+ # Spawns and traces a command for the duration of the block.
12
+ #
13
+ # @param command [String] executable path or command name
14
+ # @param args [Array<String>] command arguments
15
+ # @param options [Integer] ptrace options passed to Tracee.spawn
16
+ # @yieldparam tracee [Rptrace::Tracee]
17
+ # @return [Object] block return value
18
+ def trace(command, *args, options: Tracee::DEFAULT_TRACE_OPTIONS)
19
+ tracee = Tracee.spawn(command, *args, options: options)
20
+ yield tracee
21
+ ensure
22
+ safe_detach(tracee)
23
+ end
24
+
25
+ # Runs a command and yields syscall enter/exit events.
26
+ #
27
+ # @param command [String] executable path or command name
28
+ # @param args [Array<String>] command arguments
29
+ # @param follow_children [Boolean] follow clone/fork/vfork descendants
30
+ # @param yield_seccomp [Boolean] yield seccomp stop events as {Rptrace::SeccompEvent}
31
+ # @yieldparam event [Rptrace::SyscallEvent, Rptrace::SeccompEvent]
32
+ # @return [void]
33
+ def strace(command, *args, follow_children: false, yield_seccomp: false)
34
+ options = follow_children ? FOLLOW_CHILD_TRACE_OPTIONS : Tracee::DEFAULT_TRACE_OPTIONS
35
+ trace(command, *args, options: options) do |tracee|
36
+ if follow_children
37
+ strace_with_children(tracee, yield_seccomp: yield_seccomp) { |event| yield event }
38
+ else
39
+ strace_single(tracee, yield_seccomp: yield_seccomp) { |event| yield event }
40
+ end
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def strace_single(tracee, yield_seccomp:)
47
+ loop do
48
+ tracee.syscall
49
+ event = tracee.wait(flags: Constants::WALL)
50
+ break if event.exited? || event.signaled?
51
+ if yield_seccomp && event.seccomp_event?
52
+ yield build_seccomp_event(tracee)
53
+ next
54
+ end
55
+ next unless event.syscall_stop?
56
+
57
+ syscall = tracee.current_syscall
58
+ syscall_args = tracee.syscall_args
59
+
60
+ yield SyscallEvent.new(tracee: tracee, syscall: syscall, args: syscall_args, phase: :enter)
61
+
62
+ tracee.syscall
63
+ exit_event = tracee.wait(flags: Constants::WALL)
64
+ break if exit_event.exited? || exit_event.signaled?
65
+
66
+ yield SyscallEvent.new(
67
+ tracee: tracee,
68
+ syscall: syscall,
69
+ args: syscall_args,
70
+ return_value: tracee.syscall_return,
71
+ phase: :exit
72
+ )
73
+ end
74
+ end
75
+
76
+ def strace_with_children(root_tracee, yield_seccomp:)
77
+ tracees = { root_tracee.pid => root_tracee }
78
+ pending_syscalls = {}
79
+ begin
80
+ root_tracee.syscall
81
+ rescue NoProcessError
82
+ return
83
+ end
84
+
85
+ while tracees.any?
86
+ event = Tracee.wait_any(flags: Constants::WALL)
87
+ tracee = tracees.fetch(event.pid) { tracees[event.pid] = Tracee.new(event.pid) }
88
+
89
+ if event.exited? || event.signaled?
90
+ pending_syscalls.delete(event.pid)
91
+ tracees.delete(event.pid)
92
+ next
93
+ end
94
+
95
+ if event.fork_like_event?
96
+ child_pid = tracee.event_message
97
+ child_tracee = tracees.fetch(child_pid) do
98
+ created = Tracee.new(child_pid)
99
+ created.set_options(FOLLOW_CHILD_TRACE_OPTIONS)
100
+ tracees[child_pid] = created
101
+ end
102
+ pending_syscalls.delete(child_pid)
103
+ resume_tracee_syscall(child_tracee, tracees: tracees, pending_syscalls: pending_syscalls)
104
+ end
105
+
106
+ if yield_seccomp && event.seccomp_event?
107
+ yield build_seccomp_event(tracee)
108
+ elsif event.syscall_stop?
109
+ if pending_syscalls.key?(event.pid)
110
+ entry = pending_syscalls.delete(event.pid)
111
+ yield SyscallEvent.new(
112
+ tracee: tracee,
113
+ syscall: entry.fetch(:syscall),
114
+ args: entry.fetch(:args),
115
+ return_value: tracee.syscall_return,
116
+ phase: :exit
117
+ )
118
+ else
119
+ syscall = tracee.current_syscall
120
+ syscall_args = tracee.syscall_args
121
+ pending_syscalls[event.pid] = { syscall: syscall, args: syscall_args }
122
+ yield SyscallEvent.new(tracee: tracee, syscall: syscall, args: syscall_args, phase: :enter)
123
+ end
124
+ end
125
+
126
+ resume_tracee_syscall(tracee, tracees: tracees, pending_syscalls: pending_syscalls)
127
+ end
128
+ ensure
129
+ tracees&.each_value { |tracee| safe_detach(tracee) }
130
+ end
131
+
132
+ def resume_tracee_syscall(tracee, tracees:, pending_syscalls:)
133
+ tracee.syscall
134
+ rescue NoProcessError
135
+ pending_syscalls.delete(tracee.pid)
136
+ tracees.delete(tracee.pid)
137
+ nil
138
+ end
139
+
140
+ def build_seccomp_event(tracee)
141
+ metadata_flags = begin
142
+ tracee.seccomp_metadata_flag_names
143
+ rescue Error
144
+ []
145
+ end
146
+ SeccompEvent.new(
147
+ tracee: tracee,
148
+ syscall: tracee.current_syscall,
149
+ data: tracee.seccomp_data,
150
+ metadata_flags: metadata_flags
151
+ )
152
+ end
153
+
154
+ def safe_detach(tracee)
155
+ begin
156
+ tracee&.detach
157
+ rescue Error, Errno::ESRCH
158
+ nil
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rptrace
4
+ # Base error for ptrace operations.
5
+ class Error < StandardError
6
+ attr_reader :errno, :request
7
+
8
+ def initialize(message = nil, errno: nil, request: nil)
9
+ @errno = errno
10
+ @request = request
11
+
12
+ if errno && request
13
+ super("ptrace(#{request}): #{message} (errno=#{errno})")
14
+ else
15
+ super(message)
16
+ end
17
+ end
18
+ end
19
+
20
+ # Error raised for EPERM.
21
+ class PermissionError < Error; end
22
+ # Error raised for ESRCH.
23
+ class NoProcessError < Error; end
24
+ # Error raised for EBUSY.
25
+ class BusyError < Error; end
26
+ # Error raised for EINVAL.
27
+ class InvalidArgError < Error; end
28
+ # Unsupported architecture error.
29
+ class UnsupportedArchError < StandardError; end
30
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rptrace
4
+ # waitpid status wrapper with ptrace-specific helpers.
5
+ class Event
6
+ # ptrace event code to symbolic name map for inspect output.
7
+ EVENT_NAME_BY_CODE = {
8
+ Constants::PTRACE_EVENT_FORK => "fork",
9
+ Constants::PTRACE_EVENT_VFORK => "vfork",
10
+ Constants::PTRACE_EVENT_CLONE => "clone",
11
+ Constants::PTRACE_EVENT_EXEC => "exec",
12
+ Constants::PTRACE_EVENT_VFORK_DONE => "vfork_done",
13
+ Constants::PTRACE_EVENT_EXIT => "exit",
14
+ Constants::PTRACE_EVENT_SECCOMP => "seccomp",
15
+ Constants::PTRACE_EVENT_STOP => "stop"
16
+ }.freeze
17
+
18
+ attr_reader :pid, :raw_status
19
+
20
+ def initialize(pid, raw_status)
21
+ @pid = Integer(pid)
22
+ @raw_status = Integer(raw_status)
23
+ end
24
+
25
+ # @return [Boolean]
26
+ def stopped?
27
+ WaitStatus.stopped?(raw_status)
28
+ end
29
+
30
+ # @return [Boolean]
31
+ def exited?
32
+ WaitStatus.exited?(raw_status)
33
+ end
34
+
35
+ # @return [Boolean]
36
+ def signaled?
37
+ WaitStatus.signaled?(raw_status)
38
+ end
39
+
40
+ # @return [Boolean]
41
+ def continued?
42
+ WaitStatus.continued?(raw_status)
43
+ end
44
+
45
+ # @return [Integer]
46
+ def stop_signal
47
+ WaitStatus.stop_signal(raw_status)
48
+ end
49
+
50
+ # @return [Integer]
51
+ def exit_status
52
+ WaitStatus.exit_status(raw_status)
53
+ end
54
+
55
+ # @return [Integer]
56
+ def term_signal
57
+ WaitStatus.term_signal(raw_status)
58
+ end
59
+
60
+ # @return [Boolean]
61
+ def syscall_stop?
62
+ stopped? && stop_signal == (Signal.list.fetch("TRAP") | 0x80)
63
+ end
64
+
65
+ # @return [Integer]
66
+ def event_code
67
+ (raw_status >> 16) & 0xFFFF
68
+ end
69
+
70
+ def fork_event?
71
+ event_code == Constants::PTRACE_EVENT_FORK
72
+ end
73
+
74
+ def clone_event?
75
+ event_code == Constants::PTRACE_EVENT_CLONE
76
+ end
77
+
78
+ def vfork_event?
79
+ event_code == Constants::PTRACE_EVENT_VFORK
80
+ end
81
+
82
+ def vfork_done_event?
83
+ event_code == Constants::PTRACE_EVENT_VFORK_DONE
84
+ end
85
+
86
+ def exec_event?
87
+ event_code == Constants::PTRACE_EVENT_EXEC
88
+ end
89
+
90
+ def exit_event?
91
+ event_code == Constants::PTRACE_EVENT_EXIT
92
+ end
93
+
94
+ def seccomp_event?
95
+ event_code == Constants::PTRACE_EVENT_SECCOMP
96
+ end
97
+
98
+ # @return [Boolean]
99
+ def fork_like_event?
100
+ fork_event? || clone_event? || vfork_event?
101
+ end
102
+
103
+ # @return [String]
104
+ def inspect
105
+ "#<#{self.class} pid=#{pid} status=0x#{raw_status.to_s(16)} #{state_summary}>"
106
+ end
107
+
108
+ private
109
+
110
+ def state_summary
111
+ return "state=syscall_stop" if syscall_stop?
112
+ return "state=exited(#{exit_status})" if exited?
113
+ return "state=continued" if continued?
114
+ if stopped?
115
+ event_name = EVENT_NAME_BY_CODE[event_code]
116
+ summary = "state=stopped(#{signal_label(stop_signal)})"
117
+ return "#{summary} event=#{event_name}" if event_name
118
+
119
+ return summary
120
+ end
121
+ return "state=signaled(#{signal_label(term_signal)})" if signaled?
122
+
123
+ "state=unknown"
124
+ end
125
+
126
+ def signal_label(number)
127
+ signal_name = Signal.signame(number)
128
+ signal_name = "SIG#{signal_name}" unless signal_name.start_with?("SIG")
129
+ signal_name
130
+ rescue ArgumentError
131
+ "SIG#{number}"
132
+ end
133
+ end
134
+ end