F# obfuscator

A comprehensive guide on using an F# obfuscation tool

In this guide, you will learn how to enable obfuscation for F#.

Contents

Why obfuscate F# applications?

F# is a .NET language. It means that an F# compiler produces binaries that contain MSIL code and metadata that are easy to explore, extract and modify. A .NET decompiler reconstructs the source code in F# from a compiled code. Type names are open for everyone; it allows your dishonest competitors to understand how your application is organized and reproduce the same ideas without wasting time. Also, embedded resources are stored as-is. Of course, nobody wants their intellectual property, including source codes and assets, to be used without permission.

.NET obfuscators to the rescue: they hide embedded resources, turning their extraction into an arduous task. But what is more important, a .NET obfuscator jumbles MSIL instructions and renames types, fields, and methods, so the code becomes weird: instead of several simple instructions, it produces tons of ones.

Let’s look at how to use ArmDot, a .NET obfuscator. First of all, we need to create a sample application to obfuscate it later.

A sample F# application

You can find complete source code on GitHub: https://github.com/Softanics/FSharpObfuscationSample

Run Visual Studio, select F#, and choose Console Application. Name the project FSharpObfuscationSample and let Visual Studio create files.

Place the following code to Program.fs:

// Learn more about F# at http://docs.microsoft.com/dotnet/fsharp

open System
open System.Security.Cryptography
open System.Text

let sha256 (data : byte array) : string =
    use sha256 = SHA256.Create()
    (StringBuilder(), sha256.ComputeHash(data))
    ||> Array.fold (fun sb b -> sb.Append(b.ToString("x2")))
    |> string

let checkPassword (text: string) : bool =
    let bytes = Encoding.UTF8.GetBytes text
    let hash = sha256 bytes
    hash.Equals "9997ee6bc0524093f7dfb2ae8f80a997d75504bfd2e829f54a2a0cd3172adad8"

[<EntryPoint>]
let main argv =
    let password = Console.ReadLine()
    let correct = checkPassword password
    if correct
    then printfn "correct"
    else printfn "wrong"
    0 // return an integer exit code

A user enters a password, the program checks it and displays whether the entered password is correct or not.

Let’s test the application works well:

V:\Projects\FSharpObfuscationSample\FSharpObfuscationSample\bin\Debug\net5.0>FSharpObfuscationSample.exe
123
wrong

V:\Projects\FSharpObfuscationSample\FSharpObfuscationSample\bin\Debug\net5.0>FSharpObfuscationSample.exe
armdot
correct

V:\Projects\FSharpObfuscationSample\FSharpObfuscationSample\bin\Debug\net5.0>

Great, it is working!

Let’s look at the MSIL code generated by the compiler. We use dnSpy to explore the code. Run dnSpy, open \bin\Debug\net5.0\FSharpObfuscationSample.dll.

The code looks quite obviously:

// Program
// Token: 0x06000002 RID: 2 RVA: 0x000020B4 File Offset: 0x000002B4
public static bool checkPassword(string text)
{
    byte[] bytes = Encoding.UTF8.GetBytes(text);
    string hash = Program.sha256(bytes);
    return hash.Equals("9997ee6bc0524093f7dfb2ae8f80a997d75504bfd2e829f54a2a0cd3172adad8");
}
// Program
// Token: 0x06000003 RID: 3 RVA: 0x000020E0 File Offset: 0x000002E0
[EntryPoint]
public static int main(string[] argv)
{
    string password = Console.ReadLine();
    bool correct = Program.checkPassword(password);
    if (correct)
    {
        ExtraTopLevelOperators.PrintFormatLine<Unit>(new PrintfFormat<Unit, TextWriter, Unit, Unit, Unit>("correct"));
    }
    else
    {
        ExtraTopLevelOperators.PrintFormatLine<Unit>(new PrintfFormat<Unit, TextWriter, Unit, Unit, Unit>("wrong"));
    }
    return 0;
}
// Program
// Token: 0x06000001 RID: 1 RVA: 0x00002050 File Offset: 0x00000250
public static string sha256(byte[] data)
{
    string result;
    using (SHA256 sha256 = SHA256.Create())
    {
        StringBuilder stringBuilder = ArrayModule.Fold<byte, StringBuilder>(Program.sha256@10.@_instance, new StringBuilder(), sha256.ComputeHash(data));
        StringBuilder stringBuilder2 = stringBuilder;
        result = stringBuilder2.ToString();
    }
    return result;
}

