保护客户端-服务器边界

*此内容使用人工智能(Beta)翻译,可能包含错误。若要查看英文页面,请点按 此处

任何时候客户端可以在服务器上触发某个操作时,都存在滥用的潜力。虽然 RemoteEventsRemoteFunctions 是这里最常见的攻击向量,但其他实例如 ProximityPrompt 也容易受到攻击。本节将介绍如何保护这个边界。

验证远程输入

从客户端发送的每一条数据在使用之前都必须经过服务器的验证。根据远程操作的内容应用这些验证层。

上下文/权限验证

对于影响体验状态、进程或其他玩家的远程调用,这一点是必需的。服务器必须验证玩家是否有必要的权限来发出请求。例如,玩家是否足够接近商店以购买物品?他们是否有打开门所需的钥匙?他们的角色是否处于存活状态?

对于以下远程调用而言,验证是必要的:

  • 授予奖励或修改玩家进程
  • 影响其他玩家或共享的体验状态
  • 执行有距离、时间或权限要求的操作(例如,商店距离)

类型和结构验证

利用者可能发起的另一种攻击是发送技术上有效的类型,但使它们极大、长或其他畸形。例如,如果服务器必须在一个字符串上执行一个随长度扩展的昂贵操作,利用者可以发送一个极大的或畸形的字符串来拖慢服务器。

另一个常见攻击是利用者可能会用 tables 来替代 Instance。复杂的有效负载可以模拟本该是普通对象引用的内容。

例如,在一个 体验内商店系统 中,物品数据如价格存储在 NumberValue 对象中,利用者可能通过以下方式绕过所有其他检查:

LocalScript in StarterPlayerScripts

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local itemDataFolder = ReplicatedStorage:WaitForChild("ItemData")
local buyItemEvent = ReplicatedStorage:WaitForChild("BuyItemEvent")
local payload = {
Name = "Ultra Blade",
ClassName = "Folder",
Parent = itemDataFolder,
Price = {
Name = "Price",
ClassName = "NumberValue",
Value = 0, -- 负值也可能被使用,导致给予货币而不是消耗货币!
},
}
-- 发送恶意有效负载到服务器(这将被拒绝)
print(buyItemEvent:InvokeServer(payload)) -- 输出 "false Invalid item provided"
-- 发送真实物品到服务器(这将会通过!)
print(buyItemEvent:InvokeServer(itemDatafolder["Real Blade"])) -- 输出 "true" 和如果购买成功的剩余货币
Script in ServerScriptStorage

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local itemDataFolder = ReplicatedStorage:WaitForChild("ItemData")
local buyItemEvent = ReplicatedStorage:WaitForChild("BuyItemEvent")
local function buyItem(player, item)
-- 检查传递的物品是否不是伪造的,并且在 ItemData 文件夹中
if typeof(item) ~= "Instance" or not item:IsDescendantOf(itemDataFolder) then
return false, "Invalid item provided"
end
-- 然后服务器可以根据以下示例流程继续处理购买
end
-- 将 "buyItem()" 绑定到远程函数的回调
buyItemEvent.OnServerInvoke = buyItem

值验证

除了验证类型和数据外,您还应验证通过 RemoteEventsRemoteFunctions 传递的值,确保它们在请求的上下文中是有效和合逻辑的。这对于处理货币、物品、数字输入或用户生成内容的远程调用是至关重要的。您必须确保值在预期范围内(例如,购买数量大于零)并且提供的 ID 或名称是有效的(例如,itemId 对应于您游戏中的真实物品)。

数字值的特殊考虑: inf 和 NaN 是有效的数字类型,但如果利用者发送它们且未被正确处理,则可能导致重大问题。使用以下函数检测并拒绝它们:


local function isNaN(n: number): boolean
-- NaN 永远不等于自身
return n ~= n
end
local function isInf(n: number): boolean
-- 数字可能是 -inf 或 +inf
return math.abs(n) == math.huge
end

NaN 的危险

利用者可以发送 NaN(不是数字)作为参数。NaN 是独特危险的,因为它的类型是“数字”,但在所有标准比较中都失败,从而允许它微妙地绕过似乎安全的逻辑检查。让我们看看一个脆弱的交易系统,在该系统中,玩家提出报价。


