核符號結(jié)構(gòu)查看與偏移分析:SymbolTypeViewer實踐)
簡介SymbolTypeViewer 1.0.0.6 是一款面向逆向工程、內(nèi)核與驅(qū)動分析及 Windows 二進制研究的符號結(jié)構(gòu)查看工具主要解決 PDB 符號獲取繁瑣、結(jié)構(gòu)體成員與內(nèi)存布局難以直觀呈現(xiàn)的問題。它能自動下載符號文件以樹狀結(jié)構(gòu)瀏覽類型及其成員標(biāo)記結(jié)構(gòu)體中未使用的填充區(qū)域并將結(jié)構(gòu)翻譯為 C 語言 .h 頭文件或 IDA 的 .idc 腳本同時支持類型名后綴、指針寬度凍結(jié)、正則搜索以及按目錄批量處理模塊適合需要快速核對內(nèi)核符號布局、復(fù)用結(jié)構(gòu)定義或排查對齊問題的中高級開發(fā)者。包內(nèi)共 8 個文件以 exe 主程序與 application 部署清單為主輔以 txt 說明、htm 許可頁面及 manifest、ico 等配套文件壓縮包約 1.26MB體量輕便。目前已有 753 人學(xué)習(xí)可作為結(jié)構(gòu)體分析與符號處理的實用輔助工具。1. 一個偏移對不上的下午符號類型查看器能替你省掉什么調(diào)內(nèi)核態(tài)問題最難受的不是崩潰是崩潰之后手里只有一個地址和一句“這里應(yīng)該是某個結(jié)構(gòu)體的第幾個字段”。翻文檔版本對不上翻社區(qū)文章偏移早變了問同事對方回一句“你自己dt一下”。這種時候唯一能信的只有符號文件本身。SymbolTypeViewer 這個包干的就是這件事把 PDB 里的內(nèi)核符號結(jié)構(gòu)攤開讓你直接看到某個模塊在某個版本下某個類型有多少成員、每個成員落在第幾個字節(jié)、占幾個字節(jié)、是不是位域、是不是匿名嵌套。它不讀寫內(nèi)存也不是調(diào)試器純粹是個類型信息的瀏覽器。適合三類人寫驅(qū)動的、做內(nèi)核態(tài)排障的、以及剛接觸符號體系、被一屏結(jié)構(gòu)體輸出勸退的新手。1.0.0.6 這個版本號說明它還在快速迭代用之前最好先確認(rèn)它認(rèn)不認(rèn)你手上的符號格式。2. 內(nèi)核符號結(jié)構(gòu)的第一層PDB 里存的不是源碼是類型記錄2.1 類型信息在符號文件里是怎么組織的很多人以為符號文件里存的是“結(jié)構(gòu)體源碼文本”所以一打開就期待看到一行行struct {...}。不是。PDB 內(nèi)部把類型信息單獨放進一條流通常叫 TPI 流里面是一條條定長記錄靠類型索引TypeIndex互相引用。一條結(jié)構(gòu)體記錄里存的是這個類型的名字、總大小、字段列表的索引、是否前向聲明字段列表里每個成員存的是成員名、類型索引、起始偏移、訪問權(quán)限。關(guān)鍵點在于“類型索引”這層間接。成員并不直接寫“我是 4 字節(jié)無符號整數(shù)”而是寫一個指向整數(shù)類型記錄的索引。所以你看到的嵌套結(jié)構(gòu)體、指針、數(shù)組、聯(lián)合體本質(zhì)都是一串索引跳轉(zhuǎn)。理解這一點后面所有“為什么字段顯示不全”的問題都能自己解釋清楚。記錄類型承載內(nèi)容常見誤讀結(jié)構(gòu)體記錄類型名、總大小、字段表索引、前向聲明位把前向聲明當(dāng)成空結(jié)構(gòu)體字段列表記錄成員的順序與數(shù)量以為順序等于內(nèi)存順序成員記錄成員名、類型索引、起始偏移以為偏移是相對字段表而非相對類型起點位域記錄起始位、位寬、底層整數(shù)類型按字節(jié)讀讀出臟數(shù)據(jù)聯(lián)合體記錄各成員的共同起始偏移把 union 大小當(dāng)成最大成員之外的值前向聲明位是最容易被忽略的一格。當(dāng)編譯器只看到struct Foo;而沒看到定義時PDB 里會留一條帶前向標(biāo)志的結(jié)構(gòu)體記錄大小為 0、字段表為空。工具展開它當(dāng)然是空的這不是工具壞了。提示看到某個結(jié)構(gòu)體大小為 0 且成員列表為空先懷疑前向聲明再去懷疑符號文件損壞能省掉大量排查時間。2.2 環(huán)境準(zhǔn)備與第一次打開落地的第一步不是打開工具是確認(rèn)三件事目標(biāo)模塊的位數(shù)、它的 PDB 簽名、以及本地符號緩存放在哪。這三件事任何一件錯了工具都會“加載成功但內(nèi)容不對”比直接報錯更難查。常見的做法是先看模塊 PE 頭里的調(diào)試目錄拿到 PDB 文件名、GUID 和 Age。GUID 加 Age 就是符號文件的“身份證”緩存目錄名也是它。用 PowerShell 快速核對# 1. 當(dāng)前會話生效的符號路徑工具和調(diào)試器一般讀同一個變量 $env:_NT_SYMBOL_PATH # 2. 符號緩存目錄的條目數(shù)量幾十 G 的緩存很常見 $cache C:\symbols (Get-ChildItem $cache -Directory -ErrorAction SilentlyContinue).Count # 3. 按模塊名反查緩存里的 PDB # 注意緩存里的目錄名是 GUIDAge不是模塊名所以只能按文件名過濾 Get-ChildItem $cache -Filter *.pdb -Recurse -ErrorAction SilentlyContinue | Where-Object { $_.Name -like ntkrnlmp* -or $_.Name -like ntoskrnl* } | Select-Object FullName, Length, LastWriteTime第一段輸出是符號路徑格式后面第 3 章細(xì)講。第二段只是讓你對緩存體量有數(shù)緩存過大時首次索引會明顯變慢。第三段按文件名過濾是因為緩存目錄名被 GUID 占滿肉眼根本認(rèn)不出來。如果你在FullName里看到同一個模塊有多個不同 GUID 目錄說明本機堆了多個版本的符號這本身不是問題但工具加載時選錯版本就會讀到舊布局。環(huán)境準(zhǔn)備清單按順序過一遍確認(rèn)目標(biāo)模塊是 32 位還是 64 位這決定指針寬度是 4 還是 8直接改變所有含指針的結(jié)構(gòu)體大小。拿到模塊的 PDB 簽名GUID Age這是唯一能判定符號匹配的依據(jù)。把符號緩存目錄固定下來別讓不同工具各寫各的。確認(rèn)磁盤剩余空間內(nèi)核符號單套動輒幾百兆到一兩 G。2.3 一次完整的類型查詢該看哪幾列工具界面各家做得不一樣但一份類型信息里真正有用的就那幾列。字段展開后我會按這個順序讀先看類型總大小再看成員偏移是否單調(diào)遞增然后看有沒有0x0000之外重復(fù)偏移的成員那是聯(lián)合體最后看指針成員占幾字節(jié)用來反推位數(shù)。舉個通用例子假設(shè)你看到一個成員偏移是0x18、類型是指針、大小 8 字節(jié)。那么下一個成員的偏移應(yīng)該是0x20。如果工具顯示下一個成員在0x1c只有兩種可能中間夾了 4 字節(jié)的位域或填充或者這個結(jié)構(gòu)體被#pragma pack(1)壓過。這兩種情況的區(qū)別很關(guān)鍵——前者說明布局正常后者說明你手上的符號對應(yīng)的編譯選項和你以為的不一樣后面所有偏移都要重新算。再一個易錯點成員偏移是相對類型起點的不是相對某個基類或前一個成員。有些工具會在嵌套展開時顯示相對偏移切回去看絕對值才能對上內(nèi)存里的地址。3. 符號路徑的三層結(jié)構(gòu)緩存、服務(wù)器與離線包的配法3.1 符號路徑的語法與查找順序符號路徑不是簡單的“目錄列表”它有固定語法srv*本地緩存*符號服務(wù)器地址。三段分別表示用符號客戶端、緩存落在這個目錄、源從這個地址拉。多條路徑之間用分號隔開按從左到右的順序查找第一個命中的就返回。這個順序直接決定行為。把服務(wù)器寫在前面、緩存寫在后面等于每次查詢都先去遠(yuǎn)端問一遍慢且不穩(wěn)定把緩存寫前面命中就返回未命中才回源這才是正常用法。我一般會配成三段# 第一段緩存目錄必須放在最前命中即返回 # 第二段本地私有符號目錄放自己編譯出來的驅(qū)動 PDB # 第三段符號服務(wù)器只作為兜底 export _NT_SYMBOL_PATHsrv*C:\\symbols*symbol-server;C:\\private_symbols參數(shù)說明srv后面緊跟的那個*不能省第一個*后面是緩存路徑第二個*后面是源地址。symbol-server是占位實際填你環(huán)境里配的符號服務(wù)地址。分號后面那段本地目錄不加srv*前綴因為它是純本地查找不走符號客戶端邏輯。私有符號目錄放第二的原因是自己編譯的模塊版本少、查得快沒必要每次都去緩存里翻。注意路徑里帶空格時必須整體加引號Windows 下反斜杠要寫雙份否則最后一段會被吞掉表現(xiàn)是“符號路徑設(shè)置了但完全不生效”。3.2 在線拉取的最小配置與常見失敗點最小可用配置就是上面那三段。但實際落地時第一次拉取經(jīng)常卡住原因通常是緩存目錄不可寫、代理配置不一致、或者目標(biāo)模塊的符號在服務(wù)端根本沒有公開。判斷“有沒有公開符號”有個快速辦法看緩存目錄里有沒有對應(yīng) GUID 的子目錄。拉取成功會生成GUIDAge目錄并在里面放 PDB拉取失敗往往連目錄都不建。如果是目錄建了但里面只有個幾 K 的臨時文件多半是網(wǎng)絡(luò)中斷刪掉該目錄重試即可。另一個常被忽略的點是緩存目錄的寫權(quán)限。某些環(huán)境下工具以低權(quán)限運行緩存目錄在系統(tǒng)盤根目錄寫不進去于是每次都回源、每次都失敗日志里還不一定報權(quán)限錯誤。3.3 離線符號包內(nèi)網(wǎng)與斷網(wǎng)環(huán)境的做法生產(chǎn)環(huán)境經(jīng)常不能連外網(wǎng)這時候就要預(yù)先制作離線符號包。做法是找一臺能聯(lián)網(wǎng)、且系統(tǒng)版本與目標(biāo)機完全一致的機器把需要的模塊符號全部拉下來再整體拷進去。所謂完全一致指的是補丁級別一致因為內(nèi)核模塊的 PDB 簽名隨每次更新變化差一個補丁就對不上。整理結(jié)構(gòu)時保持緩存的原始層級別自己重排目錄符號客戶端認(rèn)的就是 GUID 目錄名層級示例路徑作用誰生成根目錄C:\symbols緩存根符號路徑指向它人工創(chuàng)建索引標(biāo)記C:\symbols\index2.txt標(biāo)記該目錄是完整緩存避免重復(fù)校驗符號客戶端條目錄C:\symbols\GUIDAge\單個模塊版本的符號符號客戶端符號文件上述目錄下的.pdb實際的類型與符號數(shù)據(jù)符號客戶端可執(zhí)行文件同一目錄下的.sys/.exe與 PDB 配套校驗用符號客戶端打包前做一次校驗逐個比對 PDB 內(nèi)部的簽名與目標(biāo)機模塊 PE 調(diào)試目錄里的 GUIDAge。這一步?jīng)]有捷徑工具能讀出來的簽名和你在目標(biāo)機上看到的一致才算打包成功。校驗通過后整體壓縮內(nèi)網(wǎng)解壓到相同路徑符號路徑指向解壓后的根目錄即可不需要再配服務(wù)器段。4. 結(jié)構(gòu)體偏移的讀法成員、位域與對齊怎么落到字節(jié)上4.1 成員偏移、位域與聯(lián)合體的三種讀法普通成員最好讀名字、偏移、類型、大小四列對齊就行。真正容易翻車的是位域。位域在符號里不是一個獨立的字節(jié)范圍而是“起始位 位寬 底層類型”三元組起始位是從類型起點算的位偏移不是字節(jié)偏移。舉個例子某個標(biāo)志字段占 1 位起始位是 0x3048位寬 1底層類型 4 字節(jié)。那么它落在第 6 個字節(jié)的最低位。如果你按字節(jié)讀讀出來是0x01還是0x00全看這一個 bit和相鄰的 31 個 bit 混在一起直接拿去當(dāng)布爾值用就會出錯。正確做法是取出 4 字節(jié)整數(shù)右移 48 位跨字節(jié)時先按字節(jié)序拼再和(1 位寬) - 1做與運算。聯(lián)合體則相反它好讀但容易誤判。聯(lián)合體成員在符號里全部共享同一個起始偏移大小取最大成員。工具展開聯(lián)合體時你會看到一堆偏移完全相同的成員這是正常現(xiàn)象不是數(shù)據(jù)錯亂。類型場景符號里的表現(xiàn)讀法要點普通成員唯一偏移 類型索引直接用偏移注意相對類型起點位域位偏移 位寬按位取不要按字節(jié)取匿名嵌套成員名為空 嵌套類型索引展開后偏移要累加父級起點聯(lián)合體多個成員同一偏移大小取最大成員不是求和指針數(shù)組數(shù)組記錄 元素類型偏移遞增步長等于元素大小匿名嵌套是最容易算錯的一項。工具展開匿名結(jié)構(gòu)體時顯示的偏移有兩種口徑相對匿名結(jié)構(gòu)體自身或相對最外層類型。前者需要你手動加上父級的起始偏移后者可以直接用但嵌套層級一深就分不清。我的習(xí)慣是只看最外層那層的絕對偏移中間層只作結(jié)構(gòu)參考。4.2 用類型總大小反推對齊和編譯選項結(jié)構(gòu)體總大小是校驗布局的最好工具。拿到一個類型先記下大小再逐字段累加“偏移 大小”最后和總大小比對。差值就是尾部填充。如果差值大得離譜或者某個成員的偏移不是它自然對齊的倍數(shù)基本可以斷定這個類型被pack壓過或者你手上的符號對應(yīng)的編譯單元用了非默認(rèn)對齊。具體做法設(shè)指針寬度為 P4 或 8。遍歷成員檢查每個成員的偏移是否是min(成員大小, P)的倍數(shù)。不是的話要么是位域、要么是 pack。這個方法我在核對跨版本差異時用得最多幾秒鐘就能判斷一份符號是不是“另一套編譯選項”出來的。4.3 交叉驗證和調(diào)試器輸出對一遍單看一個工具的輸出容易自己騙自己穩(wěn)妥做法是和內(nèi)核調(diào)試器的類型輸出對一遍。兩邊都讀同一份 PDB正常情況應(yīng)該逐字段一致不一致說明其中一個加載了別的版本。# 在調(diào)試器會話里先確認(rèn)符號加載狀態(tài) 0: kd .reload /f nt 0: kd lm vm nt # 只打一個字段減少輸出噪音 0: kd dt nt!_EPROCESS ImageFileName # 打整個類型比對總大小和字段順序 0: kd dt nt!_EPROCESS第一組命令的作用是強制重載內(nèi)核模塊符號并打印模塊信息其中會包含 PDB 的簽名這是判斷“加載的是不是同一份”的依據(jù)。第二條只打單個字段適合快速核對某個偏移。第三條打全類型輸出很長但總大小那一行最有價值——兩個工具的總大小必須完全相同差一個字節(jié)都說明符號版本不同。對不上時不要急著懷疑工具先比簽名。簽名一致而偏移不一致那才是真問題通常意味著有一個工具在做類型重寫或者讀的是緩存里的另一份副本。5. 避坑符號加載成功卻讀不出字段的五個現(xiàn)場5.1 現(xiàn)場一字段全是無名成員名字一欄空著現(xiàn)象結(jié)構(gòu)體能展開成員數(shù)量也對但每個成員的名字都是空的或者顯示成占位符只有偏移和大小。原因這是典型的 FASTLINK 產(chǎn)物。編譯時用了快速鏈接選項PDB 里只保留了類型骨架和指向原始編譯產(chǎn)物的引用成員名沒有完整寫進去。另一種可能是符號被裁剪過只保留了公開符號。解決換用完整符號。如果符號來自你自己的構(gòu)建去掉快速鏈接選項重編如果來自外部找同版本的完整符號包。判斷方法很簡單——看 PDB 文件體積完整符號通常是快速鏈接版本的幾倍到十幾倍。5.2 現(xiàn)場二偏移整體偏移了幾十個字節(jié)現(xiàn)象字段名都對但每個成員的偏移和預(yù)期值差一個固定量結(jié)構(gòu)體總大小也偏大或偏小。原因加載了同一模塊另一個補丁版本的符號。GUID 不同但模塊名相同緩存里堆著好幾份工具按名字匹配就選錯了。解決別按模塊名找符號按 GUIDAge 找。把緩存里該模塊的所有目錄列出來逐個和目標(biāo)機 PE 調(diào)試目錄里的簽名比對只保留匹配的那一份其余移走。5.3 現(xiàn)場三緩存目錄被別的工具寫臟現(xiàn)象昨天還能正常展開今天同一個模塊打不開或者打開后內(nèi)容殘缺緩存目錄里出現(xiàn)大量幾 K 的臨時文件。原因多個工具共用同一個緩存目錄其中一個在拉取過程中被中斷留下了半截文件后續(xù)查詢命中這個半截文件直接解析失敗。解決清掉該 GUID 目錄重拉。長期方案是給每個工具分配獨立的緩存目錄或者至少約定只有一個工具負(fù)責(zé)寫入、其余只讀。5.4 現(xiàn)場四加載大符號文件時卡死或內(nèi)存暴漲現(xiàn)象打開幾個 G 的符號目錄后界面無響應(yīng)任務(wù)管理器里內(nèi)存一路漲到幾個 G。原因工具在啟動時做了全量索引把所有類型記錄一次性讀進內(nèi)存。內(nèi)核符號的類型記錄動輒幾十萬條全量加載必然吃內(nèi)存。解決優(yōu)先按模塊加載而不是按目錄加載只加載當(dāng)前排查需要的那一個模塊。如果工具支持延遲索引把全量索引關(guān)掉。排查時也盡量按類型名搜索別一上來就展開整棵樹。5.5 現(xiàn)場五結(jié)構(gòu)體大小和文檔對不上現(xiàn)象字段偏移全部自洽但總大小比預(yù)期大 8 字節(jié)或小 4 字節(jié)。原因三種可能——位數(shù)判斷錯了32 位符號當(dāng) 64 位看、對齊選項不同、或者文檔本身對應(yīng)的是另一個版本。最常見的是第一種指針寬度差一倍含指針的結(jié)構(gòu)體大小立刻差出幾十字節(jié)。解決先看任意一個指針成員的大小4 就是 32 位8 就是 64 位。確認(rèn)位數(shù)后再按 4.2 節(jié)的方法驗算對齊。兩步都過了還差那就是文檔過期以符號為準(zhǔn)。6. 進階把類型導(dǎo)出成 JSON 做兩個版本的偏移 diff單次查詢解決的是“這個字段在哪”跨版本比對解決的是“這次更新動了什么”。后者才是真正省時間的地方——補丁前后哪些結(jié)構(gòu)體布局變了直接影響已有代碼能不能跑。我的做法是把兩個版本的類型導(dǎo)出成同一套 JSON再寫個腳本 diff。導(dǎo)出時只保留結(jié)構(gòu)體名、總大小和成員三元組名字、偏移、類型字段順序保持原樣這樣 diff 出來的結(jié)果能直接定位到具體成員。下面這個腳本處理兩份導(dǎo)出結(jié)果import json from pathlib import Path def load(path): # 導(dǎo)出文件結(jié)構(gòu){struct: _EPROCESS, size: 0x8a0, members: [...]} data json.loads(Path(path).read_text(encodingutf-8)) members {m[name]: (m[offset], m[type], m[size]) for m in data[members]} return data[struct], data[size], members def diff(old_path, new_path): name, old_size, old load(old_path) _, new_size, new load(new_path) print(f{name}: 0x{old_size:x} - 0x{new_size:x}) # 新增成員只在新區(qū)存在 for k in new.keys() - old.keys(): print(f {k} 0x{new[k][0]:x} ({new[k][1]}, {new[k][2]}B)) # 刪除成員只在老區(qū)存在 for k in old.keys() - new.keys(): print(f - {k} 0x{old[k][0]:x}) # 偏移或類型變化兩邊都有但值不同 for k in old.keys() new.keys(): if old[k][0] ! new[k][0] or old[k][1] ! new[k][1]: print(f ~ {k}: 0x{old[k][0]:x} - 0x{new[k][0]:x}, {old[k][1]} - {new[k][1]}) if __name__ __main__: diff(ep_old.json, ep_new.json)load里用字典而不是列表是為了讓后面的集合運算直接可用代價是丟掉了原始順序但對 diff 沒影響。diff分三段輸出新增、刪除、變化前綴用 - ~區(qū)分掃一眼就知道改動量級。0x{...:x}是十六進制格式化偏移一律用十六進制打印和調(diào)試器輸出對齊避免心算換算。跑出來通常長這樣變化成員舊偏移新偏移影響變化某鏈表節(jié)點0x2a80x2b0依賴固定偏移的代碼全崩新增某標(biāo)志位—0x2b8位域起點右移掩碼要重算刪除某保留字段0x300—后續(xù)成員整體前移大小整體0x8a00x8b0分配空間判斷邏輯失效這套流程我踩過的最大坑是導(dǎo)出口徑不一致一次導(dǎo)出含匿名嵌套的絕對偏移另一次是相對偏移diff 出來滿屏變化實際什么都沒動。從那以后我每次導(dǎo)出前都強制確認(rèn)一遍“偏移列是絕對還是相對”把這一條寫進腳本的注釋里。希望幫到你。本文還有配套的精品資源點擊獲取