.NET Reactor obfuscation pitfalls when the consumer is C++/CLI
Scenario: after shipping the C# motion/IO plugin to a customer wrapped as a native C++ wrapper via C++/CLI, we also wanted to obfuscate the managed DLL with .NET Reactor (Eziriz) to prevent the customer from one-click-decompiling it with dnSpy. Two seemingly independent things — combined, they crash on the customer's machine.
What broke
First deployment of the obfuscated Managed.dll to the customer site (Traditional Chinese Windows 11, CP950) running the C++ sample exe:
Customer-side symptom
An Assert Failure dialog:
Expression: [mscorlib recursive resource lookup bug]
Description: Infinite recursion during resource lookup within mscorlib.
Resource name: Arg_NullReferenceException
Corresponding Windows Event Log (.NET Runtime provider, EventID 1025):
應用程式透過 System.Environment.FailFast(string message) 要求終止處理序。
訊息: Infinite recursion during resource lookup within mscorlib.
Key stack trace (read bottom-up)
ECat_Service_CreateFromConfig(SByte*, Void**) ← C++/CLI exported entry
↓
<Module>..cctor() ← module init injected by Reactor
↓
[four levels of nested obfuscated cctor chain]
ANUmTGFYQd8qoGra3l6.GlSORSFN8ZH7tAqY0C4..cctor()
→ OGvPt1pYkgbgnM9n3TH.ilqFOPpN6W3xlxKdrtt..cctor()
→ AiQ5gEp9SVeGYOOrn4F.l3peVMpP4OSZLavrqGZ..ctor()
→ NRMHOWpkBRE0JgXhfZX.CduiUepOtuoj6c7HJl5..cctor()
→ NullReferenceException..ctor() ← cctor throws NRE
The cctor throws NullReferenceException → mscorlib tries to fetch the localized string for Arg_NullReferenceException → triggers AppDomain.OnResourceResolveEvent → Reactor's injected resolve handler throws NRE again → infinite recursion → CLR FailFast.
First attempt (failed): enable NecroBit Reflection Compatibility Mode
The intuition was that NecroBit's ResourceResolve handler isn't reentrant, so the official-docs-recommended setting:
NecroBit_Reflection_Compatibility_Mode = true
Result: same crash, no improvement.
Reason: the real culprit isn't resource lookup itself — somewhere in the cctor chain, a method actually throws an NRE. Reflection Compat Mode only stops the resource resolve handler from blowing up on re-entry; it doesn't help with "init phase throws the first NRE".
Root cause
A search of Eziriz's official Google Group turned up C++/CLI (CLR Console App) with VS2005 Problem — an identical report:
A user protected even a Hello World C++/CLI console app with the default Quick Settings (NecroBit + Obfuscation only), and got an immediate crash with
System.Reflection.TargetInvocationException.
In other words, NecroBit is incompatible with the assembly load order of mixed-mode (C++/CLI #using load path). NecroBit's module-level init depends on certain CLR state, but C++/CLI's load timing for mixed-mode assemblies is different (see Microsoft Learn: Initialization of Mixed Assemblies) — NecroBit's init is triggered before it's ready → cctor throws NRE.
Our scenario fits exactly:
LeYu.ECat.CppCli.dllis C++/CLI/clrmixed-mode- Customer
.exeloads it via the import lib - It then loads the Reactor-protected
LeYu.ECat.Managed.dllvia#using - The first touch to Managed.dll runs the module cctor → NecroBit init → boom
Pure C# EXEs loading an obfuscated DLL don't hit this; only the C++/CLI mixed-mode init path triggers it.
Final solution: a weakened-but-compatible protection profile
.nrproj configuration changes
| Setting | Was | Now | Reason |
|---|---|---|---|
NecroBit | true | false | The direct culprit — incompatible with C++/CLI |
Anti_Tampering | true | false | Hash checks fail on the #using load path |
Inject_Invalid_Metadata | true | false | Metadata mutation interacts badly with the CLR loader |
String_Encryption | true | false | Strings get referenced before the init-phase decryption is ready |
Resource_Encryption_And_Compression | true | false | Same as above |
Control_Flow_Obfuscation | false | true | Newly enabled: compensate for losing NecroBit by scrambling method bodies |
Obfuscation | true | true | Name obfuscation — the most important customer-facing protection |
Anti_ILDASM | true | true | Cheap, no side effects |
Obfuscate_Public_Types | false | false | Cannot be changed — #using references and customer callback signatures rely on the public names |
NecroBit_Reflection_Compatibility_Mode | false → true | true | With NecroBit off this no longer matters, but leaving it on is harmless |
Protection level comparison
| What an attacker with the obfuscated DLL wants to do | Before (NecroBit on, but doesn't run) | After (NecroBit off, runs) |
|---|---|---|
| Decompile to readable C# with ILSpy / dnSpy | Fully blocked | IL is visible, but garbled names + control-flow obfuscation make the logic hard to reconstruct |
| ildasm IL dump | Blocked (Anti-ILDASM) | Blocked (Anti-ILDASM) |
| grep strings for algorithm names | Blocked | Strings visible (acceptable if the lib contains no sensitive strings) |
| Forge signature and repack | Blocked (Anti-Tampering) | Not blocked (if the lib has no license logic, no payoff) |
For the threat model "motor control lib + no license mechanism + no secret algorithm", this profile is enough — the goal is mainly to stop the "decompile with dnSpy and copy-paste" adversary.
Visual Studio + .NET Reactor integration
Wire Reactor into MSBuild so every Release build obfuscates automatically — no manual dotNET_Reactor.Console.exe runs.
1. Environment variable (set once per build machine)
[Environment]::SetEnvironmentVariable(
'DOTNETREACTORROOT',
'C:\Program Files (x86)\Eziriz\.NET Reactor',
'User')
This keeps the vcxproj from hardcoding paths, and lets machines without Reactor still build (gracefully skipping rather than failing).
2. .nrproj next to the managed project
src/Managed/LeYu.ECat.Managed.nrproj — created via Reactor's GUI, with the configuration above saved into it. Don't expose this file's master key. If the repo is going public, remove .nrproj from git history first and regenerate the master key in the GUI.
3. AfterBuild target in the CppCli project
In LeYu.ECat.CppCli.vcxproj (or whichever project assembles the dist):
<Target Name="AfterBuild">
<!-- First copy every DLL from OutDir to dist/bin/ -->
<ItemGroup>
<_DistBinDlls Include="$(OutDir)*.dll" />
</ItemGroup>
<Copy SourceFiles="@(_DistBinDlls)"
DestinationFolder="$(SolutionDir)dist\bin\"
SkipUnchangedFiles="true" />
<!-- Reactor path: prefer env var, fall back to default install path -->
<PropertyGroup>
<NetReactorRoot Condition="'$(DOTNETREACTORROOT)' != ''">$(DOTNETREACTORROOT)</NetReactorRoot>
<NetReactorRoot Condition="'$(NetReactorRoot)' == ''">C:\Program Files (x86)\Eziriz\.NET Reactor</NetReactorRoot>
<NetReactorExe>$(NetReactorRoot)\dotNET_Reactor.Console.exe</NetReactorExe>
<NetReactorProject>$(SolutionDir)src\Managed\LeYu.ECat.Managed.nrproj</NetReactorProject>
<DistManagedDll>$(SolutionDir)dist\bin\LeYu.ECat.Managed.dll</DistManagedDll>
</PropertyGroup>
<!-- Only obfuscate in Release with Reactor present; otherwise skip, don't fail -->
<Message Text="Protecting $(DistManagedDll) with .NET Reactor..." Importance="high"
Condition="'$(Configuration)' == 'Release'
AND Exists('$(NetReactorExe)')
AND Exists('$(NetReactorProject)')" />
<Exec Command=""$(NetReactorExe)" -licensed -q -project "$(NetReactorProject)" -file "$(DistManagedDll)" -targetfile "$(DistManagedDll)""
Condition="'$(Configuration)' == 'Release'
AND Exists('$(NetReactorExe)')
AND Exists('$(NetReactorProject)')" />
</Target>
Key points:
- In-place overwrite:
-fileand-targetfilepoint to the same path. Reactor replacesdist/bin/Managed.dllwith the obfuscated version - No obfuscation on Debug builds: the
'$(Configuration)' == 'Release'condition; otherwise every F5 stalls waiting for obfuscation - No-Reactor machines don't fail:
Exists('$(NetReactorExe)')guards it, so CI servers and new-developer machines still build (their dist is just unobfuscated) -licensed -q: licensed mode + quiet. Without a license it falls back to trial mode and the build log fills with red warnings- Reactor processes the
dist/bin/copy, notOutDir: keepOutDirunobfuscated for local debugging; only the shipping copy is obfuscated
Maintenance advice
When upgrading .NET Reactor
A new version may have fixed NecroBit's C++/CLI compatibility. After upgrade:
- On a dev machine, set
NecroBit = trueand build the dist - Take the dist to a test machine and run the sample exe
- If it no longer crashes → it's fixed; you can raise the protection level
- If it still crashes → keep it OFF
Filling the gap left by NecroBit
Alternatives worth considering:
- Hide_Method_Calls — advanced method-call obfuscation, fewer side effects
- Stealth_Obfuscation — advanced name obfuscation mode
- A custom native obfuscation pass — obfuscate the C++/CLI bridge layer instead of the managed side
Things to never do
- ❌ Don't set
Obfuscate_Public_Typesto true — customer callbacks and#usingboth break. Public class / method / property names must be preserved. - ❌ Don't re-enable NecroBit without first running it through a test machine — we already know it doesn't run; only a Reactor update has any chance of making it work.
- ❌ Don't publish the .nrproj master key to a public repo.
Verification checklist (reuse this for future tests)
-
Build: from a Developer Command Prompt
msbuild LeYuECat.sln /p:Configuration=Release /p:Platform=x64Expected: build log shows
Protecting ...\dist\bin\LeYu.ECat.Managed.dll with .NET Reactor..., 0 errors 0 warnings. -
Local managed tests: the unobfuscated
src/Managed/bincopy; obfuscation doesn't affect unit tests:dotnet test src\Managed\Managed.Tests\Managed.Tests.csproj -c Release -
Customer-side smoke test: copy the full
dist/to a clean test machine (or Windows VM):- Run
redist/VC_redist.x64.exeonce - Go into
samples/01_InitShutdown/ msbuild 01_InitShutdown.vcxproj /p:Configuration=Release /p:Platform=x64- Run the produced
01_InitShutdown.exe - Expected: exit code 0;
ethercat_wrapper.logshowsEtherCAT initialization started ... completed; no crash dialog
- Run
-
Advanced verification (obfuscation is effective):
- Open
dist/bin/Managed.dllin dnSpy - Expected: the public
LeYu.ECat.*namespaces are visible and public class names are preserved, but inside method bodies the locals / control flow are garbage or full of meaningless goto
- Open
TL;DR
If your C# DLL will be loaded by a C++/CLI /clr consumer:
- NecroBit always OFF. Other options that depend on module init / metadata rewriting (Anti-Tampering, Inject Invalid Metadata, String/Resource Encryption) should also be off
- Keep Obfuscation + Anti-ILDASM + Control Flow Obfuscation as the primary protection
Obfuscate_Public_Typesalways OFF —#usingand callbacks both rely on the public names- Wire Reactor into MSBuild's
AfterBuildtarget, with aDOTNETREACTORROOTenv var +Exists()guard so non-Reactor machines can still build - Before shipping, always run a full sample on a clean Traditional Chinese Windows test machine — the CP950 environment is a commonly overlooked variable
References
- Eziriz forum: C++/CLI (CLR Console App) with VS2005 Problem — thread with identical symptoms
- Eziriz: .NET Reactor Features
- Eziriz: Command Line Parameters
- Microsoft Learn: Initialization of Mixed Assemblies
Related docs
- Calling C# DLLs from C++ — survey + LeYuECat case study — the prerequisite context for this obfuscation pitfall (why there's a C++/CLI mixed-mode load path in the first place)