Undo .NET Constant Obfuscation in IDA Pro
While .NET malware samples are usually easy to decompile using dnspy and similar tools, possibly after an initial unpacking step using dnspy’s debugger or a dedicated unpacker like ConfuserEx, there often remain additional “small” obfuscations of strings or constants. While these can be reverted manually, this often results in a tedious process, which calls for automation. IDA Pro, the traditional tool to perform such tasks in x86 binaries, offers a great Python interface - unfortunately only in its commercial version. Until some time ago, the support for .NETs intermediate language MSIL (Microsoft Intermediate Language) was limited, in particular when binary patching was required, but current versions can deal with this. We’ll look into a very simple example of a constant obfuscation we sometimes saw, and how a simple script can do the job. While the example is a real one, it’s nearly trivial and could also solved in other ways; but we think it’s a nice educational study.
Let’s first look into how this constant obfuscation manifests itself in the decompiled code:
if (num <= (1757753783U ^ 144094884U))
{
if (num <= (391156780U ^ 190026018U))
{
if (num != (3105210592U ^ 3177030179U))
{
if (num != (2570662017U ^ 2471372789U))
{
if (num == (3212393845U ^ 2742696059U))
{
Our script will basically calculate the XOR operations and transform this fragment into:
if (num <= 1616086803U)
{
if (num <= 469959950U)
{
if (num != 71852739U)
{
if (num != 175576948U)
{
if (num == 469959950U)
{
Another example is
DateTime kj = new DateTime(1870872117 ^ 1870870921, 1, 1, 0, 0, 0);
Which becomes
DateTime kj = new DateTime(1980, 1, 1, 0, 0, 0);
Of course we could just use dnspy’s project export feature and modify the produced .cs
files using text pattern matching and replacing the constants. However, it is tricky to find the correct patterns and parse the values correctly, mainly if more complicated obfuscations are used, and we might miss some analysis steps of constants that dnspy can do for us. One example of such a tricky case is
BindingFlags invokeAttr = (BindingFlags)633067955 ^ (BindingFlags)633063859;
The problem here is the cast to (BindingFlags)
that our text based pattern must be able to take care of, probably producing something like "(BindingFlags)4096"
. However, if we patch this on binary level and then pass it to dnspy, we get the following much nicer result:
BindingFlags invokeAttr = BindingFlags.GetProperty;
In this case, dnspy can also use the correct enum alias GetProperty
instead of 4096.
If we load the binary into IDA and examine the MSIL code, it’s easy to see how the obfuscator works - here is an example how this looks in the (BindingFlags)
case:
20 B3 D9 BB 25 ldc.i4 0x25BBD9B3
20 B3 C9 BB 25 ldc.i4 0x25BBC9B3
61 xor
There are also (rare) examples of 64 bit operations:
21 61 58 78 81 C6 24 EB F3 ldc.i8 0xF3EB24C681785861
21 00 00 00 00 00 00 00 80 ldc.i8 0x8000000000000000
61 xor
The ldc
instruction simply pushes an immediate constant encoded in the bytes behind on the stack, while the xor
instruction applies an exclusive XOR operation to the top two stack elements, as MSIL is a stack based engine. So we can scan for this sequence of instructions and replace them by just one ldc
instruction, replacing the rest by nop
operations represented by a 00
opcode, so we’d like to get the following code in above case:
00 nop
00 nop
00 nop
00 nop
00 nop
20 00 10 00 00 ldc.i4 0x1000
00 nop
We could try to just read the file as binary data, searching for the patterns using yara
like signatures, and the apply the patches. However, we might find wrong matches in data sections, so it’s preferrable to apply patches using a real disassembler. We make a few assumptions to keep the code simple:
- The 3 instructions always appear in sequence, without other instruction in between, be those simple
nop
or other instructions like jumps. In the samples we studied, this was always the case. Otherwise, more complex state machines must be implemented. - No “short” instructions, like
ldc.i4.2
, are used. These would push a very small constant (here2
) on the stack and allow to save one byte, as2
is an implicit operand encoded directly in the opcode. Short instructions are only available for constants from one to eight, but the probability for them to be used for our xor obfuscations is pretty small, if we assume the constants to be chosen randomly by the obfuscator - roughly 1 to 500 million.
So let’s start with th script. We’re using the sark module, which offers a neat assembly instruction wrapper fpr IDA Python, and define a dataclass to store the relevant values of a specific ldc
instruction:
import sark
from dataclasses import dataclass
# Ldc stores information about an ldc.i4 or ldc.i8 instruction (pushing a constant to the stack)
@dataclass
class Ldc:
ea: int # effective address of the ldc instruction
size: int # number of bytes of ldc instruction (5 for 32 bit ldc.i4, 9 for 64 bit ldc.i8)
value: int # value pushed to stack
We iterate through all functions and initialize a list of (consecutive) ldc
instructions xor_list
. Then we iterate all instructions of the function. Any non-ldc
instruction will clear xor_list
, while ldc
instructions append to it. Note that sark offers no direct way to access a 64 bit operands; the upper 32 bits are interpreted as displacement
, which we must or
with the lower 32 bits in the immediate
property:
for fct in sark.functions():
xor_list: list[Ldc] = []
for l in fct.lines:
ops = l.insn.operands # just a shortcut
if l.insn.mnem == 'ldc.i4' and len(ops) == 1 and ops[0].type.is_imm:
# ldc.i4 instruction detected, append it to xor_list (5 bytes in size)
xor_list.append(Ldc(l.ea, 5, ops[0].imm))
elif l.insn.mnem == 'ldc.i8' and len(ops) == 1 and ops[0].type.is_imm:
# ldc.i8 instruction detected, append it to xor_list (9 bytes in size)
xor_list.append(Ldc(l.ea, 9, (ops[0].displacement << 32) | ops[0].imm))
elif l.insn.mnem == 'xor' and len(xor_list) > 1:
# xor instruction detected after at least 2 consequituive ldc instructions
apply_xor(xor_list)
xor_list.clear()
else:
# Any other instruction clears the list
xor_list.clear()
apply_xor()
does the actual work, after having verified that the sizes of the previous two ldc
instrustions are the same (which they always should). Depending on this size, we patch a double word in the case of 5 bytes, or a quad word in the case of 9 bytes, with the calculated value. Here We use the
second ldc
instruction for patching. Finally, the actual xor
instruction (1 byte) and the other ldc
instruction are replaced by nop
instructions:
def apply_xor(xor_list: list[Ldc]):
op1, op2 = xor_list[-2:]
if op1.size != op2.size:
print(f'Different op sizes in fct {fct.name} on {sark.Line(ea=op1.ea)} and {sark.Line(ea=op2.ea)}, ignored')
return
# patch in calculated value to 2nd ldc:
print(f' Patching VA {l.ea:x}')
if op2.size == 5:
idc.patch_dword(op2.ea + 1, op1.value ^ op2.value)
else:
idc.patch_qword(op2.ea + 1, op1.value ^ op2.value)
# nop out actual xor instruction (1 byte):
idc.patch_byte(l.ea, 0)
# nop first ldc instruction
for i in range(op1.size):
idc.patch_byte(op1.ea + i, 0)