Obfuscation .NET applications with Azure DevOps Pipelines

This article describes a typical workflow for obfuscation .NET applications in the process of building them, using Azure DevOps Pipelines.

Contents

What is Azure DevOps?

Today, more and more companies are using services such as Azure DevOps to build, test, and deploy their .NET applications. That is the reason why developers want to know how to add obfuscation to the building process. Fortunately, it’s easy to do using NuGet packages for obfuscation, secret files of Azure DevOps, and by environment variables to pass additional information to MSBuild.

Let’s look at how it works with a sample .NET application.

Setup the project

The project will contain a simple console application that checks entered passwords. After validation, it outputs whether the password is correct or not.

Login to Azure DevOps, and create a new project, CheckPassword. Once it is ready, go to Repos to create a repository:

Add repository

Clone the repository to your local computer using the provided URL. It’s empty now, so let’s add some code.

Enter the directory of the repository, and run the following command to create a skeleton of the application:

dotnet new console --use-program-main

It’s a good idea to create .gitignore right now (you can find it on GitHub).

Add the following code to Program.cs:

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

                Console.WriteLine(string.Format($"Hash: {readableHash}"));
            }
        }
    }
}

The application will show the hash value of the entered string. Build it using dotnet build and then run. Type azure and copy the hash value somewhere:

I:\Projects\CheckPassword\bin\Debug\net7.0>CheckPassword.exe                                                                                                                                                       
Enter password: azure
Hash: efQ8O5wj+98pO9uoDas4uhlPwdp5GHCRs9N2Da2EC/g=

As now we know the expected hash value, modify the code as shown below:

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 ("efQ8O5wj+98pO9uoDas4uhlPwdp5GHCRs9N2Da2EC/g=" == readableHash)
                    Console.WriteLine("Correct");
                else
                    Console.WriteLine("Incorrect");
            }
        }
    }
}

Build it and run to ensure that it works as expected:

I:\Projects\CheckPassword\bin\Debug\net7.0>CheckPassword.exe                                                                                                                                                       Enter password: github
Incorrect
Enter password: azure
Correct

It’s time to hide the details of the application. First, add two packages to enable obfuscation:

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

ArmDot.Client contains attributes to instruct the obfuscator which obfuscation techniques to use. ArmDot.Engine.MSBuildTasks provides a task for MSBuild that should be enabled manually. In order to do that, modify CheckPassword.csproj as shown below:

<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.7.0" />
    <PackageReference Include="ArmDot.Engine.MSBuildTasks" Version="2023.7.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>

We’ve just added the new target Protect that contains the obfuscation task. It is executed right after the compiler has done its job, but before publishing the application. So the obfuscator can take the assembly located in the intermediate directory (the compiler places it there) and obfuscate it.

Rebuild the project to check that everything works well.

You will see the following warning because we have forgotten to use obfuscation attributes:

[ArmDot] warning ARMDOT0003: No methods to protect in the assembly

Return to Program.cs and add the attribute:

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 ("efQ8O5wj+98pO9uoDas4uhlPwdp5GHCRs9N2Da2EC/g=" == readableHash)
                    Console.WriteLine("Correct");
                else
                    Console.WriteLine("Incorrect");
            }
        }
    }
}

Build the project once again and run the application.

It works well:

I:\Projects\CheckPassword\bin\Debug\net7.0>CheckPassword.exe                                                                                                                                                       Enter password: hahaha
Incorrect
Enter password: azure
Correct

Commit and push the changes to Azure. Go to ReposCommits:

The first commit

Great, the first commit is here!

Enable obfuscation in Azure DevOps Pipelines

Our task now is to build the application, obfuscate it, and publish the binaries. We need a pipeline to automate the task. Go to PipelinesPipelines, create a new pipeline using the suggested template .NET Desktop. Don’t make any changes; just save it:

Add new pipeline

Pull changes to your computer. You can see the new file, azure-pipelines.yml. It describes tasks that the pipeline launches:

# .NET Desktop
# Build and run tests for .NET Desktop or Windows classic desktop solutions.
# Add steps that publish symbols, save build artifacts, and more:
# https://docs.microsoft.com/azure/devops/pipelines/apps/windows/dot-net

trigger:
- master

pool:
  vmImage: 'windows-latest'

variables:
  solution: '**/*.sln'
  buildPlatform: 'Any CPU'
  buildConfiguration: 'Release'

steps:
- task: NuGetToolInstaller@1

- task: NuGetCommand@2
  inputs:
    restoreSolution: '$(solution)'

- task: VSBuild@1
  inputs:
    solution: '$(solution)'
    platform: '$(buildPlatform)'
    configuration: '$(buildConfiguration)'

- task: VSTest@2
  inputs:
    platform: '$(buildPlatform)'
    configuration: '$(buildConfiguration)'

Let’s return to Azure DevOps and run the pipeline to ensure that it works without problems. Unfortunately, it doesn’t:

Initial job fails

The error is obvious: we don’t have a solution file. Fortunately, it’s easy to fix with two commands:

dotnet new sln
dotnet sln add CheckPassword.csproj

Commit and push the changes and return to Azure DevOps; this time it works without issues:

The pipeline worked well

How to use the licensed version in Azure DevOps?

If you use a licensed version of ArmDot, you need to specify the path of the license file. Of course, you don’t place a license file to a repository. Fortunately, Azure DevOps provides a way to store files, called secure files. The idea is to add your license file, then consume it in a pipeline, and provide the path of the license file to ArmDot using some environment variable.

Click PipelinesLibrary, Secure Files, and add the file that contains your serial code:

Add ArmDot license file to secure files

Open azure-pipelines.yml and add the task that downloads the license file; also add an environment variable that contains the path of the downloaded file:

- task: DownloadSecureFile@1
  name: armDotLicenseKey
  displayName: 'Download ArmDot license key'
  inputs:
   secureFile: 'armdot-license-key.txt'

- task: VSBuild@1
  inputs:
    solution: '$(solution)'
    platform: '$(buildPlatform)'
    configuration: '$(buildConfiguration)'
  env:
    ARMDOT_LICENSE_FILE_PATH: $(armDotLicenseKey.secureFilePath)

Now, ARMDOT_LICENSE_FILE_PATH contains the path of the license file. You can use the same way for other tasks like DotNetCoreCLI@2.

Let’s use the environment variable in the project file:

<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"
    LicenseFile="$(ARMDOT_LICENSE_FILE_PATH)"
  />
</Target>

Commit and push changes, and check how the pipeline works this time. You will see that the pipeline needs permission to use the secret file with the license. Click View and then Permit to solve it:

Pipeline needs permission

Permit license file

Run the pipeline manually. This time you will see that the license file was utilized successfully:

Pipeline uses license file

Conclusion

Using Azure DevOps simplifies the process of building .NET applications. It’s easy to add obfuscation to the building process with the help of ArmDot. Secret files in Azure DevOps allow developers to upload the license file and use it for each build.