It is time to harden the code!

How to obfuscate an F# application?

ArmDot provides different obfuscation methods. The package ArmDot.Client supplies attributes that specify obfuscation options for a type (or another item, e.g., an entire assembly or a method).

Let’s add ArmDot.Client to the project. Right-click the project, select Manage NuGet Packages…, click to Browse, and type ArmDot.Client. Install the package.

Another ArmDot package, ArmDot.Engine.MSBuildTasks provides an obfuscation task that is executed while a project building. Add ArmDot.Engine.MSBuildTasks to the project.

To add the obfuscation task, we need to edit the project file. It is a bad idea to edit a project file while it is opened in Visual Studio. Click to FileClose Solution to close the project.

Open FSharpObfuscationSample.fsproj in an editor and add the target Protect:

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

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net5.0</TargetFramework>
    <WarnOn>3390;$(WarnOn)</WarnOn>
  </PropertyGroup>

  <ItemGroup>
    <Compile Include="Program.fs" />
  </ItemGroup>

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

  <Target Name="Protect" AfterTargets="Build">
    <ItemGroup>
      <Assemblies Include="$(TargetDir)$(TargetFileName)" />
    </ItemGroup>
    <ArmDot.Engine.MSBuildTasks.ObfuscateTask
      Inputs="@(Assemblies)"
      ReferencePaths="@(_ResolveAssemblyReferenceResolvedFiles->'%(RootDir)%(Directory)')"
  />
  </Target>

</Project>

Open the project and rebuild it. Switch to Output Window. You will see:

1>[ArmDot] ArmDot [Engine Version 2022.1.0.0] (c) Softanics. All Rights Reserved
1>[ArmDot] ------ Build started: Assembly (1 of 1): FSharpObfuscationSample.dll (V:\Projects\FSharpObfuscationSample\FSharpObfuscationSample\bin\Debug\net5.0\FSharpObfuscationSample.dll) ------
1>V:\Projects\FSharpObfuscationSample\FSharpObfuscationSample\FSharpObfuscationSample.fsproj(22,5): warning : [ArmDot] warning ARMDOT0003: No methods to protect in the assembly V:\Projects\FSharpObfuscationSample\FSharpObfuscationSample\bin\Debug\net5.0\FSharpObfuscationSample.dll
1>[ArmDot] Writing protected assembly to V:\Projects\FSharpObfuscationSample\FSharpObfuscationSample\bin\Debug\net5.0\FSharpObfuscationSample.dll...
1>[ArmDot] Finished

ArmDot warns that there are no methods that exist to obfuscate.

As names of types give sensitive information, let’s obfuscate all names.

The attribute ArmDot.Client.ObfuscateNames instructs ArmDot to rename items. If an entire assembly has this attribute, ArmDot renames all items in the assembly. By default, ArmDot skips public types and methods because another assembly can use them. At the same time, you can explicitly add the attribute to a type or a method to force renaming, as shown below:

open System
open System.Security.Cryptography
open System.Text

[<assembly: ArmDot.Client.ObfuscateNames()>]

do()

[<ArmDot.Client.ObfuscateNames()>]
let sha256 (data : byte array) : string =
    use sha256 = SHA256.Create()
    (StringBuilder(), sha256.ComputeHash(data))
    ||> Array.fold (fun sb b -> sb.Append(b.ToString("x2")))
    |> string

[<ArmDot.Client.ObfuscateNames()>]
let checkPassword (text: string) : bool =
    let bytes = Encoding.UTF8.GetBytes text
    let hash = sha256 bytes
    hash.Equals "9997ee6bc0524093f7dfb2ae8f80a997d75504bfd2e829f54a2a0cd3172adad8"

[<ArmDot.Client.ObfuscateNames()>]
[<EntryPoint>]
let main argv =
    let password = Console.ReadLine()
    let correct = checkPassword password
    if correct
    then printfn "correct"
    else printfn "wrong"
    0 // return an integer exit code

