Skip to content
← All posts
· Delta1 Labs Nebula.NETObfuscationNative AOT

Obfuscating .NET Native AOT and single-file apps

Publishing as a single file or with Native AOT is packaging and compilation, not protection. Here is what each actually does to your code, why single-file apps still ship fully-recoverable managed IL, what Native AOT does and does not hide — and why obfuscation has to run on the IL before the bundle is built or the AOT compiler removes it.

There is a comforting belief going around .NET teams: that if you publish your app as a single file, or compile it with Native AOT, you no longer need to obfuscate it. The reasoning sounds plausible — “there’s no DLL to decompile anymore.” It is also wrong, and in the single-file case it is dangerously wrong. Publishing format is not a protection boundary. Single-file publishing is packaging; Native AOT is compilation. Neither was designed to stop reverse engineering, and treating them as if they were leaves exactly the code you most wanted to hide sitting in the clear.

This post is about what each publishing mode actually does to your code, what it protects and what it doesn’t, and the one structural fact that decides everything: obfuscation has to happen on the managed IL before the bundle is built or the AOT compiler runs — because afterwards there is nothing left for it to transform.

Single-file publishing is a container, not a lock

When you set PublishSingleFile, the SDK does not fuse your code into one indivisible blob. It produces a native host executable with your application’s files — managed assemblies, configuration, native dependencies — embedded inside it as resources. At launch, the host locates those embedded assemblies and loads them directly from the bundle. Crucially, the managed assemblies are still exactly what the C# compiler emitted: ordinary .NET assemblies containing ordinary IL and full metadata.

That means the single .exe is a container, and the container has a documented layout. Publicly available bundle extractors read that layout and write every embedded assembly back out to disk as a normal .dll. The round trip takes seconds and requires no skill:

# an attacker, given your "protected" single-file app
single-file-extractor MyApp.exe -o extracted/
# extracted/MyApp.dll, extracted/MyApp.Core.dll, ...  all plain IL

From there it is the same story as any unprotected assembly: open MyApp.dll in a decompiler and read near-original C#. Your license check, your API keys baked into string literals, your business logic — all of it. Single-file publishing solved a deployment problem (one file to copy) and did nothing whatsoever for confidentiality. If you would have obfuscated the DLLs before, you still have to obfuscate them now; the bundle just hides them from a casual file listing, not from anyone who types one command.

Native AOT removes the IL — but that is not a security feature

Native AOT is a genuinely different thing. With PublishAot, the IL compiler (ILC) compiles your managed code to native machine code ahead of time. The published artifact contains no JIT, and for the code that is fully AOT-compiled there is no IL and no managed method body to recover. Point a .NET decompiler at it and there is nothing for the decompiler to read — it only understands IL. Reverse engineering now means native disassembly, which is a real step up in cost. That is a nice property.

It is also a side effect, not a guarantee, and it comes with important limits that teams routinely overlook:

  • AOT is not available for every workload. It forbids runtime code generation, is strict about reflection and dynamic loading, and plenty of frameworks, plugin architectures and reflection-heavy apps simply cannot be compiled this way. The large majority of real .NET applications today are not fully AOT, which means this “protection” doesn’t exist for them.
  • Mixed and partial deployments still ship managed IL. If any part of your product is a class library consumed elsewhere, a plugin, or a component outside the AOT image, that part is back to being plain, decompilable IL.
  • Data survives the compile. String literals, embedded resources and the shape of your constants generally remain present in the native image. A disassembler plus a strings dump still leaks the "LIC-" prefix and the endpoint URL even when the surrounding logic is native.
  • It is undocumented as protection. Relying on AOT to hide your algorithm means relying on a compiler’s incidental behavior that Microsoft never promised to keep. That is not a security posture you want to build enforcement on.

So Native AOT raises the bar where it applies, and it is a welcome extra layer. It is not a replacement for protecting the managed layer — especially not for the many apps and components that can’t be AOT-compiled in the first place.

The structural fact: obfuscate the IL before it’s gone

Here is the point everything turns on. Obfuscation — renaming, string encryption, control-flow flattening, method virtualization — is an IL-to-IL transform. It reads your compiled assemblies, rewrites the IL, and writes protected assemblies back out. It needs IL as both its input and its output.

