.NET 6 obfuscation

How to obfuscate a .NET 6 application?

Contents

In this guide, you will find how to obfuscate applications that use .NET 6 with the help of modern obfuscation methods.

What is .NET 6?

.NET 6 is the next version of the .NET platform with a bunch of new features including improved performance achieved due to profile-guided optimization and ahead-of-time compilation. Also .NET 6 supports new arm64 processors. As Microsoft announced, .NET 6 is a final step of the .NET standardization which also means that the runtime is unified across mobile, desktop, and cloud. It makes .NET the most popular platform among applications developers.

So why is it important to have a tool that protects your .NET applications from hackers?

The reasons for obfuscation.

Each .NET application consists of MSIL code, metadata, and resources. MSIL code is an intermediate one. Compilers generate intermediate code that is CPU independent. .NET runtime provides a JIT compiler that converts each method into the machine code. This approach makes it possible to run a .NET application on any CPU that JIT compiler supports. On the other hand, MSIL code is much easier to read compared to machine code. It is by design: imagine that you need a low-level language that is supposed to be converted to machine code for any CPU. Clearly, such language would contain a lot of details.

Another important feature of .NET is reflection. It gives an application some information about metadata (types, methods) at runtime. If you use a compiler of an unmanaged language, like C++, it produces machine code that, in general, doesn’t contain information about used classes. On the contrary, in the .NET world an application contains complete information about each type, each field, each method.

Finally, .NET applications store embedded resources: text, images, data etc.

Thereby, any .NET application is transparent for everyone. There are even tools that can generate C# code from a .NET application; after that, you can modify the code at your discretion and build it. It looks like there is no way to hide your secrets. Obfuscation to the rescue!

How obfuscation works?

Obfuscation is a process of making internals of .NET applications complicated to decode. The very first idea is to rename types and methods to make it incomprehensible what a particular method Has been intended to.

There are several ways to make a code of methods difficult to decipher.

As metadata contains human-readable names of used types and methods, it’s a good idea to rename them using nonsense names. Names obfuscation is the first barrier for a hacker.

The next step is to make the code itself almost unreadable. Usual code contains comparisons that give useful information about how the method works. The idea of control flow obfuscation is to hide the logic by splitting original code into a set of small chunks of instructions. The next level of this idea is virtualization: not only control flow is encrypted but each original instruction is executed by a special virtual machine that virtualizes locals and stack.

Let’s look at how it works in practice!

Sample project

You can use your favorite IDE, for example, Visual Studio, Visual Studio Code, or JetBrains Rider. In this tutorial we will use the command line.

Create new folder obfuscate-net6-app and run the following command to create a console application:

dotnet new console

It will create Program.cs. Open it in an editor:

// See https://aka.ms/new-console-template for more information
Console.WriteLine("Hello, World!");

The application will check the entered password and display whether it is correct or not. In order to validate the password, the application will calculate its hash and compare with the expected value. Let’s go!

First, write the code that displays the hash value of entered string:

using System.Security.Cryptography;
using System.Text;

var password = Console.ReadLine();

using (var sha256 = SHA256.Create())
{
    var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(password));
    Console.WriteLine(Convert.ToBase64String(hash));
}

Build it and run:

>dotnet build
MSBuild version 17.3.1+2badb37d1 for .NET
  Determining projects to restore...
  All projects are up-to-date for restore.
  obfuscate-net6-app -> obfuscate-net6-app\bin\Debug\net6.0\obfuscate-net6-app.dll

Build succeeded.
    0 Warning(s)
    0 Error(s)

Time Elapsed 00:00:00.94

>bin\Debug\net6.0\obfuscate-net6-app.exe
ArmDot
hzTbLivrxGSCjSfDqNEIDuXEbj1W6IXdPikhanYqTu0=

So the hash value of ArmDot is hzTbLivrxGSCjSfDqNEIDuXEbj1W6IXdPikhanYqTu0=

Modify the code as the following:

using System.Security.Cryptography;
using System.Text;

while (true)
{
    var password = Console.ReadLine();

    using (var sha256 = SHA256.Create())
    {
        var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(password));

        if (Convert.ToBase64String(hash) == "hzTbLivrxGSCjSfDqNEIDuXEbj1W6IXdPikhanYqTu0=")
            Console.WriteLine("Correct");
        else
            Console.WriteLine("Incorrect");
    }
}

Now it compares the hash and displays if the password is correct. Build it and run:

>bin\Debug\net6.0\obfuscate-net6-app.exe
ArmDot
Correct
blablabla
Incorrect

Great! It works as expected!

Let’s look at the generated code using ildasm (please note that we are going to explore obfuscate-net6-app.dll, not obfuscate-net6-app.exe, because the executable file just runs the DLL that contains actual code):

ildasm bin\Debug\net6.0\obfuscate-net6-app.dll

You will see the body of the Main:

