23、Web游戏反外挂:前端JS加密、WebAssembly保护、请求签名校验

Web游戏的反外挂,说实话,跟客户端游戏完全是两码事。

客户端游戏,代码在用户机器上跑,你还能用各种壳、各种反调试去折腾。但Web游戏呢?JS代码直接暴露在浏览器里,用户按个F12就能看到源码。你想想看,这得多被动。

我早年做过一款页游,上线第一天就被脱机挂给刷爆了。对方直接读了我的JS逻辑,模拟了所有请求。嗯,从那以后,我对Web安全就再也不敢马虎了。

今天聊的三个点——前端JS加密WebAssembly保护请求签名校验——说白了,就是给Web游戏穿上三层铠甲。每一层都有它的作用,但单独拿出来都不够用。得组合起来。

23.1 前端JS加密:防君子不防小人

先说说JS加密。很多人觉得这玩意儿没用,因为JS最终要在浏览器里执行,你加密了,浏览器也得解密才能跑。只要解密函数在,别人就能断点调试、hook住它。

这话没错。但为什么我们还要做?

因为提高门槛。你想想,一个外挂作者要破解你的游戏,他得先看懂你的逻辑。如果你把代码混淆得跟天书一样,变量名全是a、b、c,控制流搞得乱七八糟,他得花多少时间?

我个人习惯用多层混淆。先做变量名替换,再做控制流平坦化,最后加一些死代码注入。举个例子:

// 原始代码
function checkMove(x, y) {
    if (x < 0 || x > 100) return false;
    if (y < 0 || y > 100) return false;
    return true;
}

// 混淆后
var _0x4b2f = ['\x63\x68\x65\x63\x6b\x4d\x6f\x76\x65', ...];
function _0x1a3c(_0x2d4e, _0x5f6a) {
    var _0x7b8c = function() { return false; };
    if (_0x2d4e < 0 || _0x2d4e > 100) {
        return _0x7b8c();
    }
    // 插入大量无意义分支
    for (var i = 0; i < 100; i++) {
        if (i === 50) continue;
    }
    return _0x5f6a >= 0 && _0x5f6a <= 100;
}

你看,原本一眼能看懂的边界检查,现在变得面目全非。外挂作者想逆向,得先花时间还原逻辑。

我的建议:不要只用一种混淆工具。我试过把UglifyJS、JScrambler、自定义混淆器组合使用,效果比单一工具好得多。但注意,混淆程度越高,性能损耗越大。移动端尤其明显,得做取舍。

23.2 WebAssembly保护:把核心逻辑藏起来

JS混淆说到底还是文本级别的保护。真正能藏住逻辑的,是WebAssembly

WASM是二进制格式,浏览器直接加载执行。你没法像看JS那样直接看源码。反编译WASM?可以,但出来的代码可读性极差,全是栈操作和局部变量。

我在项目中遇到过最典型的场景:游戏的核心算法,比如碰撞检测、伤害计算、随机数生成,这些逻辑如果写在JS里,外挂作者改几个变量就能无敌。但放到WASM里,他想改?得先搞懂WASM的指令集。

具体怎么做?我一般这样设计:

  1. 用C/C++或Rust编写核心逻辑。我个人偏爱Rust,内存安全,编译出来的WASM体积也小。
  2. 编译成WASM模块。用wasm-pack或者Emscripten都行。
  3. JS只负责调用WASM接口。所有关键数据都在WASM内部处理,不暴露给JS。
// Rust代码示例:简单的随机数生成
#[wasm_bindgen]
pub fn get_random_seed() -> u32 {
    // 使用内部状态,不依赖JS的Math.random
    let seed = unsafe { SEED };
    unsafe { SEED = SEED.wrapping_mul(1103515245).wrapping_add(12345); }
    seed
}

// JS调用
import { get_random_seed } from './game_core.wasm';
let seed = get_random_seed();

你看,外挂作者想控制随机数?他得先逆向WASM,找到SEED的存储位置,还得理解Rust的编译产物。这工作量,够他喝一壶的。

