Skip to main content

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 Tracer that 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, and child.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 a Trap event with live registers, and the original instruction is single-stepped and re-armed for you;
  • argument mutation at entry: on a pre-match, call.overwrite(count=1) rewrites named argument registers before the kernel runs the call (names come from the syscall table), and the matching post-match sees the new values;
  • 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/byte_by_byte.py takes the idea one step further: it rewrites the size argument of every socket read and write to 1 (call.overwrite for count-style calls, one poke inside the msghdr for recvmsg/sendmsg), so the kernel moves the whole conversation one byte per syscall, and it tracks happy-eyeballs connection churn, forked transfers, and fd reuse to do 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. Argument mutation at entry works via call.overwrite(), but there is no call.fail() or return-value replacement yet; the scenario can rewrite what a call asks for, not what the kernel answers with. No fd-lifetime tracking: dup2 aliasing and fd reuse after close are invisible to plain fd= filters (scenarios track fds themselves, as examples/byte_by_byte.py does). 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.28

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for hisstrace 2026.10.28
File Size Uploaded Provenance
hisstrace-2026.10.28.tar.gz 36.4 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for hisstrace 2026.10.28
File Interpreter ABI Platform
hisstrace-2026.10.28-py3-none-any.whl Python 3 none any Details

Total release size: 65.2 kB

Release files / hisstrace-2026.10.28.tar.gz

Download URL hisstrace-2026.10.28.tar.gz
Size 36.4 kB
Tags Source
SHA-256 checksum
How to use checksums
ea09a32437fe5493cb851bb0c10883ff01e39eab1a4f5801a815d672479a7a21
BLAKE2b-256 checksum
How to use checksums
cf1c09372b05609a1d7aefb291f01c7cdca148d75186457b27234e8d9fafdcc1
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.14.4

Release files / hisstrace-2026.10.28-py3-none-any.whl

Download URL hisstrace-2026.10.28-py3-none-any.whl
Size 28.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
9c1da0ee5c20819431d9e3803b520e67f418b968520d9fe913df6c5029daee00
BLAKE2b-256 checksum
How to use checksums
68a13b42771079c33af6869d40e9192093a130a4eba36e5ef66f5158363641c2
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.28

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.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page