クライアントがサーバー上でアクションをトリガーできる場合、悪用の可能性があります。RemoteEventsおよびRemoteFunctionsはここで最も一般的な攻撃ベクトルですが、ProximityPromptのような他のインスタンスも脆弱です。このセクションでは、この境界をどのように保護するかを説明します。
リモート入力の検証
クライアントから送信されるすべてのデータは、使用される前にサーバーによって検証されなければなりません。リモートが行うことに基づいて、これらの検証層を適用します。
コンテキスト/権限の検証
これは、ゲームの状態、進行状況、または他のプレイヤーに影響を与えるリモートに必要です。サーバーは、プレイヤーがリクエストを行うために必要な権限を持っているかどうかを確認する必要があります。たとえば、プレイヤーはショップに近すぎるか?ドアを開けるために必要な鍵を持っているか?キャラクターは生きているか?
リモートに対して検証が必要な場合:
- 報酬を付与したり、プレイヤーの進行状況を変更したりする
- 他のプレイヤーや共有ゲーム状態に影響を与える
- 距離、タイミング、または権限の要件を持つアクションを実行する(たとえば、ショップの距離)
タイプと構造の検証
悪用者が仕掛ける別の攻撃は、技術的に有効なタイプを送信するが、それを非常に大きく、長く、またはその他の形で不正にすることです。たとえば、サーバーが長さに応じてスケールする文字列に対して高価な操作を実行する必要がある場合、悪用者はサーバーを混乱させるために非常に大きなまたは不正な文字列を送信する可能性があります。
悪用者が使用する別の一般的な攻撃は、tablesをInstanceの代わりに送信することです。複雑なペイロードは、通常のオブジェクト参照を模倣することができます。
たとえば、アイテムデータ(価格など)がNumberValueオブジェクトに保存されているゲーム内ショップシステムが提供されている場合、悪用者は次のようにして他のすべてのチェックを回避することができます:
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local itemDataFolder = ReplicatedStorage:WaitForChild("ItemData")
local buyItemEvent = ReplicatedStorage:WaitForChild("BuyItemEvent")
local payload = {
Name = "ウルトラブレード",
ClassName = "Folder",
Parent = itemDataFolder,
Price = {
Name = "価格",
ClassName = "NumberValue",
Value = 0, -- 負の値も使用でき、通貨を与える結果になる可能性があります!
},
}
-- サーバーに悪意のあるペイロードを送信(これは拒否されます)
print(buyItemEvent:InvokeServer(payload)) -- 出力 "false Invalid item provided"
-- サーバーに本物のアイテムを送信(これは通過します!)
print(buyItemEvent:InvokeServer(itemDataFolder["Real Blade"])) -- 出力 "true" と残りの通貨(購入が成功した場合)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値の検証
タイプとデータの検証に加えて、RemoteEventsおよびRemoteFunctionsを通じて渡される値を検証し、リクエストされているコンテキストで有効かつ論理的であることを確認する必要があります。これは、通貨、アイテム、数値入力、またはユーザー生成コンテンツを処理するリモートにとって不可欠です。たとえば、購入数量がゼロより大きいか、アイテムIDがゲーム内の実際のアイテムに対応していることを確認する必要があります。
infおよびNaNは有効な数値タイプですが、悪用者がそれらを送信し、正しく処理されない場合、重大な問題を引き起こす可能性があります。math.isfinite()を使用して検出し、拒否します:
-- NaNまたは+/-infの数値を拒否
if not math.isfinite(offeredGold) then
return false, "無効な数値が提供されました"
endNaNの危険性
悪用者は引数としてNaN(Not a Number)を送信できます。NaNは「数値」型ですが、すべての標準比較に失敗するため、論理チェックを巧妙に回避することができます。プレイヤーがオファーを出す脆弱なトレードシステムを見てみましょう。
-- 脆弱なサーバーコード
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を呼び出して購入をリクエストできます。ただし、サーバーは、ゲームの最も信頼できる管理者として、ユーザーがアイテムを購入するのに十分なお金を持っていることを確認することが重要です。

