Develop your own C# Obfuscator

2024-06-05

Obfuscation is an important technique used to protect software from Man-At-The-End (MATE) attacks. Its purpose is to modify the code of a software application in order to make it more difficult for an attacker to understand, analyze or manipulate wile preserving its original semantics. The original idea was to provide technical protection against software theft and thus protect intellectual property. The advantages of software obfuscation are not only used for benign software to e.g. hide a sophisticated algorithm considered a trade secret, but also by adversaries to thwart static analysis techniques employed for detection.

In this blog post we will take a short look on the most common obfuscation transformations, followed up by an implementation of an obfuscator in C# for our offensive .NET tool set.

The source code can be found on Github


Obfuscation - An Overview

As a short introduction we will take a look some obfuscation techniques used by adversaries to evade static analysis techniques. If you got prior knowledge in the field and just want to the PoC, be free to skip this chapter.

As described in the introduction, the primary purpose of software obfuscation was to safeguard intellectual property by deterring malevolent actors from reverse engineering the program, which would require human effort, computing resources and financial investment, in form of exhaustive research or expensive tooling, compared to analyzing the original program.

For us the focus will be on techniques that require minimal effort from our side in the implementation and result in the greatest impact in the context of static analysis.

The three main categories of obfuscation transformations are:

  • data obfuscation
  • static code rewriting
  • dynamic code rewriting.

Data Obfuscation

Data obfuscation techniques are employed to modify the storage and representation of data, thereby concealing it from static analysis. These transformations are typically reversed at runtime to restore the original data structure.

Data obfuscation techniques include:

  • encoding
  • reordering of data
  • converting static to procedural data

Static Code Rewriting

Static code rewriting is a technique similar to that used by a compiler, in which the program code is modified during obfuscation without requiring any further runtime modification. This technique is typically applied to the binary code of a program. It is often employed in the development of metamorphic malware, where the malware is capable of modifying itself with each iteration to produce a different copy, thereby evading detection by antivirus software.

Static code rewriting techniques include:

  • opaque predicates
  • inserting dead/irrelevant code
  • instruction substitution
  • control flow flattening
  • removing standard library calls
  • mixed boolean-arithmetic

Dynamic Code Rewriting

Contrary to static code rewriting, which involves making modifications during the compilation phase, dynamic code rewriting allows the code of an executable to differ at runtime from the statically visible code.

Dynamic code rewriting techniques include:

  • packing
  • dynamic code modification
  • virtualization

Developing your own Obfuscator

The goal of this project was to invest as little time as possible to reach the maximum profit. As described in the prior section, there is a vast variety of techniques we can use to evade static analysis.

For the PoC, I decided to use only one type of transformation, which is used twice. Encoding the strings of the program by first encrypting all strings and then changing all method names.

A small warning: I am by no means a good C# developer. The last time I coded in C# was back in high school, so the code may not be the cleanest, but it still gets things done.

Boilerplate code

The first step in writing your own obfuscator is to have an interface to interact with the program code. For this I found dnlib, which is a .NET module/assembly reader and writer library.

With dnlib it is possible to access all strings and method names, inject a new method responsible for decryption into our newly obfuscated binary and save it to disk.

The following code block shows our main function. After handling the arguments, we use dnlib to load and read the .NET binary, followed by the function calls to our string encryption and method change methods.

static void Main(string[] args)
{
    // Handle arguments
    if (args.Length != 1)
    {
        Console.WriteLine("Usage: SimpleObfuscatorCSharp.exe <path_to_binary");
        return;
    }

    string filePath = args[0].ToString();
    string obfPath = filePath + "._obf.exe";

    try
    {
        // Load the assembly
        ModuleDefMD moduleDef = ModuleDefMD.Load(filePath);
        Assembly Default_Assembly;
        Default_Assembly = System.Reflection.Assembly.UnsafeLoadFrom(filePath);
        AssemblyDef Assembly = moduleDef.Assembly;
        Console.WriteLine("[+] Loaded " + filePath);
        // Encrypt the Strings
        StringEncrypt.EncryptStrings(moduleDef);
        // Change the method names
        MethodNameChange.Fire(moduleDef, Default_Assembly);
        SaveToFile(moduleDef, obfPath);

    }
    catch (Exception ex)
    {
        Console.WriteLine(ex.ToString());
    }
}

String encryption

To implement the string encryption transformation the following steps have to be performed:

  1. Create an encryption method
  2. Create a decryption method
  3. Save the decryption method into our newly obfuscated binary
  4. Perform the string encryption on each string

Encryption method

