永远不要信任客户端
如何通过校验客户端发送到服务器的所有内容来保护你的游戏模式免受作弊者侵害。
为什么?
在客户端上运行的所有内容都可以被恶意玩家修改:他们可以随时使用任何参数多次调用任意远程事件。客户端脚本会下载到他们的计算机上,因此他们也可以读取这些脚本,从而了解服务器正在监听哪些事件。
这意味着服务器绝不能盲目信任通过 Events.SubscribeRemote 或 Entity.SubscribeRemote 接收到的任何内容。例如,这样做很危险:
Server/Index.lua
-- ❌ 禁止:任何玩家不得为自己添加任意数量的资金,也不得从任何位置伤害他人
Events.SubscribeRemote("BuyItem", function(player, item_name, price)
player:SetValue("money", player:GetValue("money", 0) - price)
GiveItem(player, item_name)
end)
Events.SubscribeRemote("HitEnemy", function(player, enemy, damage)
enemy:ApplyDamage(damage)
end)
作弊者可以通过传入负数 price 调用 BuyItem 来获取金钱,或者传入 damage = 99999 调用 HitEnemy 来杀死所有人。
黄金法则
客户端只需发送玩家想要做什么(意图),而服务器利用其已掌握的数据来决定是否允许以及会发生什么:
Server/Index.lua
-- 价格是在服务器定义的,客户端无法更改
local ITEM_PRICES = {
medkit = 100,
armor = 250,
}
-- ✅ 可以:客户端仅说明其所需的物品
Events.SubscribeRemote("BuyItem", function(player, item_name)
-- 校验类型以及物品是否存在
if (type(item_name) ~= "string") then return end
local price = ITEM_PRICES[item_name]
if (not price) then return end
-- 校验玩家是否买得起
local money = player:GetValue("money", 0)
if (money < price) then return end
player:SetValue("money", money - price, true)
GiveItem(player, item_name)
end)
校验项目
对于服务器接收到的每个远程事件,请思考以下问题:
- 类型:每个参数的类型是否符合预期?作弊者可能在你期望接收数字的地方发送一个表。请使用
type(value),对于实体,请使用value:IsValid()和value:IsA(SomeClass)。 - 范围:数值是否在预期范围内(无负数值、无极小或极大值、无
NaN)? - 所有权:该玩家是否有权对该实体进行操作?例如这是他们自己的 Character,还是他们生成的 Prop?
- 距离:玩家距离是否足够近以执行该操作?比较其 Character 与目标的位置。
- 状态:当前是否可以执行该操作?例如玩家是否存活、对局是否正在进行、工具是否在其手中?
- 频率:玩家发送请求的频率是否过高?限制某项操作每秒可以发生的次数。
Server/Index.lua
-- 记录每位玩家上次使用该动作的时间
local last_use = {}
Events.SubscribeRemote("UseAbility", function(player, target)
-- 状态:必须控制着一个活着的角色
local character = player:GetControlledCharacter()
if (not character or character:IsDead()) then return end
-- 类型:目标必须是有效的角色
if (not target or not target:IsValid() or not target:IsA(Character)) then return end
-- 距离:必须小于 10 米(1000 单位)
if (character:GetLocation():Distance(target:GetLocation()) > 1000) then return end
-- 频率:每 2 秒一次
local now = Server.GetTime() -- milliseconds
if (last_use[player] and now - last_use[player] < 2000) then return end
last_use[player] = now
-- 服务器决定伤害值
target:ApplyDamage(25, "", DamageType.Unknown, Vector(), player, character)
end)
-- 玩家离开时进行清理
Player.Subscribe("Destroy", function(player)
last_use[player] = nil
end)
其他提示
- 在服务器上保留机密:API 密钥、数据库凭据以及任何不希望玩家读取的逻辑都必须保存在
Server/文件中,这些文件绝不会发送给客户端。Client/和Shared/中的所有内容都会被玩家下载。 - 优先使用服务器端事件:当游戏已在服务器触发某个事件(如
Death、PickUp、EnterVehicle)时,请使用该事件,而不是让客户端通知你。 - 使用
Attempt*事件在服务器上阻断动作,例如在AttemptEnterVehicle中返回false以防止玩家进入载具。 - 网络主控:作为实体的网络主控的客户端会模拟其物理效果,因此其位置可能会受到该客户端的影响。切勿仅根据物理位置来决定谁赢得了比赛等重要事项。
- 对于明显发送无效数据的玩家,使用
player:Kick(reason)或player:Ban(reason)进行踢出或封禁,但请注意避免网络延迟引发误报。