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.savchecksum即可定位到文件.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声明节点。

_0x5e9c75 AST

以此调用为例_0x3308ae = JSON[_0x78dcb4(0xab0)](_0x57097f),下图为调用节点。

_0x78dcb4 AST

解混淆代码中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时结合DiePlayer等关键词定位到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 人物属性修改

此类修改无需进行代码解混淆,直接在代码中搜索关键词startingWeaponcharName等关键字段即可定位到人物属性设置代码,只需注意这里的数值为十六进制即可。如初始人物:

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 武器属性修改

此类修改无需进行代码解混淆,直接在代码中搜索关键词frameNameevoSynergy等关键字段即可定位到人物属性设置代码,只需注意这里的数值为十六进制即可。如初始人物武器鞭子:

[_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"],},
],
最后修改:2022 年 04 月 27 日
如果觉得我的文章对你有用,请随意赞赏