A1Force.MauiObfuscate 1.2.3

dotnet tool install --global A1Force.MauiObfuscate --version 1.2.3
                    
This package contains a .NET tool you can call from the shell/command line.
dotnet new tool-manifest
                    
if you are setting up this repo
dotnet tool install --local A1Force.MauiObfuscate --version 1.2.3
                    
This package contains a .NET tool you can call from the shell/command line.
#tool dotnet:?package=A1Force.MauiObfuscate&version=1.2.3
                    
nuke :add-package A1Force.MauiObfuscate --version 1.2.3
                    

maui-obfuscate

Rename obfuscator for .NET MAUI apps. A dotnet tool plus an MSBuild targets file. It rewrites an already-built assembly (Release DLL + project directory + portable PDB). It does not need the MAUI workload.

It renames identifiers, flattens control flow, packs string literals, suppresses classic ildasm, adds a de4dot decoy, and stops when a debugger is attached. Each of those can be turned off in maui-obfuscation.json. A dependency with "merge": true is copied into the app. maui-obfuscate deobfuscate reads the rename maps and restores type and method names in a stack trace.

The tool renames each assembly it changes, then writes the obfuscated bytes back to the original file name. The runtime keeps loading App.dll and MyCompany.Core.dll. Beside those files it leaves:

  • App.dll.original and App.pdb.original (the build output, before renaming)
  • App.obfuscated.rename-map.json (deterministic; use it to decode stack traces)

The same pair is written for each dependency that was renamed in place. When that dependency's merge is true, it is copied into the app and that DLL is not written back. The original stays as .original. A later build replaces App.dll and App.pdb; the next obfuscation pass parks that new build as .original again.

XAML, resx, images, fonts, and other resources are not rewritten. Names those files look up have to stay, which is what the preservation rules below are for.

Install

dotnet tool install only accepts a package whose type is DotnetTool. A project cannot reference a package of that type (NU1212), so the command and the MSBuild import are two packages:

Package Install with Role
A1Force.MauiObfuscate dotnet tool install The maui-obfuscate command
A1Force.MauiObfuscate.Build PackageReference Imports the targets after Build
dotnet tool install --global A1Force.MauiObfuscate
dotnet tool update --global A1Force.MauiObfuscate

Version 1.0.0 is not a .NET tool. If install stops with Package a1force.mauiobfuscate is not a .NET tool., that version was selected. Install 1.0.2 or later:

dotnet tool install --global A1Force.MauiObfuscate --version 1.0.2

Version 0.0.1 is a tool, and it does not check a licence. Do not use it.

Local manifest instead of a global tool:

dotnet new tool-manifest
dotnet tool install A1Force.MauiObfuscate

