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.
- hisstrace-2026.10.28/LICENSE +21 -0
- hisstrace-2026.10.28/PKG-INFO +181 -0
- hisstrace-2026.10.28/README.md +162 -0
- hisstrace-2026.10.28/pyproject.toml +43 -0
- hisstrace-2026.10.28/setup.cfg +4 -0
- hisstrace-2026.10.28/src/hisstrace/__init__.py +33 -0
- hisstrace-2026.10.28/src/hisstrace/__main__.py +5 -0
- hisstrace-2026.10.28/src/hisstrace/arch.py +128 -0
- hisstrace-2026.10.28/src/hisstrace/cli.py +100 -0
- hisstrace-2026.10.28/src/hisstrace/ptrace.py +150 -0
- hisstrace-2026.10.28/src/hisstrace/scenario.py +352 -0
- hisstrace-2026.10.28/src/hisstrace/table.py +1202 -0
- hisstrace-2026.10.28/src/hisstrace/trace.py +453 -0
- hisstrace-2026.10.28/src/hisstrace.egg-info/PKG-INFO +181 -0
- hisstrace-2026.10.28/src/hisstrace.egg-info/SOURCES.txt +20 -0
- hisstrace-2026.10.28/src/hisstrace.egg-info/dependency_links.txt +1 -0
- hisstrace-2026.10.28/src/hisstrace.egg-info/entry_points.txt +2 -0
- hisstrace-2026.10.28/src/hisstrace.egg-info/top_level.txt +1 -0
- hisstrace-2026.10.28/tests/test_arch.py +43 -0
- hisstrace-2026.10.28/tests/test_scenario.py +416 -0
- hisstrace-2026.10.28/tests/test_table.py +24 -0
- hisstrace-2026.10.28/tests/test_trace.py +261 -0
|
@@ -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,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,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())
|