If FSharpObfuscationSample.dll is still opened in dnSpy, remove it and open it again. Now we see nonsense names:

Obfuscated names

Names obfuscation is the simplest way to confuse a researcher, but MSIL code remains the same: it is quite readable.

A method is a set of instructions that is executed one by one. However, there are some instructions, that change the execution flow. For example, an instruction pops a value from the execution stack and jumps to another instruction if the value is zero; otherwise, execution continues. Control flow obfuscation hides the logic of decisions related to changing execution flow.

At first glance, there are not many comparisons in our application, but let’s try to obfuscate control flow and check the result. Add the attribute ArmDot.Client.ObfuscateControlFlowAttribute to the entire assembly to confuse all methods:

open System
open System.Security.Cryptography
open System.Text

[<assembly: ArmDot.Client.ObfuscateNames()>]
[<assembly: ArmDot.Client.ObfuscateControlFlowAttribute()>]

do()

[<ArmDot.Client.ObfuscateNames()>]
let sha256 (data : byte array) : string =
    use sha256 = SHA256.Create()
    (StringBuilder(), sha256.ComputeHash(data))
    ||> Array.fold (fun sb b -> sb.Append(b.ToString("x2")))
    |> string

[<ArmDot.Client.ObfuscateNames()>]
let checkPassword (text: string) : bool =
    let bytes = Encoding.UTF8.GetBytes text
    let hash = sha256 bytes
    hash.Equals "9997ee6bc0524093f7dfb2ae8f80a997d75504bfd2e829f54a2a0cd3172adad8"

[<ArmDot.Client.ObfuscateNames()>]
[<EntryPoint>]
let main argv =
    let password = Console.ReadLine()
    let correct = checkPassword password
    if correct
    then printfn "correct"
    else printfn "wrong"
    0 // return an integer exit code

Reopen FSharpObfuscationSample.dll in dnSpy and look at the obfuscated code:

Control flow obfuscation

I would say the result is much better!

The most advanced obfuscation approach today is virtualization. While control flow obfuscation splits instructions into large units and executes them block by block, virtualization considers every single instruction as a unit. Each instruction and its operands are encoded using a unique table (generated each time randomly). So all instructions are converted to bytes. The method is replaced with an interpreter of these bytes.

Use the attribute VirtualizeCodeAttribute:

open System
open System.Security.Cryptography
open System.Text

[<assembly: ArmDot.Client.ObfuscateNames()>]
[<assembly: ArmDot.Client.ObfuscateControlFlowAttribute()>]
[<assembly: ArmDot.Client.VirtualizeCodeAttribute()>]

do()

[<ArmDot.Client.ObfuscateNames()>]
let sha256 (data : byte array) : string =
    use sha256 = SHA256.Create()
    (StringBuilder(), sha256.ComputeHash(data))
    ||> Array.fold (fun sb b -> sb.Append(b.ToString("x2")))
    |> string

[<ArmDot.Client.ObfuscateNames()>]
let checkPassword (text: string) : bool =
    let bytes = Encoding.UTF8.GetBytes text
    let hash = sha256 bytes
    hash.Equals "9997ee6bc0524093f7dfb2ae8f80a997d75504bfd2e829f54a2a0cd3172adad8"

[<ArmDot.Client.ObfuscateNames()>]
[<EntryPoint>]
let main argv =
    let password = Console.ReadLine()
    let correct = checkPassword password
    if correct
    then printfn "correct"
    else printfn "wrong"
    0 // return an integer exit code

Rebuild the project and reload FSharpObfuscationSample.dll to dnSpy:

Virtualized code

The code is just a mess now! You can look at the virtualized code on GitHub:
https://gist.github.com/Softanics/46f385d3c11c12f6f6b1c25709393fb9

Conclusion

Any .NET application needs to be obfuscated to hide code and embedded resources. An F# application is not an exception. You can add obfuscation to the building process of an F# application. ArmDot is a cross-platform obfuscator that supports the .NET Framework, .NET Core, and Mono; it runs on Windows, Linux, and macOS. It is easy to specify obfuscation options using attributes.