HISSTRACE/1 · PID 4242 · ATTACHEDx86-64 + aarch64 · stdlib only · MIT
openat(-100, 0x7ffc9d2b, 0x82400, 0) = 4   read(4, 0x7ffc9c1, 832, 0) = 832   mmap(0, 0x220d90, 1, 2050, 4, 0) = 0x7f31a4c0   socket(2, 1, 0) = 5   connect(5, 0x7ffc99a, 16) = -1 ECONNREFUSED   close(5) = 0   openat(-100, 0x7ffc8e4, 0x92400, 0) = 5   read(5, 0x7ffc912, 4096, 0) = 1871   write(1, 0x55d1fe0, 42, 0) = 42   exit_group(0) = ?   openat(-100, 0x7ffc9d2b, 0x82400, 0) = 4   read(4, 0x7ffc9c1, 832, 0) = 832   mmap(0, 0x220d90, 1, 2050, 4, 0) = 0x7f31a4c0   socket(2, 1, 0) = 5   connect(5, 0x7ffc99a, 16) = -1 ECONNREFUSED   close(5) = 0   openat(-100, 0x7ffc8e4, 0x92400, 0) = 5   read(5, 0x7ffc912, 4096, 0) = 1871   write(1, 0x55d1fe0, 42, 0) = 42   exit_group(0) = ?   

hisstrace

Programmable syscall interception and fault injection in Python. strace, with fangs.

$ pip install hisstrace
no C extensionsno dependenciesthreads · attach · -p PID

01The happy path is the tested path

Every service has error handling. Almost none of it is tested. The retry logic, the timeout branch, the "what if the disk is full" path: written once, exercised never, discovered in production. strace --inject covers the one-liners. Real faults have state: only this host, only after the first success, only while the flag file is absent. That needs a programming language, not a longer expression.

Hisstrace is a Python coroutine that waits for the syscall you care about and decides. Local variables are your state, if statements are your predicates, the order of the awaits is the order of the story.

02How it works

Write scenario.py as an async def scenario(). Run it against a command, or attach with -p PID. While your Python runs between awaits, the matched tracee stays stopped; the next await resumes it. Unmatched syscalls pass through unchanged. This is examples/httpforever.py, slightly trimmed:

from hisstrace import any_of, post, pre, ptrace async def scenario(): call = await pre.connect() # curl probes unix sockets first raw = ptrace.peek(call.tid, call.args.addr, 4) # sockaddr: port is big-endian if int.from_bytes(raw[2:4], "big") != 80: return # not the HTTP connection fd = call.args.fd while True: event = await any_of(pre.sendto(fd=fd), post.recvfrom(fd=fd), post.close(fd=fd)) if event.name == "close": break if event.data: arrow = "->" if event.name == "sendto" else "<-" print(arrow, event.data.decode(errors="replace"), end="")
$ hisstrace scenario examples/httpforever.py -- curl -s http://httpforever.com/ -> GET / HTTP/1.1 Host: httpforever.com User-Agent: curl/8.18.0 Accept: */* <- HTTP/1.1 200 OK Content-Type: text/html Transfer-Encoding: chunked Server: cloudflare ... <!DOCTYPE html> <html lang="en"> ...

03A scenario is a handful of lines

from hisstrace import any_of, post, pre async def scenario(): opened = await post.openat(flags=os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC) if opened.failed: return fd = opened.retval 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)

Matchers take argument filters; any_of resumes on whichever comes first. Threads are always traced; child processes opt in through await on.fork() and child.follow(). And the fangs: software breakpoints with live registers, memory reads, and argument mutation at entry.

from hisstrace import ptrace, tracer t = tracer() addr = symbol_addr(t.pid, "crypto/ecdh.x25519ScalarMult") t.set_breakpoint(addr) # one int3; the step and re-arm are handled trap = await next_trap() # arrives like any other event secret = ptrace.peek(trap.tid, trap.regs.rdi, 32) # scalars in registers call = await pre.read(fd=fd) call.overwrite(count=1) # the kernel is asked for one byte

That is exactly how examples/ssh_decrypted.py recovers an SSH session key and examples/byte_by_byte.py slows a whole HTTP conversation to one byte per syscall. Next commits: fail(), succeed(), replace_with().

04Small on purpose

  • ptrace(2) through ctypes. No C build step, nothing to compile, nothing to pin. Syscall tables for x86-64 and AArch64, generated from kernel headers.
  • Attach with hisstrace run -p PID: every thread, clean detach, threads spawned after the attach are picked up.
  • Honest limits: one scenario runs serially; attach needs the permission your kernel's ptrace rules grant; fd-lifetime tracking (dup2 aliasing, fd reuse) is still ahead. A narrow tool done deep, not a wide one done halfway.