A global install is the command maui-obfuscate. It is not dotnet maui-obfuscate. The dotnet host only runs a local tool that way, and otherwise looks for a program named dotnet-maui-obfuscate. The MSBuild package calls the global shim at %USERPROFILE%\.dotnet\tools\maui-obfuscate.exe (or $HOME/.dotnet/tools/maui-obfuscate). For a local tool, set MauiObfuscatorCommand to dotnet tool run maui-obfuscate -- (the -- keeps MSBuild from eating the tool's arguments).

Installing the tool does not change the app project. Reference A1Force.MauiObfuscate.Build for that. See MSBuild below. Do not add a PackageReference to A1Force.MauiObfuscate.

Licence key

Obfuscation requires a licence key. init, --help, and --version do not. A key lasts one year and is issued for one application id. maui-obfuscate reads <ApplicationId> from the project file and refuses the key when it does not match, so a key cannot be used on another app. Write the id as a literal in the project. A value that exists only in Directory.Build.props is not read. The annual price is USD $1,499.

Set the key in the environment before you build or run the tool. The tool reads A1FORCE_LICENCE_KEY. This keeps the key out of the project file and out of the MSBuild command line.

export A1FORCE_LICENCE_KEY="<key>"
maui-obfuscate --project path/to/App.csproj

On Windows PowerShell:

$env:A1FORCE_LICENCE_KEY = "<key>"
maui-obfuscate --project path/to/App.csproj

A one-off command can pass the key directly:

maui-obfuscate --licence-key "<key>" --project path/to/App.csproj

For an app that references the package, you can set MauiObfuscateLicenceKey instead. The targets pass that property to the tool. Do not commit the key. A value assigned in the project, including $(A1FORCE_LICENCE_KEY), is written into the build log.

<PropertyGroup>
  <MauiObfuscateLicenceKey>$(A1FORCE_LICENCE_KEY)</MauiObfuscateLicenceKey>
</PropertyGroup>

Leave that property empty when A1FORCE_LICENCE_KEY is already set. The tool sees the variable on its own.

Init

init writes maui-obfuscation.json next to the project. It does not check a licence key. It does not overwrite an existing file unless you pass --force.

maui-obfuscate init --project path/to/App.csproj
maui-obfuscate init --project path/to/App --settings path/to/maui-obfuscation.json --force

--project may be a .csproj or a directory that contains exactly one.

Each group has:

Field Meaning
name Label used when you read the file.
include When true, files in this group are renamed.
includePatterns Globs relative to the project (**, *, ?). * does not cross /.
excludePatterns Globs. A match excludes the file, and it wins over every include, including other groups and defaultInclude.

Globals:

Field Meaning
defaultInclude Files that match no group's include patterns. Shipped default is false.
explicitTypeIncludes Metadata type names (or * globs) to rename when that type has no PDB sequence points. A file-group exclude still wins.

Decision order for a source file:

  1. Any excludePatterns match → not renamed.
  2. Otherwise, collect every group whose includePatterns match.
  3. No group matched → defaultInclude.
  4. Any matched group has include: false → not renamed.
  5. Every matched group has include: true → renamed.

Patterns are case-insensitive. Paths from the PDB are made project-relative when they sit under the project directory. ** patterns also match a full path when the build machine's prefix does not.

What each default group does

defaultInclude is false, so a file that matches nothing is left alone. Groups with include: false still matter: if you later set defaultInclude to true, those groups stay excluded.

Group include What it matches
Application false MauiProgram.cs, App.xaml.cs, MainActivity.cs, AppDelegate.cs, Platforms/**/Program.cs, Platforms/**/Main.cs
Views and XAML code-behind false **/*.xaml.cs, **/Views/**/*.cs, **/Pages/**/*.cs
ViewModels true **/ViewModels/**/*.cs, **/*ViewModel.cs
Models true **/Models/**/*.cs, **/*Model.cs
Services true **/Services/**/*.cs, **/*Service.cs
Platforms false **/Platforms/**
Resources false Resources, Images, Fonts, Raw, Splash, AppIcon, plus **/*.xaml (not .xaml.cs), resx, and common image/font extensions
Generated false **/obj/**, **/*.g.cs, **/*.g.i.cs
Tests false **/Tests/**, **/*Tests/**, **/*Test.cs, **/*Tests.cs, and the *.Test.cs / *.Tests.cs forms

**/*.xaml does not match Foo.xaml.cs. **/*.g.cs does not match Foo.g.i.cs.

Sample results with the shipped file (true means the file is renamed):

Path Renamed Why
MauiProgram.cs, App.xaml.cs no Application
Platforms/Android/MainActivity.cs no Application and Platforms
Platforms/iOS/AppDelegate.cs, Platforms/MacCatalyst/Program.cs, Platforms/Tizen/Main.cs no Application and Platforms
Views/HomePage.xaml.cs, Views/HomePage.cs, Pages/LoginPage.cs, Custom/Widget.xaml.cs no Views
ViewModels/LoginViewModel.cs, LoginViewModel.cs yes ViewModels
Models/User.cs, UserModel.cs yes Models
Services/AuthService.cs, AuthService.cs yes Services
Platforms/Android/MainApplication.cs no Platforms
Platforms/Android/Services/PlatformService.cs no Platforms (include: false) wins over Services
Resources/Styles/Colors.xaml, images, fonts, splash, app icon no Resources
obj/.../App.AssemblyInfo.cs, Foo.g.cs, Foo.g.i.cs no Generated
ViewModels/LoginViewModel.g.cs no Generated wins over ViewModels
Tests/AuthServiceTests.cs, Services/AuthServiceTests.cs no Tests wins over Services
Helpers/Format.cs no no group, defaultInclude is false
ViewModels/Pages/DetailPage.cs no Pages (include: false) wins over ViewModels

**/*Model.cs also matches LoginViewModel.cs. Both ViewModels and Models default to include: true, so the file is still renamed. If you set Models to include: false, those *ViewModel.cs files drop out too, because exclude wins. Delete the **/*Model.cs pattern if you want ViewModels renamed while the Models group is off. The **/Models/** pattern does not match ViewModels/.

Add a custom group

Edit maui-obfuscation.json. No rebuild. Example: rename Helpers/ and Internal/, but never Helpers/PublicFacade.cs.

{
  "name": "InternalHelpers",
  "include": true,
  "includePatterns": ["**/Helpers/**/*.cs", "**/Internal/**/*.cs"],
  "excludePatterns": ["**/Helpers/PublicFacade.cs"]
}

