Skip to content

oracle-dsc/ghost-anti-exploit-course

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

22 Commits
 
 
 
 

Repository files navigation

👻 Ghost Anti-Exploit Course

Security course for gmod devs (takes alot of time to read but trust me its worth it)

License Gmod Security

Learn how to make secure gmod addons and stop getting your server owned by script kiddies.


Table of Contents


Overview

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.

Why this matters

  • 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 Networking Architecture and Security Fundamentals

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.

How networking works

The networking workflow is pretty straightforward:

  1. Server precaches messages with util.AddNetworkString()
  2. Send data with net.Start() → write functions → net.Send()
  3. Receive with net.Receive() handlers

Golden rule: never trust client input. The client is in enemy territory - assume everything sent from it is malicious.

Data types and limits

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)

The big problem

⚠️ issue: The 64kb limit + reliable channel creates easy ddos opportunities. Check gmod issue #1797 - clients can spam messages and cause "overflowed reliable channel" crashes that kill the entire server.

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)

File Stealing

The hard truth

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)

Why people steal files

  • 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

What you can do (damage control)

Keep important stuff server-side

-- 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

⚠️ Please avoid putting anything confidential in Shared or Client files:

"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

Server vs client decisions

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)

Performance balance

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)

What to accept

  • 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

Tips

  1. Design assuming theft - plan like everyone will see your code
  2. Server-side everything important - client is just for display
  3. Don't put all logic server-side - balance with performance
  4. Make ongoing value - regular updates, support, new features etc
  5. Watermark your work - at least get credit when it's stolen

Bottom line

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).


Common Exploit Categories Targeting Gmod Addons

Gmod exploits fall into a few main categories. Understanding these helps you code nicely and not like drvrej (oops).

1. Network message exploits (most common)

Message flooding

  • Volume flooding: spam empty net messages in rapid loops
  • Impact: 10k-100k messages can crash servers
  • Result: complete server dos through stream overflow

Size flooding

  • Method: send messages near the 64kb limit, or above
  • Impact: eats bandwidth and memory
  • Detection: monitor message sizes in handlers

Data injection

  • 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

2. Privilege escalation

Backdoor exploits

  • 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

Permission bypasses

  • Missing checks: no IsSuperAdmin() validation before privileged ops
  • Group failures: bad group-based access control
  • Context confusion: mixing client-server privilege contexts

3. Data validation fails

Input validation issues

  • 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

SQL injection


Networking Vulnerabilities and WriteData Exploit Patterns

WriteData attack vectors

The net.WriteData() function lets you send raw binary data, creating alot of attack opportunities:

Data injection

-- 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()

Size manipulation

  • Technique: wrong length params causing buffer issues
  • Impact: memory corruption or crashes
  • Prevention: always validate data length

Compression exploits

  • Method: malformed compressed data crashes clients/servers
  • Common pattern: compression bombs (tiny compressed → huge uncompressed)

Vulnerable patterns

-- 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.

Data type vulns

Entity reference exploits

-- dangerous: client-side entity exposure
net.WriteEntity(LocalPlayer()) -- manipulatable client entities

Negative number attacks

  • Technique: bypass poorly coded validation
  • Example: negative money values in economy systems

Table injection

  • Method: malformed tables crash lua parser
  • Prevention: validate table structure before processing

String overflow

  • Target: contexts with char limits
  • Method: send oversized strings to cause buffer issues

dos implementation

-- 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)

Secure Net Message Handling and Data Validation

Basic validation framework

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 system

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
end

Message size validation

local 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
end

Input Sanitization Techniques for Glua

String sanitization

String 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"}
    })
end

Data validation framework

local 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

Complete validation example

-- 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 Validation and Permission Checking Systems

Entity validation framework

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

Entity interaction example

-- 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()
end

Rate Limiting and Anti-Spam Implementation

Anti-spam system design

anti-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
end

Auto punishment

local 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 tools

-- 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 in glua

sql practices

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)

mysqloo practices

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()
end

Multiple 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)

Query builder

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

Secure database manager

-- 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"
end

File System Security and Access Controls

Path validation

File 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

Permission-based access

-- 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

-- 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
end

Security Guidelines and Community Standards

Security tools

The gmod community maintains alot of cool tools:

Protection suites

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

Client protection

Safety Plugin

  • Crash prevention: protection against malicious client-side code
  • Exploit mitigation: blocking common attack vectors
  • Performance optimization: reducing client-side vulns

Server hardening

gmsv_serversecure

  • Source engine protection: binary module for low-level security
  • Network hardening: protection against protocol-level attacks
  • Memory protection: preventing buffer overflow exploits

Detection tools

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

Code Examples: Vulnerable vs Secure Implementations

Vulnerable patterns (don't do this)

-- 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 patterns (do this instead)

-- 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
end

Testing Methodologies for Addon Security

Testing frameworks

GLuaTest

GLuaTest 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

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

⚠️ warning: never install testing frameworks on production servers.

Security testing examples

-- 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)

Automated security scanning

-- 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
end

Tools and Resources for Gmod Devs

Development tools

Lua language server

LuaLS 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
end

GMod lua runner

GMod 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

Security monitoring

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
end

Network debugging

netlibrarydebug 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

Security Checklist for Devs

pre-development planning

architecture security review

  • 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

dev environment setup

  • 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

network security

net message security

  • all net messages use util.AddNetworkString() precaching
  • every net.receive handler validates player with IsValid(ply)
  • message size validation implemented (len parameter 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

data validation framework

  • 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

database security

sql injection prevention

  • 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

file system security

path validation and access control

  • 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 security

entity validation

  • 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

code quality

secure coding practices

  • 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

code review and testing

  • security-focused code review process
  • automated security testing implementation
  • manual penetration testing performed
  • dependency security scanning
  • static code analysis for vulns
  • regular security assessments

monitoring and response

security monitoring

  • 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 prep

  • 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

deployment and maintenance

secure deployment

  • 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)

conclusion

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.

key principles

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.

success factors

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

community involvement

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

continuous improvement

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)

final thoughts

this course gives you the foundation for developing secure gmod addons. to make it better:

  1. start with security in mind - design security into addons from the beginning
  2. use the provided examples - adapt secure patterns to your use cases
  3. test thoroughly - implement both automated and manual security testing
  4. stay informed - monitor security communities for emerging threats
  5. 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.


additional resources

official docs

security tools

development and testing

community resources


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 ^^.

About

An entire course to protect your gmod addons from exploiters

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published