How to obfuscate .NET 7 application?

Contents

  • The reasons to obfuscate .NET 7 applications
  • The sample application
  • Why is it important to obfuscate .NET applications?
  • How to enable obfuscation?
  • Conclusion

    In this article you will learn how to protect .NET 7 applications with the help of the modern .NET obfuscation techniques.

    The reasons to obfuscate .NET 7 applications

    Recently, the new version of .NET platform, .NET 7, has been released. .NET 7 is aimed to produce fast code and can be used to create applications, running in different environments including Windows, macOS, Linux, Android and iOS. That is why more and more companies choose .NET to make new products with its help.

    Each .NET application contains its code in a special format that is used to generate machine code before running on a particular operating system. This format is called intermediate language. Unfortunately, such code is extremely easy to decode; his makes the application completely unprotected from reverse engineers: anyone can extract code to C#, review it, and even modify and rebuild it.

    The sample application

    The .NET application that we are going to use in this tutorial will ask a user to enter a password and then check whether the entered password is valid or not. To check it, the application will compare the hash of the password with a valid one. The concern is that the way the application validates passwords and the correct hash value are easy to identify. We will show how to hide both the algorithm and the hash value of the correct password.

    Let’s create a new console application. You can, of course, use your favorite IDE, like Visual Studio, or Visual Studio Code, but the idea is that making things in the command line is the best way to demonstrate how the obfuscation can be integrated into the build process because you can see all the details.

    So open the terminal, make a directory for the project, enter it and ask .NET to create a skeleton of the project (I prefer not to use top-level statements; that’s why I use –use-program-main below):

    mkdir CheckPassword
    cd CheckPassword
    dotnet new console --use-program-main
    

    Open Program.cs in your favorite editor and write the code that outputs the hash value of the entered string. That will help to know the hash value of the correct password:

    using System;
    using System.Text;
    using System.Security.Cryptography;
    
    namespace CheckPassword;
    
    class Program
    {
        static void Main(string[] args)
        {
            var password = Console.ReadLine();
    
            using (var algorithm = SHA256.Create())
            {
                var hash = algorithm.ComputeHash(Encoding.UTF8.GetBytes(password));
                var readableHash = Convert.ToBase64String(hash);
                Console.WriteLine(readableHash);
            }
        }
    }
    

    Build it and run. Then type armdot and copy the hash value:

    dotnet build
    bin\Debug\net7.0\CheckPassword.exe
    armdot
    mZfua8BSQJP337Kuj4Cpl9dVBL/S6Cn1SioM0xcq2tg=
    

    Now we know the hash value. Edit the code to check passwords:

    using System;
    using System.Text;
    using System.Security.Cryptography;
    
    namespace CheckPassword;
    
    class Program
    {
        static void Main(string[] args)
        {
            using (var algorithm = SHA256.Create())
            {
                while (true)
                {
                    Console.Write("Enter password: ");
                    var password = Console.ReadLine();
    
                    var hash = algorithm.ComputeHash(Encoding.UTF8.GetBytes(password));
                    var readableHash = Convert.ToBase64String(hash);
    
                    if ("mZfua8BSQJP337Kuj4Cpl9dVBL/S6Cn1SioM0xcq2tg=" == readableHash)
                        Console.WriteLine("Correct");
                    else
                        Console.WriteLine("Incorrect");
                }
            }
        }
    }
    

    Build it and run:

    dotnet build
    bin\Debug\net7.0\CheckPassword.exe
    Enter password: test                                                                                                                                                                                               Incorrect                                                                                                                                                                                                          Enter password: armdot                                                                                                                                                                                             Correct
    

    Great! It is working as expected!

    Why is it important to obfuscate .NET applications?

    The application is doing well, but let’s look at the intermediate code that the C# compiler placed to CheckPassword.dll. Just run ildasm.exe and check the code of the entry point:

    The code is easy to understand if it is not obfuscated

    The code is easy to understand: for example, you can see the string that stores the valid hash value. It is a good idea to hide from prying eyes the details of how the application checks the password. A .NET obfuscator to the rescue!

    How to enable obfuscation?

    Obfuscation is a process that makes original .NET code difficult for humans to comprehend. So a .NET obfuscator must be executed after C# compiler produces a .NET assembly. Is it achieved by using a special NuGet package that provides an obfuscation task. The task runs right after the compiler has done its job, and the assembly is in the intermediate directory.

    OK, let’s return to the project. Add the following packages:

    dotnet add package ArmDot.Client
    dotnet add package ArmDot.Engine.MSBuildTasks
    

    To enable the obfuscation task you add a target that is executed after the compiler finishes. Open CheckPassword.csproj and update it:

    <Project Sdk="Microsoft.NET.Sdk">
    
      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net7.0</TargetFramework>
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>
      </PropertyGroup>
    
      <ItemGroup>
        <PackageReference Include="ArmDot.Client" Version="2023.3.0" />
        <PackageReference Include="ArmDot.Engine.MSBuildTasks" Version="2023.3.0" />
      </ItemGroup>
    
    <Target Name="Protect" AfterTargets="AfterCompile" BeforeTargets="BeforePublish">
      <ItemGroup>
         <Assemblies Include="$(ProjectDir)$(IntermediateOutputPath)$(TargetFileName)" />
      </ItemGroup>
      <ArmDot.Engine.MSBuildTasks.ObfuscateTask
        Inputs="@(Assemblies)"
        ReferencePaths="@(_ResolveAssemblyReferenceResolvedFiles->'%(RootDir)%(Directory)')"
        SkipAlreadyObfuscatedAssemblies="true"
      />
    </Target>
    
    </Project>
    

    $(ProjectDir)$(IntermediateOutputPath)$(TargetFileName) is a path of the compiled assembly; the compiler places it in the intermediate directory.

    ArmDot.Engine.MSBuildTasks.ObfuscateTask is the name of the obfuscation task.

    ReferencePaths=”@(_ResolveAssemblyReferenceResolvedFiles->’%(RootDir)%(Directory)’)” helps ArmDot to find referenced assemblies.

    Let’s rebuild the project to see how it works:

    dotnet build
    
    MSBuild version 17.4.0+18d5aef85 for .NET
      Determining projects to restore...
    …
    V:\Projects\CheckPassword\CheckPassword.csproj(19,3): warning : [ArmDot] warning ARMDOT0003: No methods to protect in the assembly 
    

    Well, the obfuscation task has been executed but in fact it has not protected the assembly at all. The reason is that we have not specified what to obfuscate and how exactly.

    ArmDot can obfuscate an entire assembly (it’s not always a good idea), a particular class or a method. To tell ArmDot what we want to obfuscate, you use obfuscation attributes. It’s important to remember what kinds of obfuscation exist.

    First, it’s names’ obfuscation, which renames classes, methods, etc. As intermediate code contains names that the compiler takes from your code, it’s very easy to find out what some method is supposed to do.

    Second, it’s control flow obfuscation, that is aimed to hide how a method is implemented. The idea is to extract all branches, and then execute branches, choosing the next branch depending on the current result. It is a very powerful approach of hiding implementation details.

    Although control flow obfuscation hides implementation details, the most efficient way to do that is virtualization. While control flow obfuscation deals with branches, virtualization goes further: it converts each instruction into internal representation, and then executes instructions one by one. Original instructions disappear; instead, a hacker sees raw bytes, and the method that reads bytes, decodes them, and then acts depending on the read values.

    It’s up to the developer which obfuscation strategy to use, but the general idea is quite simple: names obfuscation is the must, this is the first line of defense against researchers. Then choose which methods are the most inviting for hackers. Then use control flow obfuscation, or virtualization. Opt for control flow obfuscation, if the method has become too slow after virtualization.

    There are several ways to instruct ArmDot what kind of obfuscation to use, but the most convenient one is to use obfuscation attributes. Let’s enable virtualization for the entry point, Main:

    using System;
    using System.Text;
    using System.Security.Cryptography;
    
    namespace CheckPassword;
    
    class Program
    {
        [ArmDot.Client.VirtualizeCode]
        static void Main(string[] args)
        {
            using (var algorithm = SHA256.Create())
            {
                while (true)
                {
                    Console.Write("Enter password: ");
                    var password = Console.ReadLine();
    
                    var hash = algorithm.ComputeHash(Encoding.UTF8.GetBytes(password));
                    var readableHash = Convert.ToBase64String(hash);
    
                    if ("mZfua8BSQJP337Kuj4Cpl9dVBL/S6Cn1SioM0xcq2tg=" == readableHash)
                        Console.WriteLine("Correct");
                    else
                        Console.WriteLine("Incorrect");
                }
            }
        }
    }
    

    If you want to specify an obfuscation attributes for an entire assembly, use the following syntax:

    using System;
    using System.Text;
    using System.Security.Cryptography;
    
    [assembly:ArmDot.Client.VirtualizeCode]
    
    namespace CheckPassword;
    
    class Program
    {
        static void Main(string[] args)
        {
            using (var algorithm = SHA256.Create())
            {
                while (true)
                {
                    Console.Write("Enter password: ");
                    var password = Console.ReadLine();
    
                    var hash = algorithm.ComputeHash(Encoding.UTF8.GetBytes(password));
                    var readableHash = Convert.ToBase64String(hash);
    
                    if ("mZfua8BSQJP337Kuj4Cpl9dVBL/S6Cn1SioM0xcq2tg=" == readableHash)
                        Console.WriteLine("Correct");
                    else
                        Console.WriteLine("Incorrect");
                }
            }
        }
    }
    

    Rebuild the project:

    dotnet build
    MSBuild version 17.4.0+18d5aef85 for .NET
    …
      [ArmDot] ------ Build started: Assembly (1 of 1): CheckPassword.dll (V:\Projects\CheckPassword\obj\Debug\net7.0\CheckPassword.dll) ------
      [ArmDot] Conversion started for method System.Void CheckPassword.Program::Main(System.String[])
      [ArmDot] Conversion finished for method System.Void CheckPassword.Program::Main(System.String[])
      [ArmDot] Writing protected assembly to V:\Projects\CheckPassword\obj\Debug\net7.0\CheckPassword.dll...
    

    Let’s look how to obfuscation code looks like:

    The obfuscated code is almost impossible to decode

    It has become huge, confusing, and very difficult to understand!

    Conclusion

    Protecting commercial applications is extremely important if they have been developed for .NET, because such applications are a very nice bit for hackers. Obfuscators offer several levels of protection from name obfuscation to code virtualization. ArmDot is easily integrated into the build process, and its attributes allow you to set the required level of protection for various parts of the application quickly.