How to obfuscate a .NET application built with the help of Avalonia?
Contents
What is Avalonia?
Avalonia is a framework for .NET developers to create GUI applications for a wide range of platforms including Windows, macOS, Linux, and others. It was inspired by WPF that works on Windows only. Avalonia utilizes the same concept when a developer uses XAML to declare UI elements and bindings, but unlike WPF, applications built with Avalonia work on virtually any platform. You just write code once, debug it in your preferred IDE on any platform, build it, and it works almost everywhere! Great step ahead, especially taking into account that .NET still doesn’t provide a standard library to create user interfaces.
Why to obfuscate?
Today more and more companies choose Avalonia to develop their commercial applications because it provides an excellent opportunity to sell products to any customer no matter what operating system the computer is running. Now add to that Avalonia support in such great IDEs like Visual Studio or JetBrains Rider, and the decision becomes obvious.
Like other .NET applications, ones built with Avalonia are not protected at all. Everyone can read the code and even convert it to well-readable C# code. Of course, this is no way for companies which want to protect their intellectual property. That’s the reason why developers need to use .NET obfuscators: they make the code illegible, hard to understand; hides used string literals and resources.
Create the sample
In this tutorial, we will create an application that checks the entered password by comparing its hash value with the expected one.
We’ll be using Visual Studio 2022 to work on the application, but the steps can actually be reproduced no matter what IDE you’re using; even the command line can cope with it.
The steps are the following:
- Write the validation code.
- Add NuGet packages for obfuscation.
- Update the project file to enable the obfuscation task.
Start Visual Studio, click Create New Project, then choose Avalonia .NET Core App (AvaloniaUI). Enter ValidatePassword as the project name.
Open MainWindow.axaml and add a textbox and a button:
<Window xmlns="https://github.com/avaloniaui" 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" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="ValidatePassword.MainWindow" Title="ValidatePassword"> <Grid Margin="10" ColumnDefinitions="Auto,*,Auto" RowDefinitions="Auto"> <Label VerticalAlignment="Center" Grid.Row="0" Grid.Column="0" Content="Enter password"/> <TextBox Name="Password" Watermark="Password" Grid.Row="0" Grid.Column="1" TextWrapping="NoWrap" /> <Button Content="Validate" Grid.Row="0" Grid.Column="2" Click="CheckPassword" /> </Grid> </Window>
Also modify MainWindow.axaml.cs as shown below to get the value of the hash:
using Avalonia.Controls; using Avalonia.Interactivity; using System.Text; using System; using System.Security.Cryptography; namespace ValidatePassword { public partial class MainWindow : Window { public MainWindow() { InitializeComponent(); } private void CheckPassword(object sender, RoutedEventArgs e) { using (var algorithm = SHA256.Create()) { var hash = algorithm.ComputeHash(Encoding.UTF8.GetBytes(Password.Text)); var readableHash = Convert.ToBase64String(hash); } } } }
Run the project and enter the correct password ArmDot is great!
Now we see that the value is 1BrxSNvk/7j7XCWvtQ4J6XawErDogK0im8dph2um89g=
Add the label to show the status of entered password:
<Grid Margin="10" ColumnDefinitions="Auto,*,Auto" RowDefinitions="Auto,Auto"> <Label VerticalAlignment="Center" Grid.Row="0" Grid.Column="0" Content="Enter password"/> <TextBox Name="Password" Watermark="Password" Grid.Row="0" Grid.Column="1" TextWrapping="NoWrap" /> <Button Content="Validate" Grid.Row="0" Grid.Column="2" Click="CheckPassword" /> <Label Name="StatusLabel" VerticalAlignment="Center" Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="3" /> </Grid>
Update the code to check the hash value and display the status:
private void CheckPassword(object sender, RoutedEventArgs e) { using (var algorithm = SHA256.Create()) { var hash = algorithm.ComputeHash(Encoding.UTF8.GetBytes(Password.Text ?? String.Empty)); var readableHash = Convert.ToBase64String(hash); if (readableHash == "1BrxSNvk/7j7XCWvtQ4J6XawErDogK0im8dph2um89g=") { StatusLabel.Content = "Valid"; } else { StatusLabel.Content = "Invalid"; } } }
Run the project to ensure it is working well:
The application is ready; now it’s time to obfuscate!
How to enable obfuscation?
Let’s have a look at the code that a researcher sees. There are various tools that show and optionally decompile an intermediate code, e.g. ildasm.exe, dotPeek, and ILSpy. ILSpy is quite productive because it tries to decompile intermediate code to C#. Run ILSpy and open ValidatePassword.dll. Then navigate to CheckPassword:
The code has been decompiled very well. The obfuscation helps to make the code non-obvious for peering eyes.
Open ValidatePassword.csproj and add two NuGet packages, ArmDot.Client and ArmDot.Engine.MSBuildTasks. Also add new target Protect that obfuscates an assembly after the compiler places it to the intermediate directory:
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>WinExe</OutputType> <TargetFramework>net6.0</TargetFramework> <Nullable>enable</Nullable> <BuiltInComInteropSupport>true</BuiltInComInteropSupport> <ApplicationManifest>app.manifest</ApplicationManifest> </PropertyGroup> <ItemGroup> <None Remove=".gitignore" /> </ItemGroup> <ItemGroup> <TrimmerRootDescriptor Include="Roots.xml" /> </ItemGroup> <ItemGroup> <PackageReference Include="ArmDot.Client" Version="2023.3.0" /> <PackageReference Include="ArmDot.Engine.MSBuildTasks" Version="2023.3.0" /> <PackageReference Include="Avalonia" Version="0.10.18" /> <PackageReference Include="Avalonia.Desktop" Version="0.10.18" /> <!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.--> <PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="0.10.18" /> <PackageReference Include="XamlNameReferenceGenerator" Version="1.3.4" /> </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>
Rebuild the project:
Rebuild started... 1>------ Rebuild All started: Project: ValidatePassword, Configuration: Debug Any CPU ------ Restored I:\Projects\AvaloniaObfuscationSample\ValidatePassword\ValidatePassword\ValidatePassword.csproj (in 75 ms). 1>[ArmDot] ArmDot [Engine Version 2023.3.0.0] (c) Softanics. All Rights Reserved 1>[ArmDot] Reading license key from C:\ProgramData\ArmDot\ArmDotLicense.key 1>[ArmDot] THIS PROGRAM IS UNREGISTERED. Buy a license at https://www.armdot.com/order.html 1>[ArmDot] Names obfuscation started 1>[ArmDot] Names obfuscation finished 1>[ArmDot] ------ Build started: Assembly (1 of 1): ValidatePassword.dll (I:\Projects\AvaloniaObfuscationSample\ValidatePassword\ValidatePassword\obj\Debug\net6.0\ValidatePassword.dll) ------ 1>I:\Projects\AvaloniaObfuscationSample\ValidatePassword\ValidatePassword\ValidatePassword.csproj(31,3): warning : [ArmDot] warning ARMDOT0003: No methods to protect in the assembly I:\Projects\AvaloniaObfuscationSample\ValidatePassword\ValidatePassword\obj\Debug\net6.0\ValidatePassword.dll 1>[ArmDot] Writing protected assembly to I:\Projects\AvaloniaObfuscationSample\ValidatePassword\ValidatePassword\obj\Debug\net6.0\ValidatePassword.dll... 1>[ArmDot] Finished 1>ValidatePassword -> I:\Projects\AvaloniaObfuscationSample\ValidatePassword\ValidatePassword\bin\Debug\net6.0\ValidatePassword.dll 1>Done building project "ValidatePassword.csproj". ========== Rebuild All: 1 succeeded, 0 failed, 0 skipped ========== ========== Elapsed 00:07.125 ==========
ArmDot has done its job but we have not specified any obfuscation options. Use obfuscation attributes in order to do that.
ArmDot provides several ways to obfuscate assemblies. Each way has the corresponding obfuscation attribute:
- Names obfuscation changes names of types, methods, fields etc. When names become meaningless, it gives no useful information for a hacker. The attribute is ObfuscateNames.
- Control flow obfuscation is applied to a method. It hides the information about control flow, i.e. the order of instructions execution. The attribute is ObfuscateControlFlow.
- Virtualization is an advanced version of control flow obfuscation. The attribute is VirtualizeCode.
Let’s virtualize CheckPassword and obfuscate names:
using Avalonia.Controls; using Avalonia.Interactivity; using System.Text; using System; using System.Security.Cryptography; [assembly:ArmDot.Client.ObfuscateNames] namespace ValidatePassword { public partial class MainWindow : Window { public MainWindow() { InitializeComponent(); } [ArmDot.Client.VirtualizeCode] private void CheckPassword(object sender, RoutedEventArgs e) { using (var algorithm = SHA256.Create()) { var hash = algorithm.ComputeHash(Encoding.UTF8.GetBytes(Password.Text ?? String.Empty)); var readableHash = Convert.ToBase64String(hash); if (readableHash == "1BrxSNvk/7j7XCWvtQ4J6XawErDogK0im8dph2um89g=") { StatusLabel.Content = "Valid"; } else { StatusLabel.Content = "Invalid"; } } } } }
Rebuild it… Suddenly, we got two errors:
Avalonia error XAMLIL: Unable to find suitable setter or adder for property Click of type Avalonia.Controls:Avalonia.Controls.Button for argument System.Runtime:System.String, available setter parameter lists are: Avalonia error XAMLIL: System.EventHandler`1<Avalonia.Interactivity.RoutedEventArgs> Line 12, position 59.
The reason is that the name of CheckPassword had been already changed, but then Avalonia tried to find the original name as it is specified in MainWindow.axaml. That’s why we have to exclude the method from names obfuscation.
Also it’s important to disable namespace obfuscation (if names obfuscation enabled, it also obfuscates names of namespaces, by default) as Avalonia calls Assembly.GetManifestResourceStream. So disable it:
using Avalonia.Controls; using Avalonia.Interactivity; using System.Text; using System; using System.Security.Cryptography; [assembly: ArmDot.Client.ObfuscateNames] [assembly: ArmDot.Client.ObfuscateNamespaces(Enable = false)] namespace ValidatePassword { public partial class MainWindow : Window { public MainWindow() { InitializeComponent(); } [ArmDot.Client.VirtualizeCode] [ArmDot.Client.ObfuscateNames(Enable = false)] private void CheckPassword(object sender, RoutedEventArgs e) { using (var algorithm = SHA256.Create()) { var hash = algorithm.ComputeHash(Encoding.UTF8.GetBytes(Password.Text ?? String.Empty)); var readableHash = Convert.ToBase64String(hash); if (readableHash == "1BrxSNvk/7j7XCWvtQ4J6XawErDogK0im8dph2um89g=") { StatusLabel.Content = "Valid"; } else { StatusLabel.Content = "Invalid"; } } } } }
This time the build has succeeded. Run the application and check whether it is working as expected.
What about the obfuscated code? Return to ILSpy, right-click the assembly and click Reload. Now we can see something remarkable: several new methods are added. The code of CheckPassword got messy:
Yes, it’s virtually impossible to realize what the code is doing!
Wrap-up
.NET applications contain intermediate code that is human readable. Tools like ILSpy is even able to convert such code to C# code that is ready to compile. If your commercial application is not open-source, you definitely don’t want to make its source code available for everyone. Obfuscators convert methods to a set of instructions that produce the same result, but these instructions are not easy for understanding.