roblox-security
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseRoblox Security & Anti-Exploit
Roblox 安全与反漏洞
Use this skill when designing security systems, auditing existing code for vulnerabilities, or hardening a game against common exploit vectors.
在设计安全系统、审计现有代码漏洞或加固游戏以抵御常见攻击向量时使用此技能。
Core Principle
核心原则
The client is compromised. Always. Exploiters run arbitrary Luau on the client via injection tools. Every LocalScript, every ReplicatedStorage module, every client-side value is readable and writable by attackers. The server is the only source of truth.
客户端始终处于被攻陷状态。攻击者可通过注入工具在客户端运行任意Luau代码。所有LocalScript、ReplicatedStorage模块以及客户端侧的值都能被攻击者读取和修改。服务器是唯一的可信数据源。
Exploit Vectors & Mitigations
攻击向量与缓解措施
Movement Exploits
移动漏洞
| Attack | How it works | Mitigation |
|---|---|---|
| Speed hack | Client moves faster than WalkSpeed allows | Server-side velocity check per Heartbeat |
| Teleport | Client sets HumanoidRootPart.CFrame directly | Server tracks last valid position, reject jumps > threshold |
| Fly hack | Client removes gravity/collision | Server checks if player is grounded or has valid flight state |
| Noclip | Client passes through CanCollide parts | Server raycasts between positions to detect wall passes |
luau
-- Server-side movement validation (basic)
local MAX_SPEED = 16 * 1.5 -- WalkSpeed + tolerance
local MAX_TELEPORT = 50 -- studs per check
local lastPositions: {[Player]: Vector3} = {}
game:GetService("RunService").Heartbeat:Connect(function()
for _, player in Players:GetPlayers() do
local char = player.Character
if not char then continue end
local root = char:FindFirstChild("HumanoidRootPart")
if not root then continue end
local pos = root.Position
local last = lastPositions[player]
if last then
local dist = (pos - last).Magnitude
if dist > MAX_TELEPORT then
-- Snap back to last valid position
root.CFrame = CFrame.new(last)
end
end
lastPositions[player] = pos
end
end)| 攻击类型 | 攻击原理 | 缓解方案 |
|---|---|---|
| 加速外挂 | 客户端移动速度超过WalkSpeed限制 | 服务器端每帧(Heartbeat)检查移动速度 |
| 瞬移外挂 | 客户端直接设置HumanoidRootPart.CFrame | 服务器追踪玩家上一个有效位置,拒绝超过阈值的位置跳跃 |
| 飞行外挂 | 客户端移除重力/碰撞检测 | 服务器检查玩家是否处于地面或拥有合法飞行状态 |
| 穿墙外挂 | 客户端穿过CanCollide属性开启的部件 | 服务器在两个位置之间进行射线检测,判断是否穿墙 |
luau
-- Server-side movement validation (basic)
local MAX_SPEED = 16 * 1.5 -- WalkSpeed + tolerance
local MAX_TELEPORT = 50 -- studs per check
local lastPositions: {[Player]: Vector3} = {}
game:GetService("RunService").Heartbeat:Connect(function()
for _, player in Players:GetPlayers() do
local char = player.Character
if not char then continue end
local root = char:FindFirstChild("HumanoidRootPart")
if not root then continue end
local pos = root.Position
local last = lastPositions[player]
if last then
local dist = (pos - last).Magnitude
if dist > MAX_TELEPORT then
-- Snap back to last valid position
root.CFrame = CFrame.new(last)
end
end
lastPositions[player] = pos
end
end)Remote Exploits
远程攻击
| Attack | How it works | Mitigation |
|---|---|---|
| Remote spam | Fire remotes at extreme rates | Per-player rate limiter |
| Argument spoofing | Send wrong types/values | Validate every argument type and range |
| Remote sniffing | Read remote names to reverse-engineer API | Doesn't matter if validation is solid |
| Replay attack | Re-fire a valid remote call | Idempotency checks, transaction IDs |
luau
-- Rate limiter pattern
local rateLimits: {[Player]: {[string]: number}} = {}
local RATE_LIMIT = 10 -- calls per second
local function checkRate(player: Player, remoteName: string): boolean
local now = os.clock()
local playerLimits = rateLimits[player]
if not playerLimits then
playerLimits = {}
rateLimits[player] = playerLimits
end
local lastCall = playerLimits[remoteName] or 0
if now - lastCall < 1 / RATE_LIMIT then
return false -- rate limited
end
playerLimits[remoteName] = now
return true
end
-- Argument validation pattern
local function validatePurchase(player: Player, itemId: unknown, quantity: unknown): boolean
if typeof(itemId) ~= "string" then return false end
if typeof(quantity) ~= "number" then return false end
if quantity ~= math.floor(quantity) then return false end -- must be integer
if quantity < 1 or quantity > 99 then return false end -- sane range
if not ITEM_CATALOG[itemId] then return false end -- item must exist
return true
end| 攻击类型 | 攻击原理 | 缓解方案 |
|---|---|---|
| 远程调用刷屏 | 高频触发远程调用 | 为每个玩家设置调用频率限制器 |
| 参数伪造 | 发送错误类型/数值的参数 | 验证每个参数的类型和取值范围 |
| 远程调用嗅探 | 读取远程调用名称以逆向工程API | 只要验证机制足够完善,此攻击无需过度担忧 |
| 重放攻击 | 重复触发合法的远程调用 | 幂等性检查、使用唯一交易ID |
luau
-- Rate limiter pattern
local rateLimits: {[Player]: {[string]: number}} = {}
local RATE_LIMIT = 10 -- calls per second
local function checkRate(player: Player, remoteName: string): boolean
local now = os.clock()
local playerLimits = rateLimits[player]
if not playerLimits then
playerLimits = {}
rateLimits[player] = playerLimits
end
local lastCall = playerLimits[remoteName] or 0
if now - lastCall < 1 / RATE_LIMIT then
return false -- rate limited
end
playerLimits[remoteName] = now
return true
end
-- Argument validation pattern
local function validatePurchase(player: Player, itemId: unknown, quantity: unknown): boolean
if typeof(itemId) ~= "string" then return false end
if typeof(quantity) ~= "number" then return false end
if quantity ~= math.floor(quantity) then return false end -- must be integer
if quantity < 1 or quantity > 99 then return false end -- sane range
if not ITEM_CATALOG[itemId] then return false end -- item must exist
return true
endEconomy Exploits
经济系统攻击
| Attack | How it works | Mitigation |
|---|---|---|
| Item duplication | Race condition in trade/save | Session locking, atomic operations |
| Negative purchase | Send negative quantity to gain items | Validate quantity > 0 server-side |
| Transaction replay | Replay a purchase remote | Unique transaction IDs, check if already processed |
| DataStore rollback | Exploit save timing to duplicate | Session locking with server JobId |
| 攻击类型 | 攻击原理 | 缓解方案 |
|---|---|---|
| 物品复制 | 交易/保存时的竞态条件 | 会话锁定、原子操作 |
| 负数量购买 | 发送负数量参数以获取物品 | 服务器端验证数量必须大于0 |
| 交易重放 | 重放购买远程调用 | 使用唯一交易ID,检查是否已处理 |
| DataStore回滚 | 利用保存时机漏洞复制物品 | 使用服务器JobId进行会话锁定 |
DataStore Exploits
DataStore攻击
| Attack | How it works | Mitigation |
|---|---|---|
| Save spam | Force repeated saves to exhaust budget | Server-controlled save intervals only |
| Session hijack | Fake session to duplicate across servers | Session lock with JobId verification |
| BindToClose skip | Exploit shutdown timing | BindToClose with parallel saves + timeout |
| 攻击类型 | 攻击原理 | 缓解方案 |
|---|---|---|
| 保存刷屏 | 强制重复保存以耗尽配额 | 仅由服务器控制保存间隔 |
| 会话劫持 | 伪造会话以跨服务器复制数据 | 结合JobId验证的会话锁定 |
| 跳过BindToClose | 利用关机时机漏洞 | BindToClose结合并行保存与超时机制 |
Security Audit Checklist
安全审计 checklist
Run through this for every game before publish:
CRITICAL (game-breaking if missing):
[ ] All game state is server-authoritative
[ ] All RemoteEvent handlers validate types of EVERY argument
[ ] All RemoteEvent handlers have rate limiting
[ ] DataStore operations use session locking
[ ] No client-side currency/inventory mutations
[ ] MarketplaceService purchases verified via ProcessReceipt
[ ] No sensitive logic in LocalScripts or ReplicatedStorage
HIGH (exploitable if missing):
[ ] Player movement is server-validated
[ ] BindToClose saves protected against data loss
[ ] Trading system uses atomic operations
[ ] No trusting client-reported values (damage, position, items)
[ ] RemoteFunction return values not trusted by server
MEDIUM (quality/fairness):
[ ] Cooldowns enforced server-side (not just client UI)
[ ] Leaderboard values computed server-side
[ ] Anti-AFK detection for reward systems
[ ] Chat filter applied (TextService:FilterStringAsync)发布游戏前需逐一检查以下内容:
CRITICAL (缺失会导致游戏崩溃):
[ ] 所有游戏状态由服务器管控
[ ] 所有RemoteEvent处理器验证每个参数的类型
[ ] 所有RemoteEvent处理器设置频率限制
[ ] DataStore操作使用会话锁定
[ ] 客户端不修改货币/库存数据
[ ] MarketplaceService购买通过ProcessReceipt验证
[ ] 敏感逻辑不放在LocalScript或ReplicatedStorage中
HIGH (缺失会导致可被攻击):
[ ] 玩家移动由服务器验证
[ ] BindToClose保存机制防止数据丢失
[ ] 交易系统使用原子操作
[ ] 不信任客户端上报的值(伤害、位置、物品)
[ ] 服务器不信任RemoteFunction的返回值
MEDIUM (影响游戏质量/公平性):
[ ] 冷却时间由服务器端强制执行(而非仅客户端UI)
[ ] 排行榜数值由服务器计算
[ ] 奖励系统设置防挂机检测
[ ] 应用聊天过滤(TextService:FilterStringAsync)Patterns
设计模式
Never Trust Client Values
永远不要信任客户端的值
luau
-- BAD: Client tells server how much damage to deal
DamageRemote.OnServerEvent:Connect(function(player, target, damage)
target.Humanoid:TakeDamage(damage) -- exploiter sends 999999
end)
-- GOOD: Server computes damage from game state
AttackRemote.OnServerEvent:Connect(function(player, targetId)
local weapon = getEquippedWeapon(player)
if not weapon then return end
local target = resolveTarget(targetId)
if not target then return end
if not isInRange(player, target, weapon.Range) then return end
if not checkCooldown(player, weapon) then return end
local damage = weapon.BaseDamage * getDamageMultiplier(player)
target.Humanoid:TakeDamage(damage)
end)luau
-- BAD: Client tells server how much damage to deal
DamageRemote.OnServerEvent:Connect(function(player, target, damage)
target.Humanoid:TakeDamage(damage) -- exploiter sends 999999
end)
-- GOOD: Server computes damage from game state
AttackRemote.OnServerEvent:Connect(function(player, targetId)
local weapon = getEquippedWeapon(player)
if not weapon then return end
local target = resolveTarget(targetId)
if not target then return end
if not isInRange(player, target, weapon.Range) then return end
if not checkCooldown(player, weapon) then return end
local damage = weapon.BaseDamage * getDamageMultiplier(player)
target.Humanoid:TakeDamage(damage)
end)Session Locking
会话锁定
luau
-- Prevent data duplication across servers
local function acquireSessionLock(player: Player, data): boolean
local lockKey = "SessionLock_" .. player.UserId
local currentLock = data:GetMetadata().SessionLock
if currentLock and currentLock ~= game.JobId then
-- Another server owns this data
-- Either wait for expiry or kick
return false
end
data:SetMetadata({SessionLock = game.JobId})
return true
endluau
-- Prevent data duplication across servers
local function acquireSessionLock(player: Player, data): boolean
local lockKey = "SessionLock_" .. player.UserId
local currentLock = data:GetMetadata().SessionLock
if currentLock and currentLock ~= game.JobId then
-- Another server owns this data
-- Either wait for expiry or kick
return false
end
data:SetMetadata({SessionLock = game.JobId})
return true
endSanity Checks (Defense in Depth)
合理性检查(纵深防御)
Even with server authority, add sanity checks for values that should be bounded:
luau
-- Clamp values that should never exceed known bounds
local function sanitizePlayerStats(stats)
stats.Level = math.clamp(stats.Level, 1, MAX_LEVEL)
stats.Gold = math.max(stats.Gold, 0) -- never negative
stats.Health = math.clamp(stats.Health, 0, stats.MaxHealth)
return stats
end即使有服务器权限管控,仍需对有边界限制的值添加合理性检查:
luau
-- Clamp values that should never exceed known bounds
local function sanitizePlayerStats(stats)
stats.Level = math.clamp(stats.Level, 1, MAX_LEVEL)
stats.Gold = math.max(stats.Gold, 0) -- never negative
stats.Health = math.clamp(stats.Health, 0, stats.MaxHealth)
return stats
endWhat NOT to Do
禁忌操作
- Don't obfuscate client code — it doesn't stop exploiters and makes debugging harder
- Don't use _G for security state — it's globally readable and writable
- Don't kick without logging — you need data to distinguish false positives from real exploits
- Don't over-validate movement — too strict = legitimate players get false-flagged on lag spikes. Use tolerance.
- Don't rely on client-side anti-cheat — exploiters disable it first
- 不要混淆客户端代码——这无法阻止攻击者,还会增加调试难度
- 不要用_G存储安全状态——它是全局可读可写的
- 不要不记录就踢出玩家——你需要数据区分误判与真实攻击
- 不要过度验证移动——过于严格会导致合法玩家在网络波动时被误判,需设置容错范围
- 不要依赖客户端反作弊——攻击者会首先禁用它