前言

Sigil 是一個用來封裝 RPG Maker 遊戲資產的工具。

它的目的並不是做出「絕對無法破解」的加密。
只要玩家能在自己的電腦上執行遊戲,理論上就一定有辦法在某個時間點取得解密後的內容。

我真正想做的是:

不讓圖片、音訊、資料庫與程式碼直接攤在資料夾裡,讓創作者能從技術上表達「我不希望這些資產被隨意翻閱」。

這比較接近一道界線,而不是一堵不可能跨過的牆。
有心逆向的人仍然能跨過去,但是不能再假裝自己只是「不小心點進資料夾」了。

目前 Sigil 已經在 Linux 上完成概念驗證。
它可以把一份 RPG Maker MV 遊戲封裝起來,再讓 NW.js 在看不到原始資產檔案的情況下正常讀取遊戲內容;解密後的資產只停留在記憶體,不會先被展開到暫存目錄。

Sigil Preview v0.0.97

聽起來很美好。
然後我開始思考 Windows 版要怎麼做。

嗯,這個架構看起來真的非常像惡意程式會做的事。

目前的 PoC 到底做了什麼

先把資產收進一個封包

一般的 RMMV 部署會把圖片、音訊、JavaScript、JSON 等內容原封不動地放在 www/ 裡。
玩家只要打開資料夾,便能直接查看大部分遊戲資產。

Sigil 的第一步,是把其中的唯讀內容收進 assets.sigil。
封包裡有一份索引,用來記錄每個資產的路徑、大小及位置;索引與每個檔案則各自經過驗證加密。

這裡的「驗證」很重要。
如果封包被修改、金鑰不正確,Sigil 會直接拒絕提供內容,而不是把一段不知道被動過什麼手腳的資料交給遊戲。

同時,相同的來源、設定與金鑰會產生逐位元相同的輸出。
這可以讓後續建置與發佈流程確認「這兩次封裝真的是同一份東西」,也比較容易檢查是否混入不該出現的檔案。

封裝過程不會修改來源專案,也不會覆寫已存在的輸出。
所有東西會先在暫存的 staging 目錄準備完成,確認成功後才一次發布;如果中途失敗,就把這次建立的半成品清掉。

簡單說,這一層只負責:

  • 哪些檔案要進入封包
  • 如何驗證封包沒有損壞或遭到修改
  • 如何在知道路徑的情況下取回單一資產

它不認識 RMMV,也不在乎最後是 NW.js 還是其他程式來讀。
因此,這也是目前最有機會直接移植到其他平台的部分。

用自己的 Launcher 接手入口

只把資產塞進封包沒有用,因爲 NW.js 仍然會照原本的方式尋找 www/index.html。
所以封裝時,Sigil 會保留原本的 NW.js runtime,再用自己的 Launcher 接手原遊戲執行檔名稱。

玩家啓動遊戲時,最先執行的其實是 Sigil Launcher。
它會從自身所在位置找到封包與原始 runtime,確認設定是否合法,也先驗證封包能不能用。

每份遊戲需要的金鑰與 runtime 路徑會附加在該遊戲專屬的 Launcher 尾端,因此正式輸出不需要另外擺一個 .key 檔案,也不需要叫玩家輸入任何東西。

當然,把金鑰放在 Launcher 裡並不代表它從此無法被取得。
這只是在目前的威脅模型下,避免把金鑰用純文字檔放在旁邊,順便讓正常玩家不必參與整個解密流程。

在 Linux PoC 中,Launcher 會把金鑰寫進一個封閉的匿名記憶體檔案,再連同已經開啓的封包一起交給 NW.js。
最後,它透過 LD_PRELOAD 要求系統在載入 NW.js 時,一併載入 Sigil 的原生攔截元件。

讓 NW.js 以爲檔案一直都在

這是整個 PoC 最關鍵,也最麻煩的部分。

我不希望修改遊戲裡的 JavaScript,更不可能要求每一款遊戲改寫所有讀檔方式。
因此,Sigil 選擇在更底層攔截檔案操作。

當 NW.js 想開啓 www/ 底下的檔案時,原生攔截元件會先判斷該路徑是不是 Sigil 管理的唯讀資產。
如果不是,就交回原本的系統函式處理;如果是,便從封包中驗證並解密該資產。

解密後的內容會被寫入 Linux 提供的匿名記憶體檔案,再把真正由核心配發的檔案描述符交給 NW.js。