For the encryption AES-128 CBC was used. The key and IV are hardcoded, but this will not matter for our usecase. After the string is encrypted it is returned as a base64 encoded string.

public static string EncryptString(string plaintext){
    byte[] encryptionKey = new byte[]
{
    0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6,
    0xab, 0xf7, 0x97, 0x91, 0x45, 0x92, 0x77, 0xe8,
    0x4a, 0x8c, 0x07, 0x15, 0x2b, 0x7e, 0x15, 0x16,
    0x28, 0xae, 0xd2, 0xa6, 0xab, 0xf7, 0x97, 0x91
};

    // Hardcoded AES initialization vector (IV) (128 bits = 16 bytes)
    byte[] iv = new byte[]
    {
    0x1a, 0xf7, 0x13, 0x98, 0x37, 0xd2, 0x11, 0x34,
    0x25, 0x62, 0x4b, 0x67, 0x88, 0x5f, 0x3c, 0x2a
    };

    byte[] encrypted;
    string base64_encrypted;
    using (Aes aesAlg = Aes.Create())
    {
        

        aesAlg.Key = encryptionKey;
        aesAlg.IV = iv;
        aesAlg.Mode = CipherMode.CBC;
        

        ICryptoTransform encryptor = aesAlg.CreateEncryptor(aesAlg.Key, aesAlg.IV);

        using (var msEncrypt = new System.IO.MemoryStream())
        {
            using (var csEncrypt = new CryptoStream(msEncrypt, encryptor, CryptoStreamMode.Write))
            {
                using(StreamWriter sw = new StreamWriter(csEncrypt))
                sw.Write(plaintext);
                encrypted = msEncrypt.ToArray();
            }

        }
    }
    using (RNGCryptoServiceProvider rng = new RNGCryptoServiceProvider())
    {
        base64_encrypted = Convert.ToBase64String(encrypted);
    }
    return base64_encrypted;
}

Decryption method

For the decryption method we define our key and IV, which are purposely set to default values. Afterwards we extract the ciphertext by converting it from a base64 string and decrypt it using the key and IV. This will be the method we save into our new binary.

public static string DecryptString(string ciphertext_b64){
    string encryptionKey = "DEFAULT_KEY";
    string iv = "DEFAULT_IV";
    byte[] ciphertext;
    byte[] new_encryptionKey, new_iv;
    using (RNGCryptoServiceProvider rng = new RNGCryptoServiceProvider())
    {
        ciphertext = Convert.FromBase64String(ciphertext_b64);
        new_encryptionKey = Convert.FromBase64String(encryptionKey);
        new_iv = Convert.FromBase64String(iv);  
    }
    string plaintext;
    using (Aes aesAlg = Aes.Create())
    {
        aesAlg.Key = new_encryptionKey;
        aesAlg.IV = new_iv;
        aesAlg.Mode = CipherMode.CBC;
        
        ICryptoTransform decryptor = aesAlg.CreateDecryptor(aesAlg.Key, aesAlg.IV);

        byte[] decryptedBytes;
        using (var msDecrypt = new System.IO.MemoryStream(ciphertext))
        {
            using (var csDecrypt = new CryptoStream(msDecrypt, decryptor, CryptoStreamMode.Read))
            {
                using (StreamReader reader = new StreamReader(csDecrypt))
                {
                    plaintext = reader.ReadToEnd();
                }
            }
        }

        return plaintext;
    }
}

Time for the encryption

Now we can finally start encrypting. The first step is to load our own modules so we can access our decryption method. After iterating through our methods and finding the decryption method, we can add it to our new binary. Then we replace the default values of the key and IV with the base64 encoded values of our key and IV. The next step is to iterate over each method and encrypt the strings. After the encryption, we add a call to our decryption method.

public static void EncryptStrings(ModuleDefMD module){
    // Load the module containing the StringEncrypt class
    ModuleDef typeModule = ModuleDefMD.Load(typeof(StringEncrypt).Module);
    Console.WriteLine("[+] Injecting decryption method");
    MethodDef decryptMethod = FindAndPrepareDecryptMethod(typeModule);

    if (decryptMethod != null)
    {
        module.GlobalType.Methods.Add(decryptMethod);
        UpdateDecryptMethodBody(decryptMethod);
        Console.WriteLine("[+] Encrypting all strings");
        EncryptAllStrings(module, decryptMethod);
    }
}

