reverse/README.md

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/,不進版控。

作者加的三道防線

  1. javascript-obfuscator 混淆:字串陣列、自訂 base64 解碼器、陣列旋轉、約 580 個 wrapper 函式、proxy object、控制流平坦化、數字拆成算式,還有兩個 self-defending 檢查。
  2. 整個遊戲包進 IIFE:G、hurtPlayer、gainExp 全部變成閉包裡的區域變數。外面看 window.hurtPlayer 不存在、typeof G === "undefined"。這才是外掛壞掉的原因,跟字串混淆無關。
  3. 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

現成工具踩到的坑

這些都已經在流程裡處理掉了:

驗證

命名:用作者的原名

混淆前的原始碼其實一直在原作者 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

所以現在的命名改成:

還原度

拿最早的混淆版(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:

遷移結果記在下面的「版本紀錄」。

版本紀錄

每一版 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。

  1. @run-at document-start 時同步呼叫 document.open(),中止原本的 HTML 解析,parser 不會讀到 <script src="game.js">。

    • 不能用 window.stop()。它會把 parser 標成 aborted,依規範之後的 document.open() 會被忽略,Chrome 也不會開始渲染(requestAnimationFrame 永遠不觸發)。這個坑實際踩過。
  2. 抓回 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 根本不存在。
  3. 抓官方的 game.js 並算 SHA-256。有對照表的話,在主 IIFE 結尾的 ;}())); 前面插入匯出程式:

    Object.defineProperty(window, "hurtPlayer", {
      get: function () { return _0x3106b7 },
      set: function (v) { _0x3106b7 = v }   // 直接改閉包裡的綁定
    });
    
  4. 用 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 也用來防止遊戲跑兩次:晚到的那份看到標記不對就會停下。

  5. mod-core.js 用同樣的方式放到遊戲那一邊。它照 v2.5 的寫法換 window.hurtPlayer,setter 會改掉閉包裡的綁定,遊戲之後的呼叫就走到新函式。

  6. 沒有對照表的版本:原樣載入官方 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>/,不更動基準

流程如下:

  1. 從原作者 repo 下載 base.json 記錄的基準版,並還原。
  2. 還原新版。
  3. 遷移命名,跑 check.mjs。
  4. 把對照表加進 maps.json,重新產生外掛。舊版本的對照表會保留。
  5. 在原作者 repo 的 commit 裡找出這一版,設成下次比對的基準。

比對報告裡,~ 是模糊配對,通常就是這次改過的函式;✗ 是找不到對應的舊名稱。新出現的函式會保留 _0x… 名稱,要命名的話就加進 names.json。

自動追蹤

watch.sh 在 siao-server 上由 systemd timer 每 15 分鐘執行一次(systemd/)。沒有更新時,它只抓一次 game.js 算 hash 就結束,不會呼叫 Claude。

有新版時依序做這些事,任何一關沒過就停下來,已經改的檔案會還原:

  1. update.sh:還原、遷移命名、產生外掛。
  2. 檢查新命名表裡有外掛需要的名稱(src/mod-core.js 的 NEEDS 那一行,目前 35 個),check.mjs 的未綁定識別字清單跟原本一樣。
  3. 找到這一版在原作者 repo 的 commit。
  4. test/live.mjs 對線上遊戲跑外掛的功能測試,27 項全部通過。
  5. analyze.mjs 把新舊兩版還原碼的 diff 交給 Claude(claude -p),請它寫兩段說明。Claude 只輸出 JSON 文字,不碰任何檔案;失敗、逾時或格式不對就用模板。
  6. 在上面「版本紀錄」表格加一列,commit 並推送外掛 repo。
  7. 部落格:在文章的版本紀錄加一段,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 外掛對照表、比對基準