這個做法有一個非常大的好處:
只要成功跨過「開檔」這個入口,後面的讀取、定位、取得檔案資訊、記憶體映射、複製描述符與關閉,都可以繼續使用 Linux 核心原本的行爲。

我不用在程式裡假裝一整套檔案描述符,更不用猜 Chromium 做了什麼。
對 NW.js 來說,它拿到的就是一個真的檔案,只是內容存在記憶體裡,磁碟上找不到對應的明文。

事情當然沒有單純到只攔截一個「開啓檔案」就結束。

實際追蹤後,我發現 NW.js 在開啓入口前會先詢問真實路徑,有些地方會走標準 I/O,載入過程也會先查詢檔案資訊與列舉目錄。
所以目前的攔截層除了開檔,也需要回答路徑解析、檔案是否存在、權限與目錄內容等問題。

這部分最像是在演戲:

磁碟上明明沒有那些檔案,但是所有人來問時,Sigil 都得回答得像它們真的存在。

存檔不能一起封起來

www/ 中的大部分內容都是唯讀資產,但是 www/save/ 顯然不是。
如果把它也放進唯讀封包,玩家大概進遊戲五分鐘就會開始問候我。

目前 Linux 版會把真正的存檔放在輸出根目錄的 save/,再從 www/save 建立一條受控的路徑橋接。
攔截層遇到這個範圍時完全不碰它,直接交回 Linux 處理建立、寫入、重新命名、備份與刪除。

換句話說,現在的遊戲目錄其實同時存在兩個世界:

  • 唯讀資產看似位於 www/,實際來自加密封包
  • 存檔真的存在磁碟上,而且維持原本的可寫行爲

PoC 已經走到哪裡

目前使用真實 RMMV 1.6.1 遊戲測試時,Sigil 封裝了 184 個唯讀資產。
在十秒的 NW.js 啓動追蹤中,攔截層處理了 61 次實際開檔,涵蓋 HTML、JavaScript、JSON、PNG、TTF、CSS 與 OGG。

另一份系統呼叫追蹤則記錄了 11,673 次開檔相關操作,沒有發現 www/ 資產繞過 Sigil 直接落到核心。
存檔的建立、讀取、重新命名、備份與刪除也已通過自動測試,真實遊戲持續運行兩分鐘時,設定檔能正常寫入。

所以這份 PoC 已經證明:

不修改遊戲內容,也不把明文資產解壓到磁碟,仍然可以讓 NW.js 從 Sigil 封包讀取一款真實的 RMMV 遊戲。

這不等於正式版已經完成。
不同遊戲、不同 NW.js 版本、效能與更完整的互動式驗收都還在後面,但是最重要的讀取鏈路已經接通了。

然後是 Windows

可以沿用的東西其實不多

Rust 可以跨平台,不代表這份程式換個編譯目標就會自動變成 Windows 版。

目前的封包格式、索引、驗證加密與大部分輸入安全檢查,可以合理地保留下來。
但是從 Launcher 開始,幾乎每一步都用了 Linux 特有的能力:

  • 以 exec 讓原始 runtime 接手目前程序
  • 用可繼承的檔案描述符交接金鑰與封包
  • 用 LD_PRELOAD 把攔截層放進 NW.js
  • 用匿名記憶體檔案提供真正的檔案語意
  • 用 symbolic link 把存檔導向可寫目錄
  • 用 Linux 的 no-clobber rename 發布完整輸出

Windows 不是沒有相似的零件,只是沒有一套可以照原樣拼回去的組合。

第一個難題:攔截層要怎麼進入 NW.js

Linux 的 LD_PRELOAD 是一個明確的動態載入機制。
只要設定好環境,dynamic loader 就會先載入 Sigil 的共享函式庫,讓它有機會接管指定的 libc 函式。

Windows 沒有一個能直接取代它的通用開關。

若要維持目前「完全不修改遊戲程式碼」的方向,最接近的做法通常會變成:讓 Launcher 啓動 NW.js、把 Sigil DLL 載入該程序,再 hook 它使用的檔案 API。
問題是「把 DLL 放進另一個程序並改變 API 行爲」本身,就是惡意程式、遊戲外掛與作弊工具經常使用的技術。

另一條路是利用 DLL 搜尋順序,放置一個 NW.js 會主動載入的代理 DLL。
但是 Windows 官方本來就把不安全的 DLL 搜尋與預載視爲攻擊面,這條路除了容易引起安全產品注意,也可能製造新的劫持漏洞。

而且 NW.js / Chromium 不是永遠只有一個程序。
renderer 與其他輔助程序是否都載入攔截層、子程序如何取得封包與金鑰、不同 sandbox 設定會不會阻擋它,全部都要重新驗證。

