跳至主要内容

.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 /clr mixed-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 配置變更

設定原本改為理由
NecroBittruefalse直接元兇,與 C++/CLI 不相容
Anti_Tamperingtruefalsehash 檢查在 #using 載入路徑會失效
Inject_Invalid_Metadatatruefalse改 metadata 易與 CLR 載入器互動失敗
String_EncryptiontruefalseInit 階段字串解密還沒就緒就被引用
Resource_Encryption_And_Compressiontruefalse同上
Control_Flow_Obfuscationfalsetrue新開:補 NecroBit 的位、scrambling method body
Obfuscationtruetrue名稱混淆,最重要的客戶端防護
Anti_ILDASMtruetruecheap、無副作用
Obfuscate_Public_Typesfalsefalse不能改#using 引用、客戶 callback 簽章都靠 public 名稱
NecroBit_Reflection_Compatibility_Modefalse → truetrueNecroBit 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="&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>

幾個重點:

  • 就地覆蓋-file-targetfile 同一個路徑。Reactor 會把 dist/bin/Managed.dll 換成混淆版
  • Debug build 不混淆:Condition 上 '$(Configuration)' == 'Release',不然每次 F5 都要等混淆
  • 沒裝 Reactor 不 failExists('$(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 相容性。升級後:

  1. 先在 dev 機把 NecroBit = true 開回去 build dist
  2. 把 dist 帶到測試機跑 sample exe
  3. 若不再 crash → 表示已修,可提升保護
  4. 若還是 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

驗證流程(複用此清單以後測試)

  1. 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。

  2. 本機 managed test: 測未混淆的 src/Managed/bin 那份,混淆過程不影響 unit test:

    dotnet test src\Managed\Managed.Tests\Managed.Tests.csproj -c Release
  3. 客戶端 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
  4. 進階驗證(混淆有效):

    • 用 dnSpy 開 dist/bin/Managed.dll
    • 預期:public namespace LeYu.ECat.* 仍可見、public class 名也保留,但 method body 內的變數 / 控制流是亂碼或大量無意義 goto

TL;DR

如果你的 C# DLL 會被 C++/CLI /clr consumer 載入:

  1. NecroBit 永遠 OFF。其他依賴 module init / metadata 改寫的選項(Anti-Tampering、Inject Invalid Metadata、String/Resource Encryption)也一併關掉
  2. 保留 Obfuscation + Anti-ILDASM + Control Flow Obfuscation 作為主要防護
  3. Obfuscate_Public_Types 永遠 OFF#using 與 callback 都靠 public 名稱
  4. 把 Reactor 接上 MSBuild 的 AfterBuild target,用 DOTNETREACTORROOT env var + Exists() guard,讓沒裝 Reactor 的機器也能 build
  5. 出貨前一定要在「乾淨的繁中 Windows 測試機」跑一次完整 sample,CP950 environment 是常被忽略的變因

參考資料

相關文件