.NET Reactor 混淆 C# DLL 給 C++/CLI consumer 的踩雷紀錄
情境:C# 的 motion/IO plugin 用 C++/CLI 包成 native C++ wrapper 出貨給客戶 後,又想用 .NET Reactor (Eziriz) 把 managed DLL 混淆,避免客戶 dnSpy 一鍵反編譯。看似互相獨立的兩件事,組起來會在客戶機 crash。
撞到的問題
第一次把混淆過的 Managed.dll 部署到客戶現場(繁中 Windows 11,CP950)執行 C++ sample exe:
客戶端表現
跳出 Assert Failure 對話框:
Expression: [mscorlib recursive resource lookup bug]
Description: Infinite recursion during resource lookup within mscorlib.
Resource name: Arg_NullReferenceException
對應 Windows Event Log(.NET Runtime Provider,EventID 1025):
應用程式透過 System.Environment.FailFast(string message) 要求終止處理序。
訊息: Infinite recursion during resource lookup within mscorlib.
關鍵 stack trace(由下往上讀)
ECat_Service_CreateFromConfig(SByte*, Void**) ← C++/CLI exported entry
↓
<Module>..cctor() ← Reactor 注入的 module init
↓
[四層巢狀 obfuscated cctor 鏈]
ANUmTGFYQd8qoGra3l6.GlSORSFN8ZH7tAqY0C4..cctor()
→ OGvPt1pYkgbgnM9n3TH.ilqFOPpN6W3xlxKdrtt..cctor()
→ AiQ5gEp9SVeGYOOrn4F.l3peVMpP4OSZLavrqGZ..ctor()
→ NRMHOWpkBRE0JgXhfZX.CduiUepOtuoj6c7HJl5..cctor()
→ NullReferenceException..ctor() ← 在 cctor 裡丟 NRE
cctor 裡丟 NullReferenceException → mscorlib 為了取 Arg_NullReferenceException 的本地化字串 → 觸發 AppDomain.OnResourceResolveEvent → Reactor 注入的 resolve handler 又丟 NRE → 無限遞迴 → CLR FailFast。
第一次嘗試(失敗):開啟 NecroBit Reflection Compatibility Mode
直覺以為是 NecroBit 的 ResourceResolve handler 不可重入,所以打開官方文件建議的:
NecroBit_Reflection_Compatibility_Mode = true
結果:相同 crash,沒救到。
理由:真正的兇手不是 resource lookup 本身,是 cctor 鏈裡某個方法 真的 丟了 NRE。Reflection Compat Mode 只能讓 resource resolve handler 在被重入時不再炸第二次,但對「初始化階段就炸第一次」沒幫助。
根本原因
查 Eziriz 官方 Google Group 上的 C++/CLI (CLR Console App) with VS2005 Problem,有完全一樣的回報:
一個 user 把連 Hello World C++/CLI console app 都用預設 Quick Settings(只開 NecroBit + Obfuscation)保護,執行直接 crash 並丟
System.Reflection.TargetInvocationException。
也就是說,NecroBit 與 mixed-mode(C++/CLI #using 載入路徑)的 assembly 載入順序不相容。NecroBit 的 module-level init 依賴某些 CLR state,但 C++/CLI 載入 mixed-mode assembly 的時序不一樣(見 Microsoft Learn: Initialization of Mixed Assemblies),NecroBit 的 init 還沒就緒就被觸發 → cctor 炸 NRE。
我們的場景剛好符合:
LeYu.ECat.CppCli.dll是 C++/CLI/clrmixed-mode- 客戶
.exe透過 import lib 載入它 - 它再透過
#using載入受 Reactor 保護的LeYu.ECat.Managed.dll - Managed.dll 第一次被觸碰時跑 module cctor → NecroBit init → 炸
純 C# 的 EXE 直接載入混淆 DLL 不會撞到這個問題;只有 C++/CLI 走 mixed-mode init 路徑才會觸發。
最終解法:弱化但相容的保護組合
.nrproj 配置變更
| 設定 | 原本 | 改為 | 理由 |
|---|---|---|---|
NecroBit | true | false | 直接元兇,與 C++/CLI 不相容 |
Anti_Tampering | true | false | hash 檢查在 #using 載入路徑會失效 |
Inject_Invalid_Metadata | true | false | 改 metadata 易與 CLR 載入器互動失敗 |
String_Encryption | true | false | Init 階段字串解密還沒就緒就被引用 |
Resource_Encryption_And_Compression | true | false | 同上 |
Control_Flow_Obfuscation | false | true | 新開:補 NecroBit 的位、scrambling method body |
Obfuscation | true | true | 名稱混淆,最重要的客戶端防護 |
Anti_ILDASM | true | true | cheap、無副作用 |
Obfuscate_Public_Types | false | false | 不能改 — #using 引用、客戶 callback 簽章都靠 public 名稱 |
NecroBit_Reflection_Compatibility_Mode | false → true | true | NecroBit off 後其實已不影響,留著無妨 |
防護層級對比
| 攻擊者拿到 obfuscated DLL 想做的事 | 原本(NecroBit on,但跑不起來) | 新方案(NecroBit off,能跑) |
|---|---|---|
| ILSpy / dnSpy 反編譯出可讀 C# | 完全擋 | 看得到 IL,但名稱亂碼 + 控制流混淆讓邏輯難重建 |
| ildasm dump IL | 擋(Anti-ILDASM) | 擋(Anti-ILDASM) |
| 直接 grep 字串看到 algorithm 名 | 擋 | 看得到字串(如果 lib 沒含敏感字串可接受) |
| 假冒簽章重打包 | 擋(Anti-Tampering) | 不擋(若 lib 沒 license 邏輯,沒利可圖) |
對「馬達控制 lib + 沒有 license 機制 + 沒有秘密 algorithm」的 threat model,這個組合的防護層級夠用 — 主要目的是攔住「用 dnSpy 一鍵 reverse 然後複製貼上」的對手。
Visual Studio 與 .NET Reactor 整合
把 Reactor 串進 MSBuild,每次 Release build 自動混淆,不需要手動跑 dotNET_Reactor.Console.exe。
1. 環境變數(每台 build 機設一次)
[Environment]::SetEnvironmentVariable(
'DOTNETREACTORROOT',
'C:\Program Files (x86)\Eziriz\.NET Reactor',
'User')
讓 vcxproj 不用 hardcode 路徑,沒裝 Reactor 的機器也能 build(會 graceful skip 而不是 fail)。
2. .nrproj 放在 managed 專案旁
src/Managed/LeYu.ECat.Managed.nrproj — 用 Reactor GUI 建立,把上一節的設定存進去。不要把這個檔案的 master key 公開;如果 repo 改成 public,要先把 .nrproj 從 git history 移除並在 GUI 重新產生 master key。
3. CppCli 專案的 AfterBuild target
在 LeYu.ECat.CppCli.vcxproj(或任何負責組裝 dist 的專案)裡:
<Target Name="AfterBuild">
<!-- 先把 OutDir 裡所有 DLL 複製到 dist/bin/ -->
<ItemGroup>
<_DistBinDlls Include="$(OutDir)*.dll" />
</ItemGroup>
<Copy SourceFiles="@(_DistBinDlls)"
DestinationFolder="$(SolutionDir)dist\bin\"
SkipUnchangedFiles="true" />
<!-- Reactor 路徑:優先用 env var,fallback 預設安裝路徑 -->
<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>
<!-- 只在 Release + Reactor 存在時 obfuscate;其他情況 skip 不 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>
幾個重點:
- 就地覆蓋:
-file跟-targetfile同一個路徑。Reactor 會把dist/bin/Managed.dll換成混淆版 - Debug build 不混淆:Condition 上
'$(Configuration)' == 'Release',不然每次 F5 都要等混淆 - 沒裝 Reactor 不 fail:
Exists('$(NetReactorExe)')守住,CI 機或新人開發機照樣能 build(只是出來的 dist 是未混淆版) -licensed -q:licensed 模式 + quiet。沒授權會 fall back 到 trial mode,build log 會看到大紅字- Reactor 處理的是
dist/bin/那份,不是OutDir:保留OutDir為未混淆版方便本地 debug,只有出貨那份被混淆
維護建議
升級 .NET Reactor 版本時
新版可能修了 NecroBit 的 C++/CLI 相容性。升級後:
- 先在 dev 機把
NecroBit = true開回去 build dist - 把 dist 帶到測試機跑 sample exe
- 若不再 crash → 表示已修,可提升保護
- 若還是 crash → 維持目前 OFF
想補回 NecroBit 等級的保護時
可以考慮的替代:
- Hide_Method_Calls — 進階方法呼叫混淆,副作用較少
- Stealth_Obfuscation — 進階名稱混淆模式
- 自製 native obfuscation pass — 對 C++/CLI bridge 那層做混淆而不是 managed
千萬不要做的事
- ❌ 不要把
Obfuscate_Public_Types開成 true — 客戶 callback 與#using都會掛。Public 類別/方法/屬性名稱必須保留。 - ❌ 不要在沒先撞測試機的情況下把 NecroBit 開回去 — 我們已經知道跑不起來,只有 Reactor 改版後才有機會 work。
- ❌ 不要把 .nrproj 的 master key 公開到 public repo。
驗證流程(複用此清單以後測試)
-
Build: 從 Developer Command Prompt
msbuild LeYuECat.sln /p:Configuration=Release /p:Platform=x64預期:build log 出現
Protecting ...\dist\bin\LeYu.ECat.Managed.dll with .NET Reactor...、0 error 0 warning。 -
本機 managed test: 測未混淆的
src/Managed/bin那份,混淆過程不影響 unit test:dotnet test src\Managed\Managed.Tests\Managed.Tests.csproj -c Release -
客戶端 smoke test: 把整個
dist/拷到乾淨的測試機(或 Windows VM):- 跑一 次
redist/VC_redist.x64.exe - 進
samples/01_InitShutdown/ msbuild 01_InitShutdown.vcxproj /p:Configuration=Release /p:Platform=x64- 執行產出的
01_InitShutdown.exe - 預期:exit code 0、
ethercat_wrapper.log出現EtherCAT initialization started ... completed、無對話框 crash
- 跑一 次
-
進階驗證(混淆有效):
- 用 dnSpy 開
dist/bin/Managed.dll - 預期:public namespace
LeYu.ECat.*仍可見、public class 名也保留,但 method body 內的變數 / 控制流是亂碼或大量無意義 goto
- 用 dnSpy 開
TL;DR
如果你的 C# DLL 會被 C++/CLI /clr consumer 載入:
- NecroBit 永遠 OFF。其他依賴 module init / metadata 改寫的選項(Anti-Tampering、Inject Invalid Metadata、String/Resource Encryption)也一併關掉
- 保留 Obfuscation + Anti-ILDASM + Control Flow Obfuscation 作為主要防護
Obfuscate_Public_Types永遠 OFF,#using與 callback 都靠 public 名稱- 把 Reactor 接上 MSBuild 的
AfterBuildtarget,用DOTNETREACTORROOTenv var +Exists()guard,讓沒裝 Reactor 的機器也能 build - 出貨前一定要在「乾淨的繁中 Windows 測試機」跑一次完整 sample,CP950 environment 是常被忽略的變因