Obfuscator for .NET 9

In this guide, we will demonstrate how to use a .NET obfuscator for applications targeting the new .NET 9. You will learn basic obfuscation techniques, such as control flow obfuscation and virtualization, and how to apply them in real-world scenarios.

Contents

Why Protect .NET Applications?

The challenge of protecting .NET applications from hackers lies not only in the fact that the bytecode generated by compilers and executed by the runtime environment is easily “readable”, but also in that all names – classes, methods, properties – are included in the executable file as-is.

Moreover, tools that can generate C# code from bytecode have existed for quite some time (and now even Visual Studio can do this!). This means that distributing .NET application without any form of protection is essentially the same as distributing it with its source code. And typically, that’s not what companies developing commercial software want, right?

Since the release of the very first version of the .NET runtime, this issue has been a concern for software developers. Fortunately, solutions were introduced almost immediately to make it more difficult for hackers to decompile applications. As these solutions focused on obscuring the code, they came to be known as obfuscators. Let’s take a closer look at what obfuscators can offer developers.

What Methods of Protection Exist?

Before diving into an example of an application to protect, let’s explore the different methods of protection available. Understanding how they work will help you use obfuscators effectively.

When the issue of protecting .NET code first arose, it became clear that the initial step should be to assign unintelligible names to classes, methods, and other identifiers. The goal was to confuse (or even mislead) code analysts, as these names reveal a wealth of information! This is a basic but rather rudimentary level of protection, as the code itself remains highly readable. So, the next challenge was figuring out how to obscure the code itself. But how could this be done?

For a long time, there was only one way to obfuscate code: control flow obfuscation. As the name suggests, this method is designed to make the branching logic in code – conditional and unconditional jumps, as well as loops – difficult for a hacker to understand. These control flows provide significant insights into the logic of the code. With this method, each execution branch (a set of sequentially executed instructions) in a method is isolated, and the entire method is transformed into a mega-loop that alternates between executing different branches.

However, with this approach, the instructions within each execution branch are still visible to a hacker. A few years ago, a new technique called virtualization began to be used, not only for protecting .NET applications but also for native code. The idea behind virtualization is to have an interpreter execute each instruction individually. In this way, the original instructions can be encoded in a special, non-obvious manner. This means that the hacker would need to reverse-engineer the interpreter code (often called a virtual machine) as well as the format in which the instructions are encoded. Since the encoding format can vary from one method to another, virtualization has proven to be a highly effective way to protect applications.

Now that you are familiar with the basic approaches to obfuscation, let’s move on to creating an example application to practice protection techniques.

Creating a Sample Application.

We will create a sample application that performs a specific action we want to conceal from others. The goal is to hide the logic of what the application does. For instance, this application could include a form where users enter a password, and it displays whether the password is correct or not. In real-world scenarios, such an application might operate in either full or trial mode, depending on the user’s input.

You can find the entire project on GitHub: https://github.com/Softanics/Net9ObfuscationSample

From the command line, run the following command to generate a skeleton for a WPF application:

dotnet new wpf -n Net9ObfuscationSample

Next, modify the MainWindow.xaml file to add some basic UI elements:

<Window x:Class="Net9ObfuscationSample.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:Net9ObfuscationSample"
        mc:Ignorable="d"
        Title="Password Checker" Height="450" Width="800">
    <Grid>
        <TextBlock Text="Enter Password:" VerticalAlignment="Top" Margin="20,20,0,0" HorizontalAlignment="Left"/>
        <PasswordBox x:Name="PasswordBox" VerticalAlignment="Top" Margin="20,50,20,0" HorizontalAlignment="Stretch" Height="30"/>
        <Button Content="Check Password" VerticalAlignment="Top" Margin="20,100,20,0" Height="30" Click="CheckPassword_Click"/>
        <TextBlock x:Name="ResultTextBlock" VerticalAlignment="Top" Margin="20,150,20,0" HorizontalAlignment="Center" Foreground="Red"/>
    </Grid>
</Window>

Now, add the application logic in the MainWindow.xaml.cs file:

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

namespace Net9ObfuscationSample;

/// <summary>
/// Interaction logic for MainWindow.xaml
/// </summary>
public partial class MainWindow : Window
{
    private const string CorrectPasswordHash = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"; // Example hash for "password"

    public MainWindow()
    {
        InitializeComponent();
    }