-- 脆弱的服务器代码
local function onCreateTradeOffer(player, offeredGold)
-- 1. 类型检查:这通过了!typeof(NaN) 是 "number"。
if typeof(offeredGold) ~= "number" then
return "Invalid offer"
end
-- 2. 范围检查:这被绕过了!
-- (NaN < 0) 为假。 (NaN > 1000000) 也为假。检查无效。
if offeredGold < 0 or offeredGold > 1000000 then
return "Offer out of range"
end
-- 3. 库存检查:这被绕过了!
-- (NaN > player.Gold.Value) 也为假。
if offeredGold > player.Gold.Value then
return "Not enough gold"
end
-- 脆弱性:用 NaN 黄金创建了一份欺诈性交易报价!
createTrade(player, {gold = offeredGold})
return "Trade offer created."
end

利用者成功创建了一个没有任何黄金的交易报价,因为每个检查都默默地失败。此外,这个单一的 NaN 值现在将污染任何尝试使用它的其他系统,并通过报价传播到其他玩家。

以下是一些涵盖游戏内常见功能安全设计的高层示例。

体验内商店

考虑一个有用户界面的体验内商店系统,例如一个产品选择菜单,带有一个“购买”按钮。当按下按钮时,您可以在客户端和服务器之间调用一个 RemoteFunction 请求购买。然而,重要的是服务器,即体验最可靠的管理者,确认用户有足够的钱来购买物品。

客户端到服务器通过 RemoteEvent 的示例购买流程
客户端到服务器通过 RemoteFunction 的示例购买流程

武器目标

战斗场景需要特别注意验证值,特别是通过瞄准和命中验证。

想象一种体验,其中玩家可以向另一个玩家发射激光束。与其让客户端告诉服务器谁应该受到伤害,倒不如让它告诉服务器射击的起始位置和它认为所命中的部件/位置。服务器然后可以验证以下内容:

  • 客户端报告的射击位置是否靠近服务器上的玩家角色。请注意,由于延迟,服务器和客户端会有所不同,因此需要施加额外的容忍度。
  • 客户端报告的命中位置是否与客户端报告的命中部件的位置相当接近,在服务器上。
  • 客户端报告的射击位置和客户端报告命中的位置之间没有静态障碍物。这项检查确保客户端没有尝试穿墙射击。请注意,这仅应检查静态几何体,以避免由于延迟导致的合法射击被拒绝。

此外,您可能希望实现更多服务器端验证,如下所示:

  • 跟踪玩家上次开火的时间,并验证确保他们开火的速度不会太快。
  • 跟踪每个玩家在服务器上的弹药数量,并确认开火的玩家有足够的弹药来执行武器攻击。
  • 如果您实现了团队或“玩家对抗机器人”的战斗系统,请确认被击中的角色是敌人,而不是队友。
  • 确认被击中的玩家是存活的。
  • 在服务器上存储武器和玩家状态,并确认开火的玩家没有被当前操作(如重新装填)或状态(如冲刺)所阻碍。

速率限制

任何时候通过客户端(通过远程、触碰事件、接近提示、点击检测器等)可以触发服务器端逻辑时,都有可能存在该逻辑被利用者甚至合法用户恶意刷新的可能。速率限制控制这些操作可以执行的频率,防止滥用和系统过载。尽管在客户端实施速率限制是实用的(有时也是必要的),但绝不能仅仅依赖客户端的速率限制。

对于任何可以被客户端触发的服务器端逻辑,请始终考虑这种逻辑应运行的最大速率。许多服务器端操作必须限制其频率,以防止滥用或失败,例如:

  • Roblox API 调用:后端 API 如 DataStoreServiceBadgeService 具有内置速率限制,超出后将导致您的请求失败
  • 计算成本高的操作:消耗显著服务器资源或影响其他客户端的操作
  • 服务器示例:从 ServerStorage 克隆大型模型,即使它们从未复制给客户端
  • 客户端示例:指示所有连接的客户端同时更新其 GUI
  • 可利用的机制:如果快速执行可能会被滥用的操作,例如授予无敌、传送玩家或奖励货币

令牌桶示例

