Another crash caused by uninstaller code injection into Explorer
呼叫慣例錯配讓注入代碼每圈多彈一次參數,堆疊最終把自身執行碼覆蓋殞命。
- 64 位元系統出現 32 位元 Explorer 崩潰,幾乎是代碼注入的標誌性信號。
- __stdcall 與 __cdecl 的一字錯配,讓迴圈每次多彈一次參數,堆疊緩慢蠶食自身。
- 注入代碼崩潰現場在受害進程,元凶早已離場,易讓工程師誤排假性系統 bug。
一個呼叫慣例錯配,讓反安裝程式注入的代碼在每次迴圈中悄悄多彈出一次參數,堆疊指標最終爬進了正在執行的機器碼裡,把自己活埋——崩潰數量之多,令 Windows 工程師誤以為是作業系統本身的 bug。
64 位元系統驚見 32 位元 Explorer 崩潰
在一次例行的除錯交流中,同事拿來一批神秘的 Explorer 崩潰轉儲(crash dump)請求協助分析。作者一看暫存器傾印(register dump),立刻脫口而出:「應該是有問題的反安裝程式(uninstaller)。」辨認的關鍵不在複雜的逆向分析,而在一個簡單卻不尋常的事實:這是 64 位元系統上的 32 位元 Explorer 崩潰。
Windows 的 32 位元版 Explorer 存在的唯一理由,是向後相容舊的 32 位元程式。它並不負責你的工作列、桌面或檔案總管視窗——那些全是 64 位元版 Explorer 的工作。因此,若 64 位元系統上有人在跑 32 位元 Explorer,幾乎可以確定是某個程式正在借用它做「骯髒工作」。
在這個案例中,元凶正是一支反安裝程式,它把自己的代碼注入(code injection)到 32 位元 Explorer 進程中執行,以完成一些自己不方便直接操作的任務——例如刪除被鎖定的檔案、修改需要系統進程權限才能存取的目錄。好奇心驅使作者繼續挖掘:這個反安裝程式到底哪裡出了問題?
__stdcall 誤標 __cdecl:一個宣告毀掉整個堆疊
深入分析後,作者找到了根本原因:呼叫慣例(calling convention)錯配。
呼叫慣例是一套規定「呼叫函式時,誰負責清理堆疊參數」的約定。Windows API 絕大多數函式使用 __stdcall——這個慣例規定被呼叫方(callee)負責在回傳前把參數從堆疊彈出。相對地,__cdecl 則規定呼叫方(caller)在函式回傳後自行清理。這支反安裝程式的作者犯了一個宣告錯誤:他把呼叫 Windows 函式的函式指標標成 __cdecl,而那些 Windows 函式走的實際是 __stdcall。
錯配帶來的後果是每次函式呼叫,參數都被彈出了兩次:
- Windows 函式以
__stdcall方式回傳,自行彈出參數 - 呼叫方誤以為在用
__cdecl,回傳後再次彈出同一批已不存在的參數
每呼叫一次 Windows 函式,堆疊指標(stack pointer)就被多推進一截,悄悄侵蝕本不屬於堆疊的記憶體空間。單次呼叫的偏移量看似微不足道,但乘上迴圈次數,效果便截然不同。
重試迴圈讓堆疊一格格蠶食自身代碼
問題的嚴重性在於:這段注入代碼包含一個重試迴圈——程式邏輯是:嘗試執行某個檔案操作;若失敗,暫停片刻後重試。這種設計本身合理,例如等待某個鎖定進程結束才能刪除目標檔案。但在呼叫慣例錯誤的前提下,這個「耐心等待」的機制卻成了加速自毀的引信。
每跑一圈,多彈一次參數;每多彈一次,堆疊指標再往高地址爬一格。這個迴圈顯然執行了非常多次——多到堆疊指標把整個分配給自己的堆疊空間都耗盡,然後繼續往上爬,進入了存放注入代碼本身的記憶體區段。每次迴圈,堆疊寫入的數據都在一點一點覆蓋注入代碼,如同一個人站在沙堆上,每踩一腳就多陷一點,渾然不知腳下的沙正被自己踩空。
當堆疊指標最終爬到正在執行的機器碼(machine code)正中央,崩潰就成了定局。
堆疊數據覆蓋執行碼,CPU 讀到廢料觸發崩潰
CPU 仍然按照程式計數器(program counter)指向的地址繼續取指(fetch instruction)並執行。但那個地址上的內容早已被堆疊數據覆蓋——原本是精心排列的機器指令,現在是一堆雜湊數值。CPU 試圖把這些數值解碼為指令,遇到無效指令碼(invalid opcode),立刻觸發硬體異常,程序崩潰。
作者用了一個生動的說法:這段代碼「留下了一具醜陋的屍體」。而且屍體數量之多,讓 Windows 工程師誤以為是作業系統本身出了問題——崩潰現場在 Explorer 進程裡,轉儲看起來像是原生系統問題。這正是代碼注入崩潰最棘手之處:現場在受害者(Explorer)的進程,而真正的元凶(反安裝程式)早已銷聲匿跡,留下一具難以追溯的屍體。
反安裝程式與惡意程式,技術手法如出一轍
作者在文章開頭引用了自己的名言:「任何足夠先進的反安裝程式,都與惡意程式難以區分。」這句話改寫自克拉克第三定律(Clarke's Third Law):任何足夠先進的科技都與魔法難以區分。
這個案例完美詮釋了這句話的分量。反安裝程式為了達成「乾淨卸載」,往往需要使用和惡意程式相同的技術手法:把代碼注入到系統進程、在重開機後繼續執行殘留清理、操縱登錄表(registry)與系統目錄。唯一的差別只是「意圖」——但從技術層面,作業系統和安全軟體幾乎無法區分兩者。
當一支反安裝程式同時踩上「代碼注入」與「呼叫慣例錯誤」兩個地雷,就會出現這次案例的結局:崩潰暴增、Windows 工程師被誤導、大量資源投入排查「假性系統 bug」,最後才揪出一行宣告上的失誤。技術水準不足的骯髒工作,終究會留下難堪的現場。
呼叫慣例錯配只是一個關鍵字的差異,卻能在迴圈裡讓程式一格格侵蝕自身,最終用廢料數據覆蓋自己的機器指令,走向荒誕的自毀終局。