    private void CheckPassword_Click(object sender, RoutedEventArgs e)
    {
        string enteredPassword = PasswordBox.Password;
        string enteredPasswordHash = ComputeSha256Hash(enteredPassword);

        if (enteredPasswordHash == CorrectPasswordHash)
        {
            ResultTextBlock.Text = "Password is correct!";
            ResultTextBlock.Foreground = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Colors.Green);
        }
        else
        {
            ResultTextBlock.Text = "Incorrect password!";
            ResultTextBlock.Foreground = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Colors.Red);
        }
    }

    private string ComputeSha256Hash(string rawData)
    {
        using (SHA256 sha256Hash = SHA256.Create())
        {
            byte[] bytes = sha256Hash.ComputeHash(Encoding.UTF8.GetBytes(rawData));
            StringBuilder builder = new StringBuilder();
            foreach (byte b in bytes)
            {
                builder.Append(b.ToString("x2"));
            }
            return builder.ToString();
        }
    }
}

To build and run the application, execute the following commands:

dotnet build bin\Debug\net6.0-windows\Net9ObfuscationSample.exe

Enter a password to verify that the application works as expected:

The password is correct

Looking at the current implementation, the application includes a method to compute a hash, another to compare hashes and display the result, and a constant string that stores the correct hash value. If you examine the generated .dll file using a disassembler tool like ildasm, it becomes evident that the logic and sensitive data, such as the correct hash, are completely exposed.

For example, running the following command:

ildasm Net9ObfuscationSample.dll /out:Net9ObfuscationSample.il

Reveals something like this in the output:

  .method private hidebysig instance void 
          CheckPassword_Click(object sender,
                              class [PresentationCore]System.Windows.RoutedEventArgs e) cil managed
  {
    .custom instance void [System.Runtime]System.Runtime.CompilerServices.NullableContextAttribute::.ctor(uint8) = ( 01 00 01 00 00 ) 
    // Code size       121 (0x79)
    .maxstack  2
    .locals init (string V_0,
             string V_1,
             bool V_2)
    IL_0000:  nop
    IL_0001:  ldarg.0
    IL_0002:  ldfld      class [PresentationFramework]System.Windows.Controls.PasswordBox Net9ObfuscationSample.MainWindow::PasswordBox
    IL_0007:  callvirt   instance string [PresentationFramework]System.Windows.Controls.PasswordBox::get_Password()
    IL_000c:  stloc.0
    IL_000d:  ldarg.0
    IL_000e:  ldloc.0
    IL_000f:  call       instance string Net9ObfuscationSample.MainWindow::ComputeSha256Hash(string)
    IL_0014:  stloc.1
    IL_0015:  ldloc.1
    IL_0016:  ldstr      "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a"
    + "11ef721d1542d8"
    IL_001b:  call       bool [System.Runtime]System.String::op_Equality(string,
                                                                         string)
    IL_0020:  stloc.2
    IL_0021:  ldloc.2
    IL_0022:  brfalse.s  IL_004f

    IL_0024:  nop
    IL_0025:  ldarg.0
    IL_0026:  ldfld      class [PresentationFramework]System.Windows.Controls.TextBlock Net9ObfuscationSample.MainWindow::ResultTextBlock
    IL_002b:  ldstr      "Password is correct!"
    IL_0030:  callvirt   instance void [PresentationFramework]System.Windows.Controls.TextBlock::set_Text(string)
    IL_0035:  nop
    IL_0036:  ldarg.0
    IL_0037:  ldfld      class [PresentationFramework]System.Windows.Controls.TextBlock Net9ObfuscationSample.MainWindow::ResultTextBlock
    IL_003c:  call       valuetype [PresentationCore]System.Windows.Media.Color [PresentationCore]System.Windows.Media.Colors::get_Green()
    IL_0041:  newobj     instance void [PresentationCore]System.Windows.Media.SolidColorBrush::.ctor(valuetype [PresentationCore]System.Windows.Media.Color)
    IL_0046:  callvirt   instance void [PresentationFramework]System.Windows.Controls.TextBlock::set_Foreground(class [PresentationCore]System.Windows.Media.Brush)
    IL_004b:  nop
    IL_004c:  nop
    IL_004d:  br.s       IL_0078

    IL_004f:  nop
    IL_0050:  ldarg.0
    IL_0051:  ldfld      class [PresentationFramework]System.Windows.Controls.TextBlock Net9ObfuscationSample.MainWindow::ResultTextBlock
    IL_0056:  ldstr      "Incorrect password!"
    IL_005b:  callvirt   instance void [PresentationFramework]System.Windows.Controls.TextBlock::set_Text(string)
    IL_0060:  nop
    IL_0061:  ldarg.0
    IL_0062:  ldfld      class [PresentationFramework]System.Windows.Controls.TextBlock Net9ObfuscationSample.MainWindow::ResultTextBlock
    IL_0067:  call       valuetype [PresentationCore]System.Windows.Media.Color [PresentationCore]System.Windows.Media.Colors::get_Red()
    IL_006c:  newobj     instance void [PresentationCore]System.Windows.Media.SolidColorBrush::.ctor(valuetype [PresentationCore]System.Windows.Media.Color)
    IL_0071:  callvirt   instance void [PresentationFramework]System.Windows.Controls.TextBlock::set_Foreground(class [PresentationCore]System.Windows.Media.Brush)
    IL_0076:  nop
    IL_0077:  nop
    IL_0078:  ret
  } // end of method MainWindow::CheckPassword_Click

