從一段 Proton 崩潰 log 到二進位修補:修好 PAL 引擎播完開場動畫就閃退的 bug
最近用 Proton 跑一款日系視覺小說,畫面卡在開場 logo 動畫播完的那一瞬間就閃退,連標題畫面都進不去。最後我把它一路追到遊戲引擎裡一條少寫了一個 null 檢查的程式碼,並用一段 18 bytes 的修補碼把它補好,順手保留了開場動畫。
這篇把整個過程完整記錄下來:怎麼從一份吵雜的 Wine log 定位崩潰點、為什麼「刪掉影片檔」能繞過、反組譯出錯函式看懂它在做什麼,以及最後怎麼做出一份「對齊引擎自身慣例」的二進位 patch。組合語言的部分都附上可能對應的 C/C++ 還原碼,方便理解。
分工說明(按事實): 問題發現、環境重現與全部實機驗證(確認刪
logo.wmv可繞過、套用 patch 後實測動畫正常播完並進入目錄)由 LFsWang 完成;log 分析、PAL.dll反組譯、bug 成因定位、修補方案設計與修補程式撰寫由 Claude(Anthropic, Opus 4.8) 完成。本文由 Claude 主筆。
#零、緣起:我的 AI 協作思路(by LFsWang)
這個遊戲算是我在亂翻買來放了很久沒玩的遊戲時,發現「能用 Proton 開起來」的其中一款,可惜開到一半就掛掉。在跟 Windows 上的行為比對後,我確認它是死在進入遊戲目錄前的那一刻,覺得這有修復價值,於是動手開修。
生成 Log 之後,憑以前的經驗我就知道那 Log 超級醜,自己一行行看很花時間,所以我一開始就借助 AI 來分析。AI 很快給出跟影片有關的錯誤這個結論,老實說當下我有點翻白眼,因為影片出錯是 VN 遊戲的老毛病之一,很討厭。
不過我在實機驗證後發現一件關鍵的事:logo 影片其實是「正常播放完」才閃退的。這代表問題不太像是載入解碼器的文題,而比較像是異常處理的狀況。
所以除了 AI 一開始建議的「移除影片」這種繞法之外,我進一步給出指示:設法劫持原本程式的錯誤處理環節。順著這個方向,AI 才真正把這個 Bug 反組譯定位出來,發現是事件處理迴圈少了一個 null 檢查。最後,我再指示 AI 把所有牽涉到組合語言的段落,還原成適當的 C/C++ 程式碼,好讓這份紀錄留存得更清晰、更好讀。
下面就是這趟協作的完整技術過程。
#一、現象
- 遊戲:
時計仕掛けのレイライン ―黄昏時の境界線―(PAL 引擎的視覺小說) - 啟動器:Steam + GE-Proton10-32
- 症狀:開場 logo 動畫可以正常播放,但播到結束的瞬間整個遊戲就崩潰,進不了標題目錄。
手上唯一的線索是 Proton 吐出來的 steam-11364325650971230208.log,5600 多行,絕大多數是 trace:unwind 之類的雜訊。
#二、在 log 裡找到崩潰點
先把真正的錯誤從雜訊裡撈出來。略過所有 trace: 行,只看 err: 跟例外,很快就找到關鍵段落:
1 | 95372.061 EC_COMPLETE ← DirectShow:影片播放完畢 |
三件事一目了然:
- 這是一個
c0000005Access Violation,而且是讀取0x00000000——典型的空指標解參考。 - 出錯位址
eip = 0x100159D6,崩潰當下eax = 0。 - 崩潰緊接在
EC_COMPLETE之後——也就是 DirectShow 通知「影片放完了」的那一刻。
換句話說,你看到的「進目錄前崩潰」,其實是卡在開場動畫結束的瞬間。
#三、崩潰的是誰?—— 定位模組
0x100159D6 這個位址屬於哪個模組?在 log 裡搜模組載入紀錄:
1 | Loaded "...\dll\PAL.dll" at 10000000: native |
PAL.dll 載入在基底 0x10000000,所以崩潰點的 RVA = 0x100159D6 − 0x10000000 = 0x159D6,就在遊戲引擎自己的程式碼裡,不是某個 Windows API。
再往前看播放是怎麼搭起來的:
1 | Loaded quartz.dll ← DirectShow 核心 |
所以開場動畫是一支 WMV(logo.wmv),透過 DirectShow 播放,而 Wine 底層用 GStreamer 去實作 DirectShow。播放本身沒問題(影片確實放完了),出事的是播完之後引擎自己的收尾程式碼。
#四、為什麼「刪掉 logo.wmv」就好了?
實測把 logo.wmv 移除,遊戲就能正常進目錄。原因很單純,也很重要:
問題從來不是影片,而是「影片播完之後」那段收尾邏輯。
PAL 引擎在播放前會先確認影片檔在不在;檔案不存在,它就整段開場動畫流程直接跳過,那條會崩潰的收尾路徑根本不會被執行。所以刪檔不是修好了 bug,而是讓有 bug 的程式碼沒機會跑。這是完全可行的繞法,代價只是少看一段 logo。
但我想知道為什麼會崩,而且如果可以的話,想保留動畫。於是把 PAL.dll 拆開來看。
#五、反組譯出錯函式
PAL.dll 是一個 32 位元 PE(PE32 / Intel i386),ImageBase 剛好就是 0x10000000,所以 RVA 跟執行期位址一致。把 0x159D6 周邊反組譯出來,函式入口在 0x100159C0:
1 | 100159c0 push ebp |
那串 cmp eax, 1 / 2 / 3 是決定性線索——它正好是 DirectShow 的事件碼:
| 值 | 常數 | 意義 |
|---|---|---|
1 |
EC_COMPLETE |
播放完成 |
2 |
EC_USERABORT |
使用者中止 |
3 |
EC_ERRORABORT |
錯誤中止 |
加上它呼叫 GetEvent(vtable offset 0x20)跟 FreeEventParams(offset 0x30),可以很有把握地判斷:這個函式就是 DirectShow 的「事件泵」(event pump)——一個迴圈,不斷把影片播放過程中產生的事件抽出來處理。
還原成 C++ 大概長這樣(示意,非原始碼):
1 | // sub_100159C0 — this 透過 eax 傳入(暫存器呼叫慣例) |
m_pMediaEvent 就是物件偏移 +8 的那個 IMediaEvent 介面指標。崩潰時 eax = [esi+8] = 0,接著 mov ecx,[eax] 去讀 NULL 的 vtable,當場 Access Violation。
#六、Bug 的本質:唯一漏掉的 null check
問題是:m_pMediaEvent(+8)為什麼在這個時間點會是 NULL?
把旁邊的拆除函式(teardown,結束於 0x100159B2)也反組譯出來,真相就清楚了。它負責在影片結束後釋放所有 DirectShow 介面:
1 | 10015919 mov eax, [esi+8] ; m_pMediaEvent |
還原成 C++:
1 | void CMoviePlayer::Teardown() |
於是事情串起來了:
- 影片播完,DirectShow 發出
EC_COMPLETE。 - 引擎呼叫
Teardown(),把m_pMediaEventRelease 並設成 NULL。 - 但事件泵
PumpEvents()又被呼叫了一次(或迴圈多跑了一圈),這次m_pMediaEvent已經是 NULL。 PumpEvents()偏偏沒有 null 檢查,直接m_pMediaEvent->GetEvent(...)→ 讀 NULL vtable →c0000005。
關鍵證據是這個對比:
PAL.dll 裡每一個用到這些介面的地方——
Teardown()、另一個0x10015A70的取狀態函式——全都有先cmp …, 0 / je做空值檢查;唯獨事件泵PumpEvents()漏了。
這是一個典型的 use-after-null / 競態時序 bug。在真正的 Windows 上,DirectShow 的事件投遞和介面釋放的時序剛好錯開,PumpEvents() 永遠不會在 m_pMediaEvent 被清空後又被呼叫,所以原廠從沒踩到。換到 Wine/GStreamer 實作的 DirectShow,時序不同,就穩定地踩中了這個本來就存在的漏洞。
log 裡崩潰前一行那個 ウィンドウ ハンドルが正しくありません(視窗 handle 無效)也呼應了同一件事——影片視窗在收尾階段已經失效,整個 graph 正在被拆掉。
#七、修法 A:靜態二進位修補
既然 bug 就是「少一個 null check」,最乾淨的修法就是把它補回去。
#借用引擎自己的安全出口
先看那個 js 10015a45(GetEvent 失敗時走的路)跳到哪:
1 | 10015a45 push 1 |
這段是引擎自己的安全出口:它用 mov esp, ebp 把堆疊一次還原乾淨,所以不管之前往堆疊推了幾個東西都沒關係。這代表只要在踩到 NULL 之前判斷出 m_pMediaEvent == NULL,直接跳到 0x10015A45,就等同於「GetEvent 失敗」那條已經存在、且驗證過安全的路徑。完全不必自己做堆疊手術。
#找一塊 code cave
編譯器在函式之間留了一堆 int3(0xCC)對齊填充。掃一遍 .text,找到好幾塊夠大的空白,挑了 0x100BEBD5(43 bytes)來放修補碼。
#Detour + 補丁
出錯前的兩個指令剛好 5 bytes,正好換成一個 jmp:
1 | 原始 @ 0x100159D3: 8B 46 08 8B 08 mov eax,[esi+8] ; mov ecx,[eax] |
cave 裡補上那個缺失的 null 檢查:
1 | ; @ 0x100BEBD5 |
對應的 C++ 就是把那行迴圈條件補上守衛:
1 | void CMoviePlayer::PumpEvents() |
整份修補只動了 5 個 byte,外加一段原本是 int3 的空白區,沒有覆蓋任何既有指令。改完用反組譯把三處(detour、cave、出口)都核對過,確認跳轉與位址都正確:
1 | 100159d3: e9 fd 91 0a 00 jmp 0x100bebd5 ✓ |
#修補程式
把上面的步驟寫成一支 Python 腳本:它自己解析 PE 標頭、把 RVA 換算成檔案 offset、組出 cave 裡的修補碼、確認 cave 真的是空白(全 0xCC)才寫入,最後把 detour 蓋上去。所有位址、rel32 位移都用程式算,避免手算出錯。原檔不動,輸出到一份新的 DLL:
1 | #!/usr/bin/env python3 |
執行並用 objdump 反組譯驗證三個點:
1 | $ python3 patch_pal.py PAL.dll PAL.patched.dll |
把 logo.wmv 放回去、換上修補後的 PAL.dll,開場動畫正常播完、順利進入標題目錄,不再閃退。bug 真正被修好了,而不是被繞過。
小提醒:替換前先備份原始
PAL.dll。這份 patch 綁特定版本的 DLL(視覺小說通常沒有完整性檢查,所以沒問題)。
#八、修法 B:VEH(不改檔的執行期 hook)
如果不想動原檔,可以用等價的執行期手法:做一個 32 位元 proxy DLL(例如 version.dll/winmm.dll,丟到遊戲 exe 同目錄,Wine 會優先載本地的),在 DllMain 裡裝一個 Vectored Exception Handler:
1 | static BYTE *base; // = (BYTE*)GetModuleHandleA("PAL.dll"); |
原理跟靜態 patch 一模一樣:攔到 0x159D6 的 Access Violation,就把 Eip 改到 0x15A45。因為那個出口會自己 mov esp, ebp 還原堆疊,所以零風險、不需要任何堆疊處理。
- 優點:不改原檔;跨版本只要調整 offset。
- 缺點:要額外編一個 proxy DLL。
#九、結語
回頭看,這個 bug 的因果鏈其實很乾淨:
- Wine 的 DirectShow 時序與 Windows 不同,讓事件泵在介面被釋放後又跑了一次。
- PAL 引擎漏寫了一個 null 檢查——它在別處每次都檢查,唯獨這裡沒有。
- 兩者相撞 → 空指標解參考 → 閃退。
「刪掉 logo.wmv」是最省事的繞法;但真正的修法是補回那個缺失的守衛,方式可以是 18 bytes 的靜態 patch,也可以是一個把 Eip 導向引擎自身安全出口的 VEH。最讓我滿意的一點是:修補完全沿用引擎自己的收尾路徑,沒有自創任何清理邏輯,所以行為跟「影片正常播完」完全一致。
從一份吵雜的 log,到看懂一個十幾年前編譯出來的函式在做什麼,再用幾個 byte 把它補好——逆向的樂趣大概就在這裡。
分工:問題發現、環境重現與實機驗證 — LFsWang;log 分析、PAL.dll 反組譯、bug 定位、修補設計與本文撰寫 — Claude(Anthropic, Opus 4.8)。
