Discover LinuxSecurity Features
Hacker's Corner: Complete Guide to Anti-Debugging in Linux - Part 3

In the previous part of our Hacker's Corner series, we covered anti-debugging using a trivial self-modifying code. Here, instead of blocking debugging completely, we will detect various debugger-induced activities.
Breakpoints
A breakpoint is intentional "pause" in normal execution of a program, generally used to inspect the internals of said process in more detail. This is the *most* used feature of any debugger.
On x86 CPUs, there are two types of breakpoints: hardware breakpoints and software breakpoints. While they overlap to a certain degree they are not exactly the same.
In most of debugging cases, you will be using software breakpoints, which do not need any special hardware support. These are implemented using same interrupt mechanism which is used by pretty much everything else. On x86, 3rd interrupt is used to implement a breakpoint. When you set a breakpoint, your debugger overwrites target address (where you want to put the breakpoint) with INT 3 (0xCC in hex). When this instruction gets executed, debugger gets the control back from target process, and can inspect its state (registers, memory etc). To resume the execution, debugger will silently remove breakpoint, execute the instruction, and set the breakpoint again before letting the process resume (until it terminates, or breaks). Features like step over, step out are also implemented using "transparent" software breakpoints, which are set and removed automatically by debugger. Generally, you can set any number of software breakpoints; however these cannot be set on non-code address (i.e. these can break the program only when target address content is executed; but not if the address is read from or write to).
Hardware breakpoints, on the other hand, are much more powerful and flexible than software breakpoints. These can be set to break not only on execution, but also on memory access (read and write both), I/O port access etc. These debuggers are set by writing into special "debug registers" which are largely platform specific (and not all platforms will have support for hardware breakpoints). On x86, registers DR0-3 and DR6-7 are used to set these breakpoints (DR4-5 are reserved as of now). If you have ever used "watchpoints" which let you break when certain memory address is accessed, you have used hardware breakpoints.
Here, one can try looking this inside a debugger, and then claim that this is not how software breakpoints work:
(gdb) break main
Breakpoint 1 at 0x116d
(gdb) disassemble main
Dump of assembler code for function main:
0x0000000000001169 <+0>: push rbp
0x000000000000116a <+1>: mov rbp,rsp
0x000000000000116d <+4>: lea rsi,[rip+0xe91] # 0x2005
0x0000000000001174 <+11>: lea rdi,[rip+0x2f05] # 0x4080 <[email protected]@GLIBCXX_3.4>
0x000000000000117b <+18>: call 0x1040 <[email protected]>
0x0000000000001180 <+23>: mov rdx,rax
0x0000000000001183 <+26>: mov rax,QWORD PTR [rip+0x2e46] # 0x3fd0
0x000000000000118a <+33>: mov rsi,rax
0x000000000000118d <+36>: mov rdi,rdx
0x0000000000001190 <+39>: call 0x1050 <[email protected]>
0x0000000000001195 <+44>: mov eax,0x0
0x000000000000119a <+49>: pop rbp
0x000000000000119b <+50>: ret
End of assembler dump.
(gdb)
Here, we cannot see any interrupt instruction; not because there is none; but because our debugger is lying here. It will show you disassembly as it looked before setting any breakpoints so that it matches with what compiler generated from source.
Detecting Software Breakpoint
Since we know that software breakpoints are set by overwriting 0xCC at first byte of instruction, we can easily check for such breakpoints in our code:
- Find where our target function (or any chunk of code) is located in memory
- Read 1 byte from address
- If byte is 0xCC, a breakpoint has been set
A trivial implementation looks something like this:
#include <iostream>
bool isBreakpointPresent(unsigned char *func)
{
bool result = *func == 0xCC;
return result;
}
void secret()
{
for (int i = 0; i < 10; ++i)
{
std::cout << "Try a breakpoint at secret()" << std::endl;
}
}
int main()
{
auto *ptr_secret = (unsigned char*)secret;
if (isBreakpointPresent(ptr_secret))
std::cerr << "Breakpoint detected" << std::endl;
else
secret();
return 0;
}
Let us try to see what happens if we run this under debugger:
$ gdb -q ./detect-breakpoint1
Reading symbols from ./detect-breakpoint1...
(gdb) break secret
Breakpoint 1 at 0x118e: file detect-breakpoint1.cpp, line 15.
(gdb) run
Starting program: detect-breakpoint1
Breakpoint 1, secret () at detect-breakpoint1.cpp:15
15 for (int i = 0; i < 10; ++i)
(gdb)
Wait, WHAT? It should have printed **Breakpoint detected**; but it went ahead to call *secret()* instead. Let us see where our breakpoint has been put in memory:
(gdb) bt
#0 secret () at detect-breakpoint1.cpp:15
#1 0x000055555555521e in main () at detect-breakpoint1.cpp:27
(gdb) disassemble secret
Dump of assembler code for function secret():
0x0000555555555186 <+0>: push rbp
0x0000555555555187 <+1>: mov rbp,rsp
0x000055555555518a <+4>: sub rsp,0x10
=> 0x000055555555518e <+8>: mov DWORD PTR [rbp-0x4],0x0
0x0000555555555195 <+15>: cmp DWORD PTR [rbp-0x4],0x9
0x0000555555555199 <+19>: jg 0x5555555551c9 <secret()+67>
0x000055555555519b <+21>: lea rsi,[rip+0xe62] # 0x555555556004
0x00005555555551a2 <+28>: lea rdi,[rip+0x2ed7] # 0x555555558080 <[email protected]@GLIBCXX_3.4>
0x00005555555551a9 <+35>: call 0x555555555040 <[email protected]>
0x00005555555551ae <+40>: mov rdx,rax
0x00005555555551b1 <+43>: mov rax,QWORD PTR [rip+0x2e18] # 0x555555557fd0
0x00005555555551b8 <+50>: mov rsi,rax
0x00005555555551bb <+53>: mov rdi,rdx
0x00005555555551be <+56>: call 0x555555555050 <[email protected]>
0x00005555555551c3 <+61>: add DWORD PTR [rbp-0x4],0x1
0x00005555555551c7 <+65>: jmp 0x555555555195 <secret()+15>
0x00005555555551c9 <+67>: nop
0x00005555555551ca <+68>: leave
0x00005555555551cb <+69>: ret
End of assembler dump.
(gdb)
If you notice, the debugger has not set breakpoint at very beginning. It has set breakpoint where assembly corresponding to our code starts (right after function prologue). This is why our breakpoint check is failing. But, if we set breakpoint manually on very first instruction, we can see that our detection is working:
(gdb) disassemble secret
Dump of assembler code for function secret():
0x0000555555555186 <+0>: push rbp
0x0000555555555187 <+1>: mov rbp,rsp
0x000055555555518a <+4>: sub rsp,0x10
0x000055555555518e <+8>: mov DWORD PTR [rbp-0x4],0x0
0x0000555555555195 <+15>: cmp DWORD PTR [rbp-0x4],0x9
0x0000555555555199 <+19>: jg 0x5555555551c9 <secret()+67>
0x000055555555519b <+21>: lea rsi,[rip+0xe62] # 0x555555556004
0x00005555555551a2 <+28>: lea rdi,[rip+0x2ed7] # 0x555555558080 <[email protected]@GLIBCXX_3.4>
0x00005555555551a9 <+35>: call 0x555555555040 <[email protected]>
0x00005555555551ae <+40>: mov rdx,rax
0x00005555555551b1 <+43>: mov rax,QWORD PTR [rip+0x2e18] # 0x555555557fd0
0x00005555555551b8 <+50>: mov rsi,rax
0x00005555555551bb <+53>: mov rdi,rdx
0x00005555555551be <+56>: call 0x555555555050 <[email protected]>
0x00005555555551c3 <+61>: add DWORD PTR [rbp-0x4],0x1
0x00005555555551c7 <+65>: jmp 0x555555555195 <secret()+15>
0x00005555555551c9 <+67>: nop
0x00005555555551ca <+68>: leave
0x00005555555551cb <+69>: ret
End of assembler dump.
(gdb) break * 0x0000555555555186
Breakpoint 2 at 0x555555555186: file detect-breakpoint1.cpp, line 14.
(gdb) continue
Continuing.
Breakpoint detected
[Inferior 1 (process 52508) exited normally]
(gdb)
Here, we have correctly detected that a breakpoint has been set.
Improved Detection
We can improve the detection to catch breakpoints on any instruction in our protected function:
- Find offsets of all instructions in target function, starting from location of first instruction.
- Find where our target function (or any chunk of code) is located in memory
- Read 1 byte from all offsets
- If any byte is 0xCC, a breakpoint has been set
Getting Offsets
We can compile the source code fully first; and then can use gdb to dump offsets for all instructions. For our previous example, we can list disassembled instructions and offsets as shown below:
(gdb) disassemble secret
Dump of assembler code for function secret():
0x000000000000128f <+0>: push rbp
0x0000000000001290 <+1>: mov rbp,rsp
0x0000000000001293 <+4>: sub rsp,0x10
0x0000000000001297 <+8>: mov DWORD PTR [rbp-0x4],0x0
0x000000000000129e <+15>: cmp DWORD PTR [rbp-0x4],0x9
0x00000000000012a2 <+19>: jg 0x12d2 <secret()+67>
0x00000000000012a4 <+21>: lea rsi,[rip+0xd5d] # 0x2008
0x00000000000012ab <+28>: lea rdi,[rip+0x2e0e] # 0x40c0 <[email protected]@GLIBCXX_3.4>
0x00000000000012b2 <+35>: call 0x1060 <[email protected]>
0x00000000000012b7 <+40>: mov rdx,rax
0x00000000000012ba <+43>: mov rax,QWORD PTR [rip+0x2d0f] # 0x3fd0
0x00000000000012c1 <+50>: mov rsi,rax
0x00000000000012c4 <+53>: mov rdi,rdx
0x00000000000012c7 <+56>: call 0x1090 <[email protected]>
0x00000000000012cc <+61>: add DWORD PTR [rbp-0x4],0x1
0x00000000000012d0 <+65>: jmp 0x129e <secret()+15>
0x00000000000012d2 <+67>: nop
0x00000000000012d3 <+68>: leave
0x00000000000012d4 <+69>: ret
End of assembler dump.
(gdb)
The data between *<* and *>* is the offset that we are looking for. We can run one-liner to extract all offsets in nice usable format:
$ gdb -batch -ex 'file ./detect-breakpoint2' -ex 'disassemble secret' 2>/dev/null | grep "0x" | grep "<+" | awk -F ' ' '{print $2}' | cut -c3- | rev | cut -c3- | rev | sed ':a;N;$!ba;s/\n/, /g'
0, 1, 4, 8, 15, 19, 21, 28, 35, 40, 43, 50, 53, 56, 61, 65, 67, 68, 69
This one liner can get us offsets in comma separated list, which we can copy and use in our code. Instead of checking only first byte, we have to iterate over list of offsets, find actual address of instructions, and then check if first byte is 0xCC. Sample implementation goes below:
#include <iostream>
#include <unistd.h>
#include <vector>
bool isBreakpointPresent(const unsigned char *func, const std::vector<unsigned int>& offsets)
{
bool result = false;
for (auto &i : offsets) {
if (*(func + i) == 0xCC)
{
result = true;
break;
}
}
return result;
}
void secret()
{
for (int i = 0; i < 10; ++i)
{
std::cout << "Try a breakpoint at secret()" << std::endl;
}
}
int main()
{
auto *ptr_secret = (unsigned char*)secret;
std::vector<unsigned int> offsets = {0, 1, 4, 8, 15, 19, 21, 28, 35, 40, 43, 50, 53, 56, 61, 65, 67, 68, 69};
if (isBreakpointPresent(ptr_secret, offsets))
std::cerr << "Breakpoint detected" << std::endl;
else
secret();
return 0;
}
Now let us test this version under debugger:
$ gdb -q ./detect-breakpoint2
Reading symbols from ./detect-breakpoint2...
(gdb) break secret
Breakpoint 1 at 0x1297: file detect-breakpoint2.cpp, line 26.
(gdb) run
Starting program: detect-breakpoint2
Breakpoint detected
[Inferior 1 (process 70087) exited normally]
(gdb)
And it works!
About the Author
Adhokshaj Mishra works as a security researcher (malware - Linux) at Uptycs. His interest lies in offensive and defensive side of Linux malware research. He has been working on attacks related to containers, kubernetes; and various techniques to write better malware targeting Linux platform. In his free time, he loves to dabble into applied cryptography, and present his work in various security meetups and conferences.