All work

Live

Hardening a live economy

The security side of over two years as the only developer of a live roleplay economy: finding the ways players duplicated, teleported or forged things, and closing them without taking the server down.

Role
Finding, fixing and preventing exploits, working solo
Stack
Lua (GLua), NutScript, MySQL
Context
Diverge Networks, a live roleplay server with a player-driven economy
Status
Live, with the trunk fix in testing

The problem

On a roleplay server, items are the economy. A bug that loses an item costs a player hours of play. A bug that duplicates one devalues everyone else’s. And the server can’t go down while you fix it.

As the only developer, I was also the security team. This is the side of the work players never see: finding the ways people duplicated, teleported or forged things, and closing them on a live server.

How I approach it

Shipment duplication

Live

Taking an item from a crate started an asynchronous inventory add without reserving the item first. Fast repeated requests could all pass the “is there one left?” check before any of them finished, and each walked away with an item.

I rewrote the take and drop back end. The count is now decremented and the crate locked before the asynchronous work starts, distance and amounts are checked on the server, and the item is refunded if it doesn’t fit. While I was there I fixed an admin inventory reset that could leave slots occupied by items that no longer existed.

Try it: spam Take with the old code and you leave with more than the crate held. Switch to the fix and the count holds; tick "Inventory is full" to see the refund. A simulation of the idea, not the server code.
netstream.Hook("shpUse", function(client, uniqueID, drop)
    local entity = client.nutShipment
    -- ... per-player and per-crate rate limits omitted

    if (not IsValid(entity) or entity.transactionLockout) then return end   -- one take at a time per crate
    local itemTable = nut.item.list[uniqueID]
    if (not itemTable) then return end

    -- Distance and amount are checked on the server.
    if (entity:GetPos():Distance(client:GetPos()) > 128) then return end
    local amount = entity.items[uniqueID]
    if (not isnumber(amount) or amount <= 0) then return end

    -- Reserve before waiting: take the item off the count and lock the crate
    -- *before* the asynchronous add, so repeated requests can't all pass the check.
    entity.items[uniqueID] = amount - 1
    entity.transactionLockout = true

    local function onError(err)
        if (IsValid(client)) then client:notifyLocalized(tostring(err)) end
        if (not IsValid(entity)) then return end

        -- Didn't fit in the inventory: give the item back to the crate.
        if (err == "noFit") then
            entity.items[uniqueID] = entity.items[uniqueID] + 1
        end
        entity.transactionLockout = nil
    end

    local function onSuccess(res)
        hook.Run("OnShipmentItemSpawned", client, res, entity)
        if (entity:getItemCount() < 1) then
            entity:Remove()   -- empty crates break; the lock stays until they're gone
        else
            entity.transactionLockout = nil
        end
    end

    local add = drop
        and nut.item.spawn(uniqueID, entity:GetPos() + Vector(0, 0, 16))
        or client:getChar():getInv():add(uniqueID, itemTable.maxQuantity, {})

    add:next(function(res)
        if (res.error) then return onError(res.error) end
        onSuccess(res)
    end, onError)
end)
Adapted from production code, lightly edited for readability. Never enough to rebuild the system.

Cheating a live PvP event

Live

Networked variables used by the Target PvP event revealed enough to the client for players to cheat it. I built the fix so it could go onto the live server straight away.

Forged evidence and records

Live

Trunk teleporting

Built, in testing

Donators can spawn more than one vehicle. With two trunks open at once, players could move items between them from anywhere on the map. Only one spawned vehicle’s trunk can be open at a time now. This ships with the shared trunk migration.

And many smaller ones

Keeping production safe

The fixes only matter if testing them can’t break the live server. I pushed for a production-like test server that can never touch production data, and guarded the development-only admin tools so they can’t run on production.