Put it in the groups array. excludePatterns on this group also beat includes from other groups.

Dependencies

dependencies chooses assemblies that sit beside the app output (the copies the app loads) and renames them with the same file groups. The app, and any other selected dependency that calls into them, is rewritten so those calls use the new type and member names. Assemblies that are not listed are left unchanged, and so are references to them.

"dependencies": [
  { "name": "MyCompany.Core", "include": true, "merge": true },
  { "name": "MyCompany.*", "include": true },
  { "name": "ThirdParty.LeaveAlone", "include": false }
]

name is an assembly name, a file name, or a * / ? glob. Matching is case-insensitive. An entry with "include": false wins over a broader include. Omitting include means true. An empty list, the default init writes, renames no dependencies.

Each selected assembly needs its portable PDB beside it. The tool renames MyCompany.Core.dll to MyCompany.Core.dll.original (and the PDB to MyCompany.Core.pdb.original), then writes the obfuscated assembly back to MyCompany.Core.dll. The app is treated the same way. Callers are updated, and they still load MyCompany.Core.dll, which is now the renamed dependency. Deploy that output directory as the build left it. Close the running app before building. A running process keeps those DLLs open, and the rename then fails with "being used by another process".

A glob does not select strong-named framework assemblies. Naming one exactly fails the build, because this tool does not re-sign. A glob also leaves platform assemblies beside the app. That covers names such as Microsoft.Maui.Controls, System.*, Mono.*, Java.*, Android.*, AndroidX.*, Xamarin.*, and ObjCRuntime. Name that assembly exactly to select it. A name that matches nothing beside the output is reported as a warning.

merge is per entry. Set it true to copy that dependency into the app assembly and remove its DLL from the output. Omit it, or set it false, and the DLL stays beside the app. An explicit false wins over a broader true. When the entry names the assembly being obfuscated, merge is ignored. Embedded resources move with the types. Protections run on the app and on each dependency that stays separate. A merged dependency is protected once, inside the app. A dependency that stays separate and calls a merged one is retargeted to the app. An assembly you did not include is not merged, and references to it stay. Mixed-mode assemblies are refused. A framework type the app already defines, such as a generated Microsoft.Maui.Controls type inside a dependency, stays as the app's copy. A type the application owns that is already in the app, including a partial class compiled into both projects, is combined: members that already exist stay as the app's, and members that exist only on the dependency are copied onto that type. The rename map for the dependency is still written.

Types the PDB cannot place in a file stay unrenamed unless you list them:

"explicitTypeIncludes": ["MyApp.Internal.GeneratedMarker", "MyApp.Internal.*"]

Run

Build the MAUI app first (Release, with a portable PDB). Then:

maui-obfuscate --project path/to/App.csproj --configuration Release --settings maui-obfuscation.json

Before it rewrites an assembly, the command prints this build's version and the A1Force.MauiObfuscate.Build version from the project file. Obfuscation runs only when that PackageReference version is the same literal as this build. A missing reference, a version written as an MSBuild property, or a different version stops the run before any assembly is rewritten. A target framework or runtime identifier that has no built assembly is skipped with a warning. The assemblies that were built are obfuscated.

Useful options:

Option Default Role
--project required .csproj or directory
--configuration Release Used to find bin/<configuration>/<tfm>/<AssemblyName>.dll
--settings <project>/maui-obfuscation.json Relative paths are resolved from the current directory
--assembly discovered from the project Skip discovery and obfuscate this DLL. The MSBuild target passes $(TargetPath)
--framework all TFMs that have a DLL Limit discovery to one target framework. A requested framework with no DLL is skipped with a warning.

Multi-targeted output: one preferred DLL per folder under bin/<configuration>/ (the shortest path in that folder). Reference assemblies under ref/ are skipped. A declared target framework or runtime identifier with no DLL is reported as a warning and left alone. Pass --assembly when the app's real output is not that layout. The MSBuild target does the same when $(TargetPath) is not on disk.

The app project must already build. This tool does not invoke dotnet build and does not need the MAUI workload.

MSBuild

Reference the MSBuild package from the application project:

<Project Sdk="Microsoft.NET.Sdk">
  <ItemGroup>
    <PackageReference Include="A1Force.MauiObfuscate.Build" Version="1.0.2" />
  </ItemGroup>
</Project>

NuGet imports build/A1Force.MauiObfuscate.Build.targets from that package. The file name matches the package id, which is the rule NuGet uses, so the project does not need an Import.

A1Force.MauiObfuscate.Build 1.0.1 runs dotnet maui-obfuscate. That does not start a global tool, and the build fails with dotnet-maui-obfuscate does not exist. Version 1.0.2 calls the shim directly. On 1.0.1, set the command on the app:

