How to protect your MU server website against SQL injection
Understand how SQL injection attacks your MU Online server's website and apply real defenses with prepared statements, validation, least privilege, and monitoring to shield accounts and items.
The website is the front door of your MU Online server — and, for that very reason, the favorite target of anyone looking for a shortcut. Among all web flaws, SQL injection is the most dangerous for a MU server because it strikes directly at the database that holds accounts, passwords, credit balanc
The website is the front door of your MU Online server — and, for that very reason, the favorite target of anyone looking for a shortcut. Among all web flaws, SQL injection is the most dangerous for a MU server because it strikes directly at the database that holds accounts, passwords, credit balances, and items. A single badly written query on a login form can let an attacker in without a password, read the entire database, hand themselves items and WCoin, promote their own account to GM, or simply wipe everything. It's no exaggeration: most of the private server "hacks" that became forum legend began in a text field concatenated straight into SQL.
This is an advanced, practice-oriented guide to armoring the website against this attack. We'll see how injection actually works, apply the defenses in the right order of importance — prepared statements, validation, least privilege, error handling, and monitoring — and review the points of the MU site that get attacked the most. If you're still structuring the project's foundation, it's worth starting with how to create a MU Online server and coming back to harden the web layer.
Prerequisites
To follow along and apply the fixes you need:
- Access to the website's source code (PHP in most cases, but the concept applies to any language).
- Administrative access to the database (SQL Server or MySQL/MariaDB) to adjust users and privileges.
- A test environment separate from production to validate the changes without risk.
- Basic knowledge of SQL and of how the site connects to the database (the connection string).
- A complete and recent database backup before any change.
> Security warning: never test injection payloads against the production database. Always work on a test copy. And never expose your real connection string in public code or screenshots.
How SQL injection works (the problem at its root)
The attack is born of a simple conceptual error: mixing command (SQL) with data (what the user typed). Here's a vulnerable login, the kind that still circulates on many MU sites:
<?php
// VULNERABLE CODE — never use this
$user = $_POST['user'];
$pass = $_POST['pass'];
$sql = "SELECT * FROM MEMB_INFO WHERE memb___id = '$user' AND memb__pwd = '$pass'";
$result = sqlsrv_query($conn, $sql);
What the programmer expects is for the user to type something like warrior123. But the field accepts any text. If the attacker types into the username field:
' OR '1'='1' --
The assembled query becomes:
SELECT * FROM MEMB_INFO WHERE memb___id = '' OR '1'='1' --' AND memb__pwd = '...'
The OR '1'='1' is always true, and the -- comments out the rest of the query (including the password check). Result: the login returns the first user in the table, and the attacker gets in without knowing any password. More severe variations use UNION SELECT to read other tables, or ; UPDATE / ; DROP to alter and destroy data.
The central point: the database has no way to know that ' OR '1'='1' was supposed to be a username and not part of the command, because everything arrived mixed into a single string. The root defense is to separate the two.
Defense 1 (the most important) — Prepared statements
Prepared statements (parameterized queries) solve the problem at its source: you send the command with placeholders (? or :name) and, separately, the data. The database assembles the query already knowing what is command and what is value — the user's text is never interpreted as SQL.
Here's the same login, now secure, with PDO:
<?php
// SECURE CODE — prepared statement with PDO
$stmt = $pdo->prepare(
"SELECT memb_guid, memb___id FROM MEMB_INFO
WHERE memb___id = :user AND memb__pwd = :pass"
);
$stmt->execute([
':user' => $_POST['user'],
':pass' => hash_password($_POST['pass']), // password never in plain text
]);
$account = $stmt->fetch(PDO::FETCH_ASSOC);
if ($account) {
// valid login
} else {
// invalid credentials
}
Now, if the attacker types ' OR '1'='1' --, that text becomes literally the value searched for in the memb___id field. Since no user exists with that bizarre name, the login fails — which is exactly the correct behavior. The same applies to SQL Server via sqlsrv with parameters:
<?php
$sql = "SELECT memb_guid FROM MEMB_INFO WHERE memb___id = ?";
$params = [ $_POST['user'] ];
$stmt = sqlsrv_query($conn, $sql, $params); // parameters separate from the command
Golden rule: every query that uses any value coming from the user — $_POST, $_GET, $_COOKIE, headers, API data — must be parameterized. There is no "this field is only a number, it's safe" exception. Numbers are injectable too.
The special case: parts that don't accept bind
Prepared statements parameterize values, but not table names, column names, or the direction of an ORDER BY. This is common in MU rankings that sort by a user-chosen column:
<?php
// Dangerous: column and direction coming from the user
$col = $_GET['sort']; // e.g.: "resets" — but could be injection
$sql = "SELECT Name, Resets FROM Character ORDER BY $col DESC"; // VULNERABLE
The defense here is an allowlist (a closed list of permitted values), never the raw value:
<?php
$allowed = ['Name' => 'Name', 'resets' => 'Resets', 'level' => 'cLevel'];
$col = $allowed[$_GET['sort'] ?? 'resets'] ?? 'Resets'; // only known values
$dir = ($_GET['dir'] ?? 'desc') === 'asc' ? 'ASC' : 'DESC';
$sql = "SELECT Name, Resets FROM Character ORDER BY $col $dir";
This way, the user can only choose among columns you explicitly authorized; anything else falls back to the default value.
Defense 2 — Input validation and sanitization
Prepared statements prevent injection, but validating the input is an extra layer that cuts attacks off before they even reach the database and improves data quality. The idea is: accept only what makes sense for each field.
<?php
// MU username: usually 4 to 10 characters, letters and numbers
function validateLogin(string $u): bool {
return (bool) preg_match('/^[A-Za-z0-9]{4,10}$/', $u);
}
if (!validateLogin($_POST['user'])) {
// reject before touching the database
exit('Invalid user');
}
Validate the type and the format of everything: IDs should be integers ((int)$_GET['id'] or filter_var(..., FILTER_VALIDATE_INT)), emails with FILTER_VALIDATE_EMAIL, character names against your Season's pattern. Validation is about accepting the expected (allowlist), not about trying to block the forbidden (blocklist) — filtering "dangerous words" is fragile and always has a way around.
| Typical MU site field | Recommended rule |
|---|---|
| Login / username | Allowlist regex, fixed length (e.g.: 4-10 alphanumeric) |
| Password | Minimum length, hash before comparing, never in plain text |
| Numeric ID (character, order) | Strict integer with FILTER_VALIDATE_INT |
| Email (recovery) | FILTER_VALIDATE_EMAIL |
| Sort column (ranking) | Closed allowlist of columns |
| Character name | Season pattern (length and characters) |
Defense 3 — Least privilege on the database user
Even if a flaw slips through, you want to limit the damage. The site should never connect to the database with an sa (SQL Server) or root (MySQL) user. Create a dedicated user with only the privileges needed.
-- MySQL: user with only what the site needs
CREATE USER 'web_mu'@'localhost' IDENTIFIED BY 'strong-password-here';
GRANT SELECT, INSERT, UPDATE ON muonline.* TO 'web_mu'@'localhost';
-- Note: no DROP, no broad DELETE, no ALTER, no GRANT
FLUSH PRIVILEGES;
With this user, an injected DROP TABLE simply fails for lack of permission. If a given form only reads data (a ranking, for example), ideally it should use a connection with only SELECT. Separate read connections from write connections when possible. This turns a catastrophe into a contained incident.
Defense 4 — Error handling without leaking information
Vulnerable sites tend to hand over the map of the database for free: when a query fails, they display the full SQL error message on screen, revealing table names, columns, and the structure. That's gold for the attacker (the technique is called error-based injection).
Never show a database error to the user in production:
<?php
// In production: log the error internally, show a generic message
try {
$stmt->execute($params);
} catch (PDOException $e) {
error_log('DB error: ' . $e->getMessage()); // goes to the log, not the screen
http_response_code(500);
exit('An error occurred. Please try again later.'); // generic
}
In PHP, ensure display_errors = Off in php.ini in production, while keeping log_errors = On. That way you keep seeing errors in the logs for debugging, but the visitor never sees the internal structure.
Defense 5 — Extra layers: WAF and monitoring
After fixing the code, add layers of vigilance. A WAF (Web Application Firewall) — like ModSecurity with the OWASP CRS rule set — blocks known attack patterns before they reach the application. It does not replace the code fix, but it provides a safety net and reaction time.
Also set up simple monitoring to detect attempts:
<?php
// Lightweight detection of suspicious payloads for alerting (NOT as the main defense)
function looksLikeInjection(string $v): bool {
return (bool) preg_match(
'/(\bUNION\b|\bSELECT\b.+\bFROM\b|--|\bOR\b\s+\d+=\d+|;\s*DROP)/i', $v
);
}
foreach ($_REQUEST as $k => $v) {
if (is_string($v) && looksLikeInjection($v)) {
error_log("Suspicious attempt in '$k' from IP {$_SERVER['REMOTE_ADDR']}: $v");
// fire an alert (email/Discord) and consider blocking the IP
}
}
Enable database query logs at sensitive points and configure alerts for error spikes. Detecting early is the difference between blocking one IP and discovering, weeks later, that half the server received illegal items.
MU site points that get attacked the most
Prioritize reviewing these flows, from most to least critical:
- Login — the number one target; a bypass grants account access.
- Registration — injection can create privileged accounts or corrupt data.
- Password recovery — often builds queries by email/secret question.
- Ranking and listings — the dynamic
ORDER BYis a forgotten classic. - Shop / donation — deals with balances and items; injection here becomes direct fraud.
- Player panel — password change, email, character transfers.
Review each one ensuring prepared statements, validation, and the database user with least privilege.
Common errors and fixes
| Error / bad practice | Risk | Fix |
|---|---|---|
Concatenating $_POST straight into the query | Login bypass, database theft | Migrate to a prepared statement |
| Trusting that "a numeric field is safe" | Injection via numeric parameter | Validate the integer and still parameterize |
ORDER BY $column from the user | Injection in the ORDER BY clause | Closed allowlist of columns |
Site connects as sa/root | An injected DROP wipes everything | Dedicated user with least privilege |
| Displaying the SQL error on screen | Leaks structure (error-based) | display_errors Off, generic message |
| Filtering only "dangerous words" | Bypassable, false sense of security | Allowlist by format, not blocklist |
| Password compared in plain text | Direct credential theft | Strong hash and hash-based comparison |
| No logs or alerts | Attack discovered too late | Monitor payloads and error spikes |
Launch checklist
- Complete database backup taken before any change
- All queries with user data migrated to prepared statements
- ORDER BY clauses / dynamic column names with an allowlist
- Type and format validation on all input fields
- Dedicated database user with least privilege (no DROP/ALTER/GRANT)
- Read connections separated from write connections when possible
- display_errors off in production, log_errors on
- Generic error messages for the user, details only in the log
- Passwords always hashed, never compared in plain text
- WAF (e.g.: ModSecurity + OWASP CRS) evaluated as an extra layer
- Monitoring of suspicious payloads and alerts configured
- Critical flows reviewed: login, registration, recovery, ranking, shop
- Everything tested in a separate environment before going to production
SQL injection is a serious threat, but entirely avoidable. The foundation is always the same: separate command from data with prepared statements, validate what comes in, give the site the minimum of power over the database, and watch what happens. By applying these layers, you take away the attacker's easiest shortcut and truly protect your players' accounts and items.
Frequently asked questions
Is SQL injection really common on MU Online sites?
It is one of the most exploited flaws on private servers. Many sites use old scripts that concatenate login and password straight into the query. An attacker can bypass the login, steal accounts, hand out items, or even wipe the database through a vulnerable form.
Do prepared statements alone solve everything?
They solve most of it, because they separate command from data. But some cases need extra care: dynamic table/column names and ORDER BY clauses don't accept bind and require an allowlist. Combine prepared statements with input validation and least privilege.
Do I have to replace my entire old site to be protected?
Not always, but every point that touches the database must be reviewed. Login, registration, ranking, password recovery, and shop forms are the main targets. Migrate each query to a prepared statement and validate the inputs.
Does a WAF replace fixing the code?
No. A WAF (application firewall) helps block known attacks and buys time, but it's an extra layer, not the solution. The real fix is in the code, with prepared statements and least privilege on the database user.
How do I know if my site has already been attacked by SQL injection?
Look for suspicious patterns in the logs (quotes, UNION SELECT, OR 1=1, -- comments in parameters), accounts created or changed with no origin, items that appeared out of nowhere, and spikes of database errors. Enable query logging and alerts to react early.