:PE頭位寬判定、IL修改與斷點調(diào)試)
簡介面向32位Windows系統(tǒng)的dnSpy中文版工具包定位于.NET程序集的反編譯、調(diào)試與逆向分析適合C#、VB.NET開發(fā)者及安全研究人員使用。它可反匯編已編譯程序集、查看并編輯IL中間代碼也能還原為可讀的C#或VB.NET源碼用于找回丟失源代碼、定位性能瓶頸、檢查混淆與理解模塊依賴關(guān)系。壓縮包共855個文件約85.55MB以779個dll運行庫與組件、38個pdb符號文件、13個json配置、12個xml及3個exe主程序為主體另含主題與說明文本解壓后即可獨立運行無需額外安裝.NET框架。目前已有469人學習下載。包內(nèi)保留完整界面資源與分析組件可像在IDE中一樣瀏覽類、方法、屬性與字段編輯IL后即時運行驗證并結(jié)合調(diào)試跟蹤程序行為對教學演示、代碼審查及惡意樣本的.NET代碼分析均有參考價值。1. 32 位機器上跑 dnSpy先確認它到底吃哪一套運行時一臺只能裝 32 位系統(tǒng)的老機器上面跑著一個 .NET 寫的小工具行為不對你想看它內(nèi)部到底怎么判斷的。可選路徑不多裝重型 IDE 太重純反編譯器又改不動改完還得自己重新簽名。dnSpy 正好卡在這個位置——反編譯、IL 編輯、斷點調(diào)試、回寫程序集塞在同一個殼里C# 和 VB.NET 兩種視圖都能出對 32 位 .NET Framework 程序的支持也完整。適合手上有具體 .NET 程序要讀、要改一處邏輯、又不想搭整套工程的人目標是啃加殼商業(yè)程序或者 NativeAOT 單文件它幫不上趁早換路子。2. 從 PE 頭開始讀一個 .NET 程序里的三份真相2.1 元數(shù)據(jù)、IL 與 C# 視圖為什么對不上一個 .NET 程序集里其實躺著三層東西元數(shù)據(jù)類型、方法、字段、簽名、特性、IL 指令流、以及反編譯器根據(jù) IL 還原出來的 C# 或 VB.NET 視圖。前兩層是編譯器寫進去的事實第三層是猜的。猜得準不準取決于編譯器在生成 IL 時做了多少改寫。C# 編譯器對源碼的改寫遠比想象中多foreach會變成try/finally加Dispose調(diào)用using同理async方法整個被搬進一個Xxxd__N狀態(tài)機方法體變成MoveNext里的switch帶捕獲的 lambda 會生成c__DisplayClass類局部變量被提升成字段密集的switch會變成PrivateImplementationDetails里的跳轉(zhuǎn)表加gotolock變成Monitor.Enter/Exit加try/finally自動屬性變成編譯器生成的get_/set_加一個私有字段。這些生成物在你看源碼時不存在在 IL 里一條不少。所以當你打開一個程序集看到滿屏c__DisplayClass12_0、PrivateImplementationDetails、莫名其妙的goto標簽?zāi)遣皇欠淳幾g器出錯那是它在如實轉(zhuǎn)述編譯器的產(chǎn)物。真正的失真發(fā)生在細節(jié)上泛型約束可能丟一條默認參數(shù)值可能不顯示unsafe指針會被還原成*(int*)(ptr 8)這種勉強能讀但絕編譯不過的寫法。判斷標準很樸素——右側(cè)的 C# 視圖如果能直接扔進 Visual Studio 編譯通過那是運氣編譯不過是常態(tài)。32 位環(huán)境里這一層尤其重要。老程序多是 .NET Framework 4.x 配 C# 5 或 C# 6 編譯的async和 LINQ 用得不算重閉包和迭代器則很常見。碰到y(tǒng)ield return生成的狀態(tài)機類C# 視圖會給你一個實現(xiàn)IEnumerableT的完整類字段名全是2__current這類讀它不如直接讀 IL 里的MoveNext。2.2 用 PE 頭判定目標到底該用哪種位寬調(diào)試32 位系統(tǒng)上最常見的翻車不是反編譯是位寬沒對齊目標進程是 32 位調(diào)試器以 64 位跑附加成功但斷點全是空心或者反過來一附加目標就退。判斷目標程序集是什么位寬不能看文件名也不能看它是 exe 還是 dll要看 PE 頭和 CLR 頭里的標志位。.NET 程序集的位寬有三種典型狀態(tài)ILONLY1且32BITREQUIRED0這是 AnyCPU實際位寬由宿主進程決定ILONLY1且32BITREQUIRED1強制 32 位64 位進程加載會直接失敗ILONLY1且32BITPREFERRED1AnyCPU 但默認以 32 位運行這是 .NET Framework 4.5 之后 exe 的默認行為。用一段腳本把這三項讀出來比在界面上試錯快得多。import struct, sys MACHINE {0x014c: x86, 0x8664: x64, 0xaa64: arm64} FLAG_ILONLY 0x00000001 FLAG_32BITREQUIRED 0x00000002 FLAG_32BITPREFERRED 0x00020000 def rva2off(data, sec_off, nsec, rva): # 遍歷節(jié)表把 RVA 換算成文件偏移 for i in range(nsec): b sec_off i * 40 vsize, vaddr, rawsize, rawptr struct.unpack_from(IIII, data, b 8) span max(vsize, rawsize) if vaddr rva vaddr span: return rawptr (rva - vaddr) return None def probe(path): with open(path, rb) as f: data f.read() if data[:2] ! bMZ: return {file: path, error: not MZ} pe struct.unpack_from(I, data, 0x3C)[0] # e_lfanew if data[pe:pe 4] ! bPE\0\0: return {file: path, error: not PE} machine struct.unpack_from(H, data, pe 4)[0] # COFF Machine nsec struct.unpack_from(H, data, pe 6)[0] # 節(jié)數(shù)量 optsize struct.unpack_from(H, data, pe 20)[0] # 可選頭長度 opt pe 24 plus struct.unpack_from(H, data, opt)[0] 0x20B dd opt (112 if plus else 96) # 數(shù)據(jù)目錄起點 clr_rva, _ struct.unpack_from(II, data, dd 14 * 8) # 第 15 項 CLR 頭 out {file: path, machine: MACHINE.get(machine, hex(machine)), pe32plus: plus, has_clr: clr_rva ! 0} if clr_rva: clr rva2off(data, opt optsize, nsec, clr_rva) flags struct.unpack_from(I, data, clr 16)[0] out[corflags] hex(flags) out[anycpu] bool(flags FLAG_ILONLY) and not (flags FLAG_32BITREQUIRED) out[force32] bool(flags FLAG_32BITREQUIRED) out[prefer32] bool(flags FLAG_32BITPREFERRED) return out for p in sys.argv[1:]: print(probe(p))pe 4是 COFF 頭的 Machine 字段0x014c表示 x860x8664表示 x64。pe 20是SizeOfOptionalHeader用它加可選頭起始位置就能定位節(jié)表rva2off干的就是這件事。數(shù)據(jù)目錄第 15 項索引 14指向 CLR 運行時頭頭內(nèi)偏移 16 字節(jié)處是Flags也就是常說的 CorFlags。PE32 的數(shù)據(jù)目錄從可選頭偏移 112 開始PE32 是 96這個差別寫錯一位讀出來的 Flags 就是垃圾。判讀規(guī)則CorFlags 組合實際含義32 位系統(tǒng)上的調(diào)試策略ILONLY1, 32BITREQUIRED0, 32BITPREFERRED0純 AnyCPU宿主是 32 位就用 32 位調(diào)試器ILONLY1, 32BITREQUIRED1強制 x86必須用 32 位調(diào)試器64 位加載即失敗ILONLY1, 32BITPREFERRED1AnyCPU 但默認 32 位按 32 位進程對待ILONLY0混合模式含 C/CLI位寬必須與 Machine 字段一致2.3 把運行時環(huán)境一次性核對完dnSpy 的桌面構(gòu)建依賴 .NET Framework32 位系統(tǒng)能裝的最高版本是 4.8 系列。環(huán)境不對表現(xiàn)是雙擊沒反應(yīng)、或者閃一下就退沒有任何錯誤框這種時候容易誤判成軟件壞了。用一段 PowerShell 把三個關(guān)鍵值打出來系統(tǒng)位數(shù)、已安裝的 .NET Framework 版本、當前 PowerShell 進程自己是幾位。# 1) 系統(tǒng)與進程位數(shù)當前進程位數(shù)決定注冊表視圖 [PSCustomObject]{ OS64Bit [Environment]::Is64BitOperatingSystem Proc64Bit [Environment]::Is64BitProcess CLR [Environment]::Version.ToString() } # 2) 已安裝的 .NET Framework 4.x $key HKLM:\SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full if (Test-Path $key) { $r Get-ItemProperty $key [PSCustomObject]{ Version $r.Version; Release $r.Release } } else { no .NET Framework 4.x found }Release是判斷版本的可靠依據(jù)Version字段常年不變?nèi)菀渍`導(dǎo)。常見取值461808對應(yīng) 4.7.2528040對應(yīng) 4.8533320對應(yīng) 4.8.1。另一個容易忽略的點是注冊表重定向——在 64 位系統(tǒng)上用 32 位 PowerShell 讀HKLM:\SOFTWARE會被重定向到WOW6432Node讀出來的Release可能是空的。所以核對環(huán)境時優(yōu)先用系統(tǒng)自帶的 64 位 PowerShell除非你明確知道自己在 32 位視圖里。2.4 什么時候該果斷放棄 C# 視圖有幾個信號一出現(xiàn)就該切到 IL 視圖別再和 C# 較勁方法體里出現(xiàn)成片的goto和標簽且標簽?zāi)繕寺湓趕witch之外類型里出現(xiàn)大量c__DisplayClass且方法體只有字段賦值反編譯結(jié)果里出現(xiàn)__callsite、__arglist這類運行時內(nèi)部構(gòu)造方法體直接是空的或者只有一行throw null。最后一種通常不是混淆是依賴缺失反編譯器拿不到被調(diào)用方的簽名只能給你占位。判斷依賴是否缺失有個土辦法在程序集樹里展開目標類型看方法簽名里有沒有紅色標記或者顯示成(方法)而無參數(shù)列表。有就去補搜索路徑。反過來如果簽名齊全、方法體也完整但讀起來像天書那就是混淆C# 視圖基本沒救只能讀 IL 追調(diào)用鏈。3. 一次完整的改邏輯加載、定位、改 IL、保存回寫3.1 加載與依賴解析先把搜索路徑配好把目標 exe 拖進程序集資源管理器只是第一步。真正決定你能不能讀懂的是依賴能不能解析上。做法是把目標程序所在目錄整個加進程序集搜索路徑再補上全局程序集緩存的位置。對 .NET Framework 程序緩存目錄通常在%WINDIR%\Microsoft.NET\assembly下按GAC_MSIL、GAC_32、GAC_64分了子目錄32 位系統(tǒng)上主要看GAC_32。順序也有講究。dnSpy 解析依賴時按搜索路徑順序找如果同一個程序集在目標目錄和緩存里各有一份且版本不同先命中的那份會被用上。血淚經(jīng)驗是把目標目錄放在緩存前面否則你看到的可能是系統(tǒng)里那個舊版本的方法體改完保存回去發(fā)現(xiàn)行為沒變排查半天。依賴補全之后重新展開一次程序集樹之前顯示成throw null的方法體會自動補上。這一步值得反復(fù)確認因為后面所有判斷都建立在你看到的 IL 是完整的這個前提上。3.2 導(dǎo)出工程和直接改 IL選哪條路兩條路的差別不在難易在目的。導(dǎo)出為工程適合我要通讀、要搜索、要慢慢理解直接改 IL 適合我知道要改哪一條指令越快越好。兩者的具體差異維度導(dǎo)出為工程直接改 IL 保存產(chǎn)出物一整套 .csproj、源碼、資源文件原地修改的單個程序集編譯通過率低生成類型和指針代碼基本必錯不涉及重新編譯適合場景閱讀、檢索、理解調(diào)用關(guān)系改常量、改分支、跳過校驗風險生成的工程缺引用越改越亂改錯 EH 子句邊界保存后直接崩可回滾性原程序集不動安全必須提前備份我的習慣是先用導(dǎo)出功能把源碼導(dǎo)出來當可搜索的文檔讀讀完關(guān)掉真正動手時回到原始程序集上改 IL。導(dǎo)出工程的價值在于全文搜索和方法調(diào)用關(guān)系梳理不在于拿它去編譯。導(dǎo)出時有兩個細節(jié)會讓人抓狂。一是資源文件重名多個.resources同名會互相覆蓋導(dǎo)出后工程的目錄結(jié)構(gòu)會亂二是生成的 csproj 目標框架版本需要手工改成v4.x否則一打開就是滿屏的引用錯誤。這些都是導(dǎo)出器的取舍不是 bug。3.3 改一條指令的最小操作序列假設(shè)目標是這樣一個方法某個判斷成立時返回false你想讓它返回true。對應(yīng) IL 里就是ldc.i4.0加ret或者一個brfalse.s分支。改法有兩條一條改常量一條改跳轉(zhuǎn)前者影響面更小。操作順序在程序集樹里定位到方法右鍵看 IL 視圖確認方法體完整、沒有紅色缺依賴標記。找到目標指令雙擊進入編輯。把ldc.i4.0改成ldc.i4.1或者把brfalse.s改成brtrue.s。點編譯看下方錯誤列表。編譯失敗通常是棧不平衡或者分支目標越界這時不要硬改退回原指令重新想。編譯通過后方法名前會出現(xiàn)修改標記確認只有這一處變了。保存模塊另存為新文件名不要覆蓋原始文件。參數(shù)層面要注意ldc.i4.0到ldc.i4.8是 1 字節(jié)指令ldc.i4加 4 字節(jié)立即數(shù)是 5 字節(jié)指令。如果你把短指令換成ldc.i4 0x12345678這種長指令方法體內(nèi)所有分支的偏移量都會變dnSpy 會自動重算但異常處理子句的起止邊界不一定跟著修正。改常量優(yōu)先選短指令能表達的值就是為了避開這個。分支指令同理。brtrue.s和brfalse.s都是 2 字節(jié)互換不改變長度最安全。br.s換br會從 2 字節(jié)變 5 字節(jié)能不換就不換。3.4 保存之后用反射做一次冒煙校驗保存完不等于成功。程序集能被 dnSpy 讀不代表能被運行時加載。最快的驗證方式是用一段獨立的反射探針把程序集名、強名稱狀態(tài)、類型數(shù)、方法數(shù)打出來改前改后各跑一次做對比。using System; using System.IO; using System.Reflection; static class Probe { static void Main(string[] args) { foreach (var path in args) { Console.WriteLine( Path.GetFileName(path)); try { var an AssemblyName.GetAssemblyName(path); var pk an.GetPublicKey(); Console.WriteLine( FullName : an.FullName); Console.WriteLine( StrongName : (pk ! null pk.Length 0)); } catch (Exception e) { Console.WriteLine( name err: e.Message); } try { var asm Assembly.LoadFile(Path.GetFullPath(path)); Console.WriteLine( Runtime : asm.ImageRuntimeVersion); var types asm.GetTypes(); // 依賴缺失會在這里拋 int methods 0; foreach (var t in types) methods t.GetMethods(BindingFlags.DeclaredOnly | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static).Length; Console.WriteLine( Types : types.Length); Console.WriteLine( Methods : methods); } catch (ReflectionTypeLoadException e) { Console.WriteLine( partial load, loader errors e.LoaderExceptions.Length); foreach (var le in e.LoaderExceptions) Console.WriteLine( ! le?.Message); } catch (Exception e) { Console.WriteLine( load err: e.GetType().Name e.Message); } } } }AssemblyName.GetAssemblyName只讀元數(shù)據(jù)不執(zhí)行代碼用來判斷強名稱簽名有沒有被破壞。Assembly.LoadFile會真正走一次加載器ImageRuntimeVersion能確認 CLR 版本沒被改壞。GetTypes是最有價值的一步它會強制解析所有方法的簽名和被引用的類型依賴缺一個就拋ReflectionTypeLoadException而LoaderExceptions會告訴你缺的是哪個程序集。參數(shù)上直接傳原文件和改后文件兩個路徑即可。類型數(shù)和方法數(shù)在改動前后應(yīng)該只差你動過的那一處如果差得多說明保存過程中元數(shù)據(jù)被重排了得回頭查。這個探針在 32 位系統(tǒng)上要編譯成 x86 或 AnyCPU 運行別用 x64 去加載強制 32 位的程序集那會直接拋BadImageFormatException。4. 調(diào)試環(huán)節(jié)32 位進程的坑基本都在位寬上4.1 附加之前先對位寬附加上之后再想就晚了dnSpy 的發(fā)布包按位寬分了不同構(gòu)建這不是打包習慣是因為調(diào)試器和目標進程必須同寬。32 位目標進程配 32 位調(diào)試器64 位配 64 位混搭的結(jié)果有兩種附加直接失敗并提示架構(gòu)不匹配或者附加看似成功但斷點全部顯示成空心圓進程跑完了斷點一次沒命中。目標進程dnSpy 進程典型表現(xiàn)32 位32 位正常斷點可命中32 位64 位附加報錯或斷點永不命中64 位64 位正常64 位32 位附加報錯判斷當前 dnSpy 是幾位的辦法很土但有效看任務(wù)管理器里的進程有沒有 32 位標記或者用[Environment]::Is64BitProcess在 dnSpy 的腳本窗口里讀一下。更省事的做法是32 位系統(tǒng)上直接選帶 win32/x86 字樣的那個包別用 AnyCPU 版本去碰運氣。4.2 模塊加載斷點抓構(gòu)造函數(shù)之前的行為附加到進程最大的問題是時機。目標程序一啟動就跑完了初始化等你附加進去構(gòu)造函數(shù)早執(zhí)行完斷點只能下在后續(xù)方法上。如果問題恰恰出在啟動階段附加這條路就廢了。這時改用啟動并調(diào)試讓 dnSpy 自己把目標進程拉起來在進程剛創(chuàng)建、還沒執(zhí)行到入口點之前就接管。接管之后立刻在目標模塊上設(shè)加載斷點模塊一加載就停下來此時靜態(tài)構(gòu)造函數(shù)和實例構(gòu)造函數(shù)都還沒跑正好可以單步。實踐中的注意點啟動調(diào)試時目標進程的工作目錄可能和你雙擊 exe 時不一樣。依賴相對路徑讀配置文件的程序在這里會讀不到文件然后靜默失敗表現(xiàn)是用調(diào)試器啟動就崩雙擊就正常。解決方式是在調(diào)試設(shè)置里顯式指定工作目錄指向 exe 所在目錄。4本文還有配套的精品資源點擊獲取