<PropertyGroup>
  <MauiObfuscatorCommand>$(USERPROFILE)\.dotnet\tools\maui-obfuscate.exe</MauiObfuscatorCommand>
</PropertyGroup>

On macOS and Linux that path is $(HOME)/.dotnet/tools/maui-obfuscate.

Do not reference A1Force.MauiObfuscate from the app. From 1.0.1 it is a DotnetTool package, and NuGet reports NU1212.

The reference does not install the maui-obfuscate command. Install the tool as well (see Install). The imported target runs after Build, and only for Release, after a real build (design-time builds are skipped). Set A1FORCE_LICENCE_KEY in that build's environment.

Property Default Role
MauiObfuscateAfterBuild true Set false to skip the target
MauiObfuscationSettings $(MSBuildProjectDirectory)/maui-obfuscation.json --settings
MauiObfuscateConfiguration $(Configuration) --configuration
MauiObfuscatorCommand global shim, else dotnet maui-obfuscate Full path of maui-obfuscate.exe when the global tool is installed. Local tool: dotnet tool run maui-obfuscate --
MauiObfuscateAssembly $(TargetPath) --assembly
MauiObfuscateOnAnyConfiguration false When true, also run outside Release
MauiObfuscateLicenceKey empty Passed as --licence-key. Leave empty and set A1FORCE_LICENCE_KEY so the key stays out of the log

$(TargetPath) is the assembly the build just produced, which is the right input for a multi-targeted MAUI app.

What is preserved, and why

The tool renames types and members whose source file is included. It does not edit XAML or resources, so anything MAUI or the XAML loader looks up by name has to keep that name or the app will not start.

Always kept:

  • Type names MauiProgram, App, MainActivity, AppDelegate, Program, MainApplication, and whatever type actually holds the assembly entry point.
  • Methods named CreateMauiApp and Main, and the entry-point method itself.
  • The x:Class type and its public surface (properties, fields, methods, events). Code-behind is what InitializeComponent and the resource loader bind to. Private members on that type can still be renamed.
  • x:Name members on that x:Class type, including private fields the XAML generator assigns.
  • Public properties — and every other member — of a type whose source file is excluded. Bindings and other XAML that target an excluded page or model keep working because those names are not rewritten.
  • [System.Reflection.Obfuscation(Exclude = true)]. Exclude defaults to true when the attribute is present. ApplyToMembers defaults to true, so a type-level attribute also keeps the members. The same attribute on the assembly keeps the whole assembly.
  • Virtual methods, overrides, and interface methods, so the runtime still binds OnCreate, OnAppearing, INotifyPropertyChanged, and anything else implemented against the framework.
  • P/Invoke and UnmanagedCallersOnly methods.
  • Enum field names, constructors, and compiler-generated names that contain <.

A type is renamed only when every PDB sequence-point file for that type is included. One excluded partial file keeps the type name. A member with its own sequence points follows those files. A member with none (typical fields) follows the type's file decision.

Types with no sequence points are not renamed unless explicitTypeIncludes lists them. An excluded file still wins over that list.

PDB

File groups work by mapping IL back to a source file through PDB sequence points. Build with:

<DebugType>portable</DebugType>
<DebugSymbols>true</DebugSymbols>

The SDK already does this for net8.0, including Release. The .pdb must sit beside the DLL and match it. If it is missing or stale, the tool stops. It will not pretend the assembly was renamed.

Rename map

App.obfuscated.rename-map.json lists every rename, sorted so the same input always produces the same file:

{
  "assembly": "App.dll",
  "renames": [
    {
      "kind": "Type",
      "original": "MyApp.Services.AuthService",
      "obfuscated": "MyApp.Services.a"
    },
    {
      "kind": "Method",
      "original": "MyApp.Services.AuthService::SignIn(System.String)",
      "obfuscated": "MyApp.Services.a::b"
    }
  ]
}

kind is Type, Method, Field, Property, or Event. Search the obfuscated value when a stack trace shows the short name. Names are assigned from a fixed letter sequence in sorted full-name order, skipping any name that is still in use in that scope. They do not depend on the clock.

Stack traces

maui-obfuscate deobfuscate rewrites a stack trace with those maps. It requires the same licence and <ApplicationId> as obfuscation. The restored trace goes to standard output. The file list goes to standard error.

maui-obfuscate deobfuscate --project path/to/App.csproj --map path/to/App.obfuscated.rename-map.json --stack trace.txt

