Brazil's biggest MU Online portal — since 2003
Tutorial Advanced Web

How to prevent SQL Injection in legacy forms on your MU Online server's website

Identify and fix SQL Injection vulnerabilities in legacy PHP panels for MU Online servers — from login and ranking forms to donation pages — using prepared statements, input validation, and hands-on testing.

RO Rodrigo · Updated on Aug 29, 2012 · ⏱ 16 min read
Quick answer

Many MU Online server websites run on PHP panels written years ago, adapted from community templates, with login, ranking, donation, and password recovery forms that have never been audited. This kind of legacy code is the most common target of SQL Injection: an attacker inserts a malicious SQL snip

Many MU Online server websites run on PHP panels written years ago, adapted from community templates, with login, ranking, donation, and password recovery forms that have never been audited. This kind of legacy code is the most common target of SQL Injection: an attacker inserts a malicious SQL snippet into a form field and manipulates the query to extract passwords, create admin accounts, or even drop entire tables. This tutorial shows how to identify the most common vulnerable points in these panels, fix them with prepared statements, and validate that the fix actually worked.

How SQL Injection happens in practice

The vulnerability arises when user input is concatenated directly into a SQL string. A classic example found in legacy MU panels:

// VULNERABLE - don't do this
$user = $_POST['username'];
$pass = $_POST['password'];
$query = "SELECT * FROM MEMB_INFO WHERE memb___id = '$user' AND memb__pwd = '$pass'";
$result = mysql_query($query);

If an attacker submits the username field with the value ' OR '1'='1' -- , the final query becomes SELECT * FROM MEMB_INFO WHERE memb___id = '' OR '1'='1' -- ' AND memb__pwd = '', which returns the first row in the table — usually an admin account — without needing the correct password. This is the most common login bypass attack against old MU panels.

Where to look: the highest-risk forms

Not every form carries the same risk. Prioritize your audit in this order:

FormRiskWhy
Panel/account loginCriticalAuthentication bypass, access to another player's or admin's account
Password recoveryCriticalOften uses email/security question in a concatenated query
Ranking / character searchHighFree-text search parameter, commonly GET with no sanitization
Donation / payment integrationHighIntersects with financial data and webhooks
Comments / embedded forumMediumLess access to sensitive data, but still exploitable
Visit counter / statisticsLowUsually no sensitive data, but can serve as an entry point

Step 1 — Map all entry points

Grep the panel's source code for legacy database calls and concatenation of request variables:

grep -rn "mysql_query\|mysqli_query" ./painel/ | grep '\$_'
grep -rn "\$_GET\|\$_POST\|\$_COOKIE" ./painel/ --include="*.php" | grep -i "select\|insert\|update\|delete"

Every line returned is a candidate vulnerable point. In legacy MU panels it's common to find dozens of occurrences, especially in files like login.php, ranking.php, busca_char.php, and recuperar_senha.php.

Step 2 — Migrate from mysql_* to mysqli or PDO

The mysql_* extension doesn't support native prepared statements and has been removed from PHP since version 7. If the panel still uses this extension, migrating to mysqli with parameter binding is the mandatory first step:

// SAFE - with mysqli and prepared statement
$user = $_POST['username'];
$pass = $_POST['password'];

$stmt = $mysqli->prepare("SELECT * FROM MEMB_INFO WHERE memb___id = ? AND memb__pwd = ?");
$stmt->bind_param("ss", $user, $pass);
$stmt->execute();
$result = $stmt->get_result();

With the parameter bound via bind_param, the value submitted by the attacker is never interpreted as part of the SQL command — it's always treated as literal data, regardless of its content.

Step 3 — Fix the search/ranking form

Character-name search forms often use LIKE with direct concatenation, a point frequently overlooked during fixes:

// VULNERABLE
$nome = $_GET['nome'];
$query = "SELECT * FROM Character WHERE Name LIKE '%$nome%'";

// SAFE
$nome = $_GET['nome'];
$stmt = $pdo->prepare("SELECT * FROM Character WHERE Name LIKE :nome");
$stmt->execute(['nome' => '%' . $nome . '%']);

Note that the % wildcard is added to the parameter value, not to the query string — this preserves the prepared statement's protection.

Step 4 — Handle dynamic table and column names with a whitelist

Prepared statements protect values, but not table or column names used dynamically (for example, in a ranking that sorts by a user-chosen column). In this case, use an explicit whitelist instead of binding:

$colunasPermitidas = ['Level', 'Resets', 'PkCount', 'Money'];
$ordem = $_GET['ordem'];

if (!in_array($ordem, $colunasPermitidas, true)) {
    $ordem = 'Level'; // safe default value
}

$query = "SELECT * FROM Character ORDER BY $ordem DESC LIMIT 100";

Never accept a column name directly from the request without this validation against a closed list of possible values.

Step 5 — Validate and normalize types before anything else

Beyond the prepared statement, validate the expected type of each field before using it, as an extra layer of defense:

