Linux Syscall Tracer
Inspiration
When an analyst gets a suspicious binary, the first question is what it actually does. Source code is rarely available, and reading disassembly for every sample is slow. Almost everything a program does to a system goes through system calls: opening files, starting processes, writing data, opening sockets. If you can watch those calls, you can see the behavior without the source.
The standard tool for this is strace. I wanted to understand how a tracer like that works by building one from scratch, and to shape its output for forensics: the trace is kept separate from the program's own output, so it can be saved as evidence.
What it does
The tracer runs any Linux program and prints every system call it makes, with arguments and results:
- Names each call (about 65 common x86_64 syscalls) and prints unknown ones as
syscall_N(...). - Decodes path arguments, such as the files a program opens, as strings read from the program's own memory.
- Decodes
execvecompletely, including the launch of the traced program and itsargvarray. - Decodes the contents of
writebuffers, with escapes for binary data. - Prints failures with the error text, for example
= -1 (No such file or directory). - Writes the trace to stderr, so
2> trace.logcaptures a clean trace.
How I built it
The tracer is written in C and uses ptrace, with a Makefile and no other dependencies.
- The tracer forks. The child calls
PTRACE_TRACEMEand stops itself, and the parent attaches its options and resumes it withPTRACE_SYSCALL. The child then stops at every syscall boundary. - Stops come in pairs, an entry and an exit. At entry I read the registers: the syscall number is in
orig_raxand the arguments are inrdi, rsi, rdx, r10, r8, r9. The return value is read fromraxat exit. - A pointer argument is only meaningful inside the traced program, so I read the data it points to word by word with
PTRACE_PEEKDATA. For example, the $i$-th entry of anargvarray lives at
$$\text{addr}_i = \text{base} + i \cdot \texttt{sizeof(long)}$$
and each entry is itself a pointer to a string, which takes a second read.
- The kernel reports a failed call by returning a negative errno in the range
$$-4095 \le \texttt{rax} \le -1$$
so a result in that range is printed as an error, and anything else as a normal value.
I built it one function at a time: the loop first, then names and return values, then raw arguments, then string decoding, then execve handling, then the write buffer.
What I learned
- How a debugger-style tracer controls another process, and why
ptracecalls only work while the traced process is stopped. - Why the syscall number is read from
orig_raxand notrax, and why arguments must be read at the entry stop and the result at the exit stop. - That reading another process's memory is a different problem from reading your own: a pointer is only an address in someone else's address space.
- How
execvereplaces a process image, and how much of the tracer has to be written around that fact. - How signals, ptrace events, and syscall stops are all reported through the same
waitpidcall and have to be told apart.
Challenges
- A successful
execvekilled the traced program. The kernel sends aSIGTRAPafter an exec. My loop treated every stop that wasn't a syscall as a real signal and passed it back to the child, which terminates onSIGTRAP. The fix was to enablePTRACE_O_TRACEEXEC, so an exec shows up as a distinguishable event stop, and to resume event stops with no signal. - The launch
execvewas invisible. The program's ownexecveran before the tracer had started watching syscalls. The child now stops itself before the exec, so the tracer is attached first. argvis a pointer to pointers. Printing it takes two levels of reads, a cap on how many entries to show, and handling for unreadable memory, with theNULLterminator as the only signal of where the array ends.- The last syscall has no exit.
exit_groupnever returns, so the entry and exit pairing is left open at the end, and that case has to be handled explicitly. - Mixed output. The trace and the program's output were interleaved in one stream. Moving the trace to stderr made the two separable with shell redirection.
Limitations and what's next
The tracer follows only the original process, so child processes and threads are not traced. It supports x86_64 only, and arguments other than paths, execve argv, and write buffers are shown as raw hex values. Next I would add child following, decoding of socket addresses, and a viewer for saved traces.
Built with
C, ptrace, GNU Make, Linux (WSL2)
Log in or sign up for Devpost to join the conversation.