位元架構與版本也會把測試範圍繼續放大。
Windows 上同時存在 32-bit 與 64-bit 的 RMMV 部署,載入其中的 DLL 必須使用相符架構;不同 NW.js 版本實際呼叫的檔案 API 也未必一致。Linux PoC 已經讓我學到一次,不能因爲程序活著就當作資產真的有走進 VFS,Windows 版當然也逃不掉逐版本追蹤。

第二個難題:Windows 沒有同樣好用的 memfd

Linux PoC 能夠少攔很多函式,很大一部分功勞都屬於匿名記憶體檔案。

Windows 可以建立由分頁檔支撐的共享記憶體,但是「一段可以映射的記憶體」不等於「一個能被當成普通檔案使用的 handle」。
NW.js 在取得 handle 後,還可能查詢檔案資訊、改變讀取位置、建立映射或把 handle 傳給其他元件。

如果找不到能完整承接這些語意的 Windows 核心物件,Sigil 就得自己維護虛擬 handle,並繼續攔截後續每一種可能的操作。
這會把 Linux 版「開檔後交給核心」的問題,擴大成「一路假裝到檔案關閉爲止」。

最簡單的做法當然是把資產解密到暫存檔,用完再刪掉。
可惜這剛好推翻了 Sigil 最初的設計目標,而且程式崩潰、斷電、備份軟體或即時掃描都可能讓明文活得比預期更久。

第三個難題:防毒軟體不需要理解我的理想

我原本很想直接說「移植過去絕對保證報毒」。
嚴格來說,沒有人能保證每一套防毒軟體會怎麼判斷;但是如果 Windows 版沿用原本方向,它確實會一次集齊很多非常可疑的特徵:

  • 不常見、每份遊戲內容都不同的自製 Launcher
  • 內含金鑰與加密封包
  • 啓動另一個執行檔
  • 將 DLL 載入其他程序
  • 攔截檔案 API
  • 在記憶體中解密 JavaScript 與其他資產
  • 讓磁碟上不存在的檔案看起來可以正常讀取

每一項都可以有正當用途。
但是把它們全部放在同一個程式裡,多少有種主動把自己打扮成壞人的感覺。

Windows 上還必須先分清楚幾種不同的阻擋。

第一種是 Microsoft Defender SmartScreen 的聲譽警告。
它不代表程式已經被判定爲病毒,而是檔案或發行者還沒有累積足夠信任。Microsoft 的開發者說明明確提到,沒有簽章的程式每次更新都得重新累積檔案聲譽;即使有有效簽章,新的發行者與檔案仍可能先顯示「無法辨識的應用程式」。

這對 Sigil 特別不利。
目前每份遊戲的金鑰與設定都會寫進專屬 Launcher,因此不同遊戲或不同版本通常會得到不同的檔案雜湊。若沒有一致的程式碼簽章,幾乎每款遊戲、每次更新都會從零開始。

第二種才是各種防毒引擎的偵測。
它們不只掃描檔案內容,也會觀察程序行爲;注入、hook、隱藏內容與記憶體解密這類組合,很可能讓啓發式判斷產生警報。這類誤判可以提交樣本申訴,但是 Microsoft 的軟體開發者 FAQ也直接說明,他們不提供一份讓開發者預先加入的「永不誤判名單」。

第三種是 Smart App Control 或企業環境中的 App Control。
這些機制可能根據簽章、發行者、檔案雜湊及政策,直接決定某個 EXE 或 DLL 能不能執行。Windows 11 的 Smart App Control甚至會預設阻擋無法判斷、而且沒有可信簽章的程式。

所以「我自己測試時 Defender 沒報毒」遠遠不等於可以發佈。

簽章也不是按一下就好

最基本的改善方向,是替 Launcher 與 DLL 加上 Authenticode 簽章。

但目前的 Launcher 是先複製通用模板,再把每款遊戲的金鑰與設定附加到檔尾。
簽章後再修改檔案可能破壞簽章,因此實際流程必須改成:完成遊戲專屬 Launcher 後,再簽署最後的輸出。

接著問題就來了:

到底該由 Sigil 的作者簽,還是每一位遊戲開發者自己簽?

如果由我集中簽署,就表示我的憑證要替未知的第三方遊戲內容背書,封裝服務與私鑰保護也會變成新的基礎設施。
如果由遊戲開發者自己簽,個人創作者就得理解憑證、時間戳與發佈流程,還要負擔憑證或簽署服務的成本。