// Find and prepare the decryption method for injection
private static MethodDef FindAndPrepareDecryptMethod(ModuleDef typeModule)
{
    foreach (TypeDef type in typeModule.Types)
    {
        foreach (MethodDef method in type.Methods)
        {
            if (method.Name == "DecryptString")
            {
                method.DeclaringType = null;
                method.Name = "Example123"; // Rename the method
                method.Parameters[0].Name = "\u0011";
                Console.WriteLine("[+] DecryptString found");
                return method;
            }
        }
    }
    return null;
}

// Update the method's body instructions with encryption keys
private static void UpdateDecryptMethodBody(MethodDef method)
{
    foreach (Instruction i in method.Body.Instructions)
    {
        if (i.ToString().Contains("DEFAULT_KEY"))
        {
            i.Operand = "K34VFiiu1qar95eRRZJ36EqMBxUrfhUWKK8Spqv3l5E=";
        }
        if (i.ToString().Contains("DEFAULT_IV"))
        {
            i.Operand = "GvcTmDfSETQlYktniF88Kg==";
        }
    }
}

// Encrypt all string literals in the module
private static void EncryptAllStrings(ModuleDefMD module, MethodDef decryptMethod)
{
    foreach (TypeDef typedef in module.GetTypes().ToList())
    {
        if (!typedef.HasMethods)
            continue;

        foreach (MethodDef typeMethod in typedef.Methods)
        {
            if (typeMethod.Body == null || typeMethod.Name == decryptMethod.Name)
                continue;

            EncryptMethodStrings(typeMethod, decryptMethod);
        }
    }
}

// Encrypt string literals in a single method
private static void EncryptMethodStrings(MethodDef typeMethod, MethodDef decryptMethod)
{
    foreach (Instruction instr in typeMethod.Body.Instructions.ToList())
    {
        if (instr.OpCode == OpCodes.Ldstr)
        {
            int instrIndex = typeMethod.Body.Instructions.IndexOf(instr);

            // Replace the string operand with the encrypted string
            typeMethod.Body.Instructions[instrIndex].Operand = EncryptString(typeMethod.Body.Instructions[instrIndex].Operand.ToString());

            // Insert a call to the decryption method after the encrypted string
            typeMethod.Body.Instructions.Insert(instrIndex + 1, new Instruction(OpCodes.Call, decryptMethod));
        }
    }

    // Update and optimize the method's body
    typeMethod.Body.UpdateInstructionOffsets();
    typeMethod.Body.OptimizeBranches();
    typeMethod.Body.SimplifyBranches();
}

Changing the method names

The idea was to have a list of common words in our obfuscator from which we pick 3 words and join them together. To do this, we iterate through our methods and apply our obfuscation with some restrictions. We skip the main' and .ctor’ methods. While testing the obfuscator, I noticed that even though I excluded the aforementioned method, the program still crashed. After adding a new restriction by only applying the obfuscation to methods with a length of at least 9, the problem was fixed.

public static void ChangeMethodNames(ModuleDefMD moduleDef, Assembly assembly)
        {
            Console.WriteLine("[+] Changing method names");

            IEnumerable<TypeDef> types = moduleDef.GetTypes();
            int iteration = 0;

            foreach (var type in types.ToList())
            {
                Dictionary<string, string> org_names = new Dictionary<string, string>();
                string typeRandom = GetEncodedMethodName();
                typeRandom = typeRandom + iteration;
                org_names[typeRandom] = type.Name;

                
                if (!type.Name.StartsWith("<"))
                    type.Name = typeRandom;
                else
                {
                    continue;
                }

                foreach (var method in type.Methods)
                {
                    if (method.Name == "Main")
                        continue;
                    if (method.Name == ".ctor")
                    {
                        continue;
                    }
                    if (method.Name.Length < 9)
                    {
                        continue;
                    }
                    //Console.WriteLine(method.Name);
                    string encodedStr = GetEncodedMethodName();
                    method.Name = encodedStr + iteration.ToString();
                }

                iteration++;
            }
        }

Results

The following screenshots show the decompilation of Rubeus before and after the obfuscation.

Rubeus before the obfuscation

Rubeus after the obfuscation

Currently, at the time of testing, it was possible to evade Windows Defender with some common .NET tools like Rubues and Certify.

Future Work

As this PoC shows, it does not take much work to implement a small obfuscator. In the real world, one would use such an obfuscator with more transformations in a CI/CD pipeline to obfuscate all the offensive .NET tools, as described in this wonderful blog article by r-tec.

Additionally, one could use dnlib to further inject environmental checks into the offensive tools, such as hostname and/or domain checks to bypass dynamic analysis.

There is one caveat with this obfuscator. One must manually edit the `.csproj’ file of a given project to remove all strings and change the GUID of the program before compiling it.