Pass --map once for each map, or pass the directory that contains the *.obfuscated.rename-map.json files. Omit --stack to read the trace on standard input. A frame the maps do not name is left as it was. A short name that appears in more than one map, with two different originals, is left unchanged and reported.

An obfuscation run prints the files a later deobfuscation needs:

  • every *.obfuscated.rename-map.json written for that run, including a dependency that was merged into the app
  • the command to run, with --project, --map, and --stack

The obfuscated DLL is not read. Each .pdb.original file is the PDB from before this pass, and deobfuscation does not read it. Flattening moves the IL, and the obfuscated assembly is written without a PDB, so an in file:line suffix stays as it appeared in the trace. Keep the maps where you can reach them, and leave them out of the package you upload.

Protections

init writes these on and flattens once. Set any flag to false to skip that pass.

"protections": {
  "controlFlowFlattening": true,
  "controlFlowFlatteningIterations": 1,
  "packing": true,
  "antiDebug": true,
  "antiIldasm": true,
  "antiDe4dot": true
}

Control-flow flattening rewrites a method into a dispatcher: each basic block sets a state and jumps back to a switch. The method still returns the same values. controlFlowFlatteningIterations repeats that rewrite. Omit it and each method is flattened once. The value must be from 1 to 8. A later pass that flattens nothing stops early. Each extra pass wraps the previous dispatcher and grows the method. Methods that contain an exception handler, a pinned local, or a branch that leaves a value on the stack are left unchanged, so try/catch and ?. stay valid. This is ordinary IL, so MAUI AOT (including iOS) can still compile it.

Packing replaces ldstr with a call that inflates a compressed table and decodes the string. The plaintext is not stored in the assembly. XAML, resx, images, fonts, and other resources are not packed. The same AOT limit applies: the decoder is a normal method, not a native unpacker.

Anti-debug adds a module initializer that calls Debugger.IsAttached and, when that is true, Environment.FailFast. It uses the portable managed check, so the same assembly runs on Windows, Mac, Linux, Android, and iOS. Close the debugger before launching a protected build. The check is at startup only.

Anti-ILDASM applies SuppressIldasmAttribute to the module and the assembly. Classic ildasm.exe stops. ILSpy, dnSpy, and the ildasm in current .NET SDKs do not honor it. The attribute is obsolete in .NET 6 and later and does not change how the CLR runs the assembly.

Anti-de4dot defines ConfusedByAttribute and DotfuscatorAttribute and applies both to the module and the assembly. A default de4dot run sees the Confuser signature and the Dotfuscator signature and stops. A tool that ignores those names still opens the file. The attributes are not executed unless something reflects over them. An existing type of that name is reused when it has a parameterless constructor.

Limits

  • Flattening does not rewrite methods with exception handlers or a non-empty stack at a branch. Iterations above 1 repeat the rewrite and grow the IL. The maximum is 8.
  • SuppressIldasmAttribute asks classic ildasm to stop. Other decompilers ignore it.
  • The de4dot decoy is a signature collision. It does not stop a tool that ignores ConfusedByAttribute and DotfuscatorAttribute.
  • A dependency with merge true is removed after parking .original. merge false, or omitting merge, leaves that DLL in place. An explicit false wins over a broader true. merge is ignored for the assembly being obfuscated. Strong-named and mixed-mode assemblies are refused.
  • Resources, XAML, resx, images, and fonts are not rewritten. A string in XAML, a binding path, or a reflection lookup of an old name is not updated.
  • Public properties on included types are renamed. Default settings include ViewModels, so {Binding UserName} on an included view model breaks until you set that group to include: false, narrow its patterns, or mark the property with [Obfuscation(Exclude = true)]. Properties on excluded types are kept.
  • Virtuals, interface implementations, and enum field names are kept so the app still binds to the framework. They are not scrambled.
  • Strong-named assemblies are refused. The tool does not re-sign.
  • ldstr of a type or member name is not rewritten.
  • The tool does not build the app and does not install the MAUI workload. Point it at a DLL that is already built.
  • The output has no PDB. Decode stacks with the rename map.
  • Discovery of bin/<configuration>/<tfm>/*.dll does not evaluate MSBuild conditions. The targets file passes $(TargetPath), which avoids that.
Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

This package has no dependencies.

Version Downloads Last Updated
1.2.3 0 10/26/2026
1.2.2 0 10/26/2026
1.2.1 37 10/26/2026
1.2.0 39 10/26/2026
1.1.0 55 10/24/2026
1.0.3 53 10/23/2026
1.0.2 50 10/23/2026
1.0.1 58 10/23/2026
1.0.0 53 10/23/2026
0.0.1 52 10/23/2026