FiveM

FiveM SQL Injection: How Unsafe Lua Queries Leak Your Database and How oxmysql Parameters Stop It

FiveM SQL Injection: How Unsafe Lua Queries Leak Your Database and How oxmysql Parameters Stop It

It only takes one badly written query in one resource. FiveM SQL injection happens when a script glues player-controlled text — a character name, a phone message, a plate search — straight into a SQL string, so the database can no longer tell where the query ends and the player’s input begins. The fix has existed for years and costs nothing: oxmysql’s parameterized queries. This guide shows the vulnerable pattern, the safe one, and how to audit a server full of resources you didn’t write.

Why your database is worth attacking

A roleplay server’s MySQL database holds far more than money and garages. The users table stores every player’s Rockstar license, Steam hex and Discord ID; many frameworks log last-known IPs; phone resources keep private message history; whitelist and application scripts sometimes store real email addresses. A full dump is identity data plus your entire economy in one file.

This isn’t a theoretical risk. Years back, the popular multi-character resource kashacters shipped two server events that passed client-supplied data into unparameterized queries. The Cfx.re forums filled with reports of servers losing their users tables as the exploit circulated, and the eventual patch did exactly what this article recommends: validate the input’s type and bind it as a parameter. The resource names change; the vulnerability class doesn’t.

How FiveM SQL injection happens in resource code

Lua makes string building effortless, and that’s the trap. When a query is assembled with the .. concatenation operator or string.format, whatever the player typed becomes part of the SQL text itself. An input containing a single quote closes the string value early, and the database reads everything after it as instructions instead of data. No cheat menu, no modified client required — a text field is enough.

The insidious part is that these queries pass every normal test. Type ordinary names and ordinary messages and nothing breaks, so the bug ships and sits there. It only announces itself the day someone types the wrong character on purpose.

The vulnerable pattern vs the safe one

Here’s the shape of the bug, in a plate-lookup handler like the ones found in dozens of MDT scripts:

-- VULNERABLE: player input becomes part of the SQL text
RegisterNetEvent('mdt:searchPlate', function(plate)
    local result = MySQL.query.await(
        "SELECT owner FROM owned_vehicles WHERE plate = '" .. plate .. "'"
    )
    -- whatever 'plate' contains is now executable SQL
end)

The same handler, written with an oxmysql placeholder and a sanity check:

-- SAFE: the value is bound as data, never parsed as SQL
RegisterNetEvent('mdt:searchPlate', function(plate)
    if type(plate) ~= 'string' or #plate > 8 then return end
    local result = MySQL.query.await(
        'SELECT owner FROM owned_vehicles WHERE plate = ?', { plate }
    )
end)

The ? is a positional placeholder: oxmysql sends the value separately from the query text, so a quote inside it is just a quote, not syntax. It also accepts mysql-async’s named style — WHERE identifier = @identifier with { ['@identifier'] = identifier } — so legacy resources can be fixed without rewriting them. The same rule applies across the whole API: MySQL.single.await, MySQL.scalar.await, MySQL.insert.await and MySQL.update.await all take a parameter table as the second argument. For hot paths that run every few seconds, MySQL.prepare gives you a true prepared statement with the same injection protection and better throughput.

Note what string.format doesn’t buy you: string.format("... WHERE name = '%s'", name) is concatenation wearing a suit. %s performs no escaping whatsoever.

Every place player input reaches your queries

The rule on FiveM is blunt: every argument of every server event is attacker-typed. A modified client can trigger any RegisterNetEvent handler with any arguments — the attacker never has to use your UI, so validating in NUI or client code protects nothing. The common entry points:

  • Events carrying client data — character creation names, job actions, trade amounts.
  • NUI callbacks forwarded to the server — phone messages, contact names, note apps.
  • Search fields — MDT plate and citizen lookups, phone number searches, business ledgers.
  • Chat and commands — anything a command handler writes to the database.
  • Free-text labels — housing names, crew tags, vehicle nicknames.

If a string a player can type ends up inside a query, that query needs a placeholder. There are no exceptions for “trusted” UIs.

Auditing the resources you already run

You don’t need a security scanner; you need grep. From your resources folder, look for query keywords sharing a line with concatenation or formatting:

grep -rniE "(select|insert|update|delete).*(..|string.format)" 
  --include="*.lua" resources/

Expect false positives — concatenating a table name from your own config is ugly but not player-controlled. What you’re hunting is player input on the right side of that ... Also search for '%s' inside quoted SQL, and remember queries built across multiple lines evade a single-line grep, so skim anything that assigns a query string to a variable first.

Escrowed scripts can’t be read, but they can be tested. On a dev server, put a single quote into every free-text field the script exposes — character name, message body, search box — and watch the server console. A SQL syntax error appearing the moment you submit means your input reached the query as raw text, and that resource needs a patch from its developer before it touches production. Check its manifest too: a script still bundling mysql-async in 2026 tells you how much maintenance it’s getting.

Give the server a least-privilege MySQL user

Parameters stop injection at the query; privileges cap the damage if one slips through anyway. Most tutorials have you connect as root, which means any single vulnerable resource owns every database on the box. Create a dedicated user that can only do what a game server actually does:

CREATE USER 'fivem'@'localhost' IDENTIFIED BY 'long-random-password';
GRANT SELECT, INSERT, UPDATE, DELETE ON fivem_db.* TO 'fivem'@'localhost';

No GRANT ALL, no DROP, and above all no FILE — the FILE privilege lets a successful injection read and write files on the database host, which is how a database bug becomes a full server compromise. Give your web panel, your website and your bots their own separate users with their own minimal grants, and keep MySQL bound to localhost or a private network, never the public interface.

Defense in depth: validate early, notice the weird

Placeholders make injection fail; validation makes the attempt visible. Check types and lengths at the top of every event handler — tonumber() for IDs, hard length caps for text — and reject bad input rather than trying to clean it. Then watch your console: a burst of SQL syntax errors from one resource is rarely an accident. Someone is probing, and a Discord webhook on your error log turns that probe into an alert with a player identifier attached instead of a mystery in tomorrow’s logs.

If you get hit: the first hour

A compromise usually looks like one of three things: syntax errors in the console you can’t explain, rows changed or deleted with no matching gameplay activity, or sudden money and item counts your transaction logs never recorded. When you see it, take the server down and work in this order: snapshot the current database for comparison before you restore anything, rotate the MySQL password and every credential stored near it, diff the snapshot against your last clean backup to learn what was read or changed, and identify and remove the vulnerable resource — restoring data without fixing the hole just schedules the next incident.

Don’t let a quiet history talk you out of any of this. Exploit lists for popular free and paid resources circulate in cheat communities, server lists are public, and probing is automated — small servers get scanned by the same scripts big ones do. “Nobody has found it yet” only ever means nobody has told you.

Parameterize every query, grep what you run, and give the server a database account that can’t do more than the game needs. If you’re replacing an aging resource that failed the audit, actively maintained scripts are on scripts-tebex.io and cfxmods-tebex.io, and performance-focused, current builds are on 0resmon-tebex.io — updated code is patched code, and that’s half the battle here.