Hisstrace
Programmable syscall interception and fault injection in Python.
strace, with fangs.
Pure stdlib, no dependencies. Pure Python ptrace bindings, 64-bit x86 and ARM.
Install
pip install hisstrace
What works
- ptrace(2) via ctypes: no C extensions, no dependencies;
- syscall number/name tables for x86-64 and AArch64, generated from kernel headers;
- per-architecture register access (x86-64 uses GETREGS, AArch64 uses GETREGSET);
- tracee memory reads and writes, plus
Tracer.exe_image()/Tracer.exe_vaddr()for byte-signature scans of the executable, stripped binaries included; - a
Tracerthat spawns a command or attaches to a pid and iterates syscall stops as entry/exit events; - thread tracing: every thread is followed automatically, including threads created later (PTRACE_O_TRACECLONE);
- child processes are opt-in:
await on.fork()surfaces each new process, andchild.follow()decides to trace it (the default is to let it run free); - attach to a running process and all its threads by pid (
-p), following threads spawned after the attach; - software breakpoints inside the tracee:
Tracer.set_breakpoint(addr)arms an int3, the stop surfaces as aTrapevent with live registers, and the original instruction is single-stepped and re-armed for you; hisstrace run CMD [ARGS...]: a strace-style printer, built from the scenario API like everything else.
Events carry raw register values: six unsigned arguments at entry, the raw return register at exit.
Scenarios
A scenario is one coroutine driven by syscall events:
from hisstrace import post, pre
async def scenario():
done = await post.read(count=100) # completion of the next matching read
print(f"read: {done.data!r} (retval {done.retval})")
call = await pre.write() # next write stops before it enters the kernel
print(f"write: {call.data!r}")
Run it against a command:
hisstrace scenario examples/capture.py -- python3 -c "..."
examples/httpforever.py prints curl's entire HTTP conversation with
httpforever.com, request bytes marked -> and response bytes <-, by
finding the TCP socket on port 80 and watching sendto/recvfrom on it.
examples/watch_conn.py traces a full SSH round of a Go client (socket
traffic across its thread handoff plus every getrandom, ending when the
connection closes); examples/ssh_handshake.py parses that same traffic into
the SSH handshake (KEXINIT algorithm lists, negotiation, ECDH, NEWKEYS);
examples/ssh_decrypted.py goes the last mile: it plants a breakpoint on
crypto/ecdh.x25519ScalarMult inside the process, recovers the X25519 shared
secret from the trap's registers, derives the session keys, and decrypts the
whole session in pure Python. examples/ssh_rng_via_syscall.py does the same
decryption with no symbols and no breakpoints at all: a one-byte patch forces
Go's getrandom off the vDSO and onto the syscall path, the KEX scalar is
picked out of the syscall stream, and the session decrypts from there (this
one example needs pip install cryptography; hisstrace itself has no
dependencies).
Or attach to a running process (every thread is traced, and threads spawned later are picked up automatically):
hisstrace run -p 4242
hisstrace scenario examples/trackfd.py -p 4242
Contract, v1: one scenario runs at a time. While Python code between awaits
runs, the matched tracee stays stopped; reaching the next await resumes it.
Unmatched syscalls pass through unchanged. post.X correlates entry and exit
of one invocation and returns a CompletedCall (entry args plus retval,
error, succeeded, failed, and a capped data snapshot for read/write).
any_of(...) awaits several matchers and resumes with whichever fires first:
while True:
event = await any_of(post.read(fd=fd), pre.write(fd=fd), pre.close(fd=fd))
if event.name == "close":
break
print(event.name, event.data)
See examples/trackfd.py for the full fd-tracking loop. pre.any() and
post.any() watch every syscall in that phase, which is all hisstrace run
is: a strace built from the scenario API, with argument names for every
syscall (generated from kernel prototypes) and C-string reads for pointer
arguments:
while True:
done = await post.any()
print(done.name, dict(done.args), done.retval)
# done.string("path") reads a NUL-terminated string at that argument
Child processes are the exception to pass-through: a new process stays frozen
until the scenario decides. await on.fork() delivers it; child.follow()
traces it, otherwise it runs untraced. Threads need no decision, they are
always followed.
child = await on.fork()
child.follow() # or don't: the child runs free
print("new process", child.pid, "from thread", child.parent_tid)
When the coroutine returns, a spawned tracee keeps running untraced and
run() waits it out and returns its exit status; an attached tracee is
detached and run() returns 0.
data is None for syscalls without a buffer; reads that fail give b"".
Match filters compare against raw kernel argument values, so remember that
Python adds O_CLOEXEC to os.open before the kernel sees the flags.
Named arguments come from a table generated out of the kernel's syscall
prototypes (461 syscalls; make table regenerates), with arg0..arg5 as
the fallback for anything missing, and are also usable as match filters, as
count=100 above shows.
Honest limits
One scenario runs at a time, serially. No call.fail(), argument mutation, or
return-value replacement yet; the scenario can observe and patch memory but
not yet rewrite a syscall's outcome. No fd-lifetime tracking: dup2 aliasing
and fd reuse after close are invisible to plain fd= filters. Attaching to
another user's process fails with the kernel's ptrace_scope error.
Develop
make test # unittest against src/
make lint # ruff + mypy + line counts
make build # wheel into dist/
make table # regenerate syscall tables from kernel headers
Metadata
Release files for hisstrace 2026.10.27
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| hisstrace-2026.10.27-py3-none-any.whl | Python 3 | none | any | Details |
Release files / hisstrace-2026.10.27-py3-none-any.whl
| Download URL | hisstrace-2026.10.27-py3-none-any.whl |
|---|---|
| Size | 28.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
13f445e85865de31d8828cc4353a2c6eab1ace39fd992c9e81b8577e0da3b275
|
|
BLAKE2b-256 checksum How to use checksums |
9464aa959a921a3431eb2a6c61df9b65c25c704efb140cbf8f3a1c650036c8bb
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.14.4
|
Security for hisstrace 2026.10.27
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.