hisstrace 2026.10.28__tar.gz

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (C) 2026 Aivars Kalvans <aivars.kalvans@gmail.com>
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 all
13
+ 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 THE
21
+ SOFTWARE.
@@ -0,0 +1,181 @@
1
+ Metadata-Version: 2.4
2
+ Name: hisstrace
3
+ Version: 2026.10.28
4
+ Summary: Programmable syscall interception and fault injection in Python. strace, with fangs.
5
+ Author-email: Aivars Kalvans <aivars.kalvans@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/aivarsk/hisstrace
8
+ Keywords: ptrace,strace,tracing,syscall,debugger
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: POSIX :: Linux
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Software Development :: Debuggers
14
+ Classifier: Topic :: System :: Monitoring
15
+ Requires-Python: >=3.11
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Dynamic: license-file
19
+
20
+ # Hisstrace
21
+
22
+ Programmable syscall interception and fault injection in Python.
23
+
24
+ **strace, with fangs.**
25
+
26
+ Pure stdlib, no dependencies. Pure Python ptrace bindings, 64-bit x86 and ARM.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pip install hisstrace
32
+ ```
33
+
34
+ ## What works
35
+
36
+ - ptrace(2) via ctypes: no C extensions, no dependencies;
37
+ - syscall number/name tables for x86-64 and AArch64, generated from kernel headers;
38
+ - per-architecture register access (x86-64 uses GETREGS, AArch64 uses GETREGSET);
39
+ - tracee memory reads and writes, plus `Tracer.exe_image()` / `Tracer.exe_vaddr()`
40
+ for byte-signature scans of the executable, stripped binaries included;
41
+ - a `Tracer` that spawns a command or attaches to a pid and iterates syscall
42
+ stops as entry/exit events;
43
+ - thread tracing: every thread is followed automatically, including threads
44
+ created later (PTRACE_O_TRACECLONE);
45
+ - child processes are opt-in: `await on.fork()` surfaces each new process,
46
+ and `child.follow()` decides to trace it (the default is to let it run free);
47
+ - attach to a running process and all its threads by pid (`-p`), following
48
+ threads spawned after the attach;
49
+ - software breakpoints inside the tracee: `Tracer.set_breakpoint(addr)` arms
50
+ an int3, the stop surfaces as a `Trap` event with live registers, and the
51
+ original instruction is single-stepped and re-armed for you;
52
+ - argument mutation at entry: on a pre-match, `call.overwrite(count=1)`
53
+ rewrites named argument registers before the kernel runs the call (names
54
+ come from the syscall table), and the matching post-match sees the new
55
+ values;
56
+ - `hisstrace run CMD [ARGS...]`: a strace-style printer, built from the
57
+ scenario API like everything else.
58
+
59
+ Events carry raw register values: six unsigned arguments at entry, the raw
60
+ return register at exit.
61
+
62
+ ## Scenarios
63
+
64
+ A scenario is one coroutine driven by syscall events:
65
+
66
+ ```python
67
+ from hisstrace import post, pre
68
+
69
+ async def scenario():
70
+ done = await post.read(count=100) # completion of the next matching read
71
+ print(f"read: {done.data!r} (retval {done.retval})")
72
+
73
+ call = await pre.write() # next write stops before it enters the kernel
74
+ print(f"write: {call.data!r}")
75
+ ```
76
+
77
+ Run it against a command:
78
+
79
+ ```bash
80
+ hisstrace scenario examples/capture.py -- python3 -c "..."
81
+ ```
82
+
83
+ `examples/httpforever.py` prints curl's entire HTTP conversation with
84
+ httpforever.com, request bytes marked `->` and response bytes `<-`, by
85
+ finding the TCP socket on port 80 and watching `sendto`/`recvfrom` on it.
86
+ `examples/byte_by_byte.py` takes the idea one step further: it rewrites the
87
+ size argument of every socket read and write to 1 (`call.overwrite` for
88
+ count-style calls, one poke inside the msghdr for `recvmsg`/`sendmsg`), so
89
+ the kernel moves the whole conversation one byte per syscall, and it tracks
90
+ happy-eyeballs connection churn, forked transfers, and fd reuse to do it.
91
+ `examples/watch_conn.py` traces a full SSH round of a Go client (socket
92
+ traffic across its thread handoff plus every getrandom, ending when the
93
+ connection closes); `examples/ssh_handshake.py` parses that same traffic into
94
+ the SSH handshake (KEXINIT algorithm lists, negotiation, ECDH, NEWKEYS);
95
+ `examples/ssh_decrypted.py` goes the last mile: it plants a breakpoint on
96
+ `crypto/ecdh.x25519ScalarMult` inside the process, recovers the X25519 shared
97
+ secret from the trap's registers, derives the session keys, and decrypts the
98
+ whole session in pure Python. `examples/ssh_rng_via_syscall.py` does the same
99
+ decryption with no symbols and no breakpoints at all: a one-byte patch forces
100
+ Go's getrandom off the vDSO and onto the syscall path, the KEX scalar is
101
+ picked out of the syscall stream, and the session decrypts from there (this
102
+ one example needs `pip install cryptography`; hisstrace itself has no
103
+ dependencies).
104
+
105
+ Or attach to a running process (every thread is traced, and threads spawned
106
+ later are picked up automatically):
107
+
108
+ ```bash
109
+ hisstrace run -p 4242
110
+ hisstrace scenario examples/trackfd.py -p 4242
111
+ ```
112
+
113
+ Contract, v1: one scenario runs at a time. While Python code between awaits
114
+ runs, the matched tracee stays stopped; reaching the next await resumes it.
115
+ Unmatched syscalls pass through unchanged. `post.X` correlates entry and exit
116
+ of one invocation and returns a `CompletedCall` (entry args plus `retval`,
117
+ `error`, `succeeded`, `failed`, and a capped `data` snapshot for read/write).
118
+ `any_of(...)` awaits several matchers and resumes with whichever fires first:
119
+
120
+ ```python
121
+ while True:
122
+ event = await any_of(post.read(fd=fd), pre.write(fd=fd), pre.close(fd=fd))
123
+ if event.name == "close":
124
+ break
125
+ print(event.name, event.data)
126
+ ```
127
+
128
+ See `examples/trackfd.py` for the full fd-tracking loop. `pre.any()` and
129
+ `post.any()` watch every syscall in that phase, which is all `hisstrace run`
130
+ is: a strace built from the scenario API, with argument names for every
131
+ syscall (generated from kernel prototypes) and C-string reads for pointer
132
+ arguments:
133
+
134
+ ```python
135
+ while True:
136
+ done = await post.any()
137
+ print(done.name, dict(done.args), done.retval)
138
+ # done.string("path") reads a NUL-terminated string at that argument
139
+ ```
140
+
141
+ Child processes are the exception to pass-through: a new process stays frozen
142
+ until the scenario decides. `await on.fork()` delivers it; `child.follow()`
143
+ traces it, otherwise it runs untraced. Threads need no decision, they are
144
+ always followed.
145
+
146
+ ```python
147
+ child = await on.fork()
148
+ child.follow() # or don't: the child runs free
149
+ print("new process", child.pid, "from thread", child.parent_tid)
150
+ ```
151
+
152
+ When the coroutine returns, a spawned tracee keeps running untraced and
153
+ `run()` waits it out and returns its exit status; an attached tracee is
154
+ detached and `run()` returns 0.
155
+ `data` is `None` for syscalls without a buffer; reads that fail give `b""`.
156
+ Match filters compare against raw kernel argument values, so remember that
157
+ Python adds `O_CLOEXEC` to `os.open` before the kernel sees the flags.
158
+
159
+ Named arguments come from a table generated out of the kernel's syscall
160
+ prototypes (461 syscalls; `make table` regenerates), with `arg0`..`arg5` as
161
+ the fallback for anything missing, and are also usable as match filters, as
162
+ `count=100` above shows.
163
+
164
+ ## Honest limits
165
+
166
+ One scenario runs at a time, serially. Argument mutation at entry works via
167
+ `call.overwrite()`, but there is no `call.fail()` or return-value replacement
168
+ yet; the scenario can rewrite what a call asks for, not what the kernel
169
+ answers with. No fd-lifetime tracking: `dup2` aliasing and fd reuse after
170
+ close are invisible to plain `fd=` filters (scenarios track fds themselves,
171
+ as `examples/byte_by_byte.py` does). Attaching to another user's process
172
+ fails with the kernel's `ptrace_scope` error.
173
+
174
+ ## Develop
175
+
176
+ ```bash
177
+ make test # unittest against src/
178
+ make lint # ruff + mypy + line counts
179
+ make build # wheel into dist/
180
+ make table # regenerate syscall tables from kernel headers
181
+ ```
@@ -0,0 +1,162 @@
1
+ # Hisstrace
2
+
3
+ Programmable syscall interception and fault injection in Python.
4
+
5
+ **strace, with fangs.**
6
+
7
+ Pure stdlib, no dependencies. Pure Python ptrace bindings, 64-bit x86 and ARM.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pip install hisstrace
13
+ ```
14
+
15
+ ## What works
16
+
17
+ - ptrace(2) via ctypes: no C extensions, no dependencies;
18
+ - syscall number/name tables for x86-64 and AArch64, generated from kernel headers;
19
+ - per-architecture register access (x86-64 uses GETREGS, AArch64 uses GETREGSET);
20
+ - tracee memory reads and writes, plus `Tracer.exe_image()` / `Tracer.exe_vaddr()`
21
+ for byte-signature scans of the executable, stripped binaries included;
22
+ - a `Tracer` that spawns a command or attaches to a pid and iterates syscall
23
+ stops as entry/exit events;
24
+ - thread tracing: every thread is followed automatically, including threads
25
+ created later (PTRACE_O_TRACECLONE);
26
+ - child processes are opt-in: `await on.fork()` surfaces each new process,
27
+ and `child.follow()` decides to trace it (the default is to let it run free);
28
+ - attach to a running process and all its threads by pid (`-p`), following
29
+ threads spawned after the attach;
30
+ - software breakpoints inside the tracee: `Tracer.set_breakpoint(addr)` arms
31
+ an int3, the stop surfaces as a `Trap` event with live registers, and the
32
+ original instruction is single-stepped and re-armed for you;
33
+ - argument mutation at entry: on a pre-match, `call.overwrite(count=1)`
34
+ rewrites named argument registers before the kernel runs the call (names
35
+ come from the syscall table), and the matching post-match sees the new
36
+ values;
37
+ - `hisstrace run CMD [ARGS...]`: a strace-style printer, built from the
38
+ scenario API like everything else.
39
+
40
+ Events carry raw register values: six unsigned arguments at entry, the raw
41
+ return register at exit.
42
+
43
+ ## Scenarios
44
+
45
+ A scenario is one coroutine driven by syscall events:
46
+
47
+ ```python
48
+ from hisstrace import post, pre
49
+
50
+ async def scenario():
51
+ done = await post.read(count=100) # completion of the next matching read
52
+ print(f"read: {done.data!r} (retval {done.retval})")
53
+
54
+ call = await pre.write() # next write stops before it enters the kernel
55
+ print(f"write: {call.data!r}")
56
+ ```
57
+
58
+ Run it against a command:
59
+
60
+ ```bash
61
+ hisstrace scenario examples/capture.py -- python3 -c "..."
62
+ ```
63
+
64
+ `examples/httpforever.py` prints curl's entire HTTP conversation with
65
+ httpforever.com, request bytes marked `->` and response bytes `<-`, by
66
+ finding the TCP socket on port 80 and watching `sendto`/`recvfrom` on it.
67
+ `examples/byte_by_byte.py` takes the idea one step further: it rewrites the
68
+ size argument of every socket read and write to 1 (`call.overwrite` for
69
+ count-style calls, one poke inside the msghdr for `recvmsg`/`sendmsg`), so
70
+ the kernel moves the whole conversation one byte per syscall, and it tracks
71
+ happy-eyeballs connection churn, forked transfers, and fd reuse to do it.
72
+ `examples/watch_conn.py` traces a full SSH round of a Go client (socket
73
+ traffic across its thread handoff plus every getrandom, ending when the
74
+ connection closes); `examples/ssh_handshake.py` parses that same traffic into
75
+ the SSH handshake (KEXINIT algorithm lists, negotiation, ECDH, NEWKEYS);
76
+ `examples/ssh_decrypted.py` goes the last mile: it plants a breakpoint on
77
+ `crypto/ecdh.x25519ScalarMult` inside the process, recovers the X25519 shared
78
+ secret from the trap's registers, derives the session keys, and decrypts the
79
+ whole session in pure Python. `examples/ssh_rng_via_syscall.py` does the same
80
+ decryption with no symbols and no breakpoints at all: a one-byte patch forces
81
+ Go's getrandom off the vDSO and onto the syscall path, the KEX scalar is
82
+ picked out of the syscall stream, and the session decrypts from there (this
83
+ one example needs `pip install cryptography`; hisstrace itself has no
84
+ dependencies).
85
+
86
+ Or attach to a running process (every thread is traced, and threads spawned
87
+ later are picked up automatically):
88
+
89
+ ```bash
90
+ hisstrace run -p 4242
91
+ hisstrace scenario examples/trackfd.py -p 4242
92
+ ```
93
+
94
+ Contract, v1: one scenario runs at a time. While Python code between awaits
95
+ runs, the matched tracee stays stopped; reaching the next await resumes it.
96
+ Unmatched syscalls pass through unchanged. `post.X` correlates entry and exit
97
+ of one invocation and returns a `CompletedCall` (entry args plus `retval`,
98
+ `error`, `succeeded`, `failed`, and a capped `data` snapshot for read/write).
99
+ `any_of(...)` awaits several matchers and resumes with whichever fires first:
100
+
101
+ ```python
102
+ while True:
103
+ event = await any_of(post.read(fd=fd), pre.write(fd=fd), pre.close(fd=fd))
104
+ if event.name == "close":
105
+ break
106
+ print(event.name, event.data)
107
+ ```
108
+
109
+ See `examples/trackfd.py` for the full fd-tracking loop. `pre.any()` and
110
+ `post.any()` watch every syscall in that phase, which is all `hisstrace run`
111
+ is: a strace built from the scenario API, with argument names for every
112
+ syscall (generated from kernel prototypes) and C-string reads for pointer
113
+ arguments:
114
+
115
+ ```python
116
+ while True:
117
+ done = await post.any()
118
+ print(done.name, dict(done.args), done.retval)
119
+ # done.string("path") reads a NUL-terminated string at that argument
120
+ ```
121
+
122
+ Child processes are the exception to pass-through: a new process stays frozen
123
+ until the scenario decides. `await on.fork()` delivers it; `child.follow()`
124
+ traces it, otherwise it runs untraced. Threads need no decision, they are
125
+ always followed.
126
+
127
+ ```python
128
+ child = await on.fork()
129
+ child.follow() # or don't: the child runs free
130
+ print("new process", child.pid, "from thread", child.parent_tid)
131
+ ```
132
+
133
+ When the coroutine returns, a spawned tracee keeps running untraced and
134
+ `run()` waits it out and returns its exit status; an attached tracee is
135
+ detached and `run()` returns 0.
136
+ `data` is `None` for syscalls without a buffer; reads that fail give `b""`.
137
+ Match filters compare against raw kernel argument values, so remember that
138
+ Python adds `O_CLOEXEC` to `os.open` before the kernel sees the flags.
139
+
140
+ Named arguments come from a table generated out of the kernel's syscall
141
+ prototypes (461 syscalls; `make table` regenerates), with `arg0`..`arg5` as
142
+ the fallback for anything missing, and are also usable as match filters, as
143
+ `count=100` above shows.
144
+
145
+ ## Honest limits
146
+
147
+ One scenario runs at a time, serially. Argument mutation at entry works via
148
+ `call.overwrite()`, but there is no `call.fail()` or return-value replacement
149
+ yet; the scenario can rewrite what a call asks for, not what the kernel
150
+ answers with. No fd-lifetime tracking: `dup2` aliasing and fd reuse after
151
+ close are invisible to plain `fd=` filters (scenarios track fds themselves,
152
+ as `examples/byte_by_byte.py` does). Attaching to another user's process
153
+ fails with the kernel's `ptrace_scope` error.
154
+
155
+ ## Develop
156
+
157
+ ```bash
158
+ make test # unittest against src/
159
+ make lint # ruff + mypy + line counts
160
+ make build # wheel into dist/
161
+ make table # regenerate syscall tables from kernel headers
162
+ ```
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "hisstrace"
7
+ version = "2026.10.28"
8
+ description = "Programmable syscall interception and fault injection in Python. strace, with fangs."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ authors = [{ name = "Aivars Kalvans", email = "aivars.kalvans@gmail.com" }]
13
+ keywords = ["ptrace", "strace", "tracing", "syscall", "debugger"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "Operating System :: POSIX :: Linux",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Software Development :: Debuggers",
20
+ "Topic :: System :: Monitoring",
21
+ ]
22
+ dependencies = []
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/aivarsk/hisstrace"
26
+
27
+ [project.scripts]
28
+ hisstrace = "hisstrace.cli:main"
29
+
30
+ [tool.setuptools]
31
+ package-dir = {"" = "src"}
32
+ packages = ["hisstrace"]
33
+
34
+ [tool.mypy]
35
+ python_version = "3.11"
36
+ warn_return_any = true
37
+ warn_unused_configs = true
38
+ warn_unused_ignores = true
39
+ show_error_codes = true
40
+ check_untyped_defs = true
41
+
42
+ [tool.ruff]
43
+ line-length = 120
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,33 @@
1
+ """Hisstrace: programmable syscall interception and fault injection in Python.
2
+
3
+ strace, with fangs.
4
+ """
5
+
6
+ from importlib.metadata import PackageNotFoundError, version
7
+
8
+ from .arch import current
9
+ from .scenario import CompletedCall, Matcher, any_of, on, post, pre, run, tracer
10
+ from .trace import Child, Event, TraceeExited, Tracer, Trap
11
+
12
+ try:
13
+ __version__ = version("hisstrace") # from the installed package metadata
14
+ except PackageNotFoundError: # running from a source checkout
15
+ __version__ = "dev"
16
+
17
+ __all__ = [
18
+ "Child",
19
+ "CompletedCall",
20
+ "Event",
21
+ "Matcher",
22
+ "TraceeExited",
23
+ "Tracer",
24
+ "Trap",
25
+ "any_of",
26
+ "current",
27
+ "on",
28
+ "post",
29
+ "pre",
30
+ "run",
31
+ "tracer",
32
+ "__version__",
33
+ ]
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,128 @@
1
+ """Syscall register layouts for x86-64 and AArch64.
2
+
3
+ One Arch class, configured per architecture: register block struct, syscall
4
+ number register, return register, argument registers, and whether register
5
+ access goes through GETREGS (x86-64) or GETREGSET (AArch64).
6
+ """
7
+
8
+ import ctypes
9
+ import platform
10
+
11
+ from . import ptrace
12
+ from .table import TABLES
13
+
14
+
15
+ class Amd64Regs(ctypes.Structure):
16
+ _fields_ = [
17
+ (name, ctypes.c_ulonglong)
18
+ for name in (
19
+ "r15",
20
+ "r14",
21
+ "r13",
22
+ "r12",
23
+ "rbp",
24
+ "rbx",
25
+ "r11",
26
+ "r10",
27
+ "r9",
28
+ "r8",
29
+ "rax",
30
+ "rcx",
31
+ "rdx",
32
+ "rsi",
33
+ "rdi",
34
+ "orig_rax",
35
+ "rip",
36
+ "cs",
37
+ "eflags",
38
+ "rsp",
39
+ "ss",
40
+ "fs_base",
41
+ "gs_base",
42
+ "ds",
43
+ "es",
44
+ "fs",
45
+ "gs",
46
+ )
47
+ ]
48
+
49
+
50
+ class Aarch64Regs(ctypes.Structure):
51
+ _fields_ = [(f"x{i}", ctypes.c_ulonglong) for i in range(31)]
52
+ _fields_ += [("sp", ctypes.c_ulonglong), ("pc", ctypes.c_ulonglong), ("pstate", ctypes.c_ulonglong)]
53
+
54
+
55
+ class Iovec(ctypes.Structure):
56
+ _fields_ = [("base", ctypes.c_void_p), ("len", ctypes.c_size_t)]
57
+
58
+
59
+ class Arch:
60
+ """Register access plus the syscall tables for one architecture."""
61
+
62
+ def __init__(self, machine, reg_struct, nr_reg, ret_reg, arg_regs, regset, pc_reg):
63
+ self.machine = machine
64
+ self._struct = reg_struct
65
+ self._nr, self._ret = nr_reg, ret_reg
66
+ self._args = arg_regs
67
+ self._pc = pc_reg
68
+ self._regset = regset # True: GETREGSET/SETREGSET, False: GETREGS/SETREGS
69
+ self.syscalls = TABLES.get(machine, {})
70
+ self.names = {name: number for number, name in self.syscalls.items()}
71
+
72
+ def get(self, pid):
73
+ regs = self._struct()
74
+ if self._regset:
75
+ iov = Iovec(ctypes.addressof(regs), ctypes.sizeof(regs))
76
+ ptrace.ptrace(ptrace.GETREGSET, pid, ptrace.NT_PRSTATUS, ctypes.addressof(iov))
77
+ else:
78
+ ptrace.ptrace(ptrace.GETREGS, pid, 0, ctypes.addressof(regs))
79
+ return regs
80
+
81
+ def set(self, pid, regs):
82
+ if self._regset:
83
+ iov = Iovec(ctypes.addressof(regs), ctypes.sizeof(regs))
84
+ ptrace.ptrace(ptrace.SETREGSET, pid, ptrace.NT_PRSTATUS, ctypes.addressof(iov))
85
+ else:
86
+ ptrace.ptrace(ptrace.SETREGS, pid, 0, ctypes.addressof(regs))
87
+
88
+ def number(self, regs):
89
+ return getattr(regs, self._nr)
90
+
91
+ def set_number(self, regs, value):
92
+ setattr(regs, self._nr, value)
93
+
94
+ def args(self, regs):
95
+ return tuple(getattr(regs, name) for name in self._args)
96
+
97
+ def set_arg(self, regs, index, value):
98
+ setattr(regs, self._args[index], value)
99
+
100
+ def retval(self, regs):
101
+ return getattr(regs, self._ret)
102
+
103
+ def set_retval(self, regs, value):
104
+ setattr(regs, self._ret, value)
105
+
106
+ def pc(self, regs):
107
+ return getattr(regs, self._pc)
108
+
109
+ def set_pc(self, regs, value):
110
+ setattr(regs, self._pc, value)
111
+
112
+ def syscall_name(self, number):
113
+ return self.syscalls.get(number, f"sys_{number}")
114
+
115
+
116
+ AMD64 = Arch("x86_64", Amd64Regs, "orig_rax", "rax", ("rdi", "rsi", "rdx", "r10", "r8", "r9"), False, "rip")
117
+ AARCH64 = Arch("aarch64", Aarch64Regs, "x8", "x0", tuple(f"x{i}" for i in range(6)), True, "pc")
118
+
119
+ _current = None
120
+
121
+
122
+ def current():
123
+ global _current
124
+ if _current is None:
125
+ _current = {"x86_64": AMD64, "aarch64": AARCH64}.get(platform.machine())
126
+ if _current is None:
127
+ raise RuntimeError(f"unsupported architecture: {platform.machine()}")
128
+ return _current
@@ -0,0 +1,100 @@
1
+ """hisstrace command line: raw tracing and scenario files."""
2
+
3
+ import argparse
4
+ import errno
5
+ import sys
6
+
7
+ from . import __version__
8
+ from .scenario import arg_names, post
9
+ from .scenario import load as load_scenario
10
+ from .scenario import run as run_scenario
11
+ from .trace import signed
12
+
13
+ # argument names whose values read better as the tracee's string
14
+ PATHLIKE = {"path", "pathname", "filename", "oldname", "newname", "oldpath", "newpath"}
15
+
16
+
17
+ def fmt_arg(value):
18
+ value = signed(value)
19
+ return str(value) if -0xFFFF <= value <= 0xFFFF else hex(value)
20
+
21
+
22
+ def fmt_retval(raw):
23
+ value = signed(raw)
24
+ if -4095 <= value <= -1:
25
+ name = errno.errorcode.get(-value)
26
+ return f"-1 {name}" if name else str(value)
27
+ return str(value)
28
+
29
+
30
+ def fmt_call(done):
31
+ parts = []
32
+ for name, value in zip(arg_names(done.name), done.args.raw):
33
+ if name in PATHLIKE:
34
+ text = done.string(name)
35
+ if text:
36
+ parts.append(f'{name}="{text}"')
37
+ continue
38
+ parts.append(f"{name}={fmt_arg(value)}")
39
+ suffix = "" if done.tid == done.pid else f" [tid {done.tid}]"
40
+ return f"{done.name}({', '.join(parts)}) = {fmt_retval(done.retval)}{suffix}"
41
+
42
+
43
+ def run(argv=None, pid=None):
44
+ """strace, built from the scenario API: watch every syscall completion."""
45
+
46
+ async def trace_all():
47
+ while True:
48
+ print(fmt_call(await post.any()))
49
+
50
+ return run_scenario(trace_all, argv=argv, pid=pid)
51
+
52
+
53
+ def strip_ddash(argv):
54
+ return argv[1:] if argv[:1] == ["--"] else argv
55
+
56
+
57
+ def main(argv=None):
58
+ parser = argparse.ArgumentParser(prog="hisstrace", description="strace, with fangs.")
59
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
60
+ sub = parser.add_subparsers(dest="command", required=True)
61
+ run_p = sub.add_parser("run", help="trace a command and print its syscalls")
62
+ run_p.add_argument("--pid", "-p", type=int, help="attach to a running process and its threads (before CMD)")
63
+ run_p.add_argument("argv", metavar="CMD", nargs=argparse.REMAINDER)
64
+ sc_p = sub.add_parser("scenario", help="drive a scenario coroutine against a command")
65
+ sc_p.add_argument("--pid", "-p", type=int, help="attach to a running process and its threads (before SCENARIO.py)")
66
+ sc_p.add_argument("file", metavar="SCENARIO.py")
67
+ sc_p.add_argument("argv", metavar="CMD", nargs=argparse.REMAINDER)
68
+ args = parser.parse_args(argv)
69
+ try:
70
+ if args.command == "run":
71
+ if args.pid:
72
+ if strip_ddash(args.argv):
73
+ parser.error("run takes either --pid or a command, not both")
74
+ run(pid=args.pid)
75
+ return 0
76
+ cmd = strip_ddash(args.argv)
77
+ if not cmd:
78
+ parser.error("run needs a command or --pid")
79
+ run(cmd)
80
+ return 0
81
+ if args.pid:
82
+ if strip_ddash(args.argv):
83
+ parser.error("scenario takes either --pid or a command, not both")
84
+ return run_scenario(load_scenario(args.file), pid=args.pid)
85
+ cmd = strip_ddash(args.argv)
86
+ if not cmd:
87
+ parser.error("scenario needs a command or --pid")
88
+ return run_scenario(load_scenario(args.file), cmd)
89
+ except PermissionError as exc:
90
+ sys.exit(
91
+ f"hisstrace: {exc}\n"
92
+ "attach was refused: under kernel.yama.ptrace_scope you may trace your own\n"
93
+ "descendant processes (or run as root). Your shell's other children are off limits."
94
+ )
95
+ except KeyboardInterrupt:
96
+ return 130 # attached traces end with Ctrl-C; the tracee was detached above
97
+
98
+
99
+ if __name__ == "__main__":
100
+ sys.exit(main())