A Comprehensive Guide To Airtight Remote Security

Hello Developers! As I’m sure all of you know, a large problem many games face is the abuse of their server side functions through remote events. To the inexperienced developer, exploiters have an insane amount of power, even after the rollout of filtering enabled. This guide/resource combo will help you easily go about implementing better practices and strong remote security. Before we begin though, allow me to introduce a useful tool that makes thorough type-based sanity checks extremely easy.

This is my simple Security Module, and it allows for advanced checks such as detecting math.huge or NaN to be done in a single line.
SecurityModuleBasic.rbxm (2.4 KB)

We’re going to split this guide into three parts: Part One discusses basic type checking, Part Two discusses what additional types of sanity checks you may need and simple ways to implement common non-type checks, and Part Three discusses other things you can do to secure your remotes.

Part One: Basic Type Checking

Using tools such as Remote Spy, exploiters are able to see what they’re sending to each remote, and can try to send incorrect data types to try and break your game. An example of this is disguising a table as an object or sending over a NaN value instead of a number. We can very easily combat this through type checking, but it can be annoying to do thorough checks, especially for niche things. Using the Security Module, you can make this process very easy, here’s an example:

--In this example, exampleDictionary looks like this:
{
    Color = Color3.new(1,0,0),
    Type = "Bullet",
}
RE.OnServerEvent:Connect(function(plr,velocity,root,exampleDictionary)
    --First, we need to validate every type to make sure we're getting the correct information, as the exploiter could send anything in its place.
    if Security.typecheckVector3(velocity,plr) == false then plr:Kick("What?") return end
    --If they sent either the wrong data type or nil, they will be kicked from the game, as they cannot normally send either.
    --The security module doesn't have an instance check, so we just have to do this one manually.
    if typeof(root) ~= "Instance" then plr:Kick("What?") return end
    --Back to the security module
    if security.typecheckTable(exampleDictionary,plr) == false then plr:Kick("What?") return end
    --Seeing as we know the content of the table, and we know that "Color" and "Type" should be a Color3 and a string respectively, we will also check for those.
    if security.typecheckColor(exampleDictionary.Color,plr) == false then plr:Kick("What?") return end
    if security.typecheckString(exampleDictionary.Type,plr) == false then plr:Kick("What?") return end
    --With just 5 lines, we have made sure that exploiters have to use the data types we would normally use.
end)

Punishment Severity: Kick or (maybe) Ban. Since normal players can’t change the types of what they send, you know that only exploiters will be caught. Kicking is MUCH better since you won’t permanently ban an innocent player in the event of a false positive. If your code isn’t very sound and can’t account for everything (one of the data types may be nil on the client), then there’s a chance it can ruin an innocent player’s experience and prevent them from playing your game. If you want to ban exploiters for this, make sure you’re very smart about your client side code!!!

Part Two: Additional Sanity Checks

Although type checks are great, exploiters can still work within your limits to abuse your remotes. I’m going to cover some common exploits as well as the respective sanity checks you may need to combat them.

  • Distance Check
    Let’s say that you have a remote event that damages an npc or another player. Exploiters can abuse this to damage players from very far away when they normally shouldn’t. We can fix this with a simple distance check. Here’s an example:
InflictRemote.OnServerEvent:Connect(function(plr,otherCharacter,damage)
    --First, we're going to do type checking
    if typeof(otherCharacter) ~= "Instance" then plr:Kick("What?") return end
    if not otherCharacter:IsA("Model") or not otherCharacter:FindFirstChildOfClass("Humanoid") or not otherCharacter:IsDescendantOf(workspace) then return end
    --We won't kick the player because sometimes they can send the remote right before the player/npc despawns, we're just going to chose to do nothing instead.
    if Security.typecheckNumber(damage,plr) == false then plr:Kick("What?") return end
    --Let's also make sure the player isn't dealing an absurd amount of damage for some reason
    if damage <= 0 or damage > 35 then return end
    --Time to calculate the distance between the two players.
    local P1,P2 = plr.Character:WaitForChild("HumanoidRootPart").Position,otherCharacter:WaitForChild("HumanoidRootPart").Position
    --You may also want to make sure that both models actually have a HumanoidRootPart, just to be safe
    local distance = (P1-P2).Magnitude
    if distance >= 25 then return end
    --The two characters were too far to reasonably damage each other.
end)

Punishment Severity: None. Laggy players may be able to deal damage from further distances away, so we’re just going to prevent it from doing anything rather than punishing the player

  • Incorrect Choice From A List Of Strings
    In this example, you’re allowing the player to choose between multiple classes, but there is an additional class that the player needs to unlock and can’t be accessed yet in this interaction. The exploiter is able to set their class as the locked class through this interaction. We’re going to use table.find() to fix this problem. Here’s an example:
--There are six ability classes, and three of them are starters while the other three are locked.
--The starters are Void, Solar, and Arc, while the locked ones are Stasis, Strand, and Prismatic

local StarterClasses = {"Void","Solar","Arc"}
ClassSelectRemote.OnServerEvent:Connect(function(plr,class)
    if Security.typecheckString(class,plr) == false then plr:Kick("What?") return end
    --We're going to make sure they can only choose from a select amount of options.
    if not table.find(StarterClasses,class) then banFunction(plr,"Hey, that's locked!") return end
    --Set their class
end)

Punishment Severity: Permanent Ban. Only exploiters can do this, ban them.

  • Number Is Not An Integer And Is Too High Or Low.
    Sometimes you may need to send a specific range of numbers or integers. Personally I like to use integers instead of strings to determine the type of a remote, such as 0 and 1 instead of “Normal” and “Critical”. In this situation, an exploiter can send any number instead of the ones we want or need. We can fix this by either table.find() (if you’re using integers as replacements for type variables) or through a math.floor() check and greater than/less than. Here’s an example:
