The Ptrace Anti Re Trick Revisited
Introduction
A previous blog post introduced a well-known anti reverse-engineering technique under Linux using ptrace. A program was introduced that showed different behavior on whether it is being debugged or not.
This program relied on the function ptrace()
. The program executed
ptrace()
on itself, and if it failed, it concluded that it was
debugged by another process.
In this blog post, we will go two steps further in the cat-and-mouse game of binary analysis. We will show how to defeat the previous trick so that the program can again be dynamically analyzed. And then we will show how to overcome this trick and make the life of the binary analyst hard again.
Background
In the last blog post we introduced the following program containing an Anti reverse-engineering mechanism. Its behavior depends on whether it is dynamically analyzed or not.
Its behaviour is as follows.
$ ./selfptrace
I am not traced
$ strace ./selfptrace
execve("./selfptrace", ["./selfptrace"], 0x7ffc1c38fb10 /* 67 vars */) = 0
....
ptrace(PTRACE_TRACEME) = -1 EPERM (The operation is not permitted)
...
write(1, "I am traced", 11I am traced) = 11
exit_group(1) = ?
+++ exited with 1 +++
This program cannot be analyzed using dynamic analysis as dynamic
analysis executes ptrace()
on its target. However, then the
ptrace()
that is executed by the program on itself fails and we are
in a different branch of execution.
Enabling Dynamic Analysis Again
In order to be able to dynamically analyze the program without
altering its behaviour, we can hook the ptrace()
function and substitute
it by a fake ptrace()
function that always returns zero.
First of all, let us look at the section .dynsym
that contains the
dynamic symbol table section of the binary file.
$ readelf --dyn-syms --wide selfptrace
Symbol table '.dynsym' contains 6 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __libc_start_main@GLIBC_2.34 (2)
2: 0000000000000000 0 FUNC GLOBAL DEFAULT UND printf@GLIBC_2.2.5 (3)
3: 0000000000000000 0 NOTYPE WEAK DEFAULT UND __gmon_start__
4: 0000000000000000 0 FUNC GLOBAL DEFAULT UND ptrace@GLIBC_2.2.5 (3)
5: 0000000000000000 0 FUNC GLOBAL DEFAULT UND exit@GLIBC_2.2.5 (3)
We can clearly see that the function ptrace()
of glibc is called, as the
string ptrace@GLIBC_2.2.5
is present.
Important note: There is also a syscall called ptrace()
. The library
call ptrace()
provides a wrapper around this syscall.
We can now write a library that provides an alternative implementation
of ptrace()
.
This library is loaded using LD_PRELOAD
as follows.
$ LD_PRELOAD=./bypass_ptrace_trick.so ./selfptrace
I am not traced
How can we strace this?
We could export LD_PRELOAD=/bypass_ptrace_trick.so
to set out fake
library as an environment variable to preload, or use it directly in
front of the strace command.
$ LD_PRELOAD=./bypass_ptrace_trick.so strace ./selfptrace
However, this leads to the strace process hanging, as we are also
hooking the ptrace()
functions that are executed within the strace
command.
During researching on the internet I found the following two examples which again do not work.
$ export LD_PRELOAD=/bypass_ptrace_trick.so
$ ltrace -S -l ./bypass_ptrace_trick.so ./selfptrace
$ ltrace -l ./bypass_ptrace_trick.so -x @ ./selfptrace
Again, ltrace is broken, as the ptrace()
functions in ltrace return
zero.
The correct way of stracing a binary that is LD_PRELOAD
ed with a
library is using the parameter -E
.
$ strace -E LD_PRELOAD=./bypass_ptrace_trick.so ./selfptrace
execve("./selfptrace", ["./selfptrace"], 0x5572e3ae5730 /* 68 vars */) = 0
...
openat(AT_FDCWD, "./bypass_ptrace_trick.so", O_RDONLY|O_CLOEXEC) = 3
...
write(1, "I am not traced", 15I am not traced) = 15
exit_group(0) = ?
+++ exited with 0 +++
The Next Bypass
So now that we have seen how an analyst can substitute functions, how can we as developers that are interested in protecting our software defend against that? In fact, there are multiple ways.
The first approach is to not use the function
ptrace()
, instead the syscall of the same name, as was already shown
at the end of the last blog post. In this case, the function
cannot be hooked, as it is not a call to a library and thus is not
resolved in the first place.
In this case an analyst has to patch the binary and remove the jmp
instruction for the respective branch, such that the branch is taken,
that is usually taken when the program is not ptrace
d. This can
again be circumvented by the developer, by checksumming the functions
and checking if they have been altered. If that is the case, a
separate execution branch can be taken.
However, the approach we want to take in the remainder is to stay on
the level of C code (and not use inline assembly).
One idea is to execute ptrace()
twice on itself. If the process is
not analyzed, then the first call to ptrace()
succeeds and the
second one fails. If the program is analyzed naively, then the first
and the second ptrace()
call on itself both fail. If the program is
analyzed by a substituted ptrace()
function, then both calls
succeed.
As explained above, the program can again reliable detect if it is analyzed.
$ ./selfptrace2
I am not traced
$ strace ./selfptrace2
execve("./selfptrace2", ["./selfptrace2"], 0x7ffcfc6bd5e0 /* 67 vars */) = 0
...
ptrace(PTRACE_TRACEME) = -1 EPERM (The operation is not permitted)
ptrace(PTRACE_TRACEME) = -1 EPERM (The operation is not permitted)
...
write(1, "I am traced", 11I am traced) = 11
exit_group(1) = ?
+++ exited with 1 +++
$ strace -E LD_PRELOAD=./bypass_ptrace_trick.so ./selfptrace2
execve("./selfptrace2", ["./selfptrace2"], 0x55ba89345730 /* 68 vars */) = 0
...
write(1, "I am traced", 11I am traced) = 11
exit_group(1) = ?
+++ exited with 1 +++
This concludes my blog post. In a further step of the cat-and-mouse game, the analyst would have to dive on the level of assembly and patch the jumps manually.
Acknowledgements
Thanks to my colleague esj4y who told me
about the -E
argument of strace and thus helped me progress on this
anti reversing journey.
References
- https://dev.to/nuculabs_dev/bypassing-ptrace-calls-with-ldpreload-on-linux-12jl
- https://seblau.github.io/posts/linux-anti-debugging