Now look at where IL exists in the publish pipeline:

  • The C# compiler turns your source into managed assemblies (IL). ← obfuscation can run here
  • dotnet publish with single-file embeds those IL assemblies into the host. The IL still exists, but it is now packaged — and if you only obfuscate after this, you’d have to unpack, transform and repack, which defeats the point.
  • dotnet publish with Native AOT compiles that IL to native code and discards it. After this step there is no IL at all. There is nothing left to obfuscate.

The conclusion is unavoidable: protection has to land on the assemblies between the compile and the publish. Obfuscate the IL first; then let the bundler package it or the AOT compiler consume it. Nebula.NET is built to sit in exactly that slot — it operates on the compiled assemblies, before publish.

Source .csManaged IL(from csc)Nebulaobfuscate the ILSingle-file bundleIL embedded as resourcesNative AOT compileIL compiled away to nativeObfuscation runs on the IL — before the bundle is built or AOT removes it.

Wiring it into a publish build

A single-file project’s .csproj looks like this:

<PropertyGroup>
  <OutputType>Exe</OutputType>
  <TargetFramework>net8.0</TargetFramework>
  <PublishSingleFile>true</PublishSingleFile>
  <SelfContained>true</SelfContained>
  <!-- or, for Native AOT: -->
  <!-- <PublishAot>true</PublishAot> -->
</PropertyGroup>

The job is to insert an obfuscation step after your assemblies are built and before publish bundles or AOT-compiles them. You have two clean options.

Option A — protect the build output in CI, then publish. Build normally, run Nebula over the output assemblies, and point publish at the protected copies:

dotnet build -c Release
nebula --config nebula.config.json          # rewrites the IL in place / into protected/
dotnet publish -c Release --no-build -o out  # bundles or AOT-compiles the protected IL

with a nebula.config.json that preserves everything AOT and reflection need:

{
  "schemaVersion": 1,
  "inputs": ["bin/Release/net8.0/MyApp.dll", "bin/Release/net8.0/MyApp.Core.dll"],
  "outputDirectory": "bin/Release/net8.0",
  "preservePublicApi": true,
  "encryptStrings": true,
  "controlFlowObfuscation": true,
  "autoProtectLicensing": true
}

Option B — an MSBuild hook that runs between Build and Publish, so a plain dotnet publish is already protected:

<Target Name="NebulaProtect" AfterTargets="Build" BeforeTargets="Publish">
  <Exec Command="nebula --config $(ProjectDir)nebula.config.json" />
</Target>

Either way, the AOT compiler or the single-file bundler now consumes IL that is already obfuscated. For AOT specifically, keep in mind the same discipline you use for trimming: renaming must preserve your public surface and any member reached by reflection or serialization, because the AOT toolchain is strict about both. See configuration for the preserve rules, and the general walkthrough in how to obfuscate a .NET assembly.

Before and after

The test is always the same one: look at the shipped artifact through an attacker’s tools.

Without obfuscation, the single-file case is the worst outcome — a false sense of security over trivially recoverable code:

// after running a bundle extractor on the single-file .exe, then a decompiler:
public bool IsLicensed(string key)
{
    if (!key.StartsWith("LIC-")) return false;
    return ValidateSignature(key, "https://api.example.com/v1/activate");
}

With obfuscation applied to the IL first, the extracted (or native) code no longer tells its story — the method is a flattened dispatcher, the prefix and URL are decrypted at runtime rather than printed, and on Enterprise the enforcement method is virtualized to a bytecode with no decompiler:

public bool a(string A_0)
{
    int s = 0;
    while (true) { switch (s) { /* dispatcher over reordered blocks */
        case 0: if (!A_0.StartsWith(Strings.Get(0x3f1))) { s = 9; break; } s = 4; break;
        case 9: return false;
        /* ... */ } }
}

Single-file publishing left the first version perfectly readable. AOT would have made the native case harder to decompile — but only for the fully-compiled code, only where AOT is even an option, and never for the strings. Obfuscating the IL first is what actually protects the enforcement logic in both worlds.

The takeaway

Single-file and Native AOT are good engineering choices for distribution and startup performance. Neither is a protection strategy. A single-file app ships your IL intact inside a container anyone can open; a Native AOT app hardens the native output as a side effect but can’t be used everywhere and still leaks your data. The only reliable place to protect your code is the managed IL, between compile and publish — obfuscate it there, then let the bundler or the AOT compiler do their job. If protection runs after the format has been applied, there is nothing left to protect.

Try Nebula.NET

Harden your .NET code in minutes — start with the free edition.