--Variation 1 means Startup, Variation 2 means Normal, Variation 3 means Special
--We're also going to assume number is an integer between 1 and 10
local ValidVariants = {1,2,3}
IntegerRemote.OnServerEvent:Connect(function(plr,number,variation)
    if Security.typecheckNumber(number,plr) == false then plr:Kick("What?") return end
    if Security.typecheckNumber(variation,plr) == false then plr:Kick("What?") return end
    --table.find() check for variant.
    if not table.find(ValidVariants,variation) then banFunction(plr,"Seriously?") return end
    --Let's ensure that the number is an integer and within the valid range.
    if math.floor(number) ~= number or number < 1 or number > 10 then banFunction(plr,"Seriously?") return end
end)

Punishment Severity: Permanent Ban. Only exploiters can do this, ban them.

Part Three: Other Ways To Secure Remotes (INTERMEDIATE)

This part is probably the most difficult to implement into your game. I just have three things to talk about here. First, use a networking module (I recommend Warp!) Networking modules are great, they make events fire faster and more efficiently, can encrypt remote names, and expand upon the base tools ROBLOX provides (such as timeouts for remote functions and built in rate limiting). Second, make sure players are only firing remotes they’re supposed to. For example, if you have a remote for an admin gui, make sure only admins can fire that remote with a whitelist on the server. Another example is an effects replication remote that the server uses to fire to all clients. You don’t need to fire that to the server, so ban any player that does fire one. Third, Rate limit crucial remotes. This is pretty obvious, but players shouldn’t be able to deal damage 300 times a second, or fire a setup/initialized/loaded remote more than once. You shouldn’t ban for reaching the rate limit because of lag spikes, but you should definitely kick the player who reached it. Here’s one way that you could implement rate limiting:

local RATE = 50
local PlayerMap = {}

Players.PlayerAdded:Connect(function(plr)
	PlayerMap[plr] = {tick(),0}
end)

Players.PlayerRemoving:Connect(function(plr)
	PlayerMap[plr] = nil
end)

for _,plr in ipairs(Players:GetPlayers()) do
	PlayerMap[plr] = {tick(),0}
end

Remote.OnServerEvent:Connect(function(plr)
    --Rate Limiting
    if tick()-PlayerMap[plr][1] < 1 then
        PlayerMap[plr][2] += 1
        if PlayerMap[plr][2] > RATE then plr:Kick("Rate Limit Reached") return end
    else
        PlayerMap[plr][1] = tick()
        PlayerMap[plr][2] = 1
    end
    --Code
end)

Hopefully this guide was extremely helpful, and hopefully you can make very good use of this information. By taking the extra time to add in 3-16 lines, you can protect your playerbase from being bullied by exploiters. Good luck and have a good day!

6 Likes

I just want to say that this is impossible. Metatables do not cross network boundaries, and also do not cross BindableEvents:

Metatables

If a table has a metatable, all of the metatable information is lost in the transfer.

The way Instances work is that the Instance’s ID (a 32 or so-bit number) is sent instead, so if it doesn’t exist on the receiving end, it will be nil. I personally don’t like sending Instances anyway because it just results in overall more network traffic and just doesn’t feel as “safe” to me.

Also, if you want a much more feature-complete runtime type checker (with shorter and more terse syntax), t exists:

2 Likes

Thanks for the correction! I have heard of exploiters using tables to emulate instances before, and I’ve even seen it happen in a game before. There was an exploit that bypassed a poorly made sanity check by pretending to be an object that the server script would just use .Position to determine its position

2 Likes

In that case, you can probably just leave as “disguising a table as an object”, because that’s more accurate.

2 Likes

Already ahead of you, the post was edited

1 Like

A few things to note, I didn’t see any sanity checks for nested tables or long strings, you should also make sure the string doesn’t contain any invalid utf8 chars.

Also, the rate limit method is flawed as a heartbeat loop will kill performance, it’s easier and better to do the rate limit within the OnServerEvent.

local PlayerData = {}

game:GetService("Players").PlayerAdded:Connect(function(Player)
	PlayerData[Player] = {
		RateLimit = {
			CallCount = 0,
			LastCallTime = os.clock()
		}
	}
end)

game:GetService("ReplicatedStorage"):WaitForChild("RemoteEvent").OnServerEvent:Connect(function(Player, ...)
	local Data = PlayerData[Player]
	if Data then
		if os.clock() - Data.RateLimit.LastCallTime < 1 then
			Data.RateLimit.CallCount += 1
			if Data.RateLimit.CallCount >= 15 then
				return Player:Kick("Rate limit exceeded")
			end
		else
			Data.RateLimit.CallCount = 1
			Data.RateLimit.LastCallTime = os.clock()
		end
	end
end)

game:GetService("Players").PlayerRemoving:Connect(function(Player)
	PlayerData[Player] = nil
end)
3 Likes

It’s called UniqueId, I believe it’s a hidden property that only scripts with RobloxScriptSecurity level can access.

1 Like

That is a newer property, and I don’t believe it’s used for Instance Identification just yet. According to Roblox’s file format and benchmarks by the Community, Instances are identified with 32-bits:

1 Like

I just covered some common sanity checks, not the more niche ones. Good catch on the rate limiting though, I didn’t think much of it as I handle all my rate limiting in a single module. I see how a new dev would make the mistake to make a loop for each script.