武器のターゲティング
戦闘シナリオでは、特に照準とヒットの検証において値の検証に特別な注意が必要です。
プレイヤーが他のプレイヤーにレーザービームを発射できるゲームを想像してみてください。クライアントがサーバーに誰をダメージさせるかを伝えるのではなく、発射の起点位置とヒットしたと思われる部分/位置をサーバーに伝えるべきです。サーバーは次のことを検証できます:
- クライアントが報告する発射位置がサーバー上のプレイヤーのキャラクターの近くであること。遅延のためにサーバーとクライアントはわずかに異なるため、追加の許容範囲を適用する必要があります。
- クライアントが報告するヒット位置が、クライアントが報告するヒット位置の部分の位置に合理的に近いこと。
- クライアントが報告する発射位置とクライアントが報告するヒット位置の間に静的な障害物がないこと。このチェックは、クライアントが壁を通して発射しようとしていないことを確認します。このチェックは、遅延のために有効なショットが拒否されないように、静的なジオメトリのみをチェックする必要があります。
さらに、次のようなサーバー側の検証を実装することを検討するかもしれません:
- プレイヤーが最後に武器を発射した時刻を追跡し、あまりにも早く発射していないことを確認します。
- サーバー上で各プレイヤーの弾薬量を追跡し、発射するプレイヤーが武器攻撃を実行するのに十分な弾薬を持っていることを確認します。
- チームや「プレイヤー対ボット」の戦闘システムを実装している場合、ヒットしたキャラクターが敵であり、味方ではないことを確認します。
- ヒットしたプレイヤーが生きていることを確認します。
- サーバー上で武器とプレイヤーの状態を保存し、発射するプレイヤーがリロード中やスプリント中などの現在のアクションによってブロックされていないことを確認します。
レート制限
サーバー側のロジックがクライアントによってトリガーされる可能性がある場合(リモート、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メッセージ(容量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はどこからでも見え、潜在的に相互作用できます。
- サーバーは、サーバー側のHoldDurationがゼロであってもホールドイベント(たとえば、PromptButtonHoldBegan)を受け入れます。クライアントはHoldDurationを操作して、異常に早く相互作用を行うことができます。
- Triggeredイベントのみがサーバー側の距離チェックを持っています。他のイベント(PromptButtonHoldBeganやTriggerEndedなど)は距離チェックがなく、任意のクライアントによって任意に送信される可能性があります。
ClickDetector
- サーバー上のイベントに対するチェックはまったくありません。
- 悪用者は、ClickDetectorが無効になっている場合(MaxActivationDistanceが0またはDragDetector.Enabledがfalseであっても)、任意の距離で任意のイベントを複製できます。
- ClickDetectorがクリック可能なものに親子関係を持っていなくても、イベントを複製できます。
DragDetector
- ClickDetectorから継承されたイベントにはチェックがありません。
- ドラッグイベントの場合、EnabledプロパティとPermissionPolicyプロパティはサーバーによってチェックされ、尊重されます。他のすべてのプロパティはチェックされません。
これらのイベントが発火したとき、サーバースクリプトはアクションを実行する前に独自の検証を行う必要があります:
- 有効かどうかを確認します: インスタンスが有効にされることを意図しているかどうかを、サーバー上でそのプロパティ(Enabled、HoldDuration、RunLocallyなど)を確認します。このステップは、オブジェクトが常に有効である場合は必要ありません。
- プレイヤーの状態を確認します: プレイヤーが現在の状態でアクションを実行できるかどうかを確認します。プレイヤーのキャラクターはワールドにいますか?キャラクターはオブジェクトに近すぎますか?プレイヤーがこのオブジェクトと相互作用するために生きている必要がありますか?必要に応じて、サーバー上でゲームによって維持される他のプレイヤーの状態を確認します。
- レート制限を適用します: レート制限で説明したように、クライアント側でクールダウンを適用し、サーバー側でレート制限を強制して、これらのイベントがスパムされて悪用されるのを防ぎます。最小限の時間がかかることが期待される相互作用については、クライアントがそれをあまりにも早く完了していないことを確認します。
ネットワーク所有権に関する注意: デフォルトでは、これらのオブジェクトがアンカーされていないパーツやアンカーされていないアセンブリの子である場合、悪用者は親パーツのネットワーク所有権を取得し、直接キャラクターに移動させることができ、距離チェックを回避します。重要なアクションには、アンカーされたパーツまたはネットワーク所有権APIを使用し、必要なサーバー側の検証を行ってください。
RemoteEventsおよびRemoteFunctions
RemoteFunctionsおよびRemoteEventsのスコープと影響を制限します。リモート関数/イベントの引数に基づいて、アセット(テクスチャやサウンドを含む)を動的にロードしたり、ゲームコード(特にrequireを介して)を実行したりする際には、極めて注意が必要です。クライアントがサーバーに削除または変更させるために任意のパスやインスタンス参照を指定できるリモートを実装することは避けてください。たとえそのような変更が些細に見えても、任意のインスタンス状態を変更したり、DataModelツリーに広範な変更を加えることができるリモートは、他のバグと連鎖して全体の状態やロジックに深刻な影響を与えることがあります。インスタンス引数のタイプだけでなく、クラスやDataModel内の期待される場所や構造も確認してください。
クライアントからクライアントへの通信
一部のリモートは、1つのクライアントが他のクライアントに効果をトリガーさせるために設計されています。これは、クライアントがサーバーにリモートを発火させ、その後RemoteEvent:FireAllClients()、RemoteEvent:FireClient()、または類似のメソッドを使用して情報を中継する場合に発生します。このパターンは、適切に保護されていない場合に危険です - サーバーはゲートキーパーでなければなりません、単なる中継者ではありません。
プレイヤーが雷の呪文を唱えるシナリオを想像してみてください。唱えられると、近くのプレイヤーはフラッシュを見て、音を聞き、カメラが揺れます。
脆弱なサーバースクリプトは次のようになります:
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を使用してプレイヤーデータを保存するゲームでは、悪用者が無効なデータやレース条件を利用して保存を破損させたり、DataStore内のアイテムを複製したりする可能性があります。これは、アイテムのトレード、市場、通貨システムを持つゲームでは特に問題です。
クライアント入力でプレイヤーデータに影響を与えるRemoteEventまたはRemoteFunctionを介して実行されるすべてのアクションは、以下に基づいてサニタイズされる必要があります:
- インスタンス値はDataStoreにシリアライズできず、失敗します。これを防ぐためにタイプ検証を利用してください。
- DataStoresにはデータ制限があります。任意の長さの文字列は、これを避けるためにチェックおよび/または制限されるべきであり、クライアントによって無限の任意のキーがテーブルに追加されることがないようにします。
- テーブルのインデックスはNaNまたはnilにできません。クライアントによって渡されたすべてのテーブルを反復処理し、すべてのインデックスが有効であることを確認します。
- DataStoresは有効なUTF-8文字のみを受け入れます。したがって、クライアントによって提供されたすべての文字列をutf8.len()でサニタイズして、有効であることを確認する必要があります。utf8.len()は、文字列の長さを返し、Unicode文字を1つの文字として扱います。無効なUTF-8文字が見つかると、nilと無効な文字の位置が返されます。無効なUTF-8文字列は、キーや値としてテーブルにも存在する可能性があります。
- 無限/NaN数の悪用 - 悪用者は、-1/0、0/0、またはmath.hugeのような値を送信して無限の通貨を引き起こしたり、計算を壊したりする可能性があります。NaNの危険性で説明した方法を使用して常に検証してください。
レース条件は、プレイヤーが操作間のタイミングを操作してアイテムを複製したり、データを破損させたりする場合に発生する可能性があります。
トレード悪用の例: プレイヤーがトレードを開始し、アイテムを別のプレイヤーに送信し、その後すぐにゲームを離れます。トレードが完了しますが、無効なデータのためにDataStoreの保存が失敗した場合、元のアイテムを持って再参加し、他のプレイヤーがトレードされたアイテムを保持することになります - 複製が発生します。
予防戦略:
- トレード操作の前にすべてのデータを検証します。
- すべてのプレイヤーのデータが変更をコミットする前に検証されるトランザクションのようなパターンを使用します。
- いずれかの部分が失敗した場合にすべての変更を元に戻す適切なエラーハンドリングを実装します。
MarketplaceService
MarketplaceServiceに関与するインタラクション、たとえばパスや開発者製品については、すべての購入検証とアイテム付与はサーバーで行う必要があります。具体的には、ProcessReceiptコールバックを利用して購入レシートを安全に検証します。クライアント側の信号(たとえば、PromptProductPurchaseFinished)を信頼して購入を確認することは避けてください。これらは偽造される可能性があります。ProcessReceipt関数がレシートの詳細を徹底的にチェックし、Robloxサーバーとの正当な取引を確認した後にのみアイテムや通貨を付与するようにし、特定のレシートに対してすでにアイテムが付与されている場合のケースにも対処できるようにしてください。