クライアントがサーバー上でアクションをトリガーできるときはいつでも、悪用の可能性があります。RemoteEventsやRemoteFunctionsはここで最も一般的な攻撃ベクターですが、ProximityPromptのような他のインスタンスも脆弱です。このセクションでは、この境界をどのように保護するかを説明します。
リモート入力の検証
クライアントから送信されるすべてのデータは、使用する前にサーバーによって検証されなければなりません。リモートの機能に応じて、これらの検証層を適用してください。
コンテキスト/権限の検証
これは、体験の状態、進行状況、または他のプレイヤーに影響を与えるリモートに必要です。サーバーは、プレイヤーがそのリクエストを行うのに必要な権限を持っているかどうかを検証しなければなりません。たとえば、プレイヤーは店舗に近すぎて物を購入できるのでしょうか? 彼らはドアを開くために必要な鍵を持っていますか? 彼らのキャラクターは生きていますか?
リモートの検証が必要な条件は次のとおりです:
- 報酬を付与したり、プレイヤーの進行状況を変更したりする
- 他のプレイヤーまたは共有された体験の状態に影響を与える
- 距離、タイミング、または権限要件を伴うアクションを実行する(たとえば、店舗の距離)
型と構造の検証
悪用者が仕掛ける可能性のある別の攻撃は、技術的に有効な型を送信しながら、それらを極端に大きく、長く、またはその他の形で不正にすることです。たとえば、サーバーが長さに応じてスケールする文字列に対して高価な操作を行う必要がある場合、悪用者は非常に大きいまたは不正な文字列を送信してサーバーを塞ぐ可能性があります。
悪用者のもう一つの一般的な攻撃は、tablesをInstanceの代わりに送信することです。複雑なペイロードは、通常のオブジェクト参照を模倣できます。
たとえば、アイテムデータ(価格など)がNumberValueオブジェクトに格納された体験内ショップシステムがあれば、悪用者は次のようにして他のすべてのチェックを回避することができます:
StarterPlayerScriptsのLocalScriptlocal 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" と残りの通貨を出力(購入が成功した場合)
ServerScriptStorageのScript
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
値の検証
型やデータの検証に加えて、RemoteEventsおよびRemoteFunctionsを通じて渡される値を検証し、それらが要求されているコンテキスト内で有効で論理的であることを確認する必要があります。これは、通貨、アイテム、数値入力、またはユーザー生成コンテンツを処理するリモートにとって不可欠です。値が期待される境界内にあること(例:購入数がゼロより大きいこと)、提供された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(Not a Number)を引数として送信することができます。NaNは、型が「number」であるにもかかわらず、すべての標準比較に失敗するため、論理チェックを巧妙に回避することができ、非常に危険です。プレイヤーがオファーを作成する脆弱な取引システムを考えてみましょう。
-- 脆弱なサーバーコード
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を介してクライアントからサーバーに購入リクエストを行うことができます。ただし、体験の最も信頼できる管理者であるサーバーが、ユーザーがアイテムを購入するのに十分なお金を持っていることを確認することが重要です。