而且簽章只是在回答「這份檔案是誰發佈的,而且之後沒有被修改」。
它不能保證防毒引擎喜歡程式的實際行爲,也不保證第一批下載者一定不會看到 SmartScreen。

Windows 的檔案路徑也是另一個世界

目前封包內部統一使用 UTF-8 與 / 分隔路徑,Linux 攔截層則以掛載點爲界線,正規化 .、.. 與 symbolic link 的行爲。

到了 Windows,路徑會多出磁碟代號、UNC、\\?\ 前綴、UTF-16、反斜線、大小寫不敏感、reparse point 與其他別名。
如果路由規則少考慮一種表示法,輕則遊戲找不到檔案,重則同一份資產可能有一條路徑繞過 VFS。

Linux 版用來處理存檔的 symbolic link 也不適合直接搬過去。
Windows 建立與散佈連結的權限、檔案系統及壓縮工具行爲都不一致,因此存檔旁路大概需要改成實體目錄布局,或由 Windows 版路由器直接辨識可寫範圍。

老前輩 Enigma Virtual Box

說到「把一堆檔案封進 Windows 執行檔,而且不解壓到磁碟」,當然不能假裝 Sigil 是第一個想到這件事的人。

Enigma Virtual Box 這個工具早就在做非常相似的事情。

把名詞換掉之後,這和 Sigil 現在的概念實在很像:

  • Enigma 把內容嵌入主程式,Sigil 目前使用獨立的 assets.sigil
  • Enigma 的 loader 先於原程式執行,Sigil 則先經過自己的 Launcher
  • 兩邊都攔截原程式既有的讀檔操作
  • 兩邊都試圖讓虛擬檔案留在記憶體中

所以 Enigma Virtual Box 的存在至少證明了一件事:
Windows 上的應用程式級檔案虛擬化不只是紙上談兵,而且真的能包裝成一般使用者可以執行的產品。

不過,它也留下了幾張寫着「前方有坑」的備忘錄。

它的官方文件特別提醒,虛擬 EXE 與虛擬環境只能分享給相同架構的子程序;32-bit 主程式帶 64-bit 子程序,或者反過來,就不能使用這些功能。
這剛好呼應了前面提到的問題:NW.js / Chromium 一旦跨程序,位元架構、攔截層載入與狀態傳遞就不能分開考慮。

它是一個非常有價值的對照組。
在開始 Windows 版 Sigil 前,可以先用 Enigma Virtual Box 封裝同一款最小測試遊戲,觀察 NW.js 能不能完整啓動、哪些資產讀取會跨程序,以及常見安全產品如何對待輸出。這不能直接提供 Sigil 的實作,但是能提早告訴我,哪些問題是 Windows 檔案虛擬化的共同代價,哪些才是我自己的架構挖出來的坑。

說了這麼多廢話,結論是,用 Enigma 封裝的遊戲也毫不意外地踩到 Windows 的各種警告線。
如果一個歷史如此悠長的軟體都免不了遇到這樣的問題,那我憑什麼覺得 Sigil 能解決呢?

看起來比較正規的 VFS 也有代價

Windows 其實提供官方的 Projected File System,讓使用者空間的 provider 把一份資料投影成檔案與目錄。
乍看之下,這幾乎就是 Sigil 想要的東西,而且不需要把 DLL 注入 NW.js。

但是 ProjFS 會在檔案被讀取時要求 provider 提供資料,並把內容送進本機檔案系統快取。
這和 Sigil「不以明文直接呈現」的要求有直接衝突;
且它還是 Windows 的選用功能,啓用與部署條件也不適合假設每位玩家都已經準備好。

再往下做檔案系統驅動,確實可以得到更透明的檔案語意。
代價則是管理員權限、驅動簽章、核心層安全責任與完全不同等級的開發維護成本。

另一個方向是修改或自行維護 NW.js,讓它主動認識 Sigil 封包。
這會避開注入與 API hook,卻也代表每個 NW.js 版本都可能需要對應建置,並且偏離「不修改既有遊戲與 runtime」的初始方向。

每條路都能解掉一部分問題,同時送來另一整箱新的問題。
蒸蚌。

Windows 版真正要驗證的事情

