DUNGEON REBORN game.js 逆向紀錄
對象:https://a201009200979603117-create.github.io/DUNGEON-REBORN/ 的 game.js(原作者 repo:https://github.com/a201009200979603117-create/DUNGEON-REBORN)。
2026-10-15 起作者在 game.js 加了混淆,v2.5 外掛因此失效。這個資料夾放的是:把混淆版還原成可讀程式碼的工具、命名對照表,以及作者每次更新後重新產生外掛的流程。
這裡不放任何遊戲程式碼,原版和還原後的版本都不放。工具執行時從原作者的網站或 GitHub 抓 game.js,還原結果放在 builds/,不進版控。
作者加的三道防線
- javascript-obfuscator 混淆:字串陣列、自訂 base64 解碼器、陣列旋轉、約 580 個 wrapper 函式、proxy object、控制流平坦化、數字拆成算式,還有兩個 self-defending 檢查。
- 整個遊戲包進 IIFE:
G、hurtPlayer、gainExp全部變成閉包裡的區域變數。外面看window.hurtPlayer不存在、typeof G === "undefined"。這才是外掛壞掉的原因,跟字串混淆無關。 index.html加 CSP meta:script-src 'self',禁止eval和 inline script。
另外存檔加了簽章(見下方)。我沒有找到主動偵測外掛、反除錯(debugger 迴圈)或網域鎖。
混淆逐層拆解
| 層 | 內容 | 處理方式 |
|---|---|---|
| 字串陣列 | 約 1900 個編碼字串 | reveal.mjs 在 Node 的 vm 執行原始解碼器 |
| 解碼器 | 自訂字母表 base64 → %xx → decodeURIComponent,有快取 |
直接跑原碼,不重寫演算法 |
| 旋轉器 | while(true){ parseInt(...)… push(shift()) },一直轉到算式等於常數 |
一起在 vm 執行 |
| self-defending ① | 解碼器裡 (''+function(){…}).indexOf('\n'):程式被排版過就解出錯的字串 |
在 vm 跑沒排版過的原始碼,檢查自然通過 |
| self-defending ② | 用 RegExp 測函式的 toString(),失敗就進無窮迴圈 |
同上 |
| wrapper 函式 | 每個作用域都有 function _0x4b15d0(a,b){return _0x550d(b-0x1ee,a)},可以多層巢狀 |
reveal.mjs 用 babel scope 追綁定,反覆到不動點,全部換成字串常數(約 6000 處) |
| proxy object | {'WgTpU': (a,b)=>a+b, 'LsFga': '字串'} |
cleanup.cjs(包 obfuscator-io-deobfuscator) |
| 控制流平坦化 | `'3 | 1 |
| 數字算式 | -0x24cd*-0x1+-0x13*-0x1e0+… |
同上 |
| 識別字 | 全部 _0x…,每次 build 都重新產生 |
names.json + rename.mjs + match-names.mjs |
現成工具踩到的坑
這些都已經在流程裡處理掉了:
- webcrack 認不出這個版本的解碼器,跳過之後旋轉器的
parseInt一直丟例外,變成無窮迴圈。所以字串還原改用自己寫的reveal.mjs,webcrack 只拿來排版。 - obfuscator-io-deobfuscator 的 ProxyFunctionInliner 會把遊戲自己的小函式(
itemLv、playerSpeed、cooldownNow)當成 proxy 內聯掉,參數還會不見,產生未定義的變數。cleanup.cjs加了兩個條件:只內聯「只引用自己的參數、每個參數只用一次」的函式,而且主 IIFE 頂層的函式宣告一律不動。第二條是拿原始碼對照之後才補的:svgIcon(inner)、upgradeGain(c)這種一行的遊戲函式剛好符合第一條,語意不會錯,但函式邊界會消失。 - ObjectSimplifier 的
unsafeReplace把會被修改的物件(搖桿狀態joy.active)當成常數,摺疊成if (false && …)。關掉。 - ConstantPropagator 會把遊戲自己的頂層常數全部內聯成數字。關掉之後找回 26 個常數,例如
SKILL_MAX = 5、WAVE_DURATION = 30、存檔的 key 與鹽。
驗證
check.mjs列出所有沒有綁定的識別字,結果只剩瀏覽器內建的全域(window、Math…),表示還原時沒有變數遺失。- 實際在瀏覽器載入還原版玩過,沒有錯誤。
- 把 IIFE 拆掉之後,v2.5 外掛一行都不改就能運作,表示還原後的結構跟混淆前一致。
命名:用作者的原名
混淆前的原始碼其實一直在原作者 repo 的歷史裡:5152825 是一個 merge commit,它的第二個 parent ca3534b(10/14)裡就是沒混淆的 game.js(138 KB,"use strict" 加中文註解)。這一版比第一個混淆版(f788969,10/15 23:19)早一天,中間作者加了 Boss 競技場、寶箱、分身、詛咒、存檔簽章等功能。
我一開始沒發現這份原始碼,是讀程式碼自己取的名字。後來拿原始碼當標準答案對照,結果是這樣:
| 數量 | |
|---|---|
| 原始碼的頂層宣告 | 192 |
| 我取的名字跟原名完全一樣 | 96(另外 88 個是同義詞,例如 newRun ↔ createState、W ↔ VIEW_W) |
| 意思取錯的 | 約 8 個,例如 clickSfx 其實是 restartRun、BOSS_ABILITIES 其實是終極技能表 ULTS、pendingChar 其實是 vignetteGrad |
所以現在的命名改成:
names.json:238 個頂層宣告。192 個用作者原名(原始碼的頂層宣告全部對上),46 個是 10/14 之後才加的功能,用我取的名字。locals.original.json:由truth-locals.mjs從原始碼取出 117 個函式、669 個區域變數的原名。格式跟params.ordered.json一樣,依「函式內出現順序 + 宣告指紋」儲存;指紋對不上時用 LCS 對齊,兩個錨點之間數量相同的就照順序補上。目前在最新 build 上套用了 660 個。params.ordered.json:原始碼沒有的函式,由人工命名補。- 剩下的才用啟發式猜,例如
const x = G.player猜成player、for (const x of G.enemies)猜成enemy。
還原度
拿最早的混淆版(f788969,離原始碼最近)跑完整流程,再跟 10/14 的原始碼逐個頂層宣告比對(壓縮排版、去掉註解):
| 數量 | |
|---|---|
| 兩邊都有 | 192 / 192 |
| 逐字相同 | 77 |
| token 相似度 ≥ 0.9 | 74 |
| 其他 | 41 |
不相同的原因有兩種,目前分不開:一種是還原過程的轉換,例如模板字串被轉成 + 串接,svgIcon 就是這樣;另一種是作者在 10/14 到 10/15 之間真的改了,例如 spawnBoss 改成了競技場版本。
跨 build 遷移
作者每次 push 都會重新混淆,所有 _0x… 名稱都會換掉。match-names.mjs 用兩種指紋把舊 build 的命名搬到新 build:
- 宣告本體:把
_0x…換成_之後的程式碼。兩邊都只出現一次、而且完全相同,就直接配對。 - 使用方式:每個引用處的上層運算式,本身換成
@,例如@.player、newRun(@)。用來分辨let x = null這種同形宣告,以及這次被改過的函式。
遷移結果記在下面的「版本紀錄」。
版本紀錄
每一版 game.js 的命名遷移結果與變更。watch.sh 每處理一版就在表格最後加一列,「變更」欄由 Claude 讀還原後的 diff 寫,失敗時用模板。
| 原作者 commit | build | 命名遷移 | 變更 |
|---|
| f788969(10/15 23:19 Publish DUNGEON REBORN web build) | b1ad367c | 第一個混淆版,從原始碼 ca3534b 對照命名 | 混淆、IIFE 封裝、CSP、存檔簽章 |
| 9d57201(10/16 00:06 Mobile layout fixes, orientation setting…) | e685ff94 | 比上一版多 4 個頂層宣告:螢幕方向設定、存檔簽章載入 | 手機版面、螢幕方向設定 |
| 5c02894(10/16 01:10 哥布林木棒改為耐久 2 次…) | 8b3282d3 | exact 193, fuzzy 17, missing 0 | 哥布林木棒可被玩家攻擊打掉,耐久 2 次;模糊配對到的是玩家攻擊、投射物、哥布林投擲 |
| 3b32e21(10/16 01:25 木棒耐久改為 1 次;強化 Android 防竄改) | 0b73151c | exact 222, fuzzy 14, missing 0 | 還原後只差 10 行:木棒 hp: 2 → 1、Boss 狀態機一個 if;網頁版沒有新防護 |
存檔簽章
localStorage["dungeonReborn.save.v2"] 存 JSON,…save.v2.sig 存簽章。載入時簽章不符,整份存檔就會被刪掉重置。
簽章是 cyrb53 的變體:輸入是「鹽 + JSON + 鹽」,種子 0xdeadbeef/0x41c6ce57,輸出 53 位元整數的 base36。
node sign-save.mjs '{"bestWave":99,"playerName":"我"}' # 印出可以貼到遊戲 Console 的兩行指令在線上原版驗證過:簽章正確的偽造存檔會被接受,簽章錯的會被刪掉。排行榜上的其他玩家是用固定種子產生的假資料,遊戲完全沒有連網,所以改存檔只影響自己的瀏覽器。
外掛怎麼接上去
build-mod.mjs 產生的 ../DungeonRebornMod.user.js 只包含三樣東西:載入器、每個 build 的「混淆名 → 名稱」對照表(maps.json),還有 ../src/mod-core.js。
-
@run-at document-start時同步呼叫document.open(),中止原本的 HTML 解析,parser 不會讀到<script src="game.js">。- 不能用
window.stop()。它會把 parser 標成 aborted,依規範之後的document.open()會被忽略,Chrome 也不會開始渲染(requestAnimationFrame永遠不觸發)。這個坑實際踩過。
- 不能用
-
抓回
index.html,拿掉 CSP meta 和game.js那一行,再document.write回去。- 拿掉 CSP meta 其實沒有用。Tampermonkey 真正注入時,parser 多半已經讀過
<head>前段的 CSP meta,CSP 一旦套用就記在文件上,document.open()也清不掉,頁面裡的 inline script 和eval照樣被擋。3.0.0 就是這樣讓遊戲無法開始:外掛插進去的game.js被擋下,G根本不存在。
- 拿掉 CSP meta 其實沒有用。Tampermonkey 真正注入時,parser 多半已經讀過
-
抓官方的
game.js並算 SHA-256。有對照表的話,在主 IIFE 結尾的;}()));前面插入匯出程式:Object.defineProperty(window, "hurtPlayer", { get: function () { return _0x3106b7 }, set: function (v) { _0x3106b7 = v } // 直接改閉包裡的綁定 }); -
用
GM_addElement('script', { textContent })把改過的game.js放進頁面執行。這是 Tampermonkey 以擴充功能權限插入的,不受頁面 CSP 限制。程式第一行會把<html data-dr-run>改成page-ok,載入器等最多 0.5 秒確認有執行;沒有的話改在 Tampermonkey 的沙盒裡用new Function執行(沙盒用的是擴充功能自己的 CSP)。兩邊都不行就載入同源的原版game.js,CSP 的script-src 'self'允許它。data-dr-run也用來防止遊戲跑兩次:晚到的那份看到標記不對就會停下。 -
mod-core.js用同樣的方式放到遊戲那一邊。它照 v2.5 的寫法換window.hurtPlayer,setter 會改掉閉包裡的綁定,遊戲之後的呼叫就走到新函式。 -
沒有對照表的版本:原樣載入官方
game.js,畫面底部顯示「外掛尚未支援這個版本」。
測試(test/live.mjs)用 Playwright 對線上網址驗證。外掛要等 CSP meta 被解析之後才執行,跟真正的 Tampermonkey 一樣;GM_addElement 用 DevTools 協定在頁面裡執行,模擬擴充功能權限。3.0.0 的測試是用 addInitScript 直接執行外掛,比 parser 還早,CSP 根本沒套用,所以沒測出問題。逐項驗證:測試環境的 CSP 有生效、遊戲與外掛在頁面執行、六個被換掉的函式都生效、能力等級、刷怪、刷王、清怪、下一波、遊戲加速。
遊戲更新時
cd reverse
npm install --ignore-scripts # 第一次。webcrack 相依的 isolated-vm 在新版 Node 編不過,這裡用不到它
./update.sh # 預設抓線上版,也可以給本機路徑
./update.sh --no-promote 舊版.js # 只輸出到 builds/<hash>/,不更動基準流程如下:
- 從原作者 repo 下載
base.json記錄的基準版,並還原。 - 還原新版。
- 遷移命名,跑
check.mjs。 - 把對照表加進
maps.json,重新產生外掛。舊版本的對照表會保留。 - 在原作者 repo 的 commit 裡找出這一版,設成下次比對的基準。
比對報告裡,~ 是模糊配對,通常就是這次改過的函式;✗ 是找不到對應的舊名稱。新出現的函式會保留 _0x… 名稱,要命名的話就加進 names.json。
自動追蹤
watch.sh 在 siao-server 上由 systemd timer 每 15 分鐘執行一次(systemd/)。沒有更新時,它只抓一次 game.js 算 hash 就結束,不會呼叫 Claude。
有新版時依序做這些事,任何一關沒過就停下來,已經改的檔案會還原:
update.sh:還原、遷移命名、產生外掛。- 檢查新命名表裡有外掛需要的名稱(
src/mod-core.js的NEEDS那一行,目前 35 個),check.mjs的未綁定識別字清單跟原本一樣。 - 找到這一版在原作者 repo 的 commit。
test/live.mjs對線上遊戲跑外掛的功能測試,27 項全部通過。analyze.mjs把新舊兩版還原碼的 diff 交給 Claude(claude -p),請它寫兩段說明。Claude 只輸出 JSON 文字,不碰任何檔案;失敗、逾時或格式不對就用模板。- 在上面「版本紀錄」表格加一列,commit 並推送外掛 repo。
- 部落格:在文章的版本紀錄加一段,
npm run build && npm test通過才推送。部落格失敗不會影響已經推出的外掛。
同一個 hash 最多試 3 次,之後就停止重試並在 log 留一行「放棄」,避免每 15 分鐘呼叫一次 Claude。修好之後用 watch.sh --force 重跑。--dry-run 會全部照做但不推送。
狀態、log、兩個 repo 的 clone 都在 server 的 ~/dungeon-reborn-watch/。推送用一把專用的 deploy key,只能寫入這個 repo 和部落格 repo。
檔案
| 檔案 | 用途 |
|---|---|
deob.sh |
混淆版轉可讀版:reveal.mjs → cleanup.cjs → webcrack.mjs → rename.mjs |
update.sh |
遊戲更新時用的一鍵流程 |
reveal.mjs |
在 vm 執行解碼器與旋轉器,展開所有 wrapper 呼叫 |
cleanup.cjs |
obfuscator-io-deobfuscator,收緊 proxy 判定,關掉常數傳播 |
webcrack.mjs |
排版 |
rename.mjs |
套用 names.json → locals.original.json → params.ordered.json → 啟發式 |
match-names.mjs |
跨 build 遷移命名 |
generic-order.mjs |
參數名的順序、指紋與 LCS 對齊 |
truth-locals.mjs |
從混淆前的原始碼取出區域變數原名 |
check.mjs / list-decls.mjs |
列出未綁定的識別字/列出頂層宣告 |
build-mod.mjs |
產生 ../DungeonRebornMod.user.js |
sign-save.mjs |
存檔簽章 |
watch.sh |
自動追蹤:偵測更新 → 還原 → 測試 → 推外掛 → Claude 寫說明 → 更新部落格 |
analyze.mjs / insert.mjs |
呼叫 Claude 寫變更說明(失敗用模板)/把文字插進標記之間 |
test/live.mjs |
對線上遊戲跑外掛的功能測試 |
systemd/ |
siao-server 上的 timer 與 service |
names.json / locals.original.json / params.ordered.json |
頂層命名、原始碼的區域變數名、人工命名 |
maps.json / base.json |
外掛對照表、比對基準 |