DUNGEON REBORN 逆向 - 筆記歸檔
Preface
Cutoff: 26/10/16
一款純前端、倖存者類網頁遊戲的外掛與逆向紀錄。目前狀態:混淆完全還原,原始碼的 192 個頂層宣告全部找回作者原名,外掛 v3 在最新版上全部功能可用。
這篇分兩段。(1)是 v2.5 時代:遊戲沒打包,不用逆向就能寫外掛。10/15 晚上作者把 game.js 混淆、包進 IIFE、加上 CSP,v2.5 當場失效,(2)之後是怎麼把它拆回來。
工具、命名表與完整的逆向紀錄在 https://git.siao.ai/siao/dungeon-reborn-mod。repo 裡不放任何遊戲程式碼,理由見(11)。本文的原始紀錄在「原始日誌」面板裡,每一節結尾的「本節來源」點下去會直接跳到那一份。程式碼片段是建置時從 SiaoHub 直接嵌入的,不是手貼。
分析目標:https://a201009200979603117-create.github.io/DUNGEON-REBORN/ 的 game.js。作者每次更新,server 上的排程會自動還原、測試外掛、更新這篇的版本紀錄,見(10)。
(1)v2.5:不用逆向也能寫外掛
遊戲沒有經過打包工具處理的時候,整個遊戲邏輯都在一支 game.js 裡,全是一般的全域函式。在 Console 打 String(hurtPlayer) 就能看到原始碼。這時候寫外掛只要三件事:找到狀態變數、讀懂主迴圈、換掉關鍵函式。
狀態在 let G 裡,G.player、G.enemies、G.skills 都在裡面。let 宣告的全域變數不會掛在 window 上,但 @grant none 的腳本直接寫 G 就拿得到:
function Run() {
return (typeof G !== "undefined" && G && G.player) ? G : null;
}在 SiaoHub 檢視 siao/dungeon-reborn-mod/src/mod-core.js L39-41
頂層的 function foo(){} 就是 window.foo,遊戲內部呼叫也走同一個全域綁定。把它存起來再換成自己的版本,遊戲之後每次呼叫都會先經過我的版本。無敵就是這樣做的:
const OrigHurt = window.hurtPlayer;
window.hurtPlayer = function (...Args) {
if (Cfg.God && Run()) return;
return OrigHurt.apply(this, Args);
};在 SiaoHub 檢視 siao/dungeon-reborn-mod/src/mod-core.js L44-48
一路做到 v2.5,功能有無敵、各種倍率、自動選升級、防止暫停、遊戲加速、刷怪、直接調整技能等級,還有手機版面板。真正花時間的是讀懂主迴圈,下面八個坑幾乎都是這個原因。
倍率用「覆寫」會把升級洗掉
第一版每一幀直接把 P.dmgMult 設成滑桿的值。升級拿到的傷害加成也存在 dmgMult,每幀被蓋掉一次,看起來就像倍率沒作用。改成包住 updateGame:先存原值,暫時改成「原值 × 倍率」,跑完用 try/finally 還原。
const P = Game.player;
const Nat = { Dmg: P.dmgMult, Pickup: P.pickupMult, Crit: P.crit };
// 只在本幀暫時調整,本幀結束立刻還原,所以不會吃掉你的升級
P.dmgMult = Nat.Dmg * Cfg.DmgMult;
P.pickupMult = Nat.Pickup * Cfg.PickupMult;
P.crit = Math.min(1, Nat.Crit + Cfg.CritAdd);在 SiaoHub 檢視 siao/dungeon-reborn-mod/src/mod-core.js L126-132
try {
return OrigUpdate.call(window, Dt);
} finally {
P.dmgMult = Nat.Dmg;
P.pickupMult = Nat.Pickup;
P.crit = Nat.Crit;
}在 SiaoHub 檢視 siao/dungeon-reborn-mod/src/mod-core.js L153-159
finally 很重要。原函式中途丟錯的話,數值會停在乘過的版本,下一幀再乘一次,越滾越大。
自動選升級永遠不會觸發
自動選卡原本寫在 updateGame 的包裝裡,一次都沒跑過。讀了主迴圈才知道,升級畫面出現時(G.choosing 為真)遊戲根本不呼叫 updateGame。
改成用 setInterval 每 150 毫秒看一次 G.choosing:
// 升級畫面時 updateGame 不會被呼叫,所以自動選卡要用計時器
setInterval(function () {
const Game = Run();
if (!Cfg.AutoPick || !Game || !Game.choosing || !Game.choices || !Game.choices.length) return;
let Index = Game.choices.findIndex(function (C) {
return C.def && (C.def.kind === "evolve" || C.def.kind === "fusion");
});
if (Index < 0) {
Index = Game.choices.findIndex(function (C) {
return C.def && (C.def.kind === "weapon" || C.def.kind === "skill");
});
}
if (Index < 0) Index = 0;
window.chooseUpgrade(Index);
}, 150);在 SiaoHub 檢視 siao/dungeon-reborn-mod/src/mod-core.js L178-192
「下一波」按鈕無限跳波
把波次計時器設成 1e9,結果一直跳。遊戲每幀只從計時器扣掉一個 WAVE_DURATION,計時器還是很大,每一幀都判定時間到。解法是只補到剛好一波:
NextWave: function () {
const Game = Run(); if (!Game) return;
// 只補滿到「剛好一波」:updateWave 會扣掉一個 WAVE_DURATION 並 +1 波。
// (舊版設成 1e9,每幀只扣一次所以會連續無限跳波)
Game.waveTimer = Math.max(Game.waveTimer, WAVE_DURATION);
},在 SiaoHub 檢視 siao/dungeon-reborn-mod/src/mod-core.js L253-258
改計時器之前,要先看遊戲是把它重設成 0,還是扣掉一個固定值。
原版藏了冷卻下限
攻速拉很高也沒變快。原始碼裡普攻冷卻最低 0.2 秒、技能的冷卻縮放最低 0.4。換掉 cooldownNow() 把普攻下限放寬到 0.05 秒:
// 原版普攻冷卻最低 0.2 秒,這裡放寬讓高攻速有效
const OrigCooldown = window.cooldownNow;
window.cooldownNow = function () {
const Game = Run();
if (!Game) return OrigCooldown.apply(this, arguments);
return Math.max(0.05, Game.character.stats.cooldown * Game.player.cdMult / Cfg.AtkSpeed);
};在 SiaoHub 檢視 siao/dungeon-reborn-mod/src/mod-core.js L60-66
技能那邊的 0.4 不好改,就在每幀額外扣它的冷卻:
// 技能冷卻加速 (原版 cdMult 對技能有 0.4 的下限,這裡額外加速)
if (Cfg.AtkSpeed > 1 && typeof SKILLS !== "undefined") {
for (const Id of Object.keys(Game.skills)) {
const Def = SKILLS[Id];
if (Def && !Def.update) Game.skills[Id].cd -= Dt * (Cfg.AtkSpeed - 1);
}
}在 SiaoHub 檢視 siao/dungeon-reborn-mod/src/mod-core.js L145-151
「倍率不動」最後拆出來是三個不同的 bug 疊在一起。只調高滑桿上限的話,永遠修不好。
防止暫停:入口只有一個
P 鍵、暫停鈕、打開設定,最後都走 togglePause()。換掉它,想暫停的呼叫直接忽略:
const OrigToggle = window.togglePause;
window.togglePause = function () {
const Game = Run();
if (Cfg.NoPause && Game && !Game.paused) return;
return OrigToggle.apply(this, arguments);
};在 SiaoHub 檢視 siao/dungeon-reborn-mod/src/mod-core.js L71-76
分頁在背景時瀏覽器會停掉 requestAnimationFrame,所以另外開一個計時器,用實際經過的時間補跑 updateGame,每步最多 0.05 秒、最多補 5 秒:
let BgLast = performance.now();
setInterval(function () {
const Now = performance.now();
let Elapsed = (Now - BgLast) / 1000;
BgLast = Now;
const Game = Run();
if (!Cfg.NoPause || !document.hidden || !Game) return;
if (typeof scene !== "undefined" && scene !== "playing") return;
Elapsed = Math.min(Elapsed, 5); // 最多補 5 秒,避免一次算太久
while (Elapsed > 0.0001) {
if (Game.paused || Game.choosing || Game.dead) break;
const Step = Math.min(0.05, Elapsed);
window.updateGame(Step);
Elapsed -= Step;
}
}, 250);在 SiaoHub 檢視 siao/dungeon-reborn-mod/src/mod-core.js L86-101
遊戲加速要切步
dt 直接乘倍率,步長太大會讓碰撞判定漏掉。改成一幀的時間乘上倍率後,切成每步最多 0.05 秒連跑:
// 遊戲加速:把一幀的時間 ×GameSpeed,切成每步最多 0.05 秒 (和原版單步上限一致,
// 避免步長過大造成穿牆/判定漏掉),同一幀內連跑多步。畫面仍每幀只繪製一次。
window.updateGame = function (Dt) {
const Speed = Math.max(1, Cfg.GameSpeed);
if (Speed === 1) return StepUpdate(Dt);
let Left = Dt * Speed;
let Guard = 0;
while (Left > 0.0001 && Guard++ < 400) {
const Game = Run();
if (!Game || Game.paused || Game.choosing || Game.dead) break;
const Step = Math.min(0.05, Left);
StepUpdate(Step);
Left -= Step;
}
};在 SiaoHub 檢視 siao/dungeon-reborn-mod/src/mod-core.js L162-176
低幀率會打折。原版每幀最多算 0.05 秒,在約 6 幀的分頁開 5 倍速,實際只快約 1.5 倍:每秒 6 幀 × 0.05 秒 × 5 倍 = 1.5。
手機觸控會誤觸虛擬搖桿
在面板上點東西,觸控事件會傳給遊戲,角色亂跑。面板上的 touchstart、pointerdown 都要 stopPropagation:
// 面板上的觸控/點擊不要傳給遊戲 (遊戲有虛擬搖桿,會被誤觸發移動)
["touchstart", "touchmove", "touchend", "touchcancel", "pointerdown", "mousedown", "click", "wheel"].forEach(function (Name) {
Panel.addEventListener(Name, function (Ev) { Ev.stopPropagation(); }, { passive: true });
});在 SiaoHub 檢視 siao/dungeon-reborn-mod/src/mod-core.js L298-301
iOS 還有一個坑:面板設了 user-select:none,輸入框也打不了字,input 要單獨設回可選取。
在輸入框打字會觸發遊戲熱鍵
數值輸入框裡打字,WASD、P、M 全被遊戲當熱鍵吃掉。數字框要自己攔 keydown 和 keyup:
// 在輸入框打字時不要觸發遊戲熱鍵 (WASD、P、M、` 等)
Num.addEventListener("keydown", function (Ev) { Ev.stopPropagation(); });
Num.addEventListener("keyup", function (Ev) { Ev.stopPropagation(); });在 SiaoHub 檢視 siao/dungeon-reborn-mod/src/mod-core.js L364-366
v2.5 的外掛本體到 3.1 一行都沒改,現在放在 src/mod-core.js。這一節嵌入的片段固定在 3.1 的版本(4aac4ce);3.2 補上新機制的部分見(8)的第 5 小節。
本節來源:README.md
(2)作者加了三道防線
10/15 23:19,原作者把 GitHub Pages 換成新的 build,v2.5 當場失效。之後兩個小時內又推了三版:
| commit | 時間 | 內容 | game.js |
|---|---|---|---|
ca3534b |
10/14 22:38 | 最後一版沒混淆的原始碼 | 138 KB |
f788969 |
10/15 23:19 | 第一個混淆版,加 CSP | 450 KB |
9d57201 |
10/16 00:06 | 手機版面、螢幕方向設定 | 461 KB |
5c02894 |
10/16 01:10 | 哥布林木棒耐久 2 次 | 471 KB |
3b32e21 |
10/16 01:25 | 木棒耐久 1 次、強化 Android 防竄改 | 470 KB |
作者加了三樣東西:
- javascript-obfuscator 混淆:字串陣列、自訂 base64 解碼器、陣列旋轉、約 580 個 wrapper 函式、proxy object、控制流平坦化、數字拆成算式,還有兩個 self-defending 檢查。
- 整個遊戲包進 IIFE:
(function(){ "use strict"; … })()。 index.html加 CSP meta:script-src 'self',禁止eval和 inline script。
另外存檔加了簽章,見(9)。沒有主動偵測外掛、沒有 debugger 反除錯、沒有網域鎖。
真正讓外掛壞掉的是第 2 道,不是字串混淆。包進 IIFE 之後,G、hurtPlayer 全部變成閉包裡的區域變數:
typeof window.hurtPlayer // "undefined"
typeof G // "undefined"v2.5 換函式的手法完全沒有東西可換。字串混淆只是讓我看不懂程式碼,IIFE 才是讓外掛接不上去。CSP 則擋掉從外面注入程式碼的路。
本節來源:reverse/README.md
(3)字串陣列與 self-defending:直接跑原始解碼器
混淆後的檔案頂層只有三樣東西:解碼器函式、字串陣列函式,以及一個逗號運算式 (旋轉器(陣列, 常數), function(){ 整個遊戲 }())。
- 字串陣列:約 1900 個編碼字串。
- 解碼器:自訂字母表的 base64,轉成
%xx再decodeURIComponent,有快取。 - 旋轉器:
while(true)裡用一堆parseInt(解碼(...))算一個式子,不等於常數就把陣列push(shift())轉一格,轉到對為止。
我沒有重寫解碼演算法,而是把這三段原封不動放進 Node 的 vm 執行,之後用它來解每一個呼叫:
const prelude = code.slice(decFn.start, decFn.end) + ';' + code.slice(arrFn.start, arrFn.end) + ';(' + code.slice(rot.start, rot.end) + ');';
const ctx = vm.createContext({});
vm.runInContext(prelude, ctx, { timeout: 5000 });在 SiaoHub 檢視 siao/dungeon-reborn-mod/reverse/reveal.mjs L26-28
一定要用原始碼,是因為有兩個 self-defending 檢查:
- 解碼器裡有
(''+function(){…}).indexOf('\n')。程式被排版過、函式文字裡出現換行,就改用另一條路徑,解出錯的字串。 - 另一個檢查用
RegExp測某個函式的toString(),不符合就進無窮迴圈。
排版過的程式碼會同時踩到兩個。直接跑沒排版過的原始碼,兩個檢查自然通過。
webcrack 為什麼卡住
一開始用 webcrack,它在 sandbox 裡跑到 timeout。把它送進 sandbox 的程式碼印出來,裡面有字串陣列和旋轉器,但沒有解碼器:webcrack 認不出這個版本的解碼器。旋轉器呼叫一個不存在的函式,parseInt 一直丟例外,被 catch 接住之後繼續轉,就變成無窮迴圈。obfuscator-io-deobfuscator 也一樣認不出來(Unknown reference to string array function)。
所以字串還原改用自己寫的 reveal.mjs,webcrack 只拿來排版。
本節來源:reverse/README.md
(4)wrapper 函式:用 scope 追到不動點
解碼器不會被直接呼叫。每個作用域裡都有幾個 wrapper:
function _0x4b15d0(a, b) { return _0x550d(b - 0x1ee, a); }
function _0x490513(a, b) { return _0x550d(a - -0xc7, b); }wrapper 還會呼叫上一層的 wrapper,可以疊好幾層。同一個名字在不同作用域代表不同的 wrapper,所以不能用文字取代,要用 babel 的 scope 追綁定:
- 找出「本體只有一行
return 另一個 wrapper(參數的算式)」的函式,登記成 wrapper,給它一個不會撞名的代號,定義也送進vm。 - 一直重複,直到沒有新的 wrapper 為止。
- 把所有參數都是常數的呼叫,丟進
vm求值,換成字串常數。
let changed = true, defs = [];
while (changed) {
changed = false;
traverse(ast, {
Function(path) {
const n = path.node;
if (n.body.type !== 'BlockStatement' || n.body.body.length !== 1) return;
const r = n.body.body[0];
if (!t.isReturnStatement(r) || !t.isCallExpression(r.argument)) return;
const params = new Set(n.params.map(p => p.name));
if (n.params.some(p => !t.isIdentifier(p))) return;
const target = calleeId(path.get('body.body.0.argument'), r.argument.callee);
if (!target) return;
if (!r.argument.arguments.every(a => isSimple(a, params))) return;
let b;
if (t.isFunctionDeclaration(n)) b = path.parentPath.scope.getBinding(n.id.name);
else if (t.isVariableDeclarator(path.parent) && t.isIdentifier(path.parent.id)) b = path.parentPath.scope.getBinding(path.parent.id.name);
if (!b || wrappers.has(b)) return;
const name = 'W' + (uid++);
wrappers.set(b, name);
const args = r.argument.arguments.map(a => generate(a).code).join(',');
defs.push(`function ${name}(${n.params.map(p=>p.name).join(',')}){return ${target}(${args});}`);
changed = true;
}
});
}
vm.runInContext(defs.join('\n'), ctx);在 SiaoHub 檢視 siao/dungeon-reborn-mod/reverse/reveal.mjs L48-74
每一版的數量差不多:
三版都是 0 個失敗。
本節來源:reverse/README.md
(5)現成工具會把程式改壞
字串還原之後,proxy object、控制流平坦化、數字算式交給 obfuscator-io-deobfuscator。它的預設設定有三個地方會改壞程式,而且都不會報錯。
ProxyFunctionInliner 把遊戲自己的函式內聯掉。 混淆器會產生 {'WgTpU': (a,b) => a+b} 這種 proxy,工具把它們內聯回去是對的。但它也把遊戲自己的小函式當成 proxy,而且參數會不見:
// 原本
function itemLv(id) { return (G && G.items[id]) || 0; }
const speed = base * (1 + itemLv("boots") * 0.06);
// 內聯後:_0x528f3b 是 itemLv 的參數名,已經沒有定義了
const speed = base * (1 + (G && G.items[_0x528f3b] || 0) * 0.06);playerSpeed、cooldownNow 也被內聯掉,而它們剛好是外掛要掛的函式。修法是收緊判定:只內聯「只引用自己的參數、每個參數只用一次」的函式,而且主 IIFE 頂層的函式宣告一律不動。
function pureOfParams(fn) {
const params = new Set(fn.params.map(p => p.name));
const body = t.isBlockStatement(fn.body) ? fn.body.body[0]?.argument : fn.body;
if (!body) return true;
const seen = new Map(); let ok = true;
(function walk(n, parent, key) {
if (!ok || !n || typeof n.type !== 'string') return;
if (t.isIdentifier(n)) {
const isProp = (t.isMemberExpression(parent) && key === 'property' && !parent.computed) || (t.isObjectProperty(parent) && key === 'key');
if (!isProp) {
if (!params.has(n.name)) { ok = false; return; }
seen.set(n.name, (seen.get(n.name) || 0) + 1);
if (seen.get(n.name) > 1) ok = false;
}
return;
}
for (const k of t.VISITOR_KEYS[n.type] || []) {
const v = n[k];
if (Array.isArray(v)) v.forEach(c => walk(c, n, k)); else walk(v, n, k);
}
})(body, null, null);
return ok;
}
// 主 IIFE 頂層的函式宣告是遊戲自己的函式 (例如 svgIcon、upgradeGain),混淆器產生的 proxy 不會出現在這一層
const topLevel = new WeakSet();
pf.isProxyFunctionExpression = node => !topLevel.has(node) && orig(node) && pureOfParams(node);在 SiaoHub 檢視 siao/dungeon-reborn-mod/reverse/cleanup.cjs L12-37
ObjectSimplifier 的 unsafeReplace 把會變的物件當常數。 搖桿狀態 joy = {active: false, x: 0, y: 0} 會被修改,工具卻把 joy.active 摺疊成 false,產生 if (false && (false || false))。手機搖桿等於被刪掉。關掉。
ConstantPropagator 把常數全部內聯。 SKILL_MAX、WAVE_DURATION 這些常數全部變成數字,原本的名字就消失了。關掉之後找回 26 個常數。
驗證方式是列出所有沒有綁定的識別字。還原正確的話,應該只剩瀏覽器內建的全域:
Image Infinity JSON Map Math Number Object Set String clearTimeout console
document localStorage navigator performance requestAnimationFrame screen
setTimeout undefined window
第一個坑是讀移動函式時看到一個沒有定義的 _0x528f3b 才發現的。之後每次還原都跑這個檢查。
本節來源:reverse/README.md
(6)命名:標準答案一直在 repo 裡
1. 先自己取
還原後的程式碼可以讀了,但頂層有 238 個宣告,名字全是 _0x…。我先用兩個線索取名:
- v2.5 外掛用到的名稱,例如
hurtPlayer、gainExp、SKILLS、SKILL_MAX,這些是 10/15 之前從原始碼直接看到的。 - 讀函式本身。屬性名沒有被混淆,
player.hp -= …加上「免疫」字串,就是受傷函式。
參數和區域變數交給一個子代理逐個函式讀,寫了約 480 個名字。
2. 發現原始碼
整理時發現,原作者 repo 的 5152825 是一個 merge commit。它的第二個 parent ca3534b(10/14)裡就是沒混淆的 game.js:138 KB,開頭是 "use strict" 和中文註解。
我第一輪其實找過 git 歷史,結論是「沒有乾淨的原始碼」。那是錯的。原因是 zsh 的 $c:game.js::g 被當成歷史修飾符,指令全部失敗,迴圈什麼都沒印出來,我就當成沒有。
3. 拿原始碼當標準答案
把原始碼所有綁定換成 _0x 佔位名、包進 IIFE,用(7)的跨 build 遷移工具對到第一個混淆版,就知道每個混淆名在原始碼裡叫什麼。拿來跟我自己取的名字比:
不同的 88 個大多是同義詞:newRun ↔ createState、W ↔ VIEW_W、sfx ↔ Sound。真的取錯的大約 8 個:
| 我取的 | 原名 | 實際是什麼 |
|---|---|---|
clickSfx |
restartRun |
播完點擊音效之後重開一局 |
isMeleeChar |
fireWeapon |
依角色呼叫揮砍、突刺或射箭 |
BOSS_ABILITIES |
ULTS |
終極技能表,不是 Boss 技能 |
pendingChar |
vignetteGrad |
暗角漸層的快取 |
scrollBoardToMe |
openBoard |
打開排行榜,順便捲到自己 |
所以命名全部改用原名。現在 238 個頂層宣告裡,192 個是作者原名,原始碼的頂層宣告全部對上。另外 46 個是 10/14 之後才加的功能(Boss 競技場、寶箱、分身、詛咒、存檔簽章),用我取的名字。
區域變數也一樣。把原始碼每個函式的區域變數依出現順序編號,算一個「宣告指紋」(初值的程式碼,識別字和數字都正規化),再跟還原碼的區域變數用 LCS 對齊:
export function align(refSigs, curSigs) {
const n = refSigs.length, m = curSigs.length;
const L = Array.from({ length: n + 1 }, () => new Int32Array(m + 1));
for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--)
L[i][j] = refSigs[i] === curSigs[j] ? L[i + 1][j + 1] + 1 : Math.max(L[i + 1][j], L[i][j + 1]);
const map = new Map(); let i = 0, j = 0;
while (i < n && j < m) {
if (refSigs[i] === curSigs[j]) { map.set(j, i); i++; j++; }
else if (L[i + 1][j] >= L[i][j + 1]) i++; else j++;
}
// 兩個錨點之間,兩邊剩下的數量一樣就照順序補上 (指紋只差在算式寫法,例如 80*x 與 x*80)
const pairs = [[-1, -1], ...[...map.entries()].map(([c, r]) => [r, c]).sort((a, b) => a[1] - b[1]), [n, m]];
for (let k = 0; k + 1 < pairs.length; k++) {
const [r0, c0] = pairs[k], [r1, c1] = pairs[k + 1];
if (r1 - r0 === c1 - c0) for (let d = 1; r0 + d < r1; d++) map.set(c0 + d, r0 + d);
}
return map;
}在 SiaoHub 檢視 siao/dungeon-reborn-mod/reverse/generic-order.mjs L29-46
錨點之間數量一樣的就照順序補上,處理 80 * p.pickupMult 被轉成 p.pickupMult * 80 這種指紋不同但其實是同一個變數的情況。原始碼的 669 個區域變數,在最新 build 上套用了 660 個。子代理取的名字只拿來補原始碼沒有的函式。
這也找回了兩個被內聯掉的函式。原始碼裡有 svgIcon(inner) 和 upgradeGain(c),還原碼裡沒有。它們剛好符合「純 proxy」的條件,被(5)的 ProxyFunctionInliner 內聯掉了。語意沒錯,但函式邊界不見了。(5)裡「主 IIFE 頂層的函式宣告一律不動」那一條,就是這時候補的。
4. 還原度
用最早的混淆版跑完整流程,跟 10/14 的原始碼逐個頂層宣告比對(壓縮排版、去掉註解):
以 hurtPlayer 為例,還原後的變數名稱、結構都跟原始碼一樣。唯一的差別是 10/15 版多了一段護盾免疫的判斷,那是作者那一天新加的:
function hurtPlayer(raw, fromX, fromY) {
if (G.player.shieldT > 0) { // 10/15 新增
addNumber(G.player.x, G.player.y - 56, "免疫", "heal");
return;
}
if (tryBlock()) return;
const p = G.player;
const dmg = Math.max(1, Math.round(raw * (1 - damageReduction())));
p.hp -= dmg;
…
}不相同的原因有兩種,目前分不開。一種是還原過程的轉換,例如模板字串被轉成 + 串接;另一種是作者在 10/14 到 10/15 之間真的改了,例如 spawnBoss 整個改成競技場版本。
本節來源:reverse/README.md
(7)跨 build 自動遷移命名
作者每次 push 都重新混淆,所有 _0x… 名稱都會換掉。每次重新命名一遍不可行,所以要把舊 build 的命名自動搬到新 build。
match-names.mjs 對每個頂層宣告算兩種指紋:
- 宣告本體:把
_0x…換成_之後的程式碼。兩邊都只出現一次、而且完全相同,就直接配對。 - 使用方式:每個引用處的上層運算式,本身換成
@,例如@.player、newRun(@)。
const body = fn.node.body.body;
for (const [id, b] of Object.entries(fn.scope.bindings)) {
const stmt = b.path.isFunctionDeclaration() ? b.path.node : b.path.parentPath?.node;
if (!stmt || !body.includes(stmt)) continue;
const self = norm(generate(stmt).code);
const uses = new Set();
for (const r of b.referencePaths.concat(b.constantViolations)) {
let q = r.parentPath; if (!q) continue;
if (q.isMemberExpression() && q.parentPath) q = q.parentPath;
let s = generate(q.node).code;
s = s.split(id).join('@');
uses.add('u:' + norm(s).slice(0, 80));
}
const toks = tokens(self);
for (const u of uses) { toks.add(u); for (const t of tokens(u.slice(2))) toks.add('u.' + t); }在 SiaoHub 檢視 siao/dungeon-reborn-mod/reverse/match-names.mjs L17-31
第二種是必要的。第一版只比本體,結果把 G 配到 selectedChar 上,因為兩個都是 let x = null;。改成同時比使用方式之後,G 的指紋裡有 @.player、@.enemies,selectedChar 的指紋裡有 newRun(@),就分得開了。
兩次遷移都是 0 個遺失。模糊配對到的,除了 let x = null 這種同形宣告,剩下的剛好就是這次更新改到的函式。
還原之後,版本之間的差異可以直接 diff。3b32e21 的 commit 訊息寫「強化 Android 防竄改」,但 game.js 還原後跟前一版只差 10 行:
- hp: 2,
+ hp: 1,加上 Boss 狀態機裡一個 if 的寫法。網頁版沒有新的防護,防竄改應該是在 Android 包裝那一層。
整個更新流程包成 update.sh:
本節來源:reverse/README.md
(8)外掛 v3:接上閉包
1. 不放遊戲程式碼
最直接的做法是把還原後的遊戲(拆掉 IIFE)整份塞進外掛,用它取代原版。我第一版就是這樣做的,也能跑,但這等於把作者的整份程式碼重新發佈一次。(11)會講為什麼不這樣做。
現在的做法是:執行時抓官方的 game.js,在主 IIFE 結尾的 ;}())); 前面插入一段匯出程式,在閉包裡用 getter/setter 把函式和狀態掛到 window:
// 在閉包內定義 window 上的存取子;setter 直接改閉包裡的綁定,遊戲之後的呼叫就會走到新函式
const Lines = [];
for (const [Obf, [Name, Writable]] of Object.entries(Names)) {
Lines.push("if(!(" + JSON.stringify(Name) + " in window))Object.defineProperty(window," + JSON.stringify(Name) +
",{configurable:true,get:function(){return " + Obf + "}" + (Writable ? ",set:function(v){" + Obf + "=v}" : "") + "});");
}
Game = Game.slice(0, Cut + 1) + "try{" + Lines.join("") + "}catch(e){console.error('[DUNGEON REBORN 外掛 v3] 匯出失敗',e)}" + Game.slice(Cut + 1);在 SiaoHub 檢視 siao/dungeon-reborn-mod/DungeonRebornMod.user.js L678-684
產生出來的每一行長這樣:
Object.defineProperty(window, "hurtPlayer", {
get: function () { return _0x3106b7 },
set: function (v) { _0x3106b7 = v } // 直接改閉包裡的綁定
});v2.5 的外掛本體照舊寫 window.hurtPlayer = function () {…},setter 會改掉閉包裡的綁定,遊戲之後的呼叫就會走到新函式。G 也一樣,getter 回傳的是閉包裡當下的值。所以 src/mod-core.js 一行都不用改。
外掛裡只有載入器、v2.5 本體,還有每個 build 的「混淆名 → 名稱」對照表,總共 34 KB。game.js 的 hash 不在對照表裡的話,就原樣載入官方版本,畫面底部顯示「外掛尚未支援這個版本」,不會亂掛。
2. 接手頁面:document.open,不是 window.stop
外掛用 @run-at document-start,一開始就同步呼叫 document.open(),中止原本的 HTML 解析,再把拿掉 game.js 的 HTML 寫回去:
if (window.__DR_V3__) return;
window.__DR_V3__ = true;
// document-start 時同步呼叫 document.open(),中止原本的 HTML 解析,換成拿掉 game.js 的版本。
// 不能用 window.stop():它會把 parser 標成 aborted,之後的 document.open() 依規範會被忽略,Chrome 也不會開始渲染。
// 注意:Tampermonkey 注入時 parser 多半已經讀到 index.html 的 CSP meta,CSP 一旦套用,document.open() 也清不掉,
// 頁面裡的 inline script / eval 都會被擋。所以遊戲與外掛本體不能用一般的 <script> 插入,見 RunCode()。
document.open();在 SiaoHub 檢視 siao/dungeon-reborn-mod/DungeonRebornMod.user.js L594-600
window.stop() 之後畫面完全不動
一開始的寫法是先 window.stop() 停下載入,等抓完 HTML 再 document.open()。結果頁面是空的,requestAnimationFrame 一次都沒觸發,截圖工具也逾時。
原因在規範裡:window.stop() 會把 parser 標成 aborted,之後的 document.open() 會直接被忽略。改成用 DOMParser 把整棵 documentElement 換掉,內容出現了,但 Chrome 不會開始渲染,rAF 還是不動。
最後改成在 document-start 當下同步呼叫 document.open(),不用 window.stop()。這時候腳本不是 parser 執行的(script nesting level 是 0),document.open() 有效,而且它本身就會中止原本的解析。
3. CSP 拿不掉:3.0.0 裝了遊戲無法開始
3.0.0 的想法是:CSP 寫在 index.html 的 meta 裡,只要 parser 還沒讀到它就不會生效,所以在 document-start 當下 document.open(),寫回去的 HTML 不含 CSP meta,再用一般的 <script> 插入改過的 game.js。
推上去之後,裝了外掛的人按「開始遊戲」沒有任何反應,Console 也沒有錯誤。在 Chrome 裡用真正的 Tampermonkey 重現:面板有出現,DOM 裡也沒有 CSP meta,但 G 不存在,遊戲根本沒執行。在頁面裡插一個 inline script 測試,觸發的是 script-src-elem 違規,CSP 還在。
原因是 Tampermonkey 實際注入的時間比我以為的晚。CSP meta 在 <head> 第 6 行,外掛執行時 parser 多半已經讀過它;CSP 一旦套用就記在文件上,之後 document.open() 換掉內容、刪掉 meta 都清不掉。CSP 生效之後,script-src 'self' 底下的 inline script、eval、new Function、blob URL 都跑不了,在頁面這一邊沒有任何辦法執行抓回來的程式碼。
3.1.0 改用擴充功能的權限:
// 依序嘗試:
// 1. GM_addElement 插入頁面:Tampermonkey 以擴充功能權限插入,不受頁面 CSP 限制,遊戲與外掛跑在頁面裡,Console 也摸得到 G。
// 2. 在 Tampermonkey 的沙盒裡用 new Function 執行:沙盒的 CSP 是擴充功能自己的,允許 eval。
// 兩個都不行就回傳 false,由呼叫端退回原版遊戲。
async function RunCode(Code, Url) {
const Text = Code + "\n//# sourceURL=" + Url;
if (Mode !== "sandbox") {
try {
if (Mode === null) Root().setAttribute("data-dr-run", "page");
// 執行成功的話,程式會把標記改成 page-ok
const Probe = Mode === null ? "document.documentElement.setAttribute('data-dr-run','page-ok');" : "";
GM_addElement(document.body || Root(), "script", { textContent: (Mode === null ? Guard : "") + Probe + Text });
if (Mode === null) {
for (let I = 0; I < 20 && Root().getAttribute("data-dr-run") !== "page-ok"; I++) await new Promise(R => setTimeout(R, 25));
if (Root().getAttribute("data-dr-run") === "page-ok") { Mode = "page"; return true; }
} else return true;
} catch (Err) { console.warn("[DUNGEON REBORN 外掛 v3] GM_addElement 無法執行", Err); }
// 頁面那份可能晚到:先把標記改掉,晚到的那份會在 Guard 停下
Root().setAttribute("data-dr-run", "sandbox");
}
try {
new Function(Text)();
Mode = "sandbox";
return true;
} catch (Err) {
if (Err instanceof EvalError) { console.warn("[DUNGEON REBORN 外掛 v3] 沙盒也不允許 eval", Err); return false; }
throw Err;
}
}在 SiaoHub 檢視 siao/dungeon-reborn-mod/DungeonRebornMod.user.js L608-636
GM_addElement:Tampermonkey 用擴充功能權限插入<script>,不受頁面 CSP 限制。遊戲和外掛都跑在頁面裡,Console 也摸得到G。在我的 Chrome 上走的就是這條路。- Tampermonkey 沙盒:萬一
GM_addElement沒有執行,改在沙盒裡用new Function跑。沙盒用的是擴充功能自己的 CSP,允許eval;外掛本體也放到同一邊,才拿得到匯出的存取子。 - 原版遊戲:兩邊都不行,就載入同源的
game.js。CSP 的script-src 'self'允許它,至少遊戲一定能玩。
為了不讓遊戲跑兩次,<html> 上放一個 data-dr-run 標記,頁面和沙盒都看得到。插進頁面的程式第一行會檢查它,晚到的那份會直接停下。
4. 測試
3.0.0 的測試是用 Playwright 的 addInitScript 直接執行外掛。它比 parser 還早,CSP 根本還沒套用,所以 15 項全過,卻沒測出這個問題。
現在的 test/live.mjs 改成模擬真正的 Tampermonkey:外掛要等 CSP meta 被解析之後才執行;GM_addElement 用 DevTools 協定在頁面裡執行,等同擴充功能權限。另外也會檢查測試時 CSP 確實有生效,不然測試本身就沒有意義。用新測試跑 3.0.0 會失敗(G is not defined),跟實際狀況一樣;3.1.0 全部通過:
| 項目 | 結果 |
|---|---|
| 測試環境的 CSP | 有生效 |
| 遊戲與外掛 | 在頁面執行 |
換掉的 6 個函式(hurtPlayer、gainExp、playerSpeed、cooldownNow、togglePause、updateGame) |
全部是外掛的版本 |
| 無敵、經驗 ×10、移速 ×3、防止暫停 | 有效 |
| 遊戲加速 ×3 | 實測 2.95 倍 |
| 角色等級、武器等級、技能等級、終極技能 | 有效 |
| 刷怪、刷王、清怪 | 有效 |
| 下一波 | +1,而且不會連跳 |
3.1.0 也在真正的 Tampermonkey(Chrome)裡實測過:可以選角色、進入地城,打開無敵後吃 999 傷害,血量維持 140/140。
5. 3.2.0:補上作者後來加的機制
v2.5 寫的時候,遊戲還沒有寶箱、分身和 Boss 詛咒。3.2.0 讀還原碼補上這些,同時把面板整理過。
無敵其實有洞。 v2.5 的無敵只攔 hurtPlayer。Boss 的劇毒詛咒每秒直接扣 player.hp(最多扣到 1),時限到了更直接把 hp 設成 0,兩條路都不經過 hurtPlayer。開著無敵打 Boss,照樣會被毒死。現在 die 也攔下來,每一幀結束後再把少掉的血補回去:
// 劇毒詛咒直接扣 hp、時限到直接把 hp 設成 0,不經過 hurtPlayer,所以無敵還要擋 die
if (Has("die")) {
const OrigDie = window.die;
window.die = function () {
const Game = Run();
if (Cfg.God && Game) {
Game.player.hp = Game.player.maxHp;
Game.deathNote = "";
return;
}
return OrigDie.apply(this, arguments);
};
}在 SiaoHub 檢視 siao/dungeon-reborn-mod/src/mod-core.js L74-86
P.dmgMult = Nat.Dmg;
P.pickupMult = Nat.Pickup;
P.crit = Nat.Crit;
// 劇毒詛咒直接扣 hp (最多扣到 1,不會觸發 die),無敵時把這一幀少掉的血補回來
if (Cfg.God && P.hp < Nat.Hp) P.hp = Nat.Hp;
// 時限在原版 updateArena 裡扣,所以這一幀結束後再補滿
if (Cfg.BossNoCurse && Game.fight) Game.fight.timeLeft = Game.fight.limit;
}在 SiaoHub 檢視 siao/dungeon-reborn-mod/src/mod-core.js L280-287
寶箱。 場上每 28~42 秒出現一個,最多 2 個,走過去就打開。掉落有四種,權重寫在 CHEST_LOOT:塔盾(20 秒免疫,權重 20)、古老金幣(吸全場經驗,25)、肉塊(回 40% 生命,35)、騎士頭盔(召喚 2 個分身 20 秒,20)。外掛換掉 rollLoot 來指定掉落,在 updateGame 前面加快 chestTimer;原版場上滿 2 個就不刷,上限調高時由外掛自己呼叫 spawnChest 補。
分身。 騎士頭盔召喚的藍色分身,站位角度是 anim × 0.9 + k × π,原版只有 k = 0 和 1 兩個。k 只用在站位,繪製時沒有用到,所以取 k = 2i / N 就能讓 N 個分身平均繞著玩家:
// ---------- 分身 ----------
// 原版固定 2 個,站位角度是 anim*0.9 + k*π;k 取 2i/N 就能讓 N 個分身平均繞著玩家
function ResizeClones(Game, N) {
const P = Game.player;
const Old = Game.clones;
if (!Old.length || Old.length === N) return;
const Tpl = Old[0];
Game.clones = [];
for (let I = 0; I < N; I++) {
const Prev = Old[I];
Game.clones.push(Object.assign({}, Tpl, Prev || {
x: P.x, y: P.y, attacking: false, hitDone: true, attackTime: 0, cd: 0.1 + Math.random() * 0.4
}, { k: 2 * I / N }));
}
}在 SiaoHub 檢視 siao/dungeon-reborn-mod/src/mod-core.js L192-206
分身的攻擊走 rollDamage,本來就吃傷害倍率;冷卻是 max(0.45, cooldownNow() × 1.1),有 0.45 秒的下限,攻速倍率要另外每幀扣它的 cd。
Boss 詛咒。 每次 Boss 從劇毒、混亂(移動方向反轉)、分裂三種裡抽 1 個,第 4 隻起抽 2 個。劇毒與混亂都是讀 G.fight 上的旗標,每一幀把旗標關掉、時限補滿就失效了。分裂是 Boss 本身的行為,沒有動。
// Boss 詛咒。開無敵時時限停在 5 秒,不然時限到了 die 會每幀被叫一次
const Fight = Game.fight;
if (Fight) {
if (Cfg.BossNoCurse) {
Fight.poison = false;
Fight.confuse = false;
}
if (Cfg.God) Fight.timeLeft = Math.max(Fight.timeLeft, Math.min(Fight.limit, 5));
}
// 寶箱
if (Game.chests) {
if (!Game.arena) {
if (Cfg.ChestRate > 1) Game.chestTimer -= Dt * (Cfg.ChestRate - 1);
// 原版場上滿 2 個就不刷,上限調高時由這裡補刷 (計時器照樣由原版重設)
if (Cfg.ChestMax > 2 && Game.chestTimer - Dt <= 0 && Game.chests.length >= 2 && Game.chests.length < Cfg.ChestMax && Has("spawnChest")) window.spawnChest();
}
if (Cfg.AutoChest && Has("openChest")) for (let I = Game.chests.length - 1; I >= 0; I--) window.openChest(I);
}
// 塔盾、分身
if (Cfg.KeepShield && "shieldT" in P) P.shieldT = Math.max(P.shieldT, 5); // 3 秒以下會開始閃爍
if (Game.clones) {
if (Cfg.KeepClones && Has("spawnClones")) KeepClones(Game);
ResizeClones(Game, Math.max(1, Math.min(12, Math.round(Cfg.CloneCount))));
}在 SiaoHub 檢視 siao/dungeon-reborn-mod/src/mod-core.js L231-256
其他:
- 升級卡必定幸運:換掉
luckChance,Boss 擊殺後的獎勵卡也走這裡。幸運卡效果 ×2 或一次升兩級,但已經快滿級的卡本來就不能幸運,這部分不動。 - 不寫入紀錄:用過外掛的那局死掉時,照樣算分數和排行榜名次給死亡畫面看,但不寫進存檔。
- 面板:最上面一列狀態(波次、時間、血量、Boss 倒數、寶箱、塔盾與分身秒數),每個區塊可以收合,倍率可以一鍵重設,加上 Alt 熱鍵。macOS 按著 Option 時
Ev.key會變成特殊符號,所以熱鍵判斷用Ev.code。 - 自動更新的檢查清單:
mod-core.js第 3 行的NEEDS列出用到的 35 個遊戲名稱,watch.sh直接讀這一行,不用另外維護一份。
測試加到 27 項,每個新功能一項,其中「無敵時劇毒不扣血」是照上面的洞寫的。
本節來源:reverse/README.md · README.md
(9)存檔簽章
新版的存檔多了一個簽章。localStorage 裡有兩筆:dungeonReborn.save.v2 存 JSON,dungeonReborn.save.v2.sig 存簽章。載入時簽章不符,整份存檔就會被刪掉重置,所以手改紀錄會被洗掉。
簽章是 cyrb53 的變體,輸入前後各加一次固定的鹽:
const SALT = "dr::7f3a91c2e8b04d65::v2";
export function saveSignature(json) {
let h1 = 0xdeadbeef, h2 = 0x41c6ce57;
const s = SALT + json + SALT;
for (let i = 0; i < s.length; i++) {
const c = s.charCodeAt(i);
h1 = Math.imul(h1 ^ c, 2654435761);
h2 = Math.imul(h2 ^ c, 1597334677);
}
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
return ((h2 & 0x1fffff) * 4294967296 + (h1 >>> 0)).toString(36);
}在 SiaoHub 檢視 siao/dungeon-reborn-mod/reverse/sign-save.mjs L3-15
在線上原版驗證過:簽章正確的偽造存檔會被接受,簽章錯誤的會被刪掉。
排行榜上的其他玩家是用固定種子產生的假資料,遊戲完全沒有連網,所以改存檔只影響自己的瀏覽器。
本節來源:reverse/README.md
(10)自動追蹤
作者一個晚上推了五版,每一版都重新混淆。所以我把(3)到(8)整套流程放到 siao-server 上,由 systemd timer 每 15 分鐘檢查一次。沒有更新時,它只抓一次 game.js 算 hash 就結束。
有新版時,任何一關沒過就停下來,已經改的檔案會還原:
Claude 在這裡只做一件事:讀新舊兩版還原碼的 diff,寫兩段說明,一段給 repo 的版本表格,一段給下面的版本紀錄。它只輸出 JSON 文字,不碰任何檔案,插到哪裡由腳本決定,所以文章的其他部分不會被改到。呼叫失敗、逾時或格式不對,就改用模板(commit 訊息、diff 行數、命名遷移結果),更新照樣進行。
外掛和部落格分開把關。外掛要功能測試全過才推;部落格要 npm run build && npm test 全過才推。部落格失敗不會影響已經推出的外掛。同一版最多試 3 次,之後就停止重試,在 log 留一行「放棄」,等我修好再手動重跑。
推送用一把專用的 deploy key,只能寫入外掛 repo 和部落格 repo。
這個流程是無人值守地推到正式環境的。把關讓壞掉的更新很難推出去,但不是不可能。
版本紀錄
10/16 01:25 · 3b32e21(木棒耐久改為 1 次;強化 Android 防竄改)
還原後跟上一版只差 10 行:哥布林木棒的 hp 從 2 改成 1,加上 Boss 狀態機裡一個 if 的寫法。commit 訊息說「強化 Android 防竄改」,但 game.js 裡看不到新的防護,應該是在 Android 包裝那一層。
命名遷移 exact 222, fuzzy 14, missing 0,外掛已支援(0b73151c)。
10/16 01:10 · 5c02894(哥布林木棒改為耐久 2 次、速度降低,玩家攻擊可打掉木棒)
哥布林丟出的木棒變成有耐久的投射物,玩家的攻擊可以把它打掉。模糊配對到的函式剛好就是這次改到的地方:玩家的揮砍、突刺、射箭,投射物更新,哥布林投擲。
命名遷移 exact 193, fuzzy 17, missing 0,外掛已支援(8b3282d3)。
本節來源:reverse/README.md
(11)公開範圍
遊戲程式碼一個字都不在 repo 裡。 原版、還原版、拆掉 IIFE 的版本都沒有。工具執行時從原作者的網站或 GitHub 抓 game.js,還原結果放在 builds/,不進版控。
理由跟 Hololive Dreams 那篇一樣:遊戲是原作者的著作。混淆是作者明確表達「不想被這樣讀」的意思,我可以為了互通性自己還原,但把還原結果整份重新發佈是另一回事。外掛 v3 改成執行時接上官方版本,而不是內嵌還原版,也是為了這個。
公開的有:
- 還原工具。這些是通用的 javascript-obfuscator 還原技術,不針對這款遊戲。
- 命名對照表(
names.json、locals.original.json、maps.json)。內容是識別字名稱,加上正規化過、最長 80 字元的宣告指紋。 - 本文引用的片段,都是說明需要的短片段。
存檔簽章的演算法和鹽有公開。它不是防盜拷措施,是存檔完整性檢查;鹽本來就寫在每個人瀏覽器下載的 game.js 裡;遊戲沒有線上排行榜,改了只影響自己。
這是單機遊戲的學習練習,改了只影響自己的那一局。請不要把這套方法拿去刷有線上排行榜、或會影響到別人的遊戲。
(附)踩過的坑
zsh 把 $c:game.js 的 :g 當成修飾符
for c in $(git log --format=%h -- game.js); do git show $c:game.js | head -c 80; donezsh 把 $c:g 解讀成歷史修飾符,路徑變成 …/9d57201me.js,每一圈都失敗。我看到沒有輸出,就以為那個 commit 裡沒有 game.js,結論是「找不到乾淨的原始碼」。原始碼其實一直在那裡,晚了好幾個小時才發現。
要寫成 "${c}:game.js"。更大的教訓是:沒有輸出不等於沒有結果,要先確認指令本身有成功。
用 <script> 標籤模擬 document-start
在本機測試時,我在 <head> 最前面插了一個 <script src="v3.user.js"> 當成 document-start。結果 document.open() 被忽略,寫回去的 HTML 裡又含著那個 <script>,parser 被它卡住,遊戲在 DOM 還沒建好時就執行了。
parser 插入的腳本,script nesting level 大於 0,document.open() 依規範會被忽略。這個測試方法本身就跟真正的 document-start 不一樣。改用 Playwright 的 addInitScript 才對。
addInitScript 太早,測不出 CSP
改用 addInitScript 之後,又踩到相反的問題:它比真正的 Tampermonkey 還早執行,CSP 還沒套用,3.0.0 在測試裡全部通過,實際裝起來遊戲卻無法開始。詳見(8)的第 3 小節。
教訓是模擬環境要比對真正的環境,不能只確定「時機差不多」。現在測試會先確認 CSP 有生效,才算數。
外掛要的是 WAVE_DURATION,我取成 WAVE_SECONDS
找回常數之後,我把每波秒數取名成 WAVE_SECONDS。功能測試按「下一波」時丟出 ReferenceError: WAVE_DURATION is not defined。v2.5 外掛是照原始碼寫的,名字對不上就壞。
這也是後來決定全部改用作者原名的原因之一:外掛本來就是照原始碼寫的,命名越接近原始碼,越不會壞。
還原時 ObjectSimplifier 刪掉了手機搖桿
還原後的移動函式裡出現 if (false && (false || false))。往回追,是 joy.active、joy.x、joy.y 被當成常數摺疊掉了。joy 是 const 宣告的物件,但它的屬性一直在變。
工具在 log 裡其實有印「Not replacing object … as it is modified」,但那是關掉 unsafeReplace 之後才會檢查的。
怎麼安裝
- 裝 Tampermonkey。 用 Chrome 的話還要打開
chrome://extensions,點 Tampermonkey 的「詳細資料」,打開「允許使用者指令碼」。找不到這個開關,就先打開右上角的「開發人員模式」。 - 點一鍵安裝。 裝過 v2.5 的話,Tampermonkey 會顯示「更新」。
- 打開遊戲。 右上角出現「⚔ DUNGEON REBORN 外掛」面板就成功了。桌面版按
`收合,手機點標題列展開。
只想看原始碼、不想安裝的話,打開檢視原始碼,或點文章裡任何一個「在 SiaoHub 檢視」。會跳出安裝畫面的只有上面的「一鍵安裝」。
面板沒出現?
- 先確認第 1 步的開關,再確認網址是上面那個遊戲網址。
@match只對這個網址生效。 - 打開 Console,應該要看到「
[DUNGEON REBORN 外掛 v3] 已攔截頁面載入」和「執行位置:頁面」。顯示「Tampermonkey 沙盒」也能正常玩,只是 Console 裡摸不到G。 - 按「開始遊戲」沒反應:你裝的是 3.0.0,請重新點一次「一鍵安裝」更新到 3.1.0。
- 畫面底部出現紅色的「外掛尚未支援這個版本」:作者又更新了遊戲,目前跑的是原版。等我跑一次
update.sh推上去就好。
工具鏈
| 用途 | 工具 |
|---|---|
| 字串還原 | 自己寫的 reveal.mjs(babel + Node vm) |
| proxy、控制流、算式 | obfuscator-io-deobfuscator(收緊 proxy 判定、關掉兩個 pass) |
| 排版 | webcrack(只用 unminify) |
| 命名、遷移 | 自己寫的 rename.mjs、match-names.mjs |
| 讀頁面、驗證 v2.5 | Claude in Chrome |
| 驗證 v3 | Playwright(等 CSP 生效後才執行外掛)、Claude in Chrome + 真正的 Tampermonkey |
- webcrack 要用
npm install --ignore-scripts裝。 它相依的 isolated-vm 在 Node 26 編不過,但這裡用自訂的 sandbox,用不到它。 - obfuscator-io-deobfuscator 的判定函式可以從外面換。
ProxyFunctionInliner是透過模組物件呼叫isProxyFunctionExpression,所以在外面改require(...).isProxyFunctionExpression就會生效,不用 fork。
References
- https://github.com/javascript-obfuscator/javascript-obfuscator
- https://github.com/j4k0xb/webcrack
- https://github.com/ben-sb/obfuscator-io-deobfuscator
- https://html.spec.whatwg.org/multipage/dynamic-markup-insertion.html#document-open-steps
- https://github.com/bryc/code/blob/master/jshash/experimental/cyrb53.js
- https://playwright.dev/docs/api/class-page#page-add-init-script