本帖最后由 汽汽汽汽水 于 2023-11-10 11:12 编辑
外挂的害处
外挂就像是游戏的毒瘤一般,它利用各种手段破坏游戏的数值平衡、破坏其他玩家的游戏体验,严重缩短了游戏的生命周期。
前言
- 作为网络游戏,防御外挂的关键点就是:完全不能相信客户端数据。
- 下文中所有客户端防御手段,仅作为基础防御,加大作弊者使用成本。敏感数据请务必加上服务端验证。
- 反作弊检测,不能周期性检测。如果是周期性检测,使用者完全可以尝试出规律定期开启关闭外挂。
- 外挂是无法杜绝的,反作弊操作只是增加外挂使用者的成本,只要他们付出的成本大于实际获得的利益,反外挂就算基本成功。
常见外挂类型
模拟按键 (连点器等)
这类是通过软件模拟用户点击、拖拽等操作来实现自动化操作游戏。比较典型的例子就是按键精灵、雷电模拟器行为录制、鼠大侠等,它们通常被拿来刷各种固定的日常任务等,对大部分游戏伤害较小,也比较好防御。下面列举几种典型防御方式:
1. 按钮点击处根据需求添加节流操作:
节流 : n 秒内只运行一次,若在 n 秒内重复触发,只有一次生效。
private throttle(delay = 500) {
const newtime = Date.now()
if (newtime - this._oldThrottleTime >= delay) {
// 自定义逻辑
this._curAttackNum += 1;
this.attackNumTxt.text = this._curAttackNum.toString();
// 逻辑结束
this._oldThrottleTime = Date.now()
}
}
2. 判断点击位置是否每次相同:
- 对于一些有“连招”(通过固定顺序释放某些技能来达到更高伤害的技巧)机制的PVP游戏,模拟按键外挂带来的伤害较大,我们可以通过判断每次点击按钮的位置是否相同,来检测外挂。因为如果是玩家操作几乎不可能每次都点到与上次完全相同的像素。具体流程如下:
- 目前编辑器不支持 按钮的点击位置,我们可以使用图片模拟一个按钮
- 在 UI 脚本中启用 onTouchStarted 函数,保存本次点击的位置。
- 使用 squaredDistance 函数获取两点之间距离的平方,可以减少距离运算的一次开方操作。
- 实际项目中可以对多个按钮进行连续判定更准确,如点击 A -> B -> C 依次点击为一次连招,可以判断三次按钮是否分别都点到了相同的位置。
private _lastPos: Vector2 = null;
private _minxDisSquared: number = 8; //最小距离平方
protected onTouchStarted(InGeometry: Geometry, InPointerEvent: PointerEvent): EventReply {
const textCom = this.uiWidgetBase.findChildByPath("RootCanvas/TextBlock") as TextBlock;
if (this._lastPos) {
const dis = Vector2.squaredDistance(this._lastPos, InPointerEvent.screenSpacePosition);
if (dis <= this._minxDisSquared) {
textCom.text = "两次距离太近有问题!";
} else {
textCom.text = "目前没有问题,本次距离平方:" + dis;
}
}
this._lastPos = InPointerEvent.screenSpacePosition;
return EventReply.handled; //EventReply.handled
}
3.长时间操作弹出验证码类的窗口:
这也是一种防御挂机比较常见的方式,但是需要注意弹出的时机。
变速器
变速类软件通常通过修改本地时间、hook 系统时间函数,等手段通过传递错误的时间值给游戏,使得游戏生命周期 update 函数调用间隔变快,来实现快速攻击、行走、采集物品等行为。
通过原理可以看出,只要我们在游戏中对于获取到的时间做一次验证即可较为有效的防御这类加速外挂:
1. 判断间帧间隔时间:
引擎在手机上是锁定最高 30fps 运行,简单理解为1秒最多只能调用 30 次 update 函数, dt 时间最小为 33ms (1000ms / 30 ≈ 33ms)。所以我们可以在 update 函数中对 dt 进行判断,即可简单判断出是否有加速。这里要注意的是PC上编辑器运行速度是 60fps 可以使用控制台调节为 30fps 测试:
protected onUpdate(dt: number): void {
if (dt < 0.033) {
console.log("dt间隔太小,当前为", dt);
return;
// 不再执行后续逻辑
}
}
2. 使用服务器授时:
创建一个属性同步脚本,每秒同步一下当前时间。对于采集等需要较长时间的操作可以使用网络同步下来的时间做一个本地验证,在采集结束后可以将开始时间与结束时间一起发送到服务端做一个服务端验证,待服务端验证时长无误后才发送奖励。
// 太过业务细节 下面代码为伪代码 具体实现需要参照自己有戏逻辑
// ========== server =========
@Property({ replicated: true })
private _curTime: number = 0; // 同步当前时间
protected onStart(): void {
if (SystemUtil.isServer()) {
TimeUtil.setInterval(() => {
this._curTime += 1;
}, 1)
}
}
// ========== client ==========
// 结束采集回调
private onPickEnd(): void {
// 本地判断下时间间隔
const time = Date.now();
// 太小就是作弊 不发送给服务器了
if (time - this._curTime < 5000) {
console.log("采集过快!");
} else {
// 正常就发送给服务器,但是要带上开始时间 、结束时间 以及物品 ID 方便服务器判断
Event.dispatchToServer("PickReward", this._curTime, startTime, GUID);
}
}
// ========== server =========
// 服务端接收到采集结束事件
Event.addServerListener("PickReward", (endTime, startTime, guid) => {
// 通过 guid 获取需要采集多长时间
const time = getCfgByGuid(guid);
// 判断是否正常 预防网络延迟 只要大于采集时间就行
if(endTime - startTime >= time){
// TODO 发放奖励
}
})
3. 使用多种时间函数交叉验证:
市面上一些粗制滥造的变速器一般无法做到修改所有的时间函数。我们可以利用这一点来做到针对指定的加速器防御。比如 Date.now() 就不会被虫虫助手变速效果影响,我们可以利用这个函数在客户端做简单的时间,间隔校验。
内存修改
内存修改器( GameGuardian 、CheatEngine 等)可以通过多次搜索内存值的变化,来确定某个数值具体的内存地址,然后对其修改。下面举一个常见定为攻击力地址的例子:
- 打开面板确定初始攻击力为 : 1000
- 修改器内搜索 1000 ,这时通常会出现很多个
- 通过穿脱装备将攻击力修改为其它数如 :800
- 修改器内搜索上一步搜索出的所有地址,看是否有数值从 1000 变为 800 (这里还可以使用模糊查询,查寻“变大”、“变小”、“变化中”的数值做到破解常见加密手段如“异或加密”、“倍值加密”等)
- 如此往复多次,最终定位到某个地址,然后修改它为 想要的数值
- 如果伤害计算在本地,就达到了倍攻效果。
接下来举一个比较特殊的例子,通过数值修改某个物体的数值为负数,扰乱正常的逻辑。这类方式在开发中比较容易忽视,因为通常开挂的人都是把一个数搞成 99999 来实现无限使用。
- 修改药瓶数量为 -1,并且使用一个药瓶道具
- 本地计算药品数量,判断是否等于零,但是没判断小于 0 的情况
- 这时物品被使用过后会一直减,但是永远不会等于 0
- 变为无限使用物品。
通过对原理的解析可以看出,对于内存修改类的外挂,最终还是要依赖服务端验证才能达到防御效果。但是我们可以在客户端进行一点简单的手段,增大外挂使用者破解时的成本。
- 判断更加全面,要考虑到数值大于等于、小于等于的情况。
- 简单加密使数值在内存中不以明文存在。
- 动态切换数值在内存中的位置,通过原理可以看出只要我们的内存地址在变,使用者就需要重新定位,所以我们可以间隔随机时间修改一次某个敏感数值的内存位置以达到对抗效果。
要注意的是,这通过加密数据的对抗方式,对于我们开发者也增加了额外的成本,如果加密算法太过复杂还可能拖慢游戏运行速度,所以需要大家根据实际场景制定合适的加密方案。因为最终是要在服务端进行验证的,所以可以不用太过纠结客户端防御。
网络封包修改
这类外挂通常使用网络抓包工具(WPE 、fiddler、wireshark 等),拦截我们客户端发往服务器的数据,对其进行修改或重复发送以达到作弊效果,也就是中间人攻击与重放攻击:
中间人攻击
- 外挂使用者通过软件建立了一层网络代理,这时手机的所有数据都要先经过代理然后才能发送给服务器。
- 接下来他在游戏中采集了 id 为 1 的物品,客户端这时发送一条数据通知服务器,假设为: { PlayerID:xxx,ItemId:1 }
- 这条数在经过代理服务器时被拦截,然后被修改为 { PlayerID:xxx,ItemId:1001 }
- 服务器端收到消息,添加了一个 ItemId 为 1001 的物品给玩家。
重放攻击
- 与上一种攻击类似,先通过建立代理网络来捕获所有网络数据。
- 采集某个物品,或者击杀某个小怪,客户端发送了击杀消息给服务端。
- 捕获这条数据,将它原样保存下来。
- 通过外挂软件,反复发送这条数据,欺骗服务端造成他一直在击杀怪物或采集物品的假象。
- 服务端收到多条击杀信息,添加经验、物品给玩家。
这类攻击手段不会对客户端进行任何修复,所以在客户端的防御是完全无效的。需要依赖服务端验证数据正确性来实现防御。
1. 将可收集物品打上 UUID,服务端通过 UUID 来判断是可以采集,如果可以采集就发放奖励删除这个物品生成新的。
// 伪代码
// ========== server ==========
private _itemList = [UUID1,UUID2]; //维护一个当前场景所有物体 uuid 数组
public onPickItem(playerId:number, uuid:number): void {
// 验证是否存在
if(_itemList.includes(uuid)){
// 发放奖励
// 生成新的 物品
}
}
2. 将发往服务端的消息加上 UUID (需要与时间相关)服务端验证先这条消息是否已经超过了最大时间,比如服务端当前接收时间与客户端发出时间相差了几十秒,这样肯定是不合理的。时间验证通过后再验证是否接收过,如果接收过就不处理。加上时间戳之后就可以不用让数组存储特别多 UUID,只需要存储有效时间内的所有消息即可。
// 伪代码
// ========== server ==========
private _eventList = [UUID1,UUID2]; //维护一个当前场景所有消息的 uuid 数组
public onXXXXX(uuid:string): void {
// 假设 uuid 中有时间戳 超过最大生效时间这条指令就无效化
if(!checkTime(uuid)) return;
// 验证是否存在
if(!_eventList.includes(uuid)) {
// 一些操作
}
}
3. 加密消息中数值:在用户进入游戏时,服务端给它下发一条带有随机数的消息,玩家保存这个随机数将每次发送的消息通过它当key 进行加密。服务端保存每个玩家对应的密码,在收到消息时使用密码解密。
// 伪代码
// ========== server =========
Player.onPlayerJoin.add((player) => {
// 加入游戏就发送一个随机密码 这里可以加上简单对称加密算法保证密码非明文
Event.dispatchToClient(player, "slat", " **********");
});
// ========== client =========
Event.addServerListener("slat", (slat: string) => {
// 保存密码
this._curSlat = slat;
});
// 之后每次发送消息,使用密码对关键信息加密, itemId 不再是明文而是传递密文
Event.dispatchToServer("PickItem", "{itemId:*****}");
对于上述三种常见方式,可以组合使用。比如可以使用第三种方式与第二种方式相配合,加密混合了时间戳的 UUID,这样防御效果会更强。
|