Obfuscating a multi-targeted assembly: one config across net48 and net8.0
A library that targets net48;net8.0 builds into two different assemblies, with two different reference sets and sometimes two different sets of IL. Protecting only one of them ships an unobfuscated build. Here is how Nebula.NET treats each target framework as its own obfuscation run while sharing one policy — and the per-TFM gotchas that bite if you do not.
Add a second target framework to a .csproj and you have quietly doubled your shipping surface. <TargetFrameworks>net48;net8.0</TargetFrameworks> tells the compiler to build the project twice, into two separate folders, as two distinct assemblies. They share a name and a version, but they are not the same binary: they bind against different reference assemblies, and if your code has a single #if NET8_0_OR_GREATER in it, they don’t even contain the same IL. A customer who installs the net48 build and a customer on net8.0 are running different DLLs.
That matters the moment you protect the thing. Point your obfuscator at one output folder — the one your dev machine happens to build — and you have hardened exactly one of the two assemblies you ship. The other goes out in the clear. This post is about doing it right with Nebula.NET: treating each target framework as its own obfuscation run against its own references, while a single policy governs all of them, and the handful of per-TFM details that will bite you if you’re not watching for them.
What “multi-targeted” actually produces
A multi-targeted build is not one build with a choice at the end. MSBuild runs the inner build once per TFM and lays the results out side by side:
MyLib/bin/Release/
├─ net48/
│ └─ MyLib.dll → references mscorlib, System, System.Core, …
└─ net8.0/
└─ MyLib.dll → references System.Runtime, System.Private.CoreLib, …
Same MyLib.dll name, two different assemblies. For a NuGet package the same split shows up as lib/net48/MyLib.dll and lib/net8.0/MyLib.dll, and the restore engine picks the best match for each consumer. So “obfuscate MyLib.dll” is ambiguous — there are two of them, and both have to be protected, each against the reference set it was compiled for, or signature verification and reference rewriting will be wrong.
The right way: one policy, one run per framework
Nebula obfuscates compiled assemblies, not source, so none of this needs code changes — it hooks the build output. The cleanest integration is the MSBuild package, which understands multi-targeting natively. Add the build-only package and gate it on a signal that exists only on your build agent, so developer builds stay untouched:
<ItemGroup>
<PackageReference Include="Delta1Labs.Nebula.MSBuild" Version="1.1.6" PrivateAssets="all" />
</ItemGroup>
<PropertyGroup Condition="'$(NEBULA_OBFUSCATE)' == '1'">
<NebulaObfuscate>true</NebulaObfuscate>
<NebulaConfigFile>$(MSBuildProjectDirectory)\nebula.config.json</NebulaConfigFile>
</PropertyGroup>
That’s the whole wiring for a multi-targeted project. The package runs after each inner build, so every target framework is protected into its own per-TFM nebula output folder automatically — you do not list the frameworks. The net48 DLL is obfuscated inside the net48 build with net48’s references on hand; the net8.0 DLL inside the net8.0 build with its references. One nebula.config.json drives both.
And that config carries no inputs — it is a settings-only policy. The build supplies the assembly; the file supplies the transforms and rules:
{
"preservePublicApi": true,
"controlFlowObfuscation": true,
"encryptStrings": true,
"obfuscateConstants": true,
"metadataHardening": true,
"antiTamper": true,
"exclude": [
"MyLib.Interop.NativeMethods"
],
"excludeNamespaces": [
"MyLib.Abstractions"
]
}
Those transforms (unlimited string/control-flow, constant masking, metadata hardening, anti-tamper) are Pro features; identifier renaming is in every edition. The point is that the same policy applies identically to both assemblies — you are not maintaining nebula.net48.json and nebula.net8.json.
If you’d rather keep zero package references, drive the CLI from a Directory.Build.targets and let the per-TFM guard do the fan-out for you:
<Project>
<Target Name="NebulaObfuscate" AfterTargets="Build"
Condition="'$(NEBULA_OBFUSCATE)' == '1' and '$(TargetFramework)' != ''">
<Exec Command="nebula --config "$(MSBuildThisFileDirectory)nebula.config.json" --input "$(TargetPath)" --output "$(TargetDir)nebula"" />
</Target>
</Project>
The '$(TargetFramework)' != '' condition is doing real work here. In a multi-targeted build, the target runs once inside each concrete inner build — where $(TargetFramework) is net48, then net8.0 — and $(TargetPath) points at that framework’s own DLL. The condition skips the outer aggregate build, where $(TargetFramework) is empty and there is no single assembly to process. Without it you either run against nothing or run once and miss a framework.
The per-TFM gotchas
References resolve per framework — let the build hand them over
Renaming and the final verification pass both need to see the assembly’s real references: to rewrite a cross-reference, or to confirm a transformed module still resolves. Those references differ by TFM — mscorlib/System.* for net48, the System.Runtime facade set for net8.0. Running the obfuscation inside each inner build (both approaches above do) means Nebula binds against exactly the right reference set for the framework in hand. Keep verifyOutput on (it is, by default): it re-checks the transformed module for dangling references before writing and refuses to emit a module that wouldn’t load — which is precisely the error you’d otherwise discover only when the net48 customer’s app fails to start.
#if means the two assemblies don’t have the same members
Conditional compilation is the subtle one. Consider:
public sealed class Cache
{
#if NET8_0_OR_GREATER
public void Store(ReadOnlySpan<byte> data) { /* span path */ }
#else
public void Store(byte[] data) { /* array path */ }
#endif
internal void Evict() { /* both */ }
}
The net8.0 assembly has a Store(ReadOnlySpan<byte>); the net48 one has Store(byte[]). If you write an exclude or include entry for a member, it will match under the TFMs where that member exists and silently do nothing under the others. That’s usually fine — but it means you can’t assume an allowlist that is complete for net8.0 is complete for net48. Lean on autoDetect (on by default) to preserve names required for correctness — reflection, serialization, DI and XAML targets — across every target, and reserve manual exclude entries for the genuinely framework-specific surface.
Preserve the public API consistently for a shipped library
For a library other people reference, keep preservePublicApi: true so the public type and member names survive in both assemblies — a consumer who themselves multi-target against you must see the same public surface whichever of your DLLs they resolve. Renaming only the internals still hardens the valuable logic while leaving the callable API intact. (For an end-user app that nobody references, flip it off and rename everything eligible.)
Each framework gets its own symbol map
Every run emits a symbol (rename) map alongside its output — and because the two assemblies were renamed independently, the maps differ per TFM. When you deobfuscate a stack trace from the field, you must use the map from the framework the crash came from: feeding a net8.0 trace through the net48 map produces garbage. Keep the per-TFM nebula output folders as build artifacts, named by framework, so the right map is always findable. The mechanics of mapping a trace back are in Deobfuscating stack traces.
Pack after you protect
If you ship a NuGet package, the lib/net48 and lib/net8.0 DLLs inside the .nupkg must each be the obfuscated one, not the raw compiler output. Run protection before the pack step and point packaging at the protected assemblies — the full recipe is Obfuscating a NuGet package. For a multi-assembly product where your TFMs reference each other, obfuscate the published output as one step so cross-assembly references stay consistent; see Multiple assemblies.
Verify both, not one
The failure mode this whole post guards against is invisible: your app still runs, your tests still pass, and one of your two shipped assemblies is in the clear. So check both. Open each framework’s output DLL in a decompiler and confirm the internals are renamed and the strings encrypted — the free Glass.NET decompiler is handy for a quick before/after on each TFM. Confirm a per-TFM symbol map exists for each. And keep the build-agent gate honest, so the protected build is the one that leaves the building.
Multi-targeting is now the norm for libraries, and it means your obfuscation story is a per-framework story whether you planned for it or not. The good news is that it costs you almost nothing to get right: one settings-only policy, protection that runs inside each inner build, and the discipline to verify every TFM you ship. Start from the MSBuild & CI guide and the configuration reference for the exact properties and fields.
Try Nebula.NET
Harden your .NET code in minutes — start with the free edition.