Linux PoC 驗證的是「這個產品概念能不能成立」。
Windows PoC 要回答的問題會稍微殘酷一點:

  • 能不能在不修改遊戲內容的前提下,穩定攔截 NW.js 的讀檔路徑
  • 能不能讓明文只留在記憶體,同時提供 Chromium 需要的檔案語意
  • 能不能跨過 renderer 與其他子程序,而不遺漏資產讀取
  • 能不能處理 Windows 的路徑、存檔與檔案鎖定規則
  • 能不能讓最終的 EXE、DLL 與安裝 / 解壓流程保持有效簽章
  • 能不能在 Defender、SmartScreen、Smart App Control 與常見第三方防毒環境下合理發佈

最後一點不能等全部做完才測。
如果架構選定、功能完成後才發現主要安全產品穩定攔截,那就不是補一個白名單或換個編譯參數能解決的事情,而是得回頭重選整條讀取路徑。

Windows 移植真正的門檻,不是把功能寫出來,而是把它寫得像一個值得信任的正常程式。

後記(9/13):PoC 沒有失敗,但是這條架構到此爲止

這篇文章上半段於 8/25 封筆。
但是真正讓 Sigil 停下來的結論,是我又放了一段時間後才確定的。

日前重新開啓 Sigil 專案時,我發現它已經停擺多日。
不是哪個 bug 解不掉,也不是 Linux PoC 跑不起來,而是我一直沒有辦法說服自己:目前這套架構值得繼續往 Windows 搬。

目前已經完成的概念驗證,可以縮成這條路徑:

遊戲資產
-> 透過 VFS 封裝進單一檔案
-> 攔截系統 I/O
-> RMMV / NW.js 以爲自己讀到了普通檔案

這個結構本身沒有問題。
前面那些測試已經證明,封包可以正常建立、原始資產不必留在 www/,NW.js 也確實能透過攔截層取得入口、程式碼、圖片、字型與音訊。存檔則可以從唯讀 VFS 分流,繼續交給原本的檔案系統處理。

也就是說,PoC 完成了它應該完成的工作。
它回答了「這個概念能不能運作」,答案是可以。

問題出在中間那段「攔截系統 I/O,再查表從 VFS 回傳資料」。
到了 Windows,這意味着 Sigil 必須想辦法先進入 NW.js 的程序、攔截它的檔案操作,再讓磁碟上不存在的內容看起來像普通檔案。前面提到的 DLL 載入、API hook、記憶體解密與跨程序狀態,並不是偶然附帶的實作細節,而是這套架構無法避開的核心行爲。

因此,防毒軟體的刁難也不能再被當成「等做完再處理的相容性問題」。
就算某一版成功避開誤判,下一次更新 Launcher、攔截層、封包格式或遊戲內容,都可能重新改變檔案雜湊與行爲特徵;換一套防毒引擎、換一條規則,結果也可能完全不同。

如果 Sigil 只是我自己使用的工具,或許還能叫使用者手動加入例外。
但它的目標是交給遊戲創作者封裝作品,再交給一般玩家執行。要求每位作者向玩家解釋「請先關掉防毒,這真的不是病毒」,顯然不是能接受的發佈流程。

想到這裡,我也只能承認:
PoC 沒有失敗,但是它成功到足以讓我看清楚,這條路不適合繼續產品化。

隱約看見另一條路

我一直在想,有沒有辦法把需要保護的內容移到另一層,從根本上避開攔截作業系統 I/O。

最近看到 QuickJS 與 perry 後,隱約覺得另一種架構或許有戲:

binary
-> adapter.js
-> RMMV / NW.js

這裡的 binary 可能是 EXE 或 DLL,由原本的插件檔編譯而來;
adapter.js 則留在 JavaScript 這一側,負責連接原生引擎提供的物件與 PIXI,再把需要的操作轉交給 binary。

這和目前的 VFS 方案思考方向完全不同。
舊方案盡量不碰遊戲執行邏輯,而是在最底層假裝所有資產檔案仍然存在;新的想法則是保留一層必要的 JavaScript 橋接,把部分內容移出原本可以直接翻閱的形態。

它現在還只是一張很模糊的草圖。
能不能相容現有插件、RMMV 的物件如何跨過 adapter、PIXI 操作會不會卡住,以及最後到底能保護到什麼程度,都還沒有答案。QuickJS 與 perry 的細節也留到真的做出原型後再談,現在展開只會讓這篇文章歪到九霄雲外去。

還有一點就是,這個做法可能沒辦法覆蓋到 www/data 底下的事件資訊。

先把想法寫下來,只是爲了理清接下來要驗證什麼。
等過一陣子,我應該會再撰寫一篇文章解釋遇到了什麼。

…

至於目前這套「VFS 封裝+攔截系統 I/O」的架構,基本上只能放棄了。