Learn how to make secure gmod addons and stop getting your server owned by script kiddies.
- Overview
- Gmod Networking Basics
- File Steal
- Common Exploits
- WriteData Vulns
- Secure Coding
- Input Validation
- Entity Security
- Rate Limiting
- SQL Security
- File Security
- Community Rules
- Code Examples
- Testing
- Tools
- Security Checklist
Gmod's workshop has around 2,000,000 addons and most of them are made by kids. This course shows you how to build addons that don't get your server nuked by exploiters. Understanding how attacks work so you can prevent them.
- Server protection: stop crashes, ddos, admin escalation, or any exploits
- Community safety: protect players from bad scripts
- Cool skills: learn glua at its finest
Gmod uses the net library introduced in gmod 13. It replaced the old usermessage system and allows bidirectional client-server communication with a 64kb limit per message.
The networking workflow is pretty straightforward:
- Server precaches messages with
util.AddNetworkString() - Send data with
net.Start()→ write functions →net.Send() - Receive with
net.Receive()handlers
Golden rule: never trust client input. The client is in enemy territory - assume everything sent from it is malicious.
Gmod's networking supports:
- Basic stuff: Bool, String, Int, Float
- Complex stuff: Table, Vector, Entity
- Binary data: Through
net.WriteData()(small tip: util.Decompress() has a "maxsize" argument)
Alternative: gm_express for large data (clearly recommended but requires you to add another addon in your server)
gm_express bypasses the 64kb limit using HTTP requests via Cloudflare edge servers:
- Large transfers: up to ~100mb vs 64kb net library limit
- High performance: faster than net library for large data
- Simple API: auto serialization, compression, and chunking
- Non-blocking: HTTP requests don't run on main thread
-- example: sending large dataset
-- server
local mapData = buildhugemapdata() -- could be 25mb+
express.Broadcast("map_data", { mapData })
-- client
express.Receive("map_data", function(data)
ProcessMapData(data[1])
end)There's no real way to hide your files from clients. When you send files to the client, they have them. End of story.
- Clients download everything (workshop, maps, sounds, models), everything that is "Client" or "Shared".
- Lua files are readable (even "compiled" ones can be decompiled)
- Memory dumps can extract files
- Tools exist to rip content easily (gluasteal for example)
- Paid addons are expensive, kids want them free
- Custom server content to copy features
- Lazy developers who don't want to code
- Exploiters, to find vulnerable addons
-- bad: everything on client
-- cl_admin.lua (everyone gets this file)
local function BanPlayer(target)
RunConsoleCommand("ulx", "ban", target, "999")
end
-- good: logic on server only
-- sv_admin.lua (clients never see this)
local function BanPlayer(admin, target)
if not admin:IsSuperAdmin() then return end
target:Ban(999, true)
end
-- cl_admin.lua (good client code)
local function RequestBan(target)
net.Start("RequestBan")
net.WriteEntity(target)
net.SendToServer() -- just sends request
end"One very frequent mistake i have even seen in gmod store scripts, is, that devs put the MySQL config in a shared file. And clients do also receive them with passwords." -Samuel
Put on server:
- Money/economy systems
- Admin permissions
- Database operations
- Anti-cheat logic
- Important game mechanics
Put on client:
- UI/HUD elements
- Visual effects
- Sounds and models
- User settings
- Performance-heavy stuff (but validate on server)
Gmod isn't optimized for heavy server-side processing, so don't put EVERYTHING on the server or it'll lag.
-- smart approach: hybrid system
-- server: validates and stores money
local function AddMoney(ply, amount)
if amount < 0 then return end -- validate
local current = GetPlayerMoney(ply)
SetPlayerMoney(ply, current + amount)
-- tell client new amount (just for display)
net.Start("UpdateMoney")
net.WriteInt(current + amount, 32)
net.Send(ply)
end
-- client: shows pretty UI
net.Receive("UpdateMoney", function()
local money = net.ReadInt(32)
LocalPlayer().DisplayMoney = money
UpdateMoneyHUD(money) -- just visual
end)- Your files will be stolen if they're popular enough (only clientside ofc)
- Obfuscation is easily bypassed by anyone determined, so dont assume obfuscating your addons will save you
- Focus on service value - make support/updates more valuable than the code
- Legal protection is often not worth the effort for gmod addons, and nobody cares about them
- Design assuming theft - plan like everyone will see your code
- Server-side everything important - client is just for display
- Don't put all logic server-side - balance with performance
- Make ongoing value - regular updates, support, new features etc
- Watermark your work - at least get credit when it's stolen
You can't stop file stealing in gmod. Focus on making theft less valuable than legitimate use, and keep your important stuff server-side where clients can't touch it.
The goal is damage reduction, not perfect protection (which is impossible).
Gmod exploits fall into a few main categories. Understanding these helps you code nicely and not like drvrej (oops).
- Volume flooding: spam empty net messages in rapid loops
- Impact: 10k-100k messages can crash servers
- Result: complete server dos through stream overflow
- Method: send messages near the 64kb limit, or above
- Impact: eats bandwidth and memory
- Detection: monitor message sizes in handlers
- Technique: exploit bad validation in net handlers
- Methods:
- unexpected data types
- malformed tables
- manipulated entity refs
- Goal: crash handlers or bypass security
- Special risk:
net.WriteData()with big binary payloads
- Hardcoded access: weird addons with steam ids granting superadmin
- Remote execution:
RunString()with http fetches for arbitrary code (kvacdoor most likely) - Command injection: ulx command injection for server control
- Example: june 2022 workshop incident with mass exploitation
- Missing checks: no
IsSuperAdmin()validation before privileged ops - Group failures: bad group-based access control
- Context confusion: mixing client-server privilege contexts
- Client bypass: attackers skip client-side checks
- Type confusion: handlers get unexpected data types
- Range problems: numeric inputs cause crashes
- Entity manipulation: missing
IsValid()checks
- Direct construction: unsanitized input in
sql.Query() - Bad escaping: not using
mysqlooorsql.SQLStr() - Result: unauthorized table operations
The net.WriteData() function lets you send raw binary data, creating alot of attack opportunities:
-- attack vector: malicious binary payload
local test_data = file.Read("exploit.bin", "DATA") -- its usually like a big .bin file, places in your gmod "data" folder with random data in it
net.Start("VulnerableMessage")
net.WriteUInt(#test_data, 32)
net.WriteData(test_data, #test_data)
net.SendToServer()- Technique: wrong length params causing buffer issues
- Impact: memory corruption or crashes
- Prevention: always validate data length
- Method: malformed compressed data crashes clients/servers
- Common pattern: compression bombs (tiny compressed → huge uncompressed)
-- vulnerable: no validation or permission checking
net.Receive("BanPlayer", function(len, ply)
local toBan = net.ReadEntity()
local time = net.ReadUInt(32)
toBan:Ban(time, true) -- anyone can ban anyone!
end)Why this sucks: accepts and acts on client data without any validation. Attackers can ban anyone including admins.
-- dangerous: client-side entity exposure
net.WriteEntity(LocalPlayer()) -- manipulatable client entities- Technique: bypass poorly coded validation
- Example: negative money values in economy systems
- Method: malformed tables crash lua parser
- Prevention: validate table structure before processing
- Target: contexts with char limits
- Method: send oversized strings to cause buffer issues
-- attack demonstration (patched by eprotect now)
hook.Add("Think", "flood_attack", function()
for i = 1, 100000 do
net.Start("vulnerable_message")
net.SendToServer()
end
end)Impact:
- server dos
- "reliable stream overflow" client disconnects
- resource exhaustion
- network buffer saturation (~12kb/s freezes clients in most cases)
Every net handler needs multiple validation layers following facepunch guidelines:
util.AddNetworkString("SecureMessage")
net.Receive("SecureMessage", function(len, ply)
-- basic checks first
if len > EXPECTED_MAX_SIZE then
LogSecurityEvent(ply, "oversized message attempt")
return
end
-- player validation
if not IsValid(ply) or not ply:IsSuperAdmin() then
LogSecurityEvent(ply, "unauthorized access attempt")
return
end
-- rate limiting
if not RateLimitCheck(ply, "SecureMessage") then
LogSecurityEvent(ply, "rate limit exceeded")
return
end
-- data validation
local data = net.ReadString()
if not data or type(data) ~= "string" or #data > 255 then
LogSecurityEvent(ply, "invalid data format")
return
end
-- content validation
if not ValidateStringContent(data) then
LogSecurityEvent(ply, "malicious content detected")
return
end
-- safe to process
ProcessSecureData(ply, data)
end)Rate limiting prevents dos attacks:
-- global rate limiting
local playerMessageCounts = {}
local RATE_LIMITS = {
["SecureMessage"] = {limit = 10, window = 1}, -- 10 per second
["AdminCommand"] = {limit = 5, window = 1}, -- 5 per second
["PlayerAction"] = {limit = 30, window = 60} -- 30 per minute
}
local function RateLimitCheck(ply, msgName)
if not IsValid(ply) then return false end
local steamID = ply:SteamID()
local currentTime = CurTime()
local config = RATE_LIMITS[msgName]
if not config then
-- no rate limit configured - allow but log
LogSecurityEvent(ply, "no rate limit for message: " .. msgName)
return true
end
-- init player tracking
if not playerMessageCounts[steamID] then
playerMessageCounts[steamID] = {}
end
local playerData = playerMessageCounts[steamID]
if not playerData[msgName] then
playerData[msgName] = {count = 0, lastReset = currentTime, violations = 0}
end
local msgData = playerData[msgName]
-- reset counter if window expired
if currentTime - msgData.lastReset >= config.window then
msgData.count = 0
msgData.lastReset = currentTime
end
msgData.count = msgData.count + 1
-- check if limit exceeded
if msgData.count > config.limit then
msgData.violations = msgData.violations + 1
HandleRateLimitViolation(ply, msgName, msgData.violations)
return false
end
return true
end
local function HandleRateLimitViolation(ply, msgName, violationCount)
LogSecurityEvent(ply, string.format("rate limit violation: %s (count: %d)", msgName, violationCount))
-- progressive punishment
if violationCount >= 10 then
ply:Kick("excessive rate limit violations")
elseif violationCount >= 5 then
-- temp restrictions
ply:SetNWBool("RateLimitRestricted", true)
timer.Simple(300, function() -- 5 min timeout
if IsValid(ply) then
ply:SetNWBool("RateLimitRestricted", false)
end
end)
end
-- notify admins
for _, admin in ipairs(player.GetAll()) do
if admin:IsSuperAdmin() then
admin:ChatPrint(string.format("[security] %s: rate limit violation (%s)", ply:Nick(), msgName))
end
end
endlocal MESSAGE_SIZE_LIMITS = {
["PlayerChat"] = 500,
["ConfigUpdate"] = 2048,
["FileTransfer"] = 32768 -- 32kb max
}
local function ValidateMessageSize(msgName, len)
local limit = MESSAGE_SIZE_LIMITS[msgName] or 1024 -- default 1kb
if len > limit then
return false, string.format("message too large: %d bytes (limit: %d)", len, limit)
end
return true
endString inputs need sanitization to prevent code injection and data corruption:
local function SanitizeString(input, options)
options = options or {}
-- type validation
if not input or type(input) ~= "string" then
return "", "invalid input type"
end
-- length validation
local maxLength = options.maxLength or 255
if #input > maxLength then
input = string.sub(input, 1, maxLength)
end
local minLength = options.minLength or 0
if #input < minLength then
return "", "input too short"
end
-- remove dangerous chars
if options.removeHTML then
input = string.gsub(input, "[<>\"'&]", "")
end
-- remove control chars (except newlines if allowed)
if not options.allowNewlines then
input = string.gsub(input, "[\r\n]", "")
end
-- remove null bytes and other dangerous chars
input = string.gsub(input, "%z", "")
input = string.gsub(input, "[\1-\8\11\12\14-\31]", "")
-- pattern validation
if options.pattern then
if not string.match(input, options.pattern) then
return "", "input doesn't match required pattern"
end
end
-- blacklist checking
if options.blacklist then
local lower_input = string.lower(input)
for _, banned in ipairs(options.blacklist) do
if string.find(lower_input, string.lower(banned), 1, true) then
return "", "input contains prohibited content"
end
end
end
return input, "valid"
end
-- usage examples
local function ValidatePlayerName(name)
return SanitizeString(name, {
maxLength = 32,
minLength = 1,
pattern = "^[%w%s_%-%.]+$", -- alphanumeric, spaces, underscore, dash, dot
blacklist = {"admin", "moderator", "owner"}
})
end
local function ValidateChatMessage(message)
return SanitizeString(message, {
maxLength = 500,
minLength = 1,
removeHTML = true,
allowNewlines = false,
blacklist = {"<script", "javascript:", "eval(", "RunString"}
})
endlocal Validator = {}
function Validator.ValidateString(input, options)
if type(input) ~= "string" then
return false, "expected string, got " .. type(input)
end
if options.minLength and #input < options.minLength then
return false, string.format("string too short (min: %d, got: %d)", options.minLength, #input)
end
if options.maxLength and #input > options.maxLength then
return false, string.format("string too long (max: %d, got: %d)", options.maxLength, #input)
end
if options.pattern and not string.match(input, options.pattern) then
return false, "string doesn't match required pattern"
end
if options.blacklist then
for _, banned in ipairs(options.blacklist) do
if string.find(string.lower(input), string.lower(banned), 1, true) then
return false, "string contains prohibited content: " .. banned
end
end
end
return true, input
end
function Validator.ValidateNumber(input, options)
if type(input) ~= "number" then
return false, "expected number, got " .. type(input)
end
if not math.IsFinite then
-- compatibility for older gmod versions
math.IsFinite = function(x)
return x == x and x ~= math.huge and x ~= -math.huge
end
end
if not math.IsFinite(input) then
return false, "number is not finite"
end
if options.min and input < options.min then
return false, string.format("number too small (min: %s, got: %s)", options.min, input)
end
if options.max and input > options.max then
return false, string.format("number too large (max: %s, got: %s)", options.max, input)
end
if options.integer and input ~= math.floor(input) then
return false, "expected integer, got decimal"
end
if options.positive and input <= 0 then
return false, "expected positive number"
end
return true, input
end
function Validator.ValidateVector(input, options)
if not isvector(input) then
return false, "expected vector, got " .. type(input)
end
-- check for nan or infinite values
if not (math.IsFinite(input.x) and math.IsFinite(input.y) and math.IsFinite(input.z)) then
return false, "vector contains invalid values"
end
if options.maxMagnitude then
local magnitude = input:Length()
if magnitude > options.maxMagnitude then
return false, string.format("vector magnitude too large (max: %s, got: %s)", options.maxMagnitude, magnitude)
end
end
if options.boundsMin and options.boundsMax then
if input.x < options.boundsMin.x or input.x > options.boundsMax.x or
input.y < options.boundsMin.y or input.y > options.boundsMax.y or
input.z < options.boundsMin.z or input.z > options.boundsMax.z then
return false, "vector outside allowed bounds"
end
end
return true, input
end
function Validator.ValidateTable(input, options)
if type(input) ~= "table" then
return false, "expected table, got " .. type(input)
end
if options.maxKeys then
local keyCount = table.Count(input)
if keyCount > options.maxKeys then
return false, string.format("table has too many keys (max: %d, got: %d)", options.maxKeys, keyCount)
end
end
if options.requiredKeys then
for _, key in ipairs(options.requiredKeys) do
if input[key] == nil then
return false, "missing required key: " .. tostring(key)
end
end
end
if options.allowedKeys then
for key, _ in pairs(input) do
if not table.HasValue(options.allowedKeys, key) then
return false, "disallowed key: " .. tostring(key)
end
end
end
return true, input
end-- example: secure player data update
util.AddNetworkString("UpdatePlayerData")
net.Receive("UpdatePlayerData", function(len, ply)
-- basic security checks
if not IsValid(ply) then return end
if not RateLimitCheck(ply, "UpdatePlayerData") then return end
-- read and validate data
local dataType = net.ReadString()
local valid, error = Validator.ValidateString(dataType, {
maxLength = 32,
pattern = "^[%w_]+$",
blacklist = {"admin", "superadmin", "owner"}
})
if not valid then
LogSecurityEvent(ply, "invalid data type: " .. error)
return
end
-- type-specific validation
if dataType == "nickname" then
local nickname = net.ReadString()
valid, error = Validator.ValidateString(nickname, {
minLength = 1,
maxLength = 32,
pattern = "^[%w%s_%-%.]+$"
})
if valid then
ply:SetNWString("CustomNickname", nickname)
else
ply:ChatPrint("invalid nickname: " .. error)
end
elseif dataType == "position" then
local position = net.ReadVector()
valid, error = Validator.ValidateVector(position, {
maxMagnitude = 100000,
boundsMin = Vector(-16384, -16384, -16384),
boundsMax = Vector(16384, 16384, 16384)
})
if valid and ply:IsSuperAdmin() then -- only admins can set custom positions
ply:SetPos(position)
else
LogSecurityEvent(ply, "invalid position update: " .. (error or "insufficient permissions"))
end
end
end)Entity interactions needs to have multiple validation layers to prevent manipulation:
-- entity validation levels
local VALIDATION_LEVELS = {
BASIC = 1, -- entity exists and is valid
OWNERSHIP = 2, -- player owns or has permission
DISTANCE = 3, -- within interaction range
FULL = 4 -- all checks including type and state
}
local function ValidateEntity(ent, ply, action, level)
level = level or VALIDATION_LEVELS.FULL
-- level 1: basic entity validation
if not IsValid(ent) then
return false, "invalid entity reference"
end
-- check for null entity (entindex 0)
if ent:EntIndex() == 0 then
return false, "cannot interact with world entity"
end
if level >= VALIDATION_LEVELS.OWNERSHIP then
-- level 2: ownership and permissions
if not CanPlayerInteractWithEntity(ply, ent, action) then
return false, "insufficient permissions for this entity"
end
end
if level >= VALIDATION_LEVELS.DISTANCE then
-- level 3: distance limitations
local maxDistance = GetMaxInteractionDistance(action)
local distance = ply:GetPos():Distance(ent:GetPos())
if distance > maxDistance then
return false, string.format("too far from entity (distance: %.1f, max: %.1f)", distance, maxDistance)
end
end
if level >= VALIDATION_LEVELS.FULL then
-- level 4: entity type and state validation
if not IsEntityTypeAllowed(ent:GetClass(), action) then
return false, "action not allowed on this entity type"
end
-- check entity state
if ent.GetBroken and ent:GetBroken() then
return false, "cannot interact with broken entity"
end
-- prevent interaction with protected entities
if ent:GetNWBool("Protected", false) and not ply:IsSuperAdmin() then
return false, "entity is protected"
end
end
return true, "valid"
end
-- permission checking
local function CanPlayerInteractWithEntity(ply, ent, action)
if not IsValid(ply) or not IsValid(ent) then
return false
end
-- superadmins can do anything
if ply:IsSuperAdmin() then
return true
end
-- check cppi ownership
if ent.CPPIGetOwner then
local owner = ent:CPPIGetOwner()
if IsValid(owner) and owner == ply then
return true
end
end
-- fallback to getowner
if ent.GetOwner then
local owner = ent:GetOwner()
if IsValid(owner) and owner == ply then
return true
end
end
-- check team/group permissions
if ent.GetTeamAccess then
local allowedTeams = ent:GetTeamAccess()
if table.HasValue(allowedTeams, ply:Team()) then
return true
end
end
-- check friend lists or shared access
if ent.HasAccess then
return ent:HasAccess(ply)
end
return false
end
local INTERACTION_DISTANCES = {
["use"] = 100,
["physgun"] = 200,
["tool"] = 200,
["remove"] = 150,
["repair"] = 80
}
local function GetMaxInteractionDistance(action)
return INTERACTION_DISTANCES[action] or 100
end
local ENTITY_RESTRICTIONS = {
["player"] = {}, -- players can't be acted upon
["worldspawn"] = {}, -- world cannot be modified
["gmod_camera"] = {"physgun", "remove"} -- cameras are fragile
}
local function IsEntityTypeAllowed(className, action)
local restrictions = ENTITY_RESTRICTIONS[className]
if not restrictions then
return true -- no restrictions
end
if #restrictions == 0 then
return false -- completely restricted
end
return not table.HasValue(restrictions, action)
end-- secured entity removal system
util.AddNetworkString("RemoveEntity")
net.Receive("RemoveEntity", function(len, ply)
if not IsValid(ply) then return end
if not RateLimitCheck(ply, "RemoveEntity") then return end
local ent = net.ReadEntity()
-- full validation
local valid, error = ValidateEntity(ent, ply, "remove", VALIDATION_LEVELS.FULL)
if not valid then
LogSecurityEvent(ply, "invalid entity removal: " .. error)
ply:ChatPrint("cannot remove entity: " .. error)
return
end
-- additional safety checks
if ent:IsPlayer() then
LogSecurityEvent(ply, "attempted to remove player entity")
return
end
if ent:GetClass() == "worldspawn" then
LogSecurityEvent(ply, "attempted to remove worldspawn")
return
end
-- log the removal
LogSecurityEvent(ply, string.format("removed entity: %s (id: %d)", ent:GetClass(), ent:EntIndex()))
-- safe removal
SafeRemoveEntity(ent)
end)
local function SafeRemoveEntity(ent)
if not IsValid(ent) then return end
-- call cleanup hooks
hook.Call("EntityRemoved", GAMEMODE, ent)
-- remove the entity
ent:Remove()
endanti-spam needs graduated responses with multiple protection layers ^^:
-- config
local ANTISPAM_CONFIG = {
props = {
enabled = true,
cooldown = 0.5, -- seconds between spawns
maxPerMinute = 30, -- max props per minute
maxPerHour = 200 -- max props per hour
},
tools = {
enabled = true,
cooldown = 0.1,
maxPerMinute = 100,
maxPerHour = 1000
},
chat = {
enabled = true,
cooldown = 1.0,
maxPerMinute = 20,
maxPerHour = 500,
duplicateThreshold = 3 -- max identical messages
},
commands = {
enabled = true,
cooldown = 2.0,
maxPerMinute = 10,
maxPerHour = 100
}
}
-- player data storage
local playerSpamData = {}
local function GetPlayerSpamData(ply)
local steamID = ply:SteamID()
if not playerSpamData[steamID] then
playerSpamData[steamID] = {
lastActions = {},
counts = {},
violations = {},
chatHistory = {},
punishmentLevel = 0,
lastPunishment = 0
}
end
return playerSpamData[steamID]
end
local function CheckSpamLimit(ply, actionType, data)
if not IsValid(ply) then return false end
local config = ANTISPAM_CONFIG[actionType]
if not config or not config.enabled then return true end
local currentTime = CurTime()
local playerData = GetPlayerSpamData(ply)
-- cooldown enforcement
local lastAction = playerData.lastActions[actionType] or 0
if currentTime - lastAction < config.cooldown then
return false, "cooldown active"
end
-- rate limit enforcement (per minute)
local minuteCount = playerData.counts[actionType .. "_minute"] or {}
-- clean old entries (older than 60 seconds)
for i = #minuteCount, 1, -1 do
if currentTime - minuteCount[i] > 60 then
table.remove(minuteCount, i)
end
end
if #minuteCount >= config.maxPerMinute then
return false, "rate limit exceeded (per minute)"
end
-- rate limit enforcement (per hour)
local hourCount = playerData.counts[actionType .. "_hour"] or {}
-- clean old entries (older than 3600 seconds)
for i = #hourCount, 1, -1 do
if currentTime - hourCount[i] > 3600 then
table.remove(hourCount, i)
end
end
if #hourCount >= config.maxPerHour then
return false, "rate limit exceeded (per hour)"
end
-- special check for chat duplicates
if actionType == "chat" and data then
local recent = playerData.chatHistory
local duplicates = 0
for i = #recent, math.max(1, #recent - 10), -1 do
if recent[i].message == data and currentTime - recent[i].time < 300 then
duplicates = duplicates + 1
end
end
if duplicates >= config.duplicateThreshold then
return false, "too many duplicate messages"
end
end
-- update tracking
playerData.lastActions[actionType] = currentTime
table.insert(minuteCount, currentTime)
table.insert(hourCount, currentTime)
playerData.counts[actionType .. "_minute"] = minuteCount
playerData.counts[actionType .. "_hour"] = hourCount
-- update chat history
if actionType == "chat" and data then
table.insert(playerData.chatHistory, {message = data, time = currentTime})
-- keep only last 20 messages
if #playerData.chatHistory > 20 then
table.remove(playerData.chatHistory, 1)
end
end
return true
endlocal PUNISHMENT_LEVELS = {
[1] = {duration = 60, action = "warning"}, -- 1 minute warning
[2] = {duration = 300, action = "restrict"}, -- 5 minute restriction
[3] = {duration = 1800, action = "restrict"}, -- 30 minute restriction
[4] = {duration = 0, action = "kick"}, -- kick from server
[5] = {duration = 3600, action = "ban"} -- 1 hour ban
}
local function HandleSpamViolation(ply, actionType, reason)
if not IsValid(ply) then return end
local steamID = ply:SteamID()
local playerData = GetPlayerSpamData(ply)
local currentTime = CurTime()
-- init violation tracking
if not playerData.violations[actionType] then
playerData.violations[actionType] = {count = 0, lastViolation = 0}
end
local violations = playerData.violations[actionType]
-- reset violations if enough time has passed (24 hours)
if currentTime - violations.lastViolation > 86400 then
violations.count = 0
playerData.punishmentLevel = 0
end
violations.count = violations.count + 1
violations.lastViolation = currentTime
-- determine punishment level
local punishmentLevel = math.min(violations.count, #PUNISHMENT_LEVELS)
local punishment = PUNISHMENT_LEVELS[punishmentLevel]
-- log the violation
LogSecurityEvent(ply, string.format("spam violation: %s - %s (level %d)", actionType, reason, punishmentLevel))
-- apply punishment
if punishment.action == "warning" then
ply:ChatPrint(string.format("[anti-spam] warning: %s. slow down!", reason))
elseif punishment.action == "restrict" then
ply:SetNWBool("SpamRestricted", true)
ply:ChatPrint(string.format("[anti-spam] you have been restricted for %d seconds due to: %s", punishment.duration, reason))
timer.Simple(punishment.duration, function()
if IsValid(ply) then
ply:SetNWBool("SpamRestricted", false)
ply:ChatPrint("[anti-spam] restrictions have been lifted.")
end
end)
elseif punishment.action == "kick" then
ply:Kick(string.format("spam detected: %s", reason))
elseif punishment.action == "ban" then
ply:Ban(punishment.duration, false)
NotifyAdmins(string.format("player %s (%s) has been banned for spam: %s", ply:Nick(), steamID, reason))
end
-- notify administrators
NotifyAdmins(string.format("spam alert: %s (%s) - %s (punishment: %s)", ply:Nick(), steamID, reason, punishment.action))
end
-- integration with game hooks
hook.Add("PlayerSpawnProp", "AntiSpam", function(ply, model)
if ply:GetNWBool("SpamRestricted", false) then
return false
end
local allowed, reason = CheckSpamLimit(ply, "props")
if not allowed then
HandleSpamViolation(ply, "props", reason)
return false
end
return true
end)
hook.Add("CanTool", "AntiSpam", function(ply, trace, tool)
if ply:GetNWBool("SpamRestricted", false) then
return false
end
local allowed, reason = CheckSpamLimit(ply, "tools")
if not allowed then
HandleSpamViolation(ply, "tools", reason)
return false
end
return true
end)
hook.Add("PlayerSay", "AntiSpam", function(ply, text)
local allowed, reason = CheckSpamLimit(ply, "chat", text)
if not allowed then
HandleSpamViolation(ply, "chat", reason)
return "" -- block the message
end
return text
end)-- admin commands for managing anti-spam system
concommand.Add("antispam_status", function(ply, cmd, args)
if not IsValid(ply) or not ply:IsSuperAdmin() then return end
if #args > 0 then
-- show specific player stats
local target = nil
for _, p in ipairs(player.GetAll()) do
if string.find(string.lower(p:Nick()), string.lower(args[1])) then
target = p
break
end
end
if target then
local data = GetPlayerSpamData(target)
ply:ChatPrint(string.format("=== anti-spam status: %s ===", target:Nick()))
for actionType, violations in pairs(data.violations) do
ply:ChatPrint(string.format("%s: %d violations", actionType, violations.count))
end
ply:ChatPrint(string.format("restricted: %s", target:GetNWBool("SpamRestricted", false) and "yes" or "no"))
else
ply:ChatPrint("player not found")
end
else
-- show general statistics
local totalPlayers = #player.GetAll()
local restrictedPlayers = 0
for _, p in ipairs(player.GetAll()) do
if p:GetNWBool("SpamRestricted", false) then
restrictedPlayers = restrictedPlayers + 1
end
end
ply:ChatPrint(string.format("=== anti-spam system status ==="))
ply:ChatPrint(string.format("total players: %d", totalPlayers))
ply:ChatPrint(string.format("restricted players: %d", restrictedPlayers))
ply:ChatPrint(string.format("system status: %s", "active"))
end
end)Sql injection prevention requires consistent use of gmod's built-in escaping:
-- secure: always use sql.sqlstr for user input
local function SafePlayerLookup(steamID, callback)
-- input validation first
if not steamID or type(steamID) ~= "string" then
return callback(nil, "invalid steamid format")
end
-- validate steamid format
if not string.match(steamID, "^STEAM_[0-5]:[01]:%d+$") then
return callback(nil, "invalid steamid format")
end
-- properly escape the input
local escapedSteamID = sql.SQLStr(steamID)
local query = "SELECT * FROM players WHERE steamid = " .. escapedSteamID
local result = sql.Query(query)
if result == false then
-- sql error occurred
local error = sql.LastError()
print("sql error: " .. error)
return callback(nil, error)
elseif result == nil then
-- no results found
return callback(nil, "player not found")
else
-- success
return callback(result, nil)
end
end
-- example usage
SafePlayerLookup("STEAM_0:1:12345", function(result, error)
if error then
print("error: " .. error)
else
print("found player: " .. result[1].name)
end
end)Using mysqloo with prepared statements (recommended for external MySQL databases):
-- require mysqloo module
local mysqloo = require("mysqloo")
-- database connection setup
local database
local function InitializeDatabase()
-- create database connection
database = mysqloo.connect("localhost", "username", "password", "database_name", 3306)
function database:onConnected()
print("Database connected successfully!")
end
function database:onConnectionFailed(err)
print("Database connection failed: " .. err)
end
function database:onDisconnected()
print("Database disconnected")
end
-- connect to db
database:connect()
end
-- always use prepared statements for user input
local function SafePlayerLookupMySQL(steamID, callback)
-- input validation first
if not steamID or type(steamID) ~= "string" then
return callback(nil, "invalid steamid format")
end
-- validate steamid format
if not string.match(steamID, "^STEAM_[0-5]:[01]:%d+$") then
return callback(nil, "invalid steamid format")
end
-- check if database is connected
if database:status() ~= mysqloo.DATABASE_CONNECTED then
return callback(nil, "database not connected")
end
-- use prepared statement to prevent sql injection
local query = database:prepare("SELECT * FROM players WHERE steamid = ?")
query:setString(1, steamID) -- safely bind the parameter
function query:onSuccess(data)
if #data == 0 then
callback(nil, "player not found")
else
callback(data, nil)
end
end
function query:onError(err, sql)
print("MySQL Error: " .. err)
print("Failed Query: " .. sql)
callback(nil, err)
end
query:start()
end
-- init database when script loads
InitializeDatabase()
-- example usage
timer.Simple(2, function() -- wait for connection
SafePlayerLookupMySQL("STEAM_0:1:12345", function(result, error)
if error then
print("Error: " .. error)
else
print("Found player: " .. result[1].name)
end
end)
end)Alternative using manual escaping (less preferred but still good ig):
-- alternative: using database:escape() for manual escaping
local function SafePlayerLookupMySQLEscape(steamID, callback)
if not steamID or type(steamID) ~= "string" then
return callback(nil, "invalid steamid format")
end
if not string.match(steamID, "^STEAM_[0-5]:[01]:%d+$") then
return callback(nil, "invalid steamid format")
end
if database:status() ~= mysqloo.DATABASE_CONNECTED then
return callback(nil, "database not connected")
end
-- manual escaping with mysqloo
local escapedSteamID = database:escape(steamID)
local queryStr = "SELECT * FROM players WHERE steamid = '" .. escapedSteamID .. "'"
local query = database:query(queryStr)
function query:onSuccess(data)
if #data == 0 then
callback(nil, "player not found")
else
callback(data, nil)
end
end
function query:onError(err, sql)
print("MySQL Error: " .. err)
callback(nil, err)
end
query:start()
endMultiple parameters example:
-- inserting data with multiple params
local function InsertPlayerData(steamID, playerName, playerLevel, callback)
if not steamID or not playerName or not playerLevel then
return callback(nil, "missing required parameters")
end
if database:status() ~= mysqloo.DATABASE_CONNECTED then
return callback(nil, "database not connected")
end
local query = database:prepare("INSERT INTO players (steamid, name, level) VALUES (?, ?, ?)")
query:setString(1, steamID)
query:setString(2, playerName)
query:setNumber(3, playerLevel)
function query:onSuccess(data)
callback(true, nil)
end
function query:onError(err, sql)
print("Insert Error: " .. err)
callback(nil, err)
end
query:start()
end
-- example usage
InsertPlayerData("STEAM_0:1:12345", "PlayerName", 25, function(success, error)
if error then
print("Insert failed: " .. error)
else
print("Player data inserted successfully")
end
end)For complex queries, use a safer query builder:
local QueryBuilder = {}
-- safe query builder with parameter binding
function QueryBuilder.BuildSafeQuery(template, params)
if not template or type(template) ~= "string" then
error("template must be a string")
end
if not params or type(params) ~= "table" then
error("parameters must be a table")
end
local safeQuery = template
for key, value in pairs(params) do
local placeholder = ":" .. key
local safeValue
if type(value) == "string" then
safeValue = sql.SQLStr(value)
elseif type(value) == "number" then
if not math.IsFinite(value) then
error("parameter contains invalid number: " .. key)
end
safeValue = tostring(value)
elseif type(value) == "boolean" then
safeValue = value and "1" or "0"
elseif value == nil then
safeValue = "NULL"
else
error("unsupported parameter type for key '" .. key .. "': " .. type(value))
end
safeQuery = string.gsub(safeQuery, placeholder, safeValue)
end
-- verify all placeholders were replaced
if string.find(safeQuery, ":") then
error("unresolved placeholders found in query")
end
return safeQuery
end
-- safe database operations
function QueryBuilder.SafeSelect(table, conditions, limit)
if not table or not string.match(table, "^[%w_]+$") then
error("invalid table name")
end
local query = "SELECT * FROM " .. table
if conditions and next(conditions) then
local whereClause = QueryBuilder.BuildWhereClause(conditions)
query = query .. " WHERE " .. whereClause
end
if limit and type(limit) == "number" and limit > 0 then
query = query .. " LIMIT " .. math.floor(limit)
end
return sql.Query(query)
end-- database manager with security controls
local DatabaseManager = {}
-- table whitelist for security
local ALLOWED_TABLES = {
"players",
"player_data",
"server_config",
"ban_list",
"user_permissions"
}
function DatabaseManager.IsTableAllowed(tableName)
return table.HasValue(ALLOWED_TABLES, tableName)
end
function DatabaseManager.ValidateTableStructure(tableName)
if not DatabaseManager.IsTableAllowed(tableName) then
return false, "table not in whitelist"
end
-- check if table exists
local result = sql.Query("SELECT name FROM sqlite_master WHERE type='table' AND name=" .. sql.SQLStr(tableName))
if not result then
return false, "table does not exist"
end
return true, "table is valid"
end
-- secure player data management
function DatabaseManager.SavePlayerData(ply, dataType, data)
if not IsValid(ply) then
return false, "invalid player"
end
-- validate data type
local allowedDataTypes = {"stats", "preferences", "inventory", "achievements"}
if not table.HasValue(allowedDataTypes, dataType) then
return false, "invalid data type"
end
-- serialize data safely
local serializedData = util.TableToJSON(data)
if not serializedData then
return false, "failed to serialize data"
end
-- check data size limit (1mb)
if #serializedData > 1048576 then
return false, "data too large"
end
-- use parameterized query
local template = "INSERT OR REPLACE INTO player_data (steamid, data_type, data, last_updated) VALUES (:steamid, :dataType, :data, :timestamp)"
local params = {
steamid = ply:SteamID(),
dataType = dataType,
data = serializedData,
timestamp = os.time()
}
local query = QueryBuilder.BuildSafeQuery(template, params)
local result = sql.Query(query)
if result == false then
local error = sql.LastError()
print("database error: " .. error)
return false, error
end
return true, "data saved successfully"
endFile operations need careful path validation to prevent directory traversal:
-- secure file system manager
local FileManager = {}
-- define allowed paths for different contexts
local ALLOWED_PATHS = {
["player_data"] = "data/playerdata/",
["addon_config"] = "data/mymod/config/",
["temp_files"] = "data/temp/",
["logs"] = "data/logs/",
["public"] = "data/public/"
}
-- admin paths (require elevated permissions)
local ADMIN_PATHS = {
["admin_config"] = "data/admin/config/",
["server_data"] = "data/server/",
["security_logs"] = "data/security/"
}
function FileManager.IsPathAllowed(path, context, ply)
if not path or type(path) ~= "string" then
return false, "invalid path"
end
-- normalize path separators
path = string.gsub(path, "\\", "/")
-- remove dangerous path components
if string.find(path, "%.%./") or string.find(path, "%.%.\\") then
return false, "directory traversal not allowed"
end
-- check for null bytes and other dangerous characters
if string.find(path, "%z") or string.find(path, "[\1-\31]") then
return false, "invalid characters in path"
end
-- check allowed paths
local allowedPath = ALLOWED_PATHS[context]
if allowedPath and string.StartWith(path, allowedPath) then
return true, "allowed"
end
-- check admin paths (require elevated permissions)
local adminPath = ADMIN_PATHS[context]
if adminPath and string.StartWith(path, adminPath) then
if IsValid(ply) and ply:IsSuperAdmin() then
return true, "admin access granted"
else
return false, "administrative access required"
end
end
return false, "path not in allowed list"
end
function FileManager.SafeFileRead(path, context, ply)
-- validate path
local pathAllowed, pathError = FileManager.IsPathAllowed(path, context, ply)
if not pathAllowed then
LogSecurityEvent(ply, string.format("unauthorized file read attempt: %s (%s)", path, pathError))
return nil, pathError
end
-- check if file exists
if not file.Exists(path, "DATA") then
return nil, "file does not exist"
end
-- check file size before reading
local size = file.Size(path, "DATA")
if size > 10485760 then -- 10mb limit
LogSecurityEvent(ply, string.format("attempted to read large file: %s (%d bytes)", path, size))
return nil, "file too large"
end
-- log access
LogSecurityEvent(ply, string.format("file read: %s", path))
return file.Read(path, "DATA"), nil
end-- role-based file access control
local FILE_PERMISSIONS = {
["data/admin/"] = {"superadmin"},
["data/moderator/"] = {"superadmin", "admin", "moderator"},
["data/player/"] = {"superadmin", "admin", "moderator", "user"},
["data/public/"] = {"all"},
["data/logs/"] = {"superadmin", "admin"},
["data/temp/"] = {"superadmin", "admin", "moderator"}
}
function FileManager.GetUserRole(ply)
if not IsValid(ply) then
return "guest"
end
if ply:IsSuperAdmin() then
return "superadmin"
elseif ply:IsAdmin() then
return "admin"
elseif ply:IsUserGroup("moderator") then
return "moderator"
else
return "user"
end
end
function FileManager.HasFilePermission(ply, path, operation)
local userRole = FileManager.GetUserRole(ply)
-- find matching permission rule
for pathPattern, allowedRoles in pairs(FILE_PERMISSIONS) do
if string.StartWith(path, pathPattern) then
-- check if "all" is allowed
if table.HasValue(allowedRoles, "all") then
return true
end
-- check user's role
if table.HasValue(allowedRoles, userRole) then
return true
end
return false
end
end
-- default deny if no rule matches
return false
end-- secure file upload system
util.AddNetworkString("SecureFileUpload")
local UPLOAD_CONFIG = {
maxFileSize = 1048576, -- 1mb
allowedExtensions = {".txt", ".json", ".lua", ".cfg"},
maxFilesPerUser = 10,
uploadCooldown = 30 -- seconds
}
local userUploads = {}
net.Receive("SecureFileUpload", function(len, ply)
if not IsValid(ply) then return end
-- rate limiting
if not RateLimitCheck(ply, "file_upload") then return end
-- read upload data
local fileName = net.ReadString()
local fileData = net.ReadData(net.ReadUInt(32))
local targetPath = net.ReadString()
-- validate filename
local validName, nameError = FileManager.ValidateFileName(fileName)
if not validName then
ply:ChatPrint("upload failed: " .. nameError)
return
end
-- check file extension
local extension = string.GetExtensionFromFilename(fileName)
if not table.HasValue(UPLOAD_CONFIG.allowedExtensions, extension) then
ply:ChatPrint("upload failed: file type not allowed")
LogSecurityEvent(ply, string.format("attempted upload of disallowed file type: %s", extension))
return
end
-- check file size
if #fileData > UPLOAD_CONFIG.maxFileSize then
ply:ChatPrint(string.format("upload failed: file too large (max: %d bytes)", UPLOAD_CONFIG.maxFileSize))
return
end
-- scan file content for dangerous patterns
if ScanFileContent(fileData) then
ply:ChatPrint("upload failed: file content not allowed")
LogSecurityEvent(ply, string.format("dangerous file content detected in upload: %s", fileName))
return
end
-- write file
local success, error = FileManager.SecureWrite(ply, fullPath, fileData, "player_data")
if success then
ply:ChatPrint("file uploaded successfully: " .. fileName)
else
ply:ChatPrint("upload failed: " .. error)
end
end)
function ScanFileContent(data)
local dangerousPatterns = {
"RunString",
"CompileString",
"http%.Fetch",
"file%.Write",
"sql%.Query",
"%.%.", -- directory traversal
"%z" -- null bytes
}
for _, pattern in ipairs(dangerousPatterns) do
if string.find(data, pattern) then
return true
end
end
return false
endThe gmod community maintains alot of cool tools:
Nova Defender (Samuel <3, litteraly made the best anticheat ever made)
- Anticheat: 25+ detection methods for various exploit types
- DDoS protection: network-level attack mitigation (you need to have full control over your machine tho)
- Global ban database: shared threat intelligence across servers
- Real time monitoring: active scanning for suspicious behavior
- Crash prevention: protection against malicious client-side code
- Exploit mitigation: blocking common attack vectors
- Performance optimization: reducing client-side vulns
- Source engine protection: binary module for low-level security
- Network hardening: protection against protocol-level attacks
- Memory protection: preventing buffer overflow exploits
SNTE (Say No To Exploits) (YohSambre <3, made alot of kids cry in agony)
- Exploit detection: automated scanning for common vulns
- Code analysis: static analysis of addon code
- Behavioral monitoring: runtime detection of kids
-- vulnerable #1: no validation
net.Receive("AdminCommand", function(len, ply)
local command = net.ReadString()
RunConsoleCommand(command) -- anyone can execute any command!
end)
-- vulnerable #2: client-side permission checking only
-- client code (easily bypassed):
if LocalPlayer():IsSuperAdmin() then
net.Start("DeleteAllProps")
net.SendToServer()
end
-- server code (trusts client):
net.Receive("DeleteAllProps", function(len, ply)
for _, ent in ipairs(ents.GetAll()) do
if ent:GetClass() == "prop_physics" then
ent:Remove() -- trusts that client checked permissions!
end
end
end)
-- vulnerable #3: sql injection
local function GetPlayer(name)
local query = "SELECT * FROM players WHERE name = '" .. name .. "'"
return sql.Query(query) -- direct string concat = sql injection
end
-- vulnerable #4: no rate limiting
hook.Add("PlayerSpawnProp", "SpawnProp", function(ply, model)
return true -- no limits allows spam attacks
end)
-- vulnerable #5: trusting entity refs from client
net.Receive("ModifyEntity", function(len, ply)
local ent = net.ReadEntity()
local newHealth = net.ReadFloat()
ent:SetHealth(newHealth) -- client can modify any entity!
end)-- secure #1: full validation
util.AddNetworkString("SecureAdminCommand")
local ALLOWED_COMMANDS = {
"cleanup_props",
"restart_round",
"change_map_vote",
"reload_config"
}
local adminCommandCooldowns = {}
net.Receive("SecureAdminCommand", function(len, ply)
-- player validation
if not IsValid(ply) then return end
-- permission validation
if not ply:IsSuperAdmin() then
LogSecurityEvent(ply, "unauthorized admin command attempt")
return
end
-- message size validation
if len > 100 then
LogSecurityEvent(ply, "oversized admin command message")
return
end
-- rate limiting (one command per 5 seconds)
local steamID = ply:SteamID()
local currentTime = CurTime()
if adminCommandCooldowns[steamID] and currentTime - adminCommandCooldowns[steamID] < 5 then
ply:ChatPrint("please wait before sending another admin command")
return
end
adminCommandCooldowns[steamID] = currentTime
-- command validation
local command = net.ReadString()
if not command or not table.HasValue(ALLOWED_COMMANDS, command) then
LogSecurityEvent(ply, "invalid admin command attempted: " .. tostring(command))
return
end
-- secure execution
ExecuteAdminCommand(ply, command)
-- logging
LogSecurityEvent(ply, "admin command executed: " .. command)
end)
-- secure #2: proper entity validation
util.AddNetworkString("SecureEntityModify")
net.Receive("SecureEntityModify", function(len, ply)
if not IsValid(ply) then return end
if not RateLimitCheck(ply, "entity_modify") then return end
-- read data with validation
local ent = net.ReadEntity()
local property = net.ReadString()
local value = net.ReadFloat()
-- entity validation
local valid, error = ValidateEntity(ent, ply, "modify", VALIDATION_LEVELS.FULL)
if not valid then
LogSecurityEvent(ply, "entity modification failed: " .. error)
return
end
-- property whitelist
local allowedProperties = {"health", "armor", "color"}
if not table.HasValue(allowedProperties, property) then
LogSecurityEvent(ply, "attempted to modify disallowed property: " .. property)
return
end
-- value validation
if not math.IsFinite(value) or value < 0 or value > 10000 then
LogSecurityEvent(ply, "invalid value for entity property: " .. value)
return
end
-- safe property modification
if property == "health" then
ent:SetHealth(math.min(value, ent:GetMaxHealth()))
elseif property == "armor" then
if ent:IsPlayer() then
ent:SetArmor(math.min(value, 100))
end
end
LogSecurityEvent(ply, string.format("modified entity %s property %s to %s", ent:GetClass(), property, value))
end)
-- secure #3: sql with proper escaping
local function GetPlayerSecure(name, callback)
-- input validation
if not name or type(name) ~= "string" then
return callback(nil, "invalid name parameter")
end
-- length and character validation
if #name < 1 or #name > 32 then
return callback(nil, "name length invalid")
end
if not string.match(name, "^[%w%s_%-%.]+$") then
return callback(nil, "name contains invalid characters")
end
-- properly escape the input
local escapedName = sql.SQLStr(name)
local query = "SELECT steamid, name, last_seen FROM players WHERE name = " .. escapedName .. " LIMIT 1"
local result = sql.Query(query)
if result == false then
local error = sql.LastError()
print("sql error: " .. error)
return callback(nil, "database error")
elseif result == nil then
return callback(nil, "player not found")
else
return callback(result[1], nil)
end
endGLuaTest provides testing capabilities for gmod projects:
Features:
- Simple syntax: inspired by rspec and jest
- Real gmod environment: tests run in actual gmod without addon interference
- Automated ci/cd: github workflows integration
- Async support: testing timers, hooks, and callbacks
Basic test structure:
describe("security tests", function()
it("should reject unauthorized commands", function()
expect(TestUnauthorizedAccess()).to.be.false()
end)
end)GUnit offers perfect testing:
Features:
- Perfect assertions: detailed error messages with stack traces
- Test lifecycle hooks: beforeall, afterall, beforeeach, aftereach
- Test chaining: complex test scenarios with dependencies
-- security test suite
describe("network security", function()
local testPlayer
before_each(function()
testPlayer = CreateTestPlayer()
end)
after_each(function()
if IsValid(testPlayer) then
testPlayer:Remove()
end
end)
describe("message validation", function()
it("should reject oversized messages", function()
local largeData = string.rep("a", 100000)
net.Start("TestMessage")
net.WriteString(largeData)
net.SendToServer()
expect(WasMessageProcessed()).to.be.false()
expect(GetSecurityViolationCount()).to.be.greaterThan(0)
end)
it("should validate entity references", function()
local invalidEntity = NULL
net.Start("TestEntityMessage")
net.WriteEntity(invalidEntity)
net.SendToServer()
expect(WasMessageProcessed()).to.be.false()
end)
it("should enforce rate limits", function()
-- send messages rapidly
for i = 1, 50 do
net.Start("TestMessage")
net.WriteString("test")
net.SendToServer()
end
expect(GetRateLimitViolations(testPlayer)).to.be.greaterThan(0)
end)
end)
describe("permission checking", function()
it("should block non-admin commands", function()
testPlayer:SetUserGroup("user")
local result = PermissionManager.CheckPermission(testPlayer, "ban_player", nil)
expect(result).to.be.false()
end)
it("should allow admin commands for admins", function()
testPlayer:SetUserGroup("superadmin")
local result = PermissionManager.CheckPermission(testPlayer, "ban_player", nil)
expect(result).to.be.true()
end)
end)
end)-- vuln scanner
local SecurityScanner = {}
function SecurityScanner.ScanAddon(addonPath)
local results = {
vulns = {},
warnings = {},
info = {}
}
-- scan for dangerous functions
local dangerousPatterns = {
{pattern = "RunString", severity = "high", description = "dynamic code execution"},
{pattern = "CompileString", severity = "high", description = "code compilation"},
{pattern = "http%.Fetch", severity = "medium", description = "external http requests"},
{pattern = "file%.Write", severity = "medium", description = "file system writes"},
{pattern = "sql%.Query.*%+", severity = "high", description = "potential sql injection"}
}
for _, pattern in ipairs(dangerousPatterns) do
local matches = ScanDirectoryForPattern(addonPath, pattern.pattern)
for _, match in ipairs(matches) do
table.insert(results.vulns, {
file = match.file,
line = match.line,
pattern = pattern.pattern,
severity = pattern.severity,
description = pattern.description,
context = match.context
})
end
end
return results
endLuaLS provides dev support:
Features:
- Type checking: comment-based type annotations prevent errors
- IDE integration: vscode, intellij, and other editors
- Error detection: runtime error prevention through static analysis
Setup example:
---@type Player
local ply = player.GetByID(1)
---@param message string
---@param recipient Player
local function SendSecureMessage(message, recipient)
if not IsValid(recipient) then
return false
end
recipient:ChatPrint(message)
return true
endGMod lua runner enables headless testing:
Capabilities:
- Headless environment: run scripts without booting full gmod
- Testing integration: automated testing in ci/cd pipelines
- Performance testing: benchmark addon performance
local SecurityLogger = {}
-- log levels
local LOG_LEVELS = {
DEBUG = 1,
INFO = 2,
WARNING = 3,
ERROR = 4,
CRITICAL = 5
}
-- event categories
local EVENT_CATEGORIES = {
AUTHENTICATION = "auth",
AUTHORIZATION = "authz",
INPUT_VALIDATION = "input",
RATE_LIMITING = "rate",
FILE_ACCESS = "file",
DATABASE = "db",
NETWORK = "net"
}
function SecurityLogger.LogEvent(player, category, level, event, details)
local timestamp = os.date("%y-%m-%d %h:%m:%s")
local steamID = IsValid(player) and player:SteamID() or "system"
local playerName = IsValid(player) and player:Nick() or "n/a"
local playerIP = IsValid(player) and player:IPAddress() or "n/a"
local logEntry = {
timestamp = timestamp,
steamid = steamID,
player_name = playerName,
player_ip = playerIP,
category = category,
level = level,
event = event,
details = details or {},
server_info = {
map = game.GetMap(),
players = #player.GetAll(),
uptime = CurTime()
}
}
-- write to file
local logFile = string.format("logs/security_%s.log", os.date("%y%m%d"))
local logLine = util.TableToJSON(logEntry)
file.Append(logFile, logLine .. "\n")
-- console output with color coding
local color = GetLogColor(level)
MsgC(color, string.format("[%s][%s] %s (%s): %s\n",
timestamp, category, playerName, steamID, event))
-- real-time admin notifications for high severity
if level >= LOG_LEVELS.ERROR then
NotifyAdmins(logEntry)
end
endnetlibrarydebug provides network debugging:
-- custom net message monitor
local NetMonitor = {}
function NetMonitor.Initialize()
NetMonitor.messageStats = {}
NetMonitor.suspiciousActivity = {}
-- hook all net message sending
local originalNetSend = net.Send
net.Send = function(...)
NetMonitor.RecordMessage("outgoing", ...)
return originalNetSend(...)
end
local originalNetSendToServer = net.SendToServer
net.SendToServer = function(...)
NetMonitor.RecordMessage("incoming", ...)
return originalNetSendToServer(...)
end
end
function NetMonitor.RecordMessage(direction, recipient)
local messageName = net.GetMessageName()
local messageSize = net.BytesWritten()
local timestamp = CurTime()
if not NetMonitor.messageStats[messageName] then
NetMonitor.messageStats[messageName] = {
count = 0,
total_bytes = 0,
max_size = 0,
last_seen = 0,
directions = {incoming = 0, outgoing = 0}
}
end
local stats = NetMonitor.messageStats[messageName]
stats.count = stats.count + 1
stats.total_bytes = stats.total_bytes + messageSize
stats.max_size = math.max(stats.max_size, messageSize)
stats.last_seen = timestamp
stats.directions[direction] = stats.directions[direction] + 1
-- detect suspicious patterns
if messageSize > 32768 then -- 32kb threshold
NetMonitor.FlagSuspiciousActivity("large_message", {
message = messageName,
size = messageSize,
direction = direction,
recipient = recipient
})
end
end- define security requirements and threat model
- identify sensitive data and operations
- plan permission and access control systems
- design secure communication protocols
- document security assumptions and constraints
- configure secure dev environment
- install security analysis tools (luaLS, linters)
- set up version control with security scanning
- establish secure coding guidelines
- create security-focused code review process
- all net messages use
util.AddNetworkString()precaching - every net.receive handler validates player with
IsValid(ply) - message size validation implemented (
lenparameter checking) - rate limiting applied to all user-triggered net messages
- permission checking for privileged operations
- input validation for all received data
- logging of security events and violations
- input sanitization for all user data
- type checking for received data
- range validation for numeric inputs
- string length and content validation
- entity reference validation with
IsValid() - table structure validation for complex data
- malicious content detection and filtering
- all user input escaped with
sql.SQLStr() - parameterized query system implemented
- input validation before database operations
- table and column name whitelisting
- database error handling without information disclosure
- query logging for security monitoring
- path traversal prevention (
../filtering) - directory whitelist for file operations
- file extension validation and whitelisting
- file size limits enforced
- permission-based file access control
- dangerous file content scanning
- entity existence validation with
IsValid() - ownership and permission checking (cppi compliance)
- distance-based interaction limits
- entity type and class validation
- protection for critical game entities
- entity modification logging
- error handling without information disclosure
- no hardcoded secrets or credentials
- secure random number generation where needed
- memory-safe operations (no buffer overflows)
- resource cleanup and garbage collection
- thread-safe operations where applicable
- security-focused code review process
- automated security testing implementation
- manual penetration testing performed
- dependency security scanning
- static code analysis for vulns
- regular security assessments
- security event logging
- real-time threat detection system
- performance monitoring for dos detection
- anomaly detection for unusual behavior
- admin alert system
- external siem integration (if applicable)
- incident response procedures documented
- emergency contact info maintained
- backup and recovery procedures tested
- evidence preservation capabilities
- communication plan for security incidents
- regular incident response drills
- production environment hardening
- security configuration review
- access control implementation
- network security measures
- monitoring system deployment
- backup system configuration
✅ ongoing maintenance (needed, dont do like those kids who just sell something on gmodstore and leave it without update for years)
- regular security updates and patches
- periodic security assessments
- log review and analysis procedures
- user access review and cleanup
- security awareness training for admins
- vuln disclosure procedures (unlike drvrej, CrapHead etc)
gmod addon security comes down to one rule: never trust client input. The networking system's 64kb limit and bidirectional communication create attack surfaces, but proper input validation, rate limiting, and auth checks protect against exploits.
defense in depth: implement multiple security layers instead of relying on single points of protection. combine network filtering, app-level validation, and system monitoring.
least privilege: grant users only minimum permissions needed. applies to file access, database ops, and admin capabilities.
input validation: treat all client input as potentially malicious and validate everything at the server boundary.
fail securely: when security checks fail, fail to a secure state rather than allowing dangerous operations.
effective security combines technical knowledge of gmod's networking with systematic application of security principles:
- input validation at all network entry points
- rate limiting and anti-spam to prevent dos attacks
- proper sql escaping to prevent injection
- file system access controls with path validation
- entity validation and permission systems compatible with cppi
- security event logging for threat detection
the gmod community provides tools and testing frameworks, but security depends on devs following best practices and staying vigilant:
- responsible vuln disclosure to addon authors and facepunch
- security tool dev for community benefit
- knowledge sharing through docs, tutorials, and best practices
- threat intelligence to identify and respond to idiots
security isn't a one-time thing, it requires regular updates, monitoring, and adaptation:
- regular security assessments and pentests
- automated security scanning in dev workflow if possibel
- incident response procedures for handling breaches (you are legally required to anyway)
- security awareness training for devs and admins (since most of them are braindead tbh, lua is so simple anyone can code in it)
- threat monitoring to stay ahead of attackers (try to exploit your own addon)
this course gives you the foundation for developing secure gmod addons. to make it better:
- start with security in mind - design security into addons from the beginning
- use the provided examples - adapt secure patterns to your use cases
- test thoroughly - implement both automated and manual security testing
- stay informed - monitor security communities for emerging threats
- contribute back - share security improvements with the community
by following these guidelines and staying engaged with the security community, you can create addons that contribute positively to the gmod ecosystem while protecting users and servers from security risks.
- nova defender - anticheat and security suite
- safety plugin - client-side security protection
- mysqloo - secure MySQL database lib with prepared statements
- gm_express - high-performance networking lib for large data transfers
- gmsv_serversecure - server-side source engine protection
- snte collection - say no to exploits security tools
- gluatest - modern testing framework for gmod
- gunit - unit testing framework
- lua language server - ide support and type checking
- github - facepunch issues - official bug tracking and security reports
- stack overflow - gmod security - community q&a
license: this course is released under the mit license. feel free to distribute, modify, and use these materials to improve gmod addon security.
contributing: found a security issue or want to improve this course? submit issues and pull requests to help make gmod addon development more secure for everyone.
disclaimer: this educational material is provided for defensive security purposes only. always follow responsible disclosure practices and respect the gmod community guidelines.
credits : overlordakise, yohsambre, samuel(freilichtbuehne), justplayer, zeeniik, fredyh, vurv78, danielga
If you got any questions, feel free to dm " oracledsc " on discord, and please leave a Star to support me ^^.