一个稳健且常见的速率限制方法是 令牌桶 算法。想象每个用户都有一个可以容纳一定数量令牌的桶。要执行某个操作,用户必须花费一个令牌。桶以稳定的速度补充新的令牌。这种方法允许在需要时短暂快速执行操作,但通过在一段时间内强制平均速率来防止持续垃圾邮件。


--!strict
-- 模块位于 ServerScriptService
type UserId = number
type Bucket = {
tokens: number,
last: number,
}
type TokenBucketT = {
capacity: number,
refillPerSecond: number,
buckets: { [UserId]: Bucket },
allow: (self: TokenBucketT, userId: UserId) -> boolean,
}
local TokenBucket = {}
TokenBucket.__index = TokenBucket
-- 创建一个限制器,最多允许 `capacity` 事件,以 `capacity / windowSeconds` 令牌的速率重新充满
function TokenBucket.new(capacity: number, windowSeconds: number): TokenBucketT
assert(capacity >= 1, "capacity must be >= 1")
assert(windowSeconds > 0, "windowSeconds must be > 0")
local self: TokenBucketT = {
capacity = capacity,
refillPerSecond = capacity / windowSeconds,
buckets = {},
}
return (setmetatable(self, TokenBucket) :: any) :: TokenBucketT
end
local function refill(b: Bucket, now: number, cap: number, rate: number)
local elapsed = now - b.last
if elapsed > 0 then
b.tokens = math.min(cap, b.tokens + elapsed * rate)
b.last = now
end
end
-- 如果用户将超过其限制,则返回 false
function TokenBucket:allow(userId: UserId): boolean
local now = time()
local b = self.buckets[userId]
if not b then
b = { tokens = self.capacity, last = now }
self.buckets[userId] = b
else
refill(b, now, self.capacity, self.refillPerSecond)
end
if b.tokens >= 1 then
b.tokens -= 1
return true
end
return false
end
return TokenBucket

要使用该模块,您首先需要创建一个新的限制器实例,使用 TokenBucket.new(capacity, windowSeconds)capacity 是用户可以快速发出请求的最大数量,windowSeconds 则确定 replenishing 所有令牌所需的时间。例如,TokenBucket.new(5, 10) 创建了一个限制器,允许最多 5 个请求的突发,并且每两秒补充一个令牌(10 秒 / 5 个令牌)。

在执行受保护的逻辑之前,调用 :allow(userId) 方法,如果返回 false,则拒绝该操作。当用户离开时清除其桶数据,以防内存泄漏也是一种良好的做法。

以下示例脚本演示了如何保护用于自定义聊天系统的 RemoteEvent,以防止玩家进行垃圾邮件攻击。


-- 在服务器脚本中的示例用法
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local TokenBucket = require(ServerScriptService.TokenBucket)
local ChatRemote = ReplicatedStorage:WaitForChild("ChatRemote") :: RemoteEvent
-- 每 10 秒 5 条消息(容量 5,补充速率 0.5 令牌/秒)
local chatLimiter = TokenBucket.new(5, 10)
ChatRemote.OnServerEvent:Connect(function(player: Player, message: string)
if not chatLimiter:allow(player.UserId) then
-- 速度太快:丢弃请求或发送垃圾邮件警告
return
end
-- 处理消息(经过其他验证后)
broadcastMessage(player, message)
end)
-- 清理以防内存泄漏
Players.PlayerRemoving:Connect(function(p: Player)
chatLimiter.buckets[p.UserId] = nil
end)

保护客户端触发的实例

ProximityPromptsClickDetectorsDragDetectors 不是远程,但利用者可以在任何距离、任何时间触发它们的事件,通常忽略像 EnabledMaxActivationDistance 这样的属性。这些对象必须与远程一样严格地进行保护。

ProximityPrompt

  • 利用者可以在服务器上,即使 Enabled 为 false 也可以触发任何事件。
  • EnabledMaxActivationDistanceRequiresLineOfSight 可以在客户端静默更改,因此从任何位置都可以看到并可能与任何复制的 ProximityPrompt 进行交互。
  • 服务器将接受保持事件(例如 PromptButtonHoldBegan),即使服务器端的 HoldDuration 为零。客户端可以操纵 HoldDuration,以不自然的速度执行交互。
  • 只有 Triggered 事件具有服务器端距离检查。其他事件(如 PromptButtonHoldBeganTriggerEnded)没有距离检查,可以由任何客户端任意发送。