This transparency allows anyone with basic tools to extract and understand how your application works, undermining its security.

How to Enable Obfuscation?

To enable obfuscation, you need to add two NuGet packages: ArmDot.Client and ArmDot.Engine.MSBuildTasks. The ArmDot.Client package contains obfuscation attributes that allow you to specify which obfuscation techniques should be applied to specific methods, types, or even entire assemblies. The ArmDot.Engine.MSBuildTasks package includes the obfuscation task that integrates with the build process. You’ll see how this works a bit later.

You can add these packages using the following commands:

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

After adding the packages, you need to integrate the obfuscation task into the build process. To do this, open the project file and insert the following code:

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

  <PropertyGroup>
    <OutputType>WinExe</OutputType>
    <TargetFramework>net9.0-windows</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <UseWPF>true</UseWPF>
  </PropertyGroup>

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

This target, named Protect, executes the obfuscation task ArmDot.Engine.MSBuildTasks.ObfuscateTask. While it supports several parameters, the most important one is Assemblies, which specifies the list of assemblies to obfuscate.

At this point, MSBuild will invoke the obfuscator, but we haven’t yet provided instructions on what exactly it should do. To define the obfuscation behavior, you can use obfuscation attributes. For example, to apply obfuscation at the assembly level, you can add attributes to any .cs file. I prefer to place them in AssemblyInfo.cs as shown below:

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

Now, build the project. Let’s take a look at the obfuscated code:

  .method private hidebysig instance void 
          IsContextfulFoundYearPatternFlag(object sender,
                                           class [PresentationCore]System.Windows.RoutedEventArgs e) cil managed
  {
    .custom instance void [System.Runtime]System.Runtime.CompilerServices.NullableContextAttribute::.ctor(uint8) = ( 01 00 01 00 00 ) 
    // Code size       47 (0x2f)
    .maxstack  10
    .locals init (int32 V_0,
             int32 V_1,
             int32 V_2,
             string V_3,
             string V_4,
             object V_5,
             bool V_6)
    IL_0000:  ldc.i4.1
    IL_0001:  stloc.0
    IL_0002:  ldc.i4.1
    IL_0003:  stloc.1
    IL_0004:  ldc.i4.1
    IL_0005:  stloc.1
    IL_0006:  nop
    IL_0007:  ldloc.1
    IL_0008:  ldc.i4.0
    IL_0009:  bne.un.s   IL_000f

    IL_000b:  ldc.i4.1
    IL_000c:  stloc.1
    IL_000d:  br.s       IL_002d

    IL_000f:  nop
    IL_0010:  ldloca.s   V_0
    IL_0012:  ldloca.s   V_1
    IL_0014:  ldloca.s   V_2
    IL_0016:  ldloca.s   V_3
    IL_0018:  ldloca.s   V_4
    IL_001a:  ldloca.s   V_5
    IL_001c:  ldloca.s   V_6
    IL_001e:  ldarg.0
    IL_001f:  ldsfld     native int[] Net9ObfuscationSample.MainWindow::SetObjectDatasetVersionAdded
    IL_0024:  ldloc.0
    IL_0025:  ldelem.i
    IL_0026:  calli      void(int32&,int32&,int32&,string&,string&,object&,bool&,class Net9ObfuscationSample.MainWindow)
    IL_002b:  br.s       IL_0006

    IL_002d:  nop
    IL_002e:  ret
  } // end of method MainWindow::IsContextfulFoundYearPatternFlag

What’s happening here?! Of course, it’s absolutely unclear! The power of obfuscators is to turn original, well-structured, human-readable IL code into a mess!

Wrap-Up

Without an obfuscator, any .NET application is vulnerable to hackers eager to steal your intellectual property. They can easily reverse-engineer your application, reading your code almost as if they wrote it themselves. With the right tools, they can even decompile your binaries back into readable C# code, exposing your algorithms, business logic, and proprietary techniques.

This level of exposure puts your software at risk of being copied, modified, or redistributed without your consent. To prevent this, obfuscators are essential. They transform your clean, human-readable code into complex, hard-to-understand structures, making reverse engineering extremely difficult. Don’t give attackers an easy opportunity – use obfuscators to protect your sensitive code and secure your intellectual property.