I made some significant improvements to my userspace ptrace prober today. The new way it works is pretty interesting.
There are essentially two major changes. The first is that instead of single
stepping through the program, I am now using the x86 TRAP facility (a.k.a. INT 3) to more efficiently pause at the right point. This is the same mechanism
that a debugger like GDB uses to implement break points. The idea is pretty
simple. You insert the value 0xcc at the instruction that you want to break
on. Then when the program hits that instruction the kernel will deliver
SIGTRAP to the process. From the tracer process you can use wait(),
waitpid(), or waitid() to wait until the child is delivered SIGTRAP. At
that point you can do whatever you need to do.
The reason that using a trap is more efficient is because in single stepping mode the process gets interrupted after literally every x86 instruction it executes, which imposes a significant overhead as you might imagine. When using a trap there is essentially no overhead, the only overhead is when you actually process the trap.
The second problem that I had was that I was encoding the CALL instruction to
fprintf, and the format string data, directly into the code segment of the
executable at the current instruction pointer. This actually works fine, but
there's one caveat. If you had run my tracer exactly when the tracee was in the
middle of executing fprintf() (or a routine called by fprintf()) then the
fprintf() code would actually be corrupted and the program would crash.
Here's how the new tracer works:
- Attach to the tracee as usual.
- Insert a
SYSCALLinstruction at%rip(this takes two bytes,0x0f05). - Insert a
JMP %raxinstruction right afterSYSCALL(this is also two bytes,0xffe0). - Modify the registers to encode the arguments to the
mmap(2)system call to allocate a 4096 byte anonymous page that is markedPROT_READ | PROT_EXEC. - Use
PTRACE_SINGLESTEPto execute the system call. - Verify that the
mmap(2)call worked by reading the address out of%raxand checking that it did not return -1. - Use
PTRACE_SINGLESTEPto execute theJUMP %raxinstruction we already poked into memory; conveniently, the address is already in%raxsince the kernel returns system call. - Copy a
CALLinstruction into the mmap'ed region followed by aTRAPinstruction. - Copy the
fprintf()format string right after theTRAPinstruction - Modify the registers to hold the values in
%rax,%rdi,%rsi, and%rdxneeded to do thefprintf()call. - Run
PTRACE_CONTand thenwait()for the process to return to theTRAPwe inserted. - Replace the
TRAPwith aJMP %raxto return back to the original value of%ripandPTRACE_SINGLESTEPto execute the jump. - The current instruction will still be the
SYSCALLwe encoded earlier for ourmmap(2)system call. - Set up the registers to hold the right arguments to
munmap(2)to unmap the page we allocated earlier. - Execute the
munmap(2)system call by usingPTRACE_SINGLESTEP. - Verify that the
munmap(2)call worked by checking that%raxis 0. - Poke the value at the original
%ripto replace it with the single word we previously overwrote with theSYSCALLandJMP %raxinstructions. - Restore the original register state.
- Use
PTRACE_DETACHto detach from the process.
This new tracer is both faster and more correct than the previous implementation. As before, you can find the code on GitHub at eklitzke/ptrace-call-userspace.