保護客戶端與伺服器的邊界

*此內容是使用 AI(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 = "超級刀",
ClassName = "Folder",
Parent = itemDataFolder,
Price = {
Name = "Price",
ClassName = "NumberValue",
Value = 0, -- 負值也可用,會導致給予貨幣而不是扣除它!
},
}
-- 發送惡意有效負載到伺服器(這將被拒絕)
print(buyItemEvent:InvokeServer(payload)) -- 輸出 "false Invalid item provided"
-- 發送真實物品到伺服器(這將通過!)
print(buyItemEvent:InvokeServer(itemDataFolder["真實刀"])) -- 輸出 "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, "無效的物品提供"
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 在類型上是 "number",但在所有標準比較中都失敗,這使其能夠巧妙地繞過看似安全的邏輯檢查。我們來看看一個脆弱的交易系統,其中玩家可以提出報價。


-- 脆弱的伺服器代碼
local function onCreateTradeOffer(player, offeredGold)
-- 1. 類型檢查:這通過!typeof(NaN) 是 "number"。
if typeof(offeredGold) ~= "number" then
return "無效報價"
end
-- 2. 範圍檢查:這被繞過了!
-- (NaN < 0) 是錯的。(NaN > 1000000)也是錯的。檢查不會做任何作用。
if offeredGold < 0 or offeredGold > 1000000 then
return "報價超出範圍"
end
-- 3. 庫存檢查:這被繞過了!
-- (NaN > player.Gold.Value) 是錯的。
if offeredGold > player.Gold.Value then
return "金額不足"
end
-- 脆弱性:創建一個包含 NaN 金幣的欺詐交易報價!
createTrade(player, {gold = offeredGold})
return "交易報價已創建。"
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 必須 >= 1")
assert(windowSeconds > 0, "windowSeconds 必須 > 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 決定需要多長時間來重新填充所有這些令牌。例如,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 樹進行大規模更改的遠程操作,通常會與其他錯誤鏈接在一起,對整體狀態或邏輯造成嚴重影響。檢查不僅僅是實例參數的類型,還要檢查類別和預期位置或在 DataModel 中的結構。

客戶端到客戶端的通信

某些遠程操作旨在讓一個客戶端觸發對其他客戶端的影響。當一個客戶端對伺服器發送遠程請求時,然後伺服器使用 RemoteEvent:FireAllClients()RemoteEvent:FireClient() 或類似方法來轉發信息時,就會發生這種情況。如果未正確保護,這種模式是危險的 - 伺服器必須是門衛,而不僅僅是中繼。

想像一個場景,玩家可以施放閃電法術。施放時,附近的玩家會看到閃光,聽到聲音並體驗鏡頭震動。

一個脆弱的伺服器腳本可能會看起來像這樣:

脆弱的伺服器代碼 (script in 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("閃電擊中位置:" .. 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 是我們在美國及其他國家地區的部分註冊與未註冊商標。