注意:WASM不是万能的。它仍然可以被调试,只是难度大。而且WASM和JS的交互接口是公开的,外挂作者可以hook这些接口。所以WASM保护必须配合请求签名校验一起用。

23.3 请求签名校验:让伪造请求失效

前面两层保护,都是为了让外挂作者看不懂你的逻辑。但最根本的问题还没解决:他怎么伪造请求?

Web游戏本质上就是HTTP请求的交互。外挂作者只要抓包,分析出你的请求格式,就能模拟客户端发送任意数据。你前端做得再花哨,他直接跳过前端,用脚本发请求,你怎么办?

答案就是请求签名校验

核心思路:客户端在发送请求时,用约定好的算法对请求参数生成一个签名。服务端收到后,用同样的算法验证签名。如果签名不匹配,直接拒绝。

我常用的签名流程:

  1. 客户端收集所有请求参数,按字典序排序。
  2. 拼接成字符串,加上一个时间戳随机数
  3. 用HMAC-SHA256计算签名。
  4. 将签名、时间戳、随机数一起发送给服务端。
// 前端签名生成
function generateSignature(params, secret) {
    // 1. 排序
    const keys = Object.keys(params).sort();
    let str = '';
    for (let key of keys) {
        str += key + '=' + params[key] + '&';
    }
    // 2. 加入时间戳和随机数
    const timestamp = Date.now();
    const nonce = Math.random().toString(36).substring(2);
    str += 'timestamp=' + timestamp + '&nonce=' + nonce;
    // 3. HMAC签名
    const signature = CryptoJS.HmacSHA256(str, secret).toString();
    return { signature, timestamp, nonce };
}

服务端验证时,除了校验签名,还要检查时间戳是否在合理范围内(比如5分钟内),随机数是否已被使用过(防止重放攻击)。

关键点:签名密钥绝对不能硬编码在JS里。我见过太多人直接把密钥写在代码里,结果被反编译出来。正确的做法是:密钥通过动态下发,或者使用非对称加密——客户端用公钥加密,服务端用私钥解密。

23.4 三层防护的协同工作

这三层保护不是孤立的。它们得配合起来,才能形成完整的防御体系。

下面这张图是我自己总结的架构:

Web游戏反外挂三层防护架构 第一层:前端JS加密 混淆代码、控制流平坦化、死代码注入 目标:提高逆向分析门槛 第二层:WebAssembly保护 核心算法(碰撞检测、伤害计算、随机数)放入WASM 目标:隐藏核心逻辑,防止篡改 第三层:请求签名校验 HMAC-SHA256 + 时间戳 + 随机数 + 防重放 目标:防止伪造请求和重放攻击 三层协同,缺一不可

你看这个结构:

  • JS加密保护的是表层逻辑,让外挂作者看不懂你的代码。
  • WASM保护的是核心算法,让他改不了关键数据。
  • 请求签名保护的是通信链路,让他伪造不了请求。

三层一起上,外挂作者想攻破,得同时破解三个环节。任何一个环节卡住,他就没法用。

避坑指南:我曾经犯过一个错误——把签名密钥写在了JS混淆后的代码里。结果对方通过动态调试,在内存中找到了密钥。后来我改成密钥由服务端动态下发,每次登录都不一样,这才堵住漏洞。

23.5 性能与安全的平衡

说到最后,我得提醒一句:安全是有代价的

JS混淆会让代码体积膨胀2-5倍,加载时间变长。WASM虽然执行快,但编译和初始化需要时间。签名校验每次请求都要计算,高并发下服务端压力不小。

我的做法是分级保护

保护级别 适用场景 性能损耗
非核心页面(如大厅、商城) 仅做基础混淆
普通玩法(如移动、聊天) 混淆 + 签名校验
核心玩法(如战斗、交易) 混淆 + WASM + 签名校验

这样既保证了核心逻辑的安全,又不会让所有玩家都感受到卡顿。

嗯,Web游戏反外挂这条路,没有终点。外挂作者在进步,我们的防护手段也得跟着升级。但只要你把这三层架构搭好,至少能挡住90%的脚本小子。剩下的那10%,就得靠运营和数据分析去抓了。


公众号:蓝海资料掘金营,微信deep3321