0x0 背景
首先从B站王老菊那发现了Steam
小游戏Vampire Survivors,买来等有时间玩了发现想解锁属性还挺肝,随即想搞搞事。
看下游戏目录很容易可以看出是基于Electron
做的。文件搜索Vampire Survivors
可以找到存档文件在C:\Users\USER_NAME\AppData\Roaming\Vampire_Survivors\saves
目录下,打开发现就是以Json
格式保存的,但有个checksum
字段防止玩家篡改。那么可以通过此字段作为入口进行分析。
我自己是适当修改了金币数量省肝,未修改属性及无敌等避免无趣。
另:在游戏主界面按下金手指上上下下左右左右 Esc Enter
可获得3000金币,仅可获取一次。
这个金手指仅可获取一次对应存档中CheatCodeUsed
字段
0x1 存档修改与解混淆
在steamapps\common\Vampire Survivors\resources\app
下执行文本搜索关键词SaveData.sav
或checksum
即可定位到文件.webpack\renderer\main.bundle.js
。
备份一下原文件后用Prettier格式化一下:npx prettier --write .\main.bundle.js
打开稍微看下能够看出为常见混淆思路,特征为大数组+偏移函数+解密函数。之前尝试PHP反混淆时遇到过类似的混淆,搜索资料知晓在JavScript
中为类ob混淆。解决思路同样为通过AST还原。
随意谷歌下找到挺多大佬已经写过JavaScript
解混淆的代码,前排找到篇教程中有实例代码,直接拿来针对修改使用。Gist备份 ob-decrypt.js
先按照ob混淆的特征重新排序下代码块,AST的前三段应当为大数组+偏移函数+解密函数。即为:
function a0_0x3cab() {
const _0x3ec6ef = [
"every",
"random",
...
"graphics2",
];
a0_0x3cab = function () {
return _0x3ec6ef;
};
return a0_0x3cab();
}
(function (_0x422224, _0x2db768) {
const _0x369aef = a0_0x150b,
_0x19e7b7 = _0x422224();
while (!![]) {
try {
const _0x31194f =
parseInt(_0x369aef(0xb30)) / 0x1 +
parseInt(_0x369aef(0x522)) / 0x2 +
parseInt(_0x369aef(0x8c8)) / 0x3 +
(parseInt(_0x369aef(0x405)) / 0x4) *
(parseInt(_0x369aef(0x367)) / 0x5) +
(parseInt(_0x369aef(0x1d9)) / 0x6) *
(-parseInt(_0x369aef(0x5a5)) / 0x7) +
(parseInt(_0x369aef(0x67f)) / 0x8) *
(parseInt(_0x369aef(0x6ce)) / 0x9) +
(-parseInt(_0x369aef(0xa7f)) / 0xa) *
(parseInt(_0x369aef(0x4eb)) / 0xb);
if (_0x31194f === _0x2db768) break;
else _0x19e7b7["push"](_0x19e7b7["shift"]());
} catch (_0x19db15) {
_0x19e7b7["push"](_0x19e7b7["shift"]());
}
}
})(a0_0x3cab, 0x51997)
function a0_0x150b(_0x8a0c12, _0x437a92) {
const _0x3cabcb = a0_0x3cab();
return (
(a0_0x150b = function (_0x150b54, _0x5ea125) {
_0x150b54 = _0x150b54 - 0xdf;
let _0x512b08 = _0x3cabcb[_0x150b54];
return _0x512b08;
}),
a0_0x150b(_0x8a0c12, _0x437a92)
);
}
掏出好帮手AST可视化工具丢进代码,定位到解密函数以及存档函数,参照这对上面拿到的解混淆工具进行修改。
(存档函数,其实即便不进行混淆还原也能够基本看出存档验证逻辑了,源代码如下)
[_0x5e9c75(0x1b9)]() {
const _0x78dcb4 = _0x5e9c75;
if (!_0x2797d7) return !0x1;
try {
const _0xd32b5f = _0x4fdc49(0x1beb),
_0xdc67ec = _0x4fdc49(0x3f9);
var _0x44317e = _0x4fdc49(0x17e1);
let _0x2304dc = this[_0x78dcb4(0x4da)](),
_0x1878e = _0xdc67ec["resolve"](_0x2304dc, "SaveData.sav");
if (!_0xd32b5f["existsSync"](_0x1878e))
return console[_0x78dcb4(0x128)]("SaveData not found"), !0x1;
let _0x57097f = _0xd32b5f[_0x78dcb4(0x3c3)](
_0xdc67ec[_0x78dcb4(0x479)](_0x2304dc, "SaveData.sav")
),
_0x3308ae = JSON[_0x78dcb4(0xab0)](_0x57097f), // 推测为 JSON.parse()
_0x31074d = _0x3308ae["checksum"];
_0x3308ae[_0x78dcb4(0x93b)] = ""; // 推测把 checksum 改为空字符串
let _0x46e99c = JSON[_0x78dcb4(0x206)](_0x3308ae); // 推测为 JSON.stringfy()
const _0x56614f = "DefinitelyNotSaveDataSecretKey"; // 草,我不信,绝对是SecretKey
if (
_0x31074d !=
_0x44317e[_0x78dcb4(0x5c7)]("sha256", _0x56614f) // 推测为crypto.createHmac("sha256", secret);
["update"](_0x46e99c)
[_0x78dcb4(0x81a)]("hex")
)
return console[_0x78dcb4(0x128)]("SaveData is corrupt"), !0x1;
for (const _0x54742b in _0x3308ae)
this[_0x78dcb4(0x6de)](_0x54742b) &&
(this[_0x54742b] = _0x3308ae[_0x54742b]);
return !0x0;
} catch (_0x5df523) {
return console[_0x78dcb4(0x128)](_0x5df523), !0x1;
}
}
这里可以看出核心为const _0x78dcb4 = _0x5e9c75;
而开头又有const _0x5e9c75 = a0_0x150b;
a0_0x150b
是大数组+偏移函数+解密函数中的解密函数,那么可以通过这些赋值在遍历AST时找到调用并进行替换,下图为_0x5e9c75
声明节点。
以此调用为例_0x3308ae = JSON[_0x78dcb4(0xab0)](_0x57097f)
,下图为调用节点。
解混淆代码中decrypt_arr()
函数为修改的重点,以下注释中含星号的为修改处。
function decrypt_arr(ast) {
//TODO 1 解密三部分的代码执行
let end = 3;//切片需要处理的代码块
let newAst = parse.parse('');//新建ast
let decrypt_code = ast.program.body.slice(0, end);//切片
newAst.program.body = decrypt_code// 将前3个节点替换进新建ast
let stringDecryptFunc = generator(newAst, { compact: true },).code;//转为js,由于存在格式化检测,需要指定选项,来压缩代码// 自动转义
eval(stringDecryptFunc);//执行三部分的代码
//TODO 2 准备工作及对解密三部分节点删除
let stringDecryptFuncAst = ast.program.body[end - 1];// 拿到解密函数所在的节点
let DecryptFuncName = stringDecryptFuncAst.id.name;//拿到解密函数的名字 *
var rest_code = ast.program.body.slice(end); // 剩下的节点
ast.program.body = rest_code;//剩下的节点替换
//TODO 3 加密数组还原 *
let listOfNewArrayName = [];
traverse(ast, {
enter(path) {
if (path.isVariableDeclarator()) { // 对应const _0x78dcb4 = _0x5e9c75;等
const id = path.node.id;
const init = path.node.init;
if(init == null || init.type != 'Identifier'){
return;
}
if (init.name == DecryptFuncName || listOfNewArrayName.indexOf(init.name) != -1) {
listOfNewArrayName.push(id.name);
console.log(path.toString());
eval(path.toString());
}
} else if (path.isCallExpression()) {
const callee = path.node.callee;
if (listOfNewArrayName.indexOf(callee.name) != -1) {
// console.log(path.toString());
// console.log(eval(path.toString()));
path.replaceWith(t.valueToNode(eval(path.toString())));
}
}
},
});
traverse(ast, {
CallExpression(path) {//回调表达式匹配--替换加密数组为对应的值
if (t.isIdentifier(path.node.callee, { name: DecryptFuncName })) { //当变量名与解密函数名相同时,就执行相应操作
path.replaceWith(t.valueToNode(eval(path.toString()))); // 值替换节点
}
},
});
traverse(ast, { MemberExpression: { exit: [add_Mem_str] }, }); // 成员表达式字符串合并
return ast;
}
修改完后跑一边即可解混淆,解后效果:
["loadNewSaves"]() {
const _0x78dcb4 = _0x5e9c75;
if (!_0x2797d7) return false;
try {
const _0xd32b5f = _0x4fdc49(7147),
_0xdc67ec = _0x4fdc49(1017);
var _0x44317e = _0x4fdc49(6113);
let _0x2304dc = this["getSaveDataPath"](),
_0x1878e = _0xdc67ec["resolve"](_0x2304dc, "SaveData.sav");
if (!_0xd32b5f["existsSync"](_0x1878e))
return console["log"]("SaveData not found"), false;
let _0x57097f = _0xd32b5f["readFileSync"](
_0xdc67ec["resolve"](_0x2304dc, "SaveData.sav")
),
_0x3308ae = JSON["parse"](_0x57097f), // 推测正确
_0x31074d = _0x3308ae["checksum"];
_0x3308ae["checksum"] = ""; // 推测正确
let _0x46e99c = JSON["stringify"](_0x3308ae); // 推测正确
const _0x56614f = "DefinitelyNotSaveDataSecretKey"; // 草,推测错误,居然真的不是SecretKey
if (
_0x31074d !=
_0x44317e["createHash"]("sha256", _0x56614f) // 绝,不是createHmac,而是createHash
// createHash的第二个参数为<Object> stream.transform options
// 这里传个字符串没有任何作用,唯一作用就是误导为createHmac
// 一旦相信并使用createHmac传入SecretKey那么就大错特错了。
["update"](_0x46e99c)
["digest"]("hex")
)
return console["log"]("SaveData is corrupt"), false;
for (const _0x54742b in _0x3308ae)
this["hasOwnProperty"](_0x54742b) &&
(this[_0x54742b] = _0x3308ae[_0x54742b]);
return true;
} catch (_0x5df523) {
return console["log"](_0x5df523), false;
}
}
解混淆后发现推测基本正确,但是最关键的一步计算hash
成功被误导... 看到诸如
xxx.xxx('sha256', "xxx").xxx(xxx).xxx("hex");
第一反应就是crypto.createHmac
。
但制作组很有趣,没有使用createHmac
,而是createHash
计算哈希,并放个字符串“绝对不是存档私钥”进行诱导。
createHash
的第二个参数为<Object> stream.transform options
,这里传个字符串没有任何作用,唯一作用就是误导为createHmac
,一旦相信并使用createHmac
传入SecretKey
那么就大错特错了。这波制作组在第五层。
其实猜出JSON.stringify()以及将checksum
清空后就可以试错发现createHmac
不好使而试试sha256
了
之后修改存档中Coins
字段并重新计算hash
即可达成目标。其余的字段也可自行修改,如想修改解锁的武器则在代码中搜索switch (this["weaponType"])
得到所有的武器类型名称后加到已解锁列表中。
0x2 多倍经验
["GetTaken"]() {
const _0x52cc74 = _0x416f91;
_0x1a5d11["Core"]["Player"]["xp"] +=
this["value"] * _0x1a5d11["Core"]["Player"]["growth"]; // 此处可以修改经验获取倍率
_0x1a5d11["Sound"]["PlaySound"](
_0x31ed46["Gem"],
{
volume: 0.1,
},
1,
1
);
_0x1a5d11["Core"]["PlayerUI"]["Update"]();
_0x1a5d11["Core"]["CheckForLevelUp"]();
super["GetTaken"]();
}
0x3 无敌
按照经验搜索关键词找到角色血量下降代码跳过即可。我搜的关键词为onHit
,进而发现OnGetDamaged
函数,接着搜索OnGetDamaged
时结合Die
、Player
等关键词定位到GetDamaged
函数:
["GetDamaged"](
_0x13d834 = 1,
_0xa86e5e = _0x3c6802["NONE"],
_0x3db247 = 1
) {
const _0x34ab02 = _0x4e1088;
if (
!this["receivingDamage"] &&
!(this["IsInvul"] || this["hp"] <= 0)
) {
if (this["shields"] > 0)
return (
(this["shields"] -= 1),
this["OnGetDamaged"](16777147, this["shieldInvulTime"], !1),
void _0x1f071b["Core"]["scene"]["events"]["emit"](
"Player_LostShield"
)
);
this["armor"] > 0 &&
(_0x13d834 -= this["armor"]) < 1 &&
(_0x13d834 = 1);
this["hp"] -= _0x13d834; // 注释这行即可
this["hp"] <= 0
? ((this["hp"] = 0),
this["Die"](),
_0x1f071b["Core"]["GameOver"]())
: (this["OnGetDamaged"](),
_0x1f071b["Core"]["scene"]["events"]["emit"](
"Player_ReceivedDamage"
));
_0x1f071b["Core"]["PlayerUI"]["Update"]();
}
}
将this["hp"] -= _0x13d834;
注释即可。
0x4 人物属性修改
此类修改无需进行代码解混淆,直接在代码中搜索关键词startingWeapon
或charName
等关键字段即可定位到人物属性设置代码,只需注意这里的数值为十六进制即可。如初始人物:
level: 0x1,
startingWeapon: _0x60cabf[_0x5e9c75(0x112)],
cooldown: 0x1,
charName: "Antonio",
surname: "Belpaese",
textureName: "characters",
spriteName: "Antonio_01.png",
walkingFrames: 0x4,
description:
"Gains 10% more damage every 10 levels (max +50%).",
isBought: !0x0,
price: 0x0,
maxHp: 0x78,
armor: 0x1,
regen: 0x0,
moveSpeed: 0x1,
power: 0x1,
cooldown: 0x1,
area: 0x1,
speed: 0x1,
duration: 0x1,
amount: 0x0,
luck: 0x1,
growth: 0x1,
greed: 0x1,
curse: 0x1,
magnet: 0x0,
revivals: 0x0,
反混淆后:
level: 1,
startingWeapon: _0x60cabf["WHIP"],
cooldown: 1,
charName: "Antonio",
surname: "Belpaese",
textureName: "characters",
spriteName: "Antonio_01.png",
walkingFrames: 4,
description:
"Gains 10% more damage every 10 levels (max +50%).",
isBought: true,
price: 0,
maxHp: 120,
armor: 1,
regen: 0,
moveSpeed: 1,
power: 1,
cooldown: 1,
area: 1,
speed: 1,
duration: 1,
amount: 0,
luck: 1,
growth: 1,
greed: 1,
curse: 1,
magnet: 0,
revivals: 0,
0x5 武器属性修改
此类修改无需进行代码解混淆,直接在代码中搜索关键词frameName
或evoSynergy
等关键字段即可定位到人物属性设置代码,只需注意这里的数值为十六进制即可。如初始人物武器鞭子:
[_0x60cabf[_0x5e9c75(0x112)]]: [
{
level: 0x1,
bulletType: _0x60cabf["WHIP"],
name: "Whip",
description: "Attacks horizontally, passes through enemies.",
tips: "Ignores: speed, duration.",
texture: "items",
frameName: "Whip.png",
evoSynergy: [_0x60cabf[_0x5e9c75(0xaa5)]],
evoInto: _0x60cabf[_0x5e9c75(0xb40)],
isUnlocked: !0x0,
poolLimit: 0xf,
rarity: 0x64,
interval: 0x546,
repeatInterval: 0x64,
power: 0x1,
area: 0x1,
speed: 0x1,
amount: 0x1,
hitsWalls: !0x1,
critChance: 0.2,
critMul: 0x2,
},
{ amount: 0x1 },
{ power: 0.5 },
{ power: 0.5, area: 0.1 },
{ power: 0.5 },
{ power: 0.5, area: 0.1 },
{ power: 0.5 },
{ power: 0.5, addEvolvedWeapon: _0x60cabf["VAMPIRICA"] },
],
反混淆后:
[_0x60cabf["WHIP"]]: [
{
level: 1,
bulletType: _0x60cabf["WHIP"],
name: "Whip",
description: "Attacks horizontally, passes through enemies.",
tips: "Ignores: speed, duration.",
texture: "items",
frameName: "Whip.png",
evoSynergy: [_0x60cabf["MAXHEALTH"]],
evoInto: _0x60cabf["VAMPIRICA"],
isUnlocked: true,
poolLimit: 15,
rarity: 100,
interval: 1350,
repeatInterval: 100,
power: 1,
area: 1,
speed: 1,
amount: 1,
hitsWalls: false,
critChance: 0.2,
critMul: 2,
},
{amount: 1,},
{power: 0.5,},
{power: 0.5,area: 0.1,},
{power: 0.5,},
{power: 0.5,area: 0.1,},
{power: 0.5,},
{power: 0.5,addEvolvedWeapon: _0x60cabf["VAMPIRICA"],},
],