.method private hidebysig static void  '<Main>$'(string[] args) cil managed
{
  .entrypoint
…
    IL_0023:  call       string [System.Runtime]System.Convert::ToBase64String(uint8[])
    IL_0028:  ldstr      "hzTbLivrxGSCjSfDqNEIDuXEbj1W6IXdPikhanYqTu0="
    IL_002d:  call       bool [System.Runtime]System.String::op_Equality(string,
                                                                         string)
    IL_0032:  stloc.3
    IL_0033:  ldloc.3
    IL_0034:  brfalse.s  IL_0043
    IL_0036:  ldstr      "Correct"
    IL_003b:  call       void [System.Console]System.Console::WriteLine(string)
    IL_0040:  nop
    IL_0041:  br.s       IL_004e
    IL_0043:  ldstr      "Incorrect"
    IL_0048:  call       void [System.Console]System.Console::WriteLine(string)
…

The implementation details are obvious. It is time to make this code almost unreadable.

Enable obfuscation

ArmDot provides two NuGet packages: ArmDot.Client and ArmDot.Engine.MSBuildTasks.

ArmDot.Client contains obfuscation attributes that are used to specify an obfuscation algorithm for a type, a method, or even the entire assembly.

ArmDot.Engine.MSBuildTasks provides an obfuscation task that is executed at the final stage of the building process.

Open the project file and add both packages references:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net6.0</TargetFramework>
    <RootNamespace>obfuscate_net6_app</RootNamespace>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="ArmDot.Client" Version="2022.13.0" />
    <PackageReference Include="ArmDot.Engine.MSBuildTasks" Version="2022.13.0" />
  </ItemGroup>

</Project>

Run the following command:

dotnet restore

As we want to hide the string and the implementation details, it is a good idea to apply hide strings and control flow obfuscation at the assembly level. Open Program.cs and add the following lines right after using statements as shown below:

using System.Security.Cryptography;
using System.Text;

[assembly:ArmDot.Client.HideStrings]
[assembly:ArmDot.Client.ObfuscateControlFlow]

You can read more about obfuscation attributes here.

If you are rebuilding the project right now, the application will not be obfuscated. Currently, it only contains obfuscation attributes which instruct ArmDot exactly how to obfuscate the assembly. So you need to run ArmDot. To do that, add the obfuscation task. Open the project file and add the following code immediately before closing </Project>:

<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>

Here we add a new target Protect that must be executed after the compiler placed the DLL to the intermediate directory and before publishing it. The full path of the DLL placed to the intermediate directory is $(ProjectDir)$(IntermediateOutputPath)$(TargetFileName). ArmDot might need to resolve some types used by the assembly. To help ArmDot locate assemblies for such types, we specify ReferencePaths.

Rebuild the project and run to ensure it is working well:

>dotnet build
…
  [ArmDot] ArmDot [Engine Version 2022.13.0.0] (c) Softanics. All Rights Reserved
  [ArmDot] Reading license key from C:\ProgramData\ArmDot\ArmDotLicense.key
…
  [ArmDot] ------ Build started: Assembly (1 of 1): obfuscate-net6-app.dll (obj\Debug\ne
  t6.0\obfuscate-net6-app.dll) ------
  [ArmDot] Strings encryption started for method System.Void Program::<Main>$(System.String[])
  [ArmDot] Strings encryption finished for method System.Void Program::<Main>$(System.String[])
  [ArmDot] Control flow obfuscation started for method System.Void Program::<Main>$(System.String[])
  [ArmDot] Control flow obfuscation finished for method System.Void Program::<Main>$(System.String[])
…
  [ArmDot] Writing protected assembly to I:\TEMP\obfuscate-net6-app\obj\Debug\net6.0\obfuscate-net6-app.dll...

  [ArmDot] Finished

Build succeeded.

Time Elapsed 00:00:02.68

>bin\Debug\net6.0\obfuscate-net6-app.exe
ArmDot
Correct
Something else
Incorrect

Let’s look at the obfuscated assembly. First, it now contains a bunch of new methods with senseless names that may confuse anyone:
Obfuscated assembly

What about the main method? Now it is virtually impossible to understand what is going on:

…
    IL_0027:  ldloca.s   V_0
    IL_0029:  ldloca.s   V_3
    IL_002b:  ldloca.s   V_4
    IL_002d:  ldloc.2
    IL_002e:  ldloca.s   V_5
    IL_0030:  ldloca.s   V_6
    IL_0032:  ldloca.s   V_7
    IL_0034:  ldloca.s   V_8
    IL_0036:  ldloca.s   V_9
    IL_0038:  ldloca.s   V_10
    IL_003a:  ldsfld     native int[] Program::AssemblyReferenceDependentAssemblyFlagsJson
    IL_003f:  ldloc.0
    IL_0040:  ldelem.i
    IL_0041:  calli      void(int32&,int32&,int32[]&,class [System.Runtime]System.Exception,string&,class [System.Security.Cryptography.Algorithms]System.Security.Cryptography.SHA256&,uint8[]&,object&,bool&,bool&)
…

You can find complete code at GitHub.

Benefits of obfuscation

Without obfuscation, .NET code is transparent for everyone, it is easy to understand and even convert to a high-level language like C#. Obfuscation helps to encrypt .NET code, hide used strings, and confuse a researcher.

ArmDot is a modern obfuscator that runs on Windows, Linux, and macOS. It is tightly integrated into the building process so you can seamlessly add obfuscation to your project.