Delitcrypt
Intro
This article is about reversing a rust pre-compilation macro that obfuscates strings in the compiled binary.
Another point that will be discussed is the development of a BinaryNinja plugin. I’m writing the article as I go along,
to capture the process as closely as possible, with its changes of direction, disappointments and achievements :)
The purpose of this story is not to provide a turnkey plugin, but rather to explain my reverse engineering methodologies and thoughts,
as well as to progress in the use of the Binary Ninja API. No more spoiler, let’s go!
First attempt
The target
Litcrypt is a Rust proc macro that obfuscates text using a basic XOR method.
Strings are xored at compile time to hide them, and then are “decrypted” at runtime.
The code
I wrote a dumb crackme program to study the macro, here is the source code:
1 |
|
We can see the use of lc!
macro function.
Let’s build the binary and check if strings are obfuscated.
As expected, strings are not visible in the compiled binary.
Reversing
Having a look at the main function, we quickly identify the decrypt function.
Knowing that this is a simple xor routine, it is unnecessary to reverse the function, but we need to figure out which data is xored.
By analyzing the function arguments, we notice that five args are involved. We can reasonably deduce the usefulness of each of the parameters:
- The first one is a stack variable, probably to store the result string pointer
- The second one is an initialized data pointer, and seems to be the “encrypted” string
- The third one is an integer and might be the encrypted string length Yes it is :)
- Two last parameters should be the xor key and its length Let’s xor this data and see what happens:
1
20x430c6,5) bv.read(
b'!jx15'From this point, it is possible to decrypt each string. Time to automate :)1
2
3
4
5
6
7
8
9def xor_bytes_with_key(data, key):
bytearray(len(data)) result =
for i in range(len(data)):
len(key)] result[i] = data[i] ^ key[i %
return bytes(result)
...
>>>
0x43001,0x1b),bv.read(0x430c6,5)) xor_bytes_with_key(bv.read(
b'Welcome to the FlagKeeper\\n'
Plugin dev
The strategy for this plugin consists in several steps:
- Get the full “decrypt_bytes” symbol and deal with rust name mangling
- Find all references to this symbol
- Parse operands and decrypt the strings
- Display the strings
Catch the symbol
To obtain the symbol, the plugin will get all symbols from the binary, and test if “decrypt_bytes” is part of it. I don’t know if this is the best way to get the symbol object but, well, it actually works, feel free to PR if you have any other idea ¯\(ツ)/¯
1 | syms = bv.get_symbols() |
I use an “if in” statement to get the symbol. This is made in case the decrypt function changes its prototype in the future Litecrypt version or whatever.
From the symbol object, we can easily obtain all cross-reference with the powerfull Binary Ninja API :D
Get references
As said above, the API can be used to obtain each code reference to the decrypt_bytes symbol:
1 | refs = bv.get_code_refs(target_sym.address) |
The Next step is to iterate over the generator object, and get all operands.
Operands
To get operand value, it is necessary to use an intermediate language level.
1 | current_ref.hlil.operands |
Comparing the different representations, the HLIL seems to be a good candidate, as it is the easiest to parse.
Decrypt
First we get the encrypted string:
1 | 1][2].value.value) s1 = bv.read(s1,current_ref.hlil.operands[ |
Then grab the key in the same way:
1 | 1][3].value.value s2 = current_ref.hlil.operands[ |
And here we go, we can now xor the extracted bytes:
1 | xor_bytes_with_key(s1,s2) |
And get the string in plain text \o/
Set the string somewhere
Now, the decrypted string must be linked to the decrypt function call. I made the choice to put is as comment among the decompiled code.
This is a success!
Let’s try it in real life now
Life is hard
After some research on github, I found some projects using litcrypt.
I now realise that the library is widely used for EDR bypass and malware development, and this point leads to many others:
- Binaries might be stripped
- Must be mainly compiled for windows
- The xor key must be consistent (let’s assume it’s at least 16 bytes)
A new strategy
Here is the new attack plan :
- Cross compile from linux
- Use at least a 16-byte xor key
- Strip the binary
Compilation
Compiling the original binary again, but with a 32-byte key, and with a window target:
1 | ghozt@maze:~/research/delitcrypt$ export LITCRYPT_ENCRYPT_KEY="IsThisKeyReallythirty2BytesLong?" |
Reverse
Opening the fresh compiled program in Binary Ninja, and all has changed…
How to identify the decrypt_bytes function, without any symbols? What do we know?
- The encrypted strings as well as the xor key are stored in .rodata (for ELF, .rdata for EXE) section
- The function performs a xor operation
Let’s try to manually find the function, and then try to automate the research.
Spot the needle
Scrolling in the .rdata section, we can quickly identify data that are not really printable strings:
This data has only one cross-reference to a function which signatures match with the decrypt_bytes one, but is different from the one we saw in the Linux compiled binary, with a short xor key. I thus deduce that the key size changes the function signature.
I also compile the same binary on a Windows machine to see if the signature and functions are compiled in the same way, and that’s the case.
From this, we can retrieve our “decrypt_bytes” function!
Automate
Now, the idea is to dig in Binary Ninja API, and think about a way to automatically identify the function, get all refs, parse parameters, and recover strings.
First, we need to list all data_vars, and get references for each one of them:
1 | for data_var in bv.data_vars: |
Works fine!
Now, let’s get the detailed instructions of each code reference. I made the choice to use the LLIL:
1 | ... |
Fine! The operation we are looking for is a XOR, thus it will be an instance of LowLevelILOperation of type LLIL_XOR
It is possible to reach the operation of an operand by accessing the reference operands operation.
As an example:
1 | list(bv.get_code_refs(0x14001e4b8))[0].llil.operation |
For a xor operation, two operands are involved, an ILRegister, and a LowLevelILXor.
Logically, the xor operation will be the second operand.
Let’s put it all together:
1 | for data_var in bv.data_vars: |
In the plugin code, with some error management:
1 | def find_xor(bv): |
At this point, a list of all data that are involved in a xor operation can be obtained. This code may be useful for malware analysis (or CTF). Indeed, XOR is largely used in maldev because of its low-entropy generation.
The next step is to get the caller functions for these references and check its signature. To perform this, some prerequisites are needed:
- Function that contains the xor instruction
- Get all references to this function
- Get HLIL for one of the references
- Check the signature
Using a wonderful python oneline:
1 | list(bv.get_code_refs(xored_data_ref[0].function.symbol.address))[0].hlil |
If we decompose:
1 | refs = bv.get_code_refs(ref.function.symbol.address) # get all references for the function that call the targeted xored data |
First, we can verify that the current reference HLIL is of type binaryninja.highlevelil.HighLevelILCall
1 | type(f_hlil) |
This seems to be fine. But some false positives can remain. Now let’s check the function signature.
If we check the operands:
1 | f_hlil.operands |
It results in a list containing:
- The function pointer
- A list of parameters (This what we have to check)
The plugin needs to check the operands list length, then get the second element and verify its size
1 | if len(f_hlil.operands) == 2: |
Many false positives should now be eliminated. A last check can be added on the parameters type.
- A stack address
- A data pointer
- A constant
Here are the HLILOperation types:
1 | for op in ops: |
This should eliminate a lot of false positives.
Let’s try an all-in-one plugin script :) We use the previous “Xor detection” code as code base. Now the signature must match the decrypt_bytes one:
1 | if ops[0].operation == HighLevelILOperation.HLIL_ADDRESS_OF and ops[1].operation == HighLevelILOperation.HLIL_CONST_PTR and ops[2].operation == HighLevelILOperation.HLIL_CONST: |
Here is the plugin function:
1 | def do_the_job(bv): |
It is now possible to decrypt the string (manually at the moment).
In further developments, I will try to automate the whole process and handle remaining errors:)
Real life example
Let’s try this on a real life example. This project will be used.
After a minute, the plugin locates the xor key as well as the decrypt_bytes function \o/
We are thus able to decrypt the strings manually
1 | 0x1401491e1,32) s1 = bv.read( |