Extending LLVM for Code Obfuscation (2 of 2)
In part one, we covered setting up a development environment for working with LLVM and developed a simple pass that inserted junk code into binaries during compilation to hinder signature-based detection and manual reverse engineering efforts. In this article, we develop a more complex pass that automatically encrypts string literals during the compilation process by manipulating the LLVM IR.
Where is String Obfuscation Used?
String obfuscation is a technique that is commonly used by malware to hinder manual reverse engineering and analysis by defenders. During reverse engineering, it is common to use strings embedded within the target binary to identify relevant functionality during analysis. For instance, a malware analyst might pivot off of references to a user agent string constant in a binary to identify command and control related code within a malware sample. Additionally, analysis of strings embedded within a binary is standard during dynamic analysis as in many instances, it is possible to determine information about the functionality and workings of a malware sample by merely using the strings command.
Because of this, malware developers commonly encrypt string literals within binaries to hinder this type of analysis by security teams as well as to evade signature-based detection targeting string literals. The encrypted strings get decrypted at runtime in-memory when referenced during program execution.
Outside of the realm of malware analysis, it is also common for this type of obfuscation mechanism to be used to hinder reverse engineering of applications for commercial purposes such as intellectual property protection and digital rights management (DRM).
Designing the String Encryption Mechanism
We have several options when it comes to implementing the automated string encryption mechanism. One option would be to encrypt every string literal within the binary and then call the decryption routine at the beginning of main. The advantage of this approach is that it is very stable and simple to implement. However, the disadvantage is that every string within the binary is stored in plaintext in memory, making it relatively easy for an analyst to obtain every decrypted string at once.
An alternative approach would be to encrypt every string and only decrypt the string when referenced during program execution. Once the program no longer uses the string, it should be re-encrypted to avoid unnecessarily storing cleartext strings in memory. A disadvantage of this approach is that it can be challenging to track when a program no longer references a string. This reference tracking problem opens up a risk of the obfuscation engine, inserting potential errors or bugs into the compiled code or violating assumptions made by the programmer.
One way to implement the approach mentioned previously would be to move global string constants into the IR for a specific function itself. Then the string could be decrypted and written onto the program stack. When the function returns, the string would effectively be deallocated and overwritten by subsequent function calls. The code given below shows what this transformation would look like at a high level. It starts by allocating a buffer on the stack to store the string and then writes each character into the buffer.
#include <stdio.h>
int main() {
char str[6];
str[0] = 'H';
str[1] = 'e';
str[2] = 'l';
str[3] = 'l';
str[4] = 'o';
str[5] = '\0';
printf(str);
return 0;
}
The generated code, when compiled with GCC, results in a series of move instructions that write the string into the allocated stack buffer. A disassembly of the function when compiled with GCC is given below.
(gdb) disas main
Dump of assembler code for function main:
0x0000000000001135 <+0>: push rbp
0x0000000000001136 <+1>: mov rbp,rsp
0x0000000000001139 <+4>: sub rsp,0x10
0x000000000000113d <+8>: mov BYTE PTR [rbp-0x6],0x48
0x0000000000001141 <+12>: mov BYTE PTR [rbp-0x5],0x65
0x0000000000001145 <+16>: mov BYTE PTR [rbp-0x4],0x6c
0x0000000000001149 <+20>: mov BYTE PTR [rbp-0x3],0x6c
0x000000000000114d <+24>: mov BYTE PTR [rbp-0x2],0x6f
0x0000000000001151 <+28>: mov BYTE PTR [rbp-0x1],0x0
0x0000000000001155 <+32>: lea rax,[rbp-0x6]
0x0000000000001159 <+36>: mov rdi,rax
0x000000000000115c <+39>: mov eax,0x0
0x0000000000001161 <+44>: call 0x1030 <printf@plt>
0x0000000000001166 <+49>: mov eax,0x0
0x000000000000116b <+54>: leave
0x000000000000116c <+55>: ret
End of assembler dump.
(gdb)
As mentioned previously, it is imperative to note that this transformation of a global variable to a stack-based variable is not without potential undesired consequences. The string constant now is deallocated when the function returns, violating assumptions made by the programmer about the scope of the string constant. For example, the programmer may invoke a function with a pointer to the now stack-based string constant. This function may store a reference to this string constant in a struct or global variable. If this pointer is dereferenced after the parent function returns, the behavior is undefined since subsequent function calls could allocate the stack space previously used to store the string constant.
Several approaches are possible when attempting to address these issues. The first potential solution would be to educate the programmer on the workings of the code obfuscation engine and have them write code in a manner that avoids causing this issue to arise. The downside to this approach is that it is not suitable for large legacy codebases. An alternative approach would be to support both an opt-in or opt-out approach for this type of obfuscation on a per-string basis. For instance, the programmer could specify either specific strings to be encrypted or specific strings that should not be encrypted. A third approach would be instead of allocating memory on the stack to allocate it on the heap. This change would prevent the assumptions made by the programmer on the lifetime of string constants from being violated; however, the downside of this approach is that once a string is decrypted, it remains in cleartext in memory for the duration of program execution.
Due to the string obfuscation pass being a proof of concept utility, I have elected to opt for the first method. We assume that the programmer is aware of the workings of the code obfuscation engine to avoid triggering this edge case. This solution is not optimal for large legacy codebases or use in a commercial-grade code obfuscation tool.
Developing the LLVM Pass
Now that we have selected a string obfuscation algorithm, it is time to begin the development of the string obfuscation pass. First, we need to loop through each global variable in a given source code file and determine if it is a string constant that is eligible for replacement — the code given below implements this logic.
namespace {
struct StringObfuscation : public ModulePass {
static char ID;
StringObfuscation() : ModulePass(ID) {}
std::vector<GlobalVariable*> staticStrings;
bool runOnModule(Module &M) override {
//
// Enumerate all static string constants within the module that we can encrypt
//
for(Module::global_iterator gi = M.global_begin(), ge = M.global_end(); gi != ge; ++gi) {
GlobalVariable *global = &(*gi);
std::string section(global->getSection());
//
// Determine if global variable is a string constant with an initializer and that the
// string is not an objective-c method name or llvm related metadata. Then add them to a
// list of strings that can be replaced.
//
if(global->getName().str().substr(0, 4) == ".str" &&
global->isConstant() &&
global->hasInitializer() &&
isa<ConstantDataSequential>(global->getInitializer()) &&
section != "llvm.metadata" &&
section.find("__objc_methname") == std::string::npos) {
ConstantDataArray *str = dyn_cast<ConstantDataArray>(global->getInitializer());
if(str == nullptr) {
continue;
}
staticStrings.push_back(global);
}
}
After building a list of global variables, we iterate through every instruction within every function in the code. We then examine the operands within these instructions to identify any operands referencing a global variable within our substitution list – the code given below implements this logic.
//
// We iterate over every instruction in every function within the module. From this we
// analyze the operands for every instruction to identify operands that reference global
// variables that are referenced as static string constants.
//
for(Function &F : M) {
errs() << "String Obfuscation - Processing Function: ";
errs().write_escaped(F.getName()) << "\n";
for(BasicBlock &B : F) {
for(Instruction &I : B) {
for(Use &arg : I.operands()) {
if(isa<GEPOperator>(arg)) {
GEPOperator *gepo = dyn_cast<GEPOperator>(arg);
if(isa<GlobalVariable>(gepo->getPointerOperand())) {
GlobalVariable *gv = dyn_cast<GlobalVariable>(gepo->getPointerOperand());
errs() << "GEPOperator == GlobalVariable" << "\n";
errs() << "GV:" << *gv << "\n";
// TODO: Find a better way to identify if an object exists within a vector. Alternatively
// we should switch to a set or other object that is better for this.
auto entry = std::find(staticStrings.begin(), staticStrings.end(), gv);
if(entry != staticStrings.end()) {
errs() << "Found Entry in Static Strings" << "\n";
//
// Do the work necessary to insert LLVM IR code into the beginning of
// the basic block
//
LLVMContext &Context = B.getContext();
IRBuilder<NoFolder> builder(&B);
builder.SetInsertPoint(&I);
//
// Get the contents of the string referenced by the local variable
//
ConstantDataArray *strObject = dyn_cast<ConstantDataArray>(gv->getInitializer());
StringRef strRef = strObject->getAsCString();
const char *constStr = strRef.data();
size_t constStrSize = strRef.size();
//
// Allocate a temporary stack buffer to decrypt the encrypted variable
// onto the stack
//
AllocaInst *decryptStrBuf = builder.CreateAlloca(
builder.getInt8Ty(),
builder.getInt32(constStrSize + 1) // plus one because the size doesn't include a null terminator
);
Next, we generate code for an XOR-based decryption routine that writes the encrypted string onto the stack while embedding the ciphertext within the IR code. The code given below generates the IR code for decryption.
//
// Create sequence of instructions to perform decryption operation and
// write the decrypted string to the stack variable
//
for(size_t i = 0; i < constStrSize; i++) {
//
// Generate a byte to XOR with the plaintext string at location "i" and then include
// the decryption operation with the key embedded within the decryption code
//
int8_t key = rand() % 254 + 1;
Value *plaintext = builder.CreateXor(
ConstantInt::get(Type::getInt8Ty(Context), constStr[i] ^ key),
ConstantInt::get(Type::getInt8Ty(Context), key)
);
//
// Get a pointer to the offset in the buffer corresponding to the correct
// position in the string
//
std::vector<Value *> values;
values.push_back(
Constant::getIntegerValue(Type::getInt8Ty(Context), APInt(32, i))
);
Value *gep = builder.CreateGEP(
decryptStrBuf,
values
);
//
// Construct a store operation to store the decrypted byte of
// the string onto the stack
//
Value *store = builder.CreateStore(
plaintext,
gep,
true);
}
The decryption code is inserted before the instruction that references that string literal and the operand modified to reference the local stack variable instead of the global variable. Finally, once every reference to the string literal has been replaced, the final loop deletes the global string constant variable as they are no longer needed — the code given below implements the replacement and deletion logic.
//
// Replace the reference to the global variable in the operator to
// reference the string on the stack
//
I.setOperand(arg.getOperandNo(), decryptStrBuf);
}
}
}
}
}
}
}
//
// Now that the global string constants have been moved into the IR and encrypted
// we can delete the global variables from the module so that the string constants
// are no longer present in the binary
//
while(!staticStrings.empty()) {
GlobalVariable *gv = staticStrings.back();
staticStrings.pop_back();
gv->eraseFromParent();
}
Using the String Obfuscation Pass
The steps for using the string obfuscation pass are the same as those outlined in part one. The one difference is that when invoking the opt command, you need to specify the -stringobfs parameter.
In this instance, we are compiling the program given below to demonstrate the effects of the string obfuscation pass.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main() {
char input[256] = {0};
fgets(input, sizeof(input), stdin);
printf("User Input: %s\n", input);
if(strcmp("FancyBear\n", input) == 0) {
printf("Launching 1337 APT Backdoor\n");
}
}
Examining the compiled code, we can observe the string decryption XOR routine decrypting the string and writing it onto the stack. The image given below shows a disassembly of this routine.
0x000000000040134c <+492>: cmp eax,0x0
0x000000000040134f <+495>: jne 0x40144c <main+748>
0x0000000000401355 <+501>: mov rax,rsp
0x0000000000401358 <+504>: add rax,0xffffffffffffffe0
0x000000000040135c <+508>: mov rsp,rax
0x000000000040135f <+511>: mov cl,0x41
0x0000000000401361 <+513>: xor cl,0xd
0x0000000000401364 <+516>: mov BYTE PTR [rax],cl
0x0000000000401366 <+518>: mov cl,0x2e
0x0000000000401368 <+520>: xor cl,0x4f
0x000000000040136b <+523>: mov BYTE PTR [rax+0x1],cl
0x000000000040136e <+526>: mov cl,0x6d
0x0000000000401370 <+528>: xor cl,0x18
0x0000000000401373 <+531>: mov BYTE PTR [rax+0x2],cl
0x0000000000401376 <+534>: mov cl,0x9a
0x0000000000401378 <+536>: mov dl,cl
0x000000000040137a <+538>: xor dl,0xf4
0x000000000040137d <+541>: mov BYTE PTR [rax+0x3],dl
0x0000000000401380 <+544>: mov dl,0x42
0x0000000000401382 <+546>: xor dl,0x21
0x0000000000401385 <+549>: mov BYTE PTR [rax+0x4],dl
0x0000000000401388 <+552>: mov dl,0x64
0x000000000040138a <+554>: xor dl,0xc
0x000000000040138d <+557>: mov BYTE PTR [rax+0x5],dl
0x0000000000401390 <+560>: mov dl,0xab
0x0000000000401392 <+562>: xor dl,0xc2
0x0000000000401395 <+565>: mov BYTE PTR [rax+0x6],dl
0x0000000000401398 <+568>: mov dl,0xf2
0x000000000040139a <+570>: xor dl,0x9c
0x000000000040139d <+573>: mov BYTE PTR [rax+0x7],dl
0x00000000004013a0 <+576>: mov dl,0x19
0x00000000004013a2 <+578>: xor dl,0x7e
0x00000000004013a5 <+581>: mov BYTE PTR [rax+0x8],dl
0x00000000004013a8 <+584>: xor cl,0xba
0x00000000004013ab <+587>: mov BYTE PTR [rax+0x9],cl
0x00000000004013ae <+590>: mov cl,0x5f
If we examine the strings within the compiled program, we can observe that none of the string literals used in the program are visible.
root@llvm:/home/user# strings fgets
/lib64/ld-linux-x86-64.so.2
libc.so.6
stdin
printf
fgets
memset
strcmp
__libc_start_main
GLIBC_2.2.5
__gmon_start__
H=H@@
%P@@
%\@@
[]A\A]A^A_
;*3$"
GCC: (Debian 8.3.0-6) 8.3.0
clang version 7.0.1-8 (tags/RELEASE_701/final)
crtstuff.c
deregister_tm_clones
__do_global_dtors_aux
completed.7325
__do_global_dtors_aux_fini_array_entry
frame_dummy
__frame_dummy_init_array_entry
fgets.c
__FRAME_END__
__init_array_end
_DYNAMIC
__init_array_start
__GNU_EH_FRAME_HDR
_GLOBAL_OFFSET_TABLE_
__libc_csu_fini
stdin@@GLIBC_2.2.5
However, if we examine a version of the same program compiled with GCC, we can see that as expected the strings are not encrypted and visible when running the strings command.
root@llvm:/home/user# strings fgets-gcc
/lib64/ld-linux-x86-64.so.2
mgUa
libc.so.6
puts
stdin
printf
fgets
__cxa_finalize
strcmp
__libc_start_main
GLIBC_2.2.5
_ITM_deregisterTMCloneTable
__gmon_start__
_ITM_registerTMCloneTable
u/UH
[]A\A]A^A_
User Input: %s
FancyBear
Launching 1337 APT Backdoor
;*3$"
GCC: (Debian 8.3.0-6) 8.3.0
crtstuff.c
deregister_tm_clones
__do_global_dtors_aux
completed.7325
__do_global_dtors_aux_fini_array_entry
frame_dummy
__frame_dummy_init_array_entry
fgets.c
__FRAME_END__
__init_array_end
_DYNAMIC
Conclusion
Code obfuscation has numerous use cases ranging from copyright protection of software, intellectual property protection, digital rights management (DRM), and its use by malware authors to hinder reverse engineering of malicious code and evade signature-based antivirus software.
Due to its extensible and modular architecture, LLVM provides the perfect foundation for the development of code obfuscation tools. However, due to the changing nature of the LLVM API and the lack of backwards compatibility, continual maintenance is required to maintain compatibility with current LLVM releases.
On the Praetorian red team, we have invested resources into improving code obfuscation to hinder analysis of compiled code and to simulate the evasion techniques leveraged by real-world adversaries more accurately.
The code obfuscation passes presented in this article should be considered as proof-of-concept grade tools and should not be used in commercial software applications without adequate testing.