ClickDetector

  • 在服务器上没有对任何事件进行检查。
  • 利用者可以在任何距离复制任何事件,即使 ClickDetector 被禁用(MaxActivationDistance 为 0 或 DragDetector.Enabled 为 false)。
  • 即使 ClickDetector未附加到可点击的东西,事件仍然可以被复制。

DragDetector

  • 对于从 ClickDetector 继承的事件,没有检查。
  • 对于拖动事件,Enabled 属性和 PermissionPolicy 属性会被服务器检查和尊重。所有其他属性都不会被检查。

当这些事件之一触发时,您的服务器脚本应该在采取任何行动之前执行自己的验证:

  • 检查是否已启用:通过检查服务器上的属性(例如 Enabled、HoldDuration、RunLocally 等)确保实例是打算启用的。如果对象始终启用,则此步骤不是必需的。
  • 检查玩家状态:确保玩家可以执行该操作,考虑到玩家当前的状态。玩家的角色是否在世界中?他们的角色是否足够接近对象?玩家是否需要还活着才能与此对象进行交互?根据需要检查您体验在服务器上维护的其他任何玩家状态。
  • 应用速率限制:如 速率限制 中所述,在客户端应用冷却时间并在服务器上强制实施速率限制,以防止这些事件被滥用并受到垃圾邮件攻击。对于预期需要一定时间的交互,确保客户端完成操作的速度不会过快。

关于网络所有权的注释: 默认情况下,如果这些对象是未固定部件或未固定组合体的子对象,利用者可以获得父部件的网络所有权并直接将其移动到他们的角色,绕过距离检查。对于关键操作,请使用固定部件或网络所有权 API,以及所需的服务器端验证。

RemoteEvents 和 RemoteFunctions

限制 RemoteFunctionsRemoteEvents 的范围和影响。在根据远程函数/事件的参数动态加载任何资产(即使是纹理或声音)或运行体验代码(尤其是通过 require)时要格外小心。避免实现允许客户端为服务器指定任意路径或实例引用以进行删除或修改的远程调用,即使这种修改看起来微不足道。能够改变任意实例状态或对 DataModel 树进行广泛更改的远程调用通常能够与其他错误结合在一起,严重影响整体状态或逻辑。检查不仅是实例参数的类型,还有类以及在数据模型中的预期位置或结构。

客户端到客户端的通信

某些远程设计用于让一个客户端在其他客户端上触发效果。这发生在客户端触发远程到服务器,然后再使用 RemoteEvent:FireAllClients()RemoteEvent:FireClient() 或其他类似方法中继信息。如果没有适当的保护,这种模式是危险的——服务器必须是守门人,而不仅仅是中继。

想象一种场景,玩家可以施放闪电法术。当施放时,附近的玩家看到闪光,听到声音并经历相机抖动。

一个脆弱的服务器脚本可能看起来像这样:

脆弱的服务器代码(在 ServerScriptStorage 中的脚本)

local castLightningEvent = game.ReplicatedStorage.CastLightning
-- 此服务器脚本在没有任何验证的情况下将消息中继给每一个人
castLightningEvent.OnServerEvent:Connect(function(player, strikePosition)
castLightningEvent:FireAllClients(strikePosition)
end)
客户端效果代码

local castLightningEvent = game.ReplicatedStorage.CastLightning
-- 在客户端创建视觉和声音效果
local function createLightningEffect(strikePosition)
-- 撑开相机的代码
-- 播放响亮的闪电声音的代码
-- 创建视觉闪电的代码
print("Lightning strikes at: " .. tostring(strikePosition))
end
-- 当服务器广播事件时,此客户端运行效果
castLightningEvent.OnClientEvent:Connect(createLightningEffect)

利用者可以通过以下方式滥用这个脆弱的系统:

  • 垃圾邮件:每秒多次调用远程,造成持续效果使体验变得无法玩
  • 无效数据:发送 nil、NaN 或错误类型导致所有客户端上的脚本错误
  • 未授权的使用:施放他们没有解锁的法术或没有资源施法的情况

服务器必须是 守门人,而不是中继。在广播之前,必须验证请求并为每个玩家应用速率限制。以下是可以用来验证请求的方法示例。

