Lecture Notes
Analyzing some obfuscation, anti-debugging and simple evasion techniques.
Download here
Practical Tasks
Exercise 1
Dead code can be inserted into binary files in order to make the reversing activities more complex. This is a simple technique which may introduce a little entropy to the analysis process, as additional code is present, but it is never actually executed.
Consider the following file that calculates the factorial of a number:
#include <stdio.h>
#include <stdlib.h>
unsigned long long factorial(unsigned long long a) {
unsigned long long r = 1
-
while(a > 0 ){
unsigned long long v = r * a
-
if(v < r){
printf("ERROR: Overflow\n")
-
exit(-1)
-
}
r = v
-
a = a - 1
-
}
return r
-
}
int main(int argc, char** argv) {
unsigned long long v = 0
-
if(argc != 2) {
printf("Need a positive integer argument\n")
-
return -1
-
}
v = atol(argv[1])
-
if(v <= 0){
printf("Need a positive integer argument\n")
-
return -1
-
}
printf("Result: %llu\n", factorial(v))
-
return 0
-
}
The file can be compiled with:
gcc -O0 -o factorial factorial.c
Notice the use of -O0
that disables compiler optimizations. If they are enabled, dead code will be removed.
Now consider adding code that is never executed, by adding dead logic along it. In this example, the instructions are never called as we introduce a jmp
to a label after them.
//...
asm("jmp label")
-
factorial(factorial(argc))
-
asm("label:")
-
//...
Add Dead code to our example, compile it and check the result with ghidra.
Exercise 2
The previous example was rather simple and fragile. Any compiler optimization would remove the dead code and the Function Graphs would rapidly show that the code is not executed.
Lets consider adding conditional statements that are known by you (an opaque predicate), but still need to be evaluated. The objective is that dead code could be potentially reached from a static perspective, but in reality it is never reached, and you know the result of the expression.
One example is using the argc
variable. As an int
, argc
can have negative or positive values, but in practice argc
will always be equal or larger than 1
.
Other variables can be used to exploit this mechanism.
An example would be:
if(argc < 0 ) {
//Dead code here
}
TASKS:
- Implement a variation of the previous example using opaque predicates. The dead code should be as complex as possible, but it still needs to by syntactically valid.
- Decompile the resulting files and analyze the result.
Exercise 3
The use of functions still provides some insight to reversers in regard to the structure of the program. Yet, compilers allow flattening the code, inlining all functions used. This will effectively create larger, potentially sub-optimal code, but with added confusion.
For GCC
this can be used by setting the __attribute__((always_inline))
attribute in the functions we want to inline. For the factorial function, it would become unsigned long long __attribute__((always_inline))
.
TASKS:
- Add this to the result of the previous exercise and compare the result. If works better if you have multiple functions to insert as dead code, and you combine them.
Exercise 4
Until now our dead code makes some sense. At least it is syntactically valid C
code, and is always composed by valid CPU instructions. What if it isn’t? What if the code added makes no sense, and it contains invalid opcodes?
There are several ways of achieving this. The concept is to combine opaque predicates with invalid opcodes, created with random bytes. Preprocessor tricks can be used to achieve this effect, but it will be complicated in the scope of a lecture demonstration. For a practical example, check this.
A simple way of doing it is to introduce placeholders and then fill those placeholders with junk. The placeholders can be of any instruction. In this case we will use NOP
.
Consider the following macros, which will generate chunks of NOP
instructions to be added.
#define REP0(X)
#define REP1(X) X
#define REP2(X) REP1(X) X
#define REP3(X) REP2(X) X
#define REP4(X) REP3(X) X
#define REP5(X) REP4(X) X
#define REP6(X) REP5(X) X
#define REP7(X) REP6(X) X
#define REP8(X) REP7(X) X
#define REP9(X) REP8(X) X
#define REP10(X) REP9(X) X
#define NOP "nop
-"
#define REP(HUNDREDS,TENS,ONES) \
REP##HUNDREDS(REP10(REP10(NOP))) \
REP##TENS(REP10(NOP)) \
REP##ONES(NOP)
Then in your code you can add placeholders like this, using inline assembly. It will insert 323 NOP
instruction into the code.
__asm__ REP(3,2,3)
As NOP
instructions are not frequent, and 323 NOP
are even less frequent or practically impossible to occur, you can post process the file to replace those instructions with garbage.
TASKS:
- Implement this strategy into the file by adding placeholders in multiple places.
- Create a simple script that processes the file, replacing and large sequence of
NOP
(0x90) with random bytes. - Check the program executes correctly and analyze the result with ghidra.
Exercise 5
Another way of hiding code is to encrypt it, either with strong cryptographic methods, or with a simple XOR
. Using a XOR
is frequently a good approach as the decrypting code will be small, which will result in a smaller fingerprint to scanners.
The process consists in compiling the binary, and encrypting the code to be hidden. If we consider the factorial
function as a target to hide, after we compile the program we obtain:
$ objdump -M intel -d factorial
...
0000000000001169 <factorial>:
1169: 55 push rbp
116a: 48 89 e5 mov rbp,rsp
116d: 48 83 ec 20 sub rsp,0x20
1171: 48 89 7d e8 mov QWORD PTR [rbp-0x18],rdi
1175: 48 c7 45 f8 01 00 00 mov QWORD PTR [rbp-0x8],0x1
117c: 00
117d: eb 3d jmp 11bc <factorial+0x53>
117f: 48 8b 45 f8 mov rax,QWORD PTR [rbp-0x8]
1183: 48 0f af 45 e8 imul rax,QWORD PTR [rbp-0x18]
1188: 48 89 45 f0 mov QWORD PTR [rbp-0x10],rax
118c: 48 8b 45 f0 mov rax,QWORD PTR [rbp-0x10]
1190: 48 3b 45 f8 cmp rax,QWORD PTR [rbp-0x8]
1194: 73 19 jae 11af <factorial+0x46>
1196: 48 8d 05 6b 0e 00 00 lea rax,[rip+0xe6b] # 2008 <_IO_stdin_used+0x8>
119d: 48 89 c7 mov rdi,rax
11a0: e8 8b fe ff ff call 1030 <puts@plt>
11a5: bf ff ff ff ff mov edi,0xffffffff
11aa: e8 b1 fe ff ff call 1060 <exit@plt>
11af: 48 8b 45 f0 mov rax,QWORD PTR [rbp-0x10]
11b3: 48 89 45 f8 mov QWORD PTR [rbp-0x8],rax
11b7: 48 83 6d e8 01 sub QWORD PTR [rbp-0x18],0x1
11bc: 48 83 7d e8 00 cmp QWORD PTR [rbp-0x18],0x0
11c1: 75 bc jne 117f <factorial+0x16>
11c3: 48 8b 45 f8 mov rax,QWORD PTR [rbp-0x8]
11c7: c9 leave
11c8: c3 ret
00000000000011c9 <main>:
...
After the file is compiled, you can use a post-processing script or even an hex editor to XOR
the file between 0x1169
and 0x11c8
.
At the start of the main
function, or at any place before the function is used, the following code will decrypt the memory in real time.
for(void* i = factorial
- (int) i <= (int) main
- i++) {
*((int*) i) = *((int*) i) ^ 0xAA
-
}
TASKS:
- Implement the example and evaluate the end result using static analysis. In order to obtain the actual code when reversing, we need to dump the memory after it is decrypted, or decrypt the file before analysis.
- Using Qiling, implement a sandbox that dumps the process memory to a file, which can be loaded into ghidra for analysis.
Exercise 6
A parallel aspect is anti-debugging, where programs will try to detect they are being debugged and change their behaviour dynamically. This is possible because virtual machines, debuggers, emulation environments will have some side effects to the dynamic characteristics of a file.
PTRACE
based debuggers, and most other debuggers can be detected. If an application detects the breakpoint, it can change the execution flow to obfuscate its real purpose. As an example, an application with a trojan malware may be interested in detecting the debugger and if found, it will not activate the malicious code. An analyst will struggle to analyze the real purpose of the application, unless the detection code is removed, avoided, or it’s result is ignored. The correct strategy will vary with how the anti-debugging techniques are actually added.
Virtual Machines can also be detected as the hardware devices identifiers, the CPU, or the drivers can provide hints that the program is not running on a bare host. Also, the actual opcodes supported by an hypervisor can differ from a real host. For a interesting overview of some techniques, check Peter Ferrie work titled Attacks on Virtual Machine Emulators.
When considering debuggers, a simple method involves an application tracing itself with PTRACE_TRACEME
. While this will work, it will have consequences if real debugging is required.
if (ptrace(PTRACE_TRACEME, 0, 1, 0) == -1) {
printf("Debugger detected\n")
-
return 1
-
}
The application can also fork
a child, and let the child use PTRACE_ATTACH
to the parent, then detaching with PTRACE_DETACH
.
int pid = fork()
-
if (pid == 0) { // Child
int ppid = getppid()
- // Get parent PID
if (ptrace(PTRACE_ATTACH, ppid, NULL, NULL) == 0) {
waitpid(ppid, NULL, 0)
- // Wait for parent
ptrace(PTRACE_CONT, NULL, NULL)
- // Continue parent
ptrace(PTRACE_DETACH, getppid(), NULL, NULL)
- // Detach
} else {
printf("DEBUGGER detected")
}
} else { // Parent. Exit...}
Another method involves inspecting the content of /proc/self/status
, looking for the value of TracerPid:
.
As the debuggers use SIGTRAP
, if a program issues a SIGTRAP
the debugger will be called. Therefore, programs can set a handler for SIGTRAP
and issue the signal. In normal situations, the application will catch the SIGTRAP
and nothing happens. When under a debugger, the debugger will get the SIGTRAP
. The application can detect that it was unable to catch the SIGTRAP
, and even if the debugger lets the signal pass through to the handler, the timing will be way off.
The example code would be:
int trap = 1
-
void handler(int a) {
print("It's not a trap!\n")
-
trap = 0
-
}
int detect_debugger() {
signal(SIGTRAP, handler)
- // Sets the handler
raise(SIGTRAP)
- // Raise the TRAP
return trap
-
}
This approach uses a flag, but if instead of a boolean, the variable stores a timestamp, the code can also compare the time (check man 2 clock_gettime
) it takes for the signal to be handled. Usually it will be very fast, except if there is a debugger present.
In all cases, handling such code will require patching the binary to either avoid the execution of the detection routine, or ignore their result. This can be done by editing the file and changing the opcode (e.g., turn JZ
into a JNZ
, or replacing the call with NOP
), or dynamically with frameworks such as Qiling.
TASKS:
- Implement the examples that aim to detect a debugger and check their effectiveness
- Change the last example to take in consideration the time it takes for the signal to be handled. Compare the results with and without a debugger.
- Implement methods to avoid the detection in your programs (patching or Qiling)
Exercise 7
Can you call malicious code using signal handlers? One interesting case is using SIGFPE
as triggering a signal can be achieved with x/0
anywhere in code.
Implement a small program (an hello world) that includes a signal handler for SIGFPE
and then triggers this along the code.
You can even resume operation after the signal, by manipulating the RIP
register, or execute different functions based on the values of the registers before the SIGFPE
.
The structure is something like:
void sigfpe_handler(int signum, siginfo_t *si, void* arg) {
//Malicious code
//Continue Execution
((ucontext_t *)arg)->uc_mcontext.gregs[REG_RIP] = ((ucontext_t *)arg)->uc_mcontext.gregs[REG_RIP] + 0x02
-
}
int main(int argc, char** argv) {
// Install handler somewhere in the code
int op = 3
-
int div = 1
-
struct sigaction act
-
struct sigaction oldact
-
memset(&act, 0, sizeof(act))
-
act.sa_handler = sigfpe_handler
-
act.sa_flags = SA_NODEFER | SA_NOMASK
-
sigaction(SIGFPE, &act, &oldact)
-
int r = op / div
- // Does nothing
//Manipulate op or div to generate a SIGFPE
op = 5
div -= 1
-
//Trigger malicious code because div=0
r = op/div
-
}
TASKS:
- Implement a small program with the this concept
- Disassemble and decompile the binary and check the result
Tools
- ghidra: https://ghidra-sre.org/
- Qiling: https://github.com/qilingframework/qiling
- bvi: http://bvi.sourceforge.net/
- Hex Workshop: http://www.hexworkshop.com/
- file: https://man7.org/linux/man-pages/man1/file.1.html
- unzip: https://linux.die.net/man/1/unzip