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?
- A sample F# application
- How to obfuscate an F# application?
- Conclusion
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 File – Close 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:
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:
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:
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.