Skip to main content

.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 Probleman 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.dll is C++/CLI /clr mixed-mode
  • Customer .exe loads it via the import lib
  • It then loads the Reactor-protected LeYu.ECat.Managed.dll via #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

SettingWasNowReason
NecroBittruefalseThe direct culprit — incompatible with C++/CLI
Anti_TamperingtruefalseHash checks fail on the #using load path
Inject_Invalid_MetadatatruefalseMetadata mutation interacts badly with the CLR loader
String_EncryptiontruefalseStrings get referenced before the init-phase decryption is ready
Resource_Encryption_And_CompressiontruefalseSame as above
Control_Flow_ObfuscationfalsetrueNewly enabled: compensate for losing NecroBit by scrambling method bodies
ObfuscationtruetrueName obfuscation — the most important customer-facing protection
Anti_ILDASMtruetrueCheap, no side effects
Obfuscate_Public_TypesfalsefalseCannot be changed#using references and customer callback signatures rely on the public names
NecroBit_Reflection_Compatibility_Modefalse → truetrueWith NecroBit off this no longer matters, but leaving it on is harmless

Protection level comparison

What an attacker with the obfuscated DLL wants to doBefore (NecroBit on, but doesn't run)After (NecroBit off, runs)
Decompile to readable C# with ILSpy / dnSpyFully blockedIL is visible, but garbled names + control-flow obfuscation make the logic hard to reconstruct
ildasm IL dumpBlocked (Anti-ILDASM)Blocked (Anti-ILDASM)
grep strings for algorithm namesBlockedStrings visible (acceptable if the lib contains no sensitive strings)
Forge signature and repackBlocked (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="&quot;$(NetReactorExe)&quot; -licensed -q -project &quot;$(NetReactorProject)&quot; -file &quot;$(DistManagedDll)&quot; -targetfile &quot;$(DistManagedDll)&quot;"
Condition="'$(Configuration)' == 'Release'
AND Exists('$(NetReactorExe)')
AND Exists('$(NetReactorProject)')" />
</Target>

Key points:

  • In-place overwrite: -file and -targetfile point to the same path. Reactor replaces dist/bin/Managed.dll with 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, not OutDir: keep OutDir unobfuscated 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:

  1. On a dev machine, set NecroBit = true and build the dist
  2. Take the dist to a test machine and run the sample exe
  3. If it no longer crashes → it's fixed; you can raise the protection level
  4. 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_Types to true — customer callbacks and #using both 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)

  1. Build: from a Developer Command Prompt

    msbuild LeYuECat.sln /p:Configuration=Release /p:Platform=x64

    Expected: build log shows Protecting ...\dist\bin\LeYu.ECat.Managed.dll with .NET Reactor..., 0 errors 0 warnings.

  2. Local managed tests: the unobfuscated src/Managed/bin copy; obfuscation doesn't affect unit tests:

    dotnet test src\Managed\Managed.Tests\Managed.Tests.csproj -c Release
  3. Customer-side smoke test: copy the full dist/ to a clean test machine (or Windows VM):

    • Run redist/VC_redist.x64.exe once
    • 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.log shows EtherCAT initialization started ... completed; no crash dialog
  4. Advanced verification (obfuscation is effective):

    • Open dist/bin/Managed.dll in 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

TL;DR

If your C# DLL will be loaded by a C++/CLI /clr consumer:

  1. NecroBit always OFF. Other options that depend on module init / metadata rewriting (Anti-Tampering, Inject Invalid Metadata, String/Resource Encryption) should also be off
  2. Keep Obfuscation + Anti-ILDASM + Control Flow Obfuscation as the primary protection
  3. Obfuscate_Public_Types always OFF#using and callbacks both rely on the public names
  4. Wire Reactor into MSBuild's AfterBuild target, with a DOTNETREACTORROOT env var + Exists() guard so non-Reactor machines can still build
  5. Before shipping, always run a full sample on a clean Traditional Chinese Windows test machine — the CP950 environment is a commonly overlooked variable

References