$id = $_GET['id'];
if (!ctype_digit($id)) {
    die('Invalid parameter.');
}
$id = (int) $id;

This doesn't replace the prepared statement, but it reduces the attack surface and prevents application errors from processing unexpected types elsewhere in the code.

Step 6 — Audit the payment/donation integration

Donation pages frequently receive return parameters from payment gateways (amount, reference, status) via GET/POST and use them directly in balance updates. Make sure:

  • The credited amount never comes directly from the URL parameter — always confirm the actual value with the gateway's API (signed webhook), never trust what the user's browser sends back.
  • The balance update query uses a prepared statement with the value validated server-side, not from the request.
  • Logs of every credit transaction are kept for later auditing.

Step 7 — Test the fix

After fixing, manually test with classic payloads to confirm the behavior changed:

Test payloadExpected result after the fix
' OR '1'='1 in the username fieldLogin fails normally (user not found)
'; DROP TABLE Character; --Handled error or no change — never DROP execution
admin' -- Login fails (SQL comment has no effect on the prepared statement)
1 UNION SELECT memb__pwd FROM MEMB_INFO -- in the search parameterSearch returns empty or a handled error, no leaking data from another table

If any of these payloads still alters the expected application behavior, the fix wasn't applied correctly at that point.

Step 8 — Reduce the surface with least privilege in the database

As an additional layer, ensure the database user used by the PHP panel has no DROP, ALTER, or access to other databases beyond what's necessary:

CREATE USER 'painel_web'@'localhost' IDENTIFIED BY 'strong-password-here';
GRANT SELECT, INSERT, UPDATE ON MuOnline.MEMB_INFO TO 'painel_web'@'localhost';
GRANT SELECT, INSERT, UPDATE ON MuOnline.Character TO 'painel_web'@'localhost';
-- No GRANT DROP, ALTER, or access to other databases
FLUSH PRIVILEGES;

Even if an injection goes unnoticed somewhere unaudited, this restricted user limits the possible damage.

Common errors and fixes

SymptomLikely causeFix
Login accepts ' OR '1'='1 as a valid usernameDirect SQL string concatenationMigrate to prepared statement with bind_param
Ranking search returns data from other tablesNo validation on dynamic sort columnRestrict to a fixed whitelist of allowed columns
Donation balance changed with no real paymentCredit amount trusted from the browser's requestAlways validate via a signed gateway webhook
Panel still uses mysql_queryLegacy code never migratedMigrate to mysqli/PDO with prepared statements
Panel's database user can DROP TABLEExcessive database permissionRestrict grants to the minimum necessary (SELECT/INSERT/UPDATE)

SQL Injection audit checklist

  • All login, password recovery, and search forms reviewed.
  • Every use of mysql_query/direct concatenation migrated to prepared statements.
  • Dynamic column/table names validated by whitelist, never by direct binding.
  • Donation financial values validated via API/webhook, never via request parameter.
  • Manual tests with classic payloads run after each fix.
  • Panel's database user restricted to the minimum necessary permissions.
  • Access logs reviewed for suspicious payloads already used in the past.

With the panel protected against SQL Injection, the natural next step is to review the admin access that manages this same database — see the MU Online server setup guide to review the full infrastructure behind the panel.

Frequently asked questions

My panel is old and uses mysql_query — is that already a risk?

Yes. The mysql_* extension was removed from PHP starting with version 7, and even when it still runs on old versions, it offers no native prepared statements, which encourages direct SQL string concatenation — the leading cause of SQL Injection in MU panels. Migrate to mysqli or PDO with prepared statements.

Are prepared statements enough to eliminate SQL Injection?

In the vast majority of cases, yes, as long as they're used correctly — meaning every piece of user-supplied data (GET, POST, cookie, header) is passed as a bound parameter, never concatenated into the query string. The exception is dynamic table/column names, which require a whitelist instead of binding.

How do I know if my panel has already been exploited via SQL Injection?

Look for suspicious patterns in your web server's access logs: URL parameters containing UNION SELECT, ' OR '1'='1, --, or SLEEP(. Also check whether admin accounts were created without your knowledge or whether other accounts' data appeared exposed.

Does a WAF (Web Application Firewall) replace fixing the code?

No. A WAF is an additional defense layer that can block known payloads, but it doesn't fix the vulnerability — an attacker with an uncataloged payload still gets through. Treat the WAF as a temporary mitigation while you fix the source code, never as a permanent solution.

Is it worth rewriting the whole panel from scratch because of this?

Depends on the panel's size and your budget. For small, old panels, it's usually faster to fix vulnerable forms one by one with prepared statements than to rewrite everything. For large panels with multiple systemic vulnerabilities, a gradual module-by-module rewrite is usually safer in the medium term.

RO
Founder & editor-in-chief

Rodrigo has run ViciadosMU since the portal's early days. A specialist in MU Online server creation and administration, game history and the evolution of the seasons — he wrote much of the archive before 2024.

Keep reading

Related articles