武器のターゲティング
戦闘シナリオでは、特に狙いとヒット検証の値を検証する際に特別な注意が必要です。
プレイヤーが他のプレイヤーにレーザービームを発射できる体験を想像してみてください。クライアントがサーバーに誰を傷つけるかを伝えるのではなく、影響を受けたショットの発射位置と、クライアントが打ったと思っているパーツ/位置をサーバーに伝える必要があります。サーバーは次のことを検証できます:
- クライアントが報告する発射位置がサーバー上のプレイヤーのキャラクターの近くであること。サーバーとクライアントは遅延のためにわずかに異なるため、追加のトレランスを適用する必要があります。
- クライアントが報告するヒット位置が、クライアントが報告するヒット位置のパーツの位置に適度に近いこと。
- クライアントが報告する発射位置と、クライアントが報告するヒット位置の間に静的障害物がないこと。このチェックにより、クライアントが壁を通してショットを撃とうとしていないことが確認できます。このチェックは、正当なショットが遅延により拒否されないように静的な幾何学だけを確認する必要があります。
さらに、次のようなサーバーサイド検証を実装することをお勧めします:
- 最後にプレイヤーが武器を発砲したときの追跡および検証を行い、あまりにも速く撃っていないことを確認します。
- サーバー上で各プレイヤーの弾薬量を追跡し、発射するプレイヤーが武器攻撃を実行するのに十分な弾薬を持っていることを確認します。
- チームを実装している場合や「プレイヤー対ボット」の戦闘システムがある場合、ヒットしたキャラクターが味方ではなく敵であることを確認します。
- ヒットしたプレイヤーが生きていることを確認します。
- サーバー上で武器とプレイヤーの状態を保存し、発射するプレイヤーがリロードやスプリント中のブロックされたアクションを持っていないことを確認します。
レート制限
サーバーサイドのロジックがクライアント(リモート、Touchedイベント、プロキシマリティプロンプト、ClickDetectorsなど)によってトリガーされる可能性があるたびに、悪用者や正当なユーザーによってこのロジックがスパムされる可能性があります。レート制限は、これらのアクションがどれだけ頻繁に実行されるかを制御し、悪用やシステム過負荷を防ぎます。クライアントでレート制限を実装することは実用的(時には必要)ですが、クライアント側のレート制限にのみ依存しないようにしてください。
クライアントによってトリガーされるサーバーサイドのロジックに対しては、そのロジックが実行される最大レートを常に考慮してください。多くのサーバーサイドのアクションでは、悪用や失敗を防ぐために頻度を制限する必要があります。たとえば:
- 計算が高価な操作:サーバーリソースを大量に消費したり、他のクライアントに影響を与える操作。
- サーバーの例: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はすべてのトークンが再補充されるのにかかる時間を決定します。例えば、TokenBucket.new(5, 10)は最大で5リクエストのバーストを許可し、2秒ごとに1トークンを再補充します(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メッセージ(capacity 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)
クライアントトリガーのインスタンスのセキュリティ
ProximityPrompts、ClickDetectors、およびDragDetectorsはリモートではありませんが、悪用者はこれらのイベントを任意の距離から、任意のタイミングでトリガーできるため、しばしばEnabledやMaxActivationDistanceのようなプロパティを無視します。これらのオブジェクトは、リモートと同じ厳しさで保護する必要があります。
ProximityPrompt
- 悪用者は、サーバーでEnabledがfalseになっていても、任意のイベントを発火させることができます。
- Enabled、MaxActivationDistance、およびRequiresLineOfSightはクライアントで静かに変更できるため、複製されたProximityPromptは、任意の場所から表示され、潜在的に相互作用されることがあります。
- サーバーは、ホールドイベント(PromptButtonHoldBeganなど)を、サーバー側のHoldDurationが0であっても受け入れます。クライアントはHoldDurationを操作して、異常に高速にインタラクションを実行できます。
- Triggeredイベントだけがサーバー側の距離チェックを持っています。他のイベント(PromptButtonHoldBeganやTriggerEndedなど)は距離チェックがなく、任意のクライアントから自由に送信できます。
ClickDetector
- サーバー上でのイベントに対するチェックはまったくありません。
- 悪用者は、ClickDetectorが無効であっても(0のMaxActivationDistanceまたはDragDetector.Enabledがfalseでも)、任意の距離でイベントを複製できます。
- ClickDetectorが何かクリック可能なものに親子関係にない場合でも、イベントは複製できます。
DragDetector
- ClickDetectorから継承されたイベントにはチェックがありません。
- ドラッグイベントのために、EnabledプロパティとPermissionPolicyプロパティがサーバーによってチェックされ、尊重されます。他のすべてのプロパティはチェックされません。
これらのイベントが発火した場合、サーバースクリプトはアクションを実行する前に独自の検証を行う必要があります:
- 有効かどうかを確認:インスタンスがサーバー上でのプロパティ(Enabled、HoldDuration、RunLocallyなど)をチェックして、意図したものであることを確認します。このステップは、オブジェクトが常に有効な場合は不要です。
- プレイヤーの状態を確認:プレイヤーが現在の状態でアクションを実行できるかどうか確認します。プレイヤーのキャラクターはワールド内にいますか? 彼らのキャラクターはオブジェクトに近すぎますか? プレイヤーはこのオブジェクトと相互作用するために生きている必要がありますか? 必要に応じて、あなたの体験でサーバーによって管理される他のプレイヤーの状態をチェックします。
- レート制限を適用: レート制限で説明したように、クライアントのクールダウンを適用し、サーバーでレート制限を強制して、これらのイベントがスパムされて悪用されないようにします。最小限の時間がかかることが期待されるインタラクションに対して、クライアントがあまりにも速くそれらを完了しないことを確認します。
ネットワーク所有権に関する注意:デフォルトでは、これらのオブジェクトのいずれかが固定されていないパーツや固定されていないアセンブリの子供である場合、悪用者は親パーツのネットワーク所有権を取得しそれらを直接自分のキャラクターに移動させ、距離チェックを回避することができます。重要なアクションについては、固定された部品か、ネットワーク所有権APIと必要なサーバーサイドの検証を使用してください。
RemoteEventsおよびRemoteFunctions
RemoteFunctionsおよびRemoteEventsのスコープと影響を制限してください。リモート関数/イベントの引数に基づいて、動的にアセット(テクスチャやサウンドさえも)をロードしたり、体験コードを実行したりすることに極めて注意を払ってください(特にrequireを介して)。サーバーが削除または変更するための任意のパスやインスタンスの参照をクライアントに指定させるリモートを実装しないようにしてください。このような変更が非常に些細なものであっても、任意のインスタンス状態を変更できるリモートは、他のバグと連鎖して、全体的な状態やロジックに重大な影響を与える可能性があります。インスタンス引数の型だけでなく、期待されるクラスやデータモデル内の位置や構造も確認してください。
クライアントからクライアントへの通信
いくつかのリモートは、一つのクライアントが他のクライアントの効果をトリガーできるように設計されています。これは、クライアントがサーバーにリモートを発火させ、その後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("雷がここに落ちました: " .. tostring(strikePosition))
end
-- サーバーがイベントをブロードキャストすると、このクライアントが効果を実行します
castLightningEvent.OnClientEvent:Connect(createLightningEffect)
悪用者はこの脆弱なシステムを以下のように利用できます:
- スパミング:リモートを1秒あたり何百回も呼び出して、体験を遊べなくするような連続的な効果を引き起こします。
- 無効なデータ: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を使用してプレイヤーデータを保存する体験では、悪用者が無効なデータ、不正競争の条件を利用してデータ保存を破損させたり、アイテムをデータストアに重複させたりする可能性があります。これは、アイテム取引、市場、および通貨システムを伴う体験に特に問題となります。
クライアント入力でプレイヤーデータに影響を与えるRemoteEventまたはRemoteFunctionを介して実行されるアクションは、次の条件に基づいてサニタイズされている必要があります:
- インスタンス値はデータストアにシリアル化できず、失敗します。これを防ぐために型検証を使用してください。
- データストアにはデータ制限があります。任意の長さの文字列は、これを回避するためにチェックされるか、制限されるべきです。また、クライアントによって無制限の任意のキーがテーブルに追加できないようにしてください。
- テーブルインデックスはNaNまたはnilであってはなりません。クライアントによって渡されたすべてのテーブルを反復し、すべてのインデックスが有効であることを確認します。
- データストアは有効なUTF-8文字しか受け付けません。したがって、クライアントによって提供されたすべての文字列をutf8.len()を介してサニタイズし、それらが有効であることを確認してください。utf8.len()は、文字列の長さを返しますが、unicode文字を単一の文字として扱います。無効なUTF-8文字に出会うとnilを返し、無効な文字の位置を返します。無効なUTF-8文字列も、キーおよび値としてテーブルに存在する可能性があります。
- 無限大/NaN数字の悪用 - 悪用者は-infや+infを送信し、無限通貨を引き起こすか、計算を破壊する可能性があります。NaNの危険性で説明されている方法を使用して常に検証してください。
レースコンディションは、プレイヤーが操作間のタイミングを操作してアイテムを重複させたり、データを破損させたりする際に発生する可能性があります。
取引悪用の例: プレイヤーが取引を開始し、他のプレイヤーにアイテムを送信した後、すぐにゲームを離れます。取引が完了しますが、無効なデータのためにそのデータストアの保存が失敗すると、元のアイテムを持って再接続し、他のプレイヤーが取引アイテムを保持することになり、重複が発生します。
予防戦略:
- すべてのデータを、取引操作より前に検証します。
- すべてのプレイヤーのデータが変更されることをコミットする前に検証されるようなトランザクションのようなパターンを使用します。
- いずれかの部分が失敗した場合には、すべての変更を元に戻す適切なエラーハンドリングを実装します。
MarketplaceService
MarketplaceServiceを含むインタラクション(パスや開発者製品など)では、すべての購入検証およびアイテム付与はサーバー上で発生する必要があります。特に、購入レシートを安全に検証するには、ProcessReceiptコールバックを使用してください。クライアント側の信号(たとえばPromptProductPurchaseFinished)を信頼して、サーバー検証なしに購入を確認しないでください。これは偽造可能です。あなたのProcessReceipt関数は、レシートの詳細を徹底的に確認し、Robloxサーバーと正当な取引を確認した後にのみアイテムや通貨を付与するようにし、特定のレシートのためにすでに製品が付与されている場合に対処できるように準備してください。