安全的服务器代码

local castLightningEvent = game.ReplicatedStorage.CastLightning
local playerCooldowns = {}
local COOLDOWN_TIME = 3
castLightningEvent.OnServerEvent:Connect(function(player, strikePosition)
-- 1. 类型验证
if typeof(strikePosition) ~= "Vector3" then
return -- 无效类型,静默拒绝
end
-- 2. 检查 NaN(NaN ~= NaN 是唯一检测它的方法)
if strikePosition.X ~= strikePosition.X then
return -- 包含 NaN,拒绝
end
-- 3. 示例速率限制
local lastCast = playerCooldowns[player] or 0
if tick() - lastCast < COOLDOWN_TIME then
return -- 仍在冷却中
end
-- 4. 示例权限检查
if not player:GetAttribute("HasLightningSpell") then
return -- 玩家没有此法术
end
-- 5. 示例范围验证
local character = player.Character
local humanoidRootPart = character and character:FindFirstChild("HumanoidRootPart")
if not humanoidRootPart then
return
end
local distance = (humanoidRootPart.Position - strikePosition).Magnitude
if distance > 100 then
return -- 超出范围
end
-- 所有检查通过 - 安全广播
playerCooldowns[player] = tick()
castLightningEvent:FireAllClients(player, strikePosition)
end)

通过实施这些检查,实现了减少利用者破坏其他玩家体验的难度。服务器充当了保护屏障,确保只有有效、授权的操作被广播到其他客户端。

特殊考虑

以下是您可能考虑的一些额外情况,可能需要专门处理。

数据存储操作

在使用 DataStoreService 保存玩家数据的体验中,利用者可能会利用无效数据、竞争条件来破坏保存或在 DataStore 中复制物品。在交易物品、市场和货币系统中,这尤其成问题。

确保通过 RemoteEventRemoteFunction 影响玩家数据的任何操作,基于以下内容进行清理:

  • 实例值 不能序列化成 DataStore,并会失败。利用类型验证来防止此情况。
  • DataStores 有数据限制。任意长度字符串应进行检查和/或限制,以避免此情况,并确保客户端不能向表中添加无限制的任意键。
  • 表索引不能是 NaN 或 nil。遍历客户端传递的所有表,验证所有索引有效。
  • DataStores 只能接受有效的 UTF-8 字符,因此您应通过 utf8.len() 清理客户端提供的所有字符串,以确保它们有效。utf8.len() 的返回值是字符串的长度,将 Unicode 字符视为单个字符;如果遇到无效的 UTF-8 字符,它将返回 nil 以及无效字符的位置。请注意,作为键和值的无效 UTF-8 字符串也可能存在于表中。
  • 无限/NaN 数字利用 - 利用者可能发送值如 -1/0、0/0 或 math.huge 来引发无限货币或破坏计算。请始终使用 NaN 的危险 中描述的方法进行验证。

当玩家在操作之间操纵时机以复制物品或损坏数据时,可能会发生竞争条件。

交易利用示例: 一名玩家发起交易,将其物品发送给另一名玩家,然后立即离开游戏。如果交易完成但由于无效数据无法保存其 DataStore,则他们重新加入,并且原始物品保持不变,而另一名玩家则保留交易的物品 - 造成复制。

预防策略:

  • 在任何交易操作之前验证所有数据
  • 使用事务模式,在提交任何更改之前验证所有玩家的数据
  • 实施适当的错误处理,如果任何部分失败,则撤销所有更改

MarketplaceService

对于涉及 MarketplaceService 的交互,例如通行证或开发者产品,所有购买验证和物品授予必须发生在服务器上。具体来说,利用 ProcessReceipt 回调安全地验证购买收据。切勿相信客户端信号,例如 PromptProductPurchaseFinished,在没有服务器验证的情况下确认购买,因为这些信号可以被伪造。确保您的 ProcessReceipt 函数彻底检查收据细节,仅在与 Roblox 服务器确认了合法交易后授予物品或货币,并准备好处理已为给定收据授予产品的情况。

©2026 Roblox Corporation、Roblox、Roblox 标志及 Powering Imagination 是我们在美国及其他国家或地区的注册与未注册商标。