Cómo prevenir SQL Injection en formularios legados del sitio de tu servidor de MU Online
Identifica y corrige vulnerabilidades de SQL Injection en paneles PHP legados de servidores de MU Online — desde formularios de login y ranking hasta páginas de donación — usando prepared statements, validación de entrada y pruebas prácticas.
Muchos sitios de servidores de MU Online funcionan sobre paneles PHP escritos hace años, adaptados de plantillas de la comunidad, con formularios de login, ranking, donación y recuperación de contraseña que nunca fueron auditados. Este tipo de código legado es el blanco más común de SQL Injection: u
Muchos sitios de servidores de MU Online funcionan sobre paneles PHP escritos hace años, adaptados de plantillas de la comunidad, con formularios de login, ranking, donación y recuperación de contraseña que nunca fueron auditados. Este tipo de código legado es el blanco más común de SQL Injection: un atacante inserta un fragmento de SQL malicioso en un campo de formulario y manipula la consulta para extraer contraseñas, crear cuentas administrativas o incluso eliminar tablas enteras. Este tutorial muestra cómo identificar los puntos vulnerables más comunes en estos paneles, corregirlos con prepared statements, y validar que la corrección realmente funcionó.
Cómo ocurre el SQL Injection en la práctica
La vulnerabilidad nace cuando la entrada del usuario se concatena directamente dentro de una cadena SQL. Un ejemplo clásico encontrado en paneles legados de MU:
// VULNERABLE - no hagas esto
$user = $_POST['username'];
$pass = $_POST['password'];
$query = "SELECT * FROM MEMB_INFO WHERE memb___id = '$user' AND memb__pwd = '$pass'";
$result = mysql_query($query);
Si un atacante envía en el campo de usuario el valor ' OR '1'='1' -- , la consulta final se convierte en SELECT * FROM MEMB_INFO WHERE memb___id = '' OR '1'='1' -- ' AND memb__pwd = '', que retorna la primera fila de la tabla — generalmente una cuenta administrativa — sin necesitar la contraseña correcta. Este es el ataque de bypass de login más común contra paneles de MU antiguos.
Dónde buscar: los formularios de mayor riesgo
No todos los formularios tienen el mismo riesgo. Prioriza la auditoría en este orden:
| Formulario | Riesgo | Motivo |
|---|---|---|
| Login del panel/cuenta | Crítico | Bypass de autenticación, acceso a cuenta ajena o de admin |
| Recuperación de contraseña | Crítico | Muchas veces usa email/pregunta en una consulta concatenada |
| Ranking / búsqueda de personaje | Alto | Parámetro de búsqueda libre, común en GET sin sanitización |
| Donación / integración de pago | Alto | Se cruza con datos financieros y webhooks |
| Comentarios / foro integrado | Medio | Menor acceso a datos sensibles, pero aún explotable |
| Contador de visitas / estadísticas | Bajo | Generalmente sin dato sensible, pero puede servir de punto de entrada |
Paso 1 — Levantar todos los puntos de entrada
Haz un grep en el código fuente del panel buscando llamadas antiguas a la base de datos y concatenación de variables de request:
grep -rn "mysql_query\|mysqli_query" ./painel/ | grep '\$_'
grep -rn "\$_GET\|\$_POST\|\$_COOKIE" ./painel/ --include="*.php" | grep -i "select\|insert\|update\|delete"
Cada línea retornada es un candidato a punto vulnerable. En paneles legados de MU es común encontrar decenas de ocurrencias, especialmente en archivos como login.php, ranking.php, busca_char.php y recuperar_senha.php.
Paso 2 — Migrar de mysql_* a mysqli o PDO
La extensión mysql_* no soporta prepared statements nativos y fue eliminada de PHP desde la versión 7. Si el panel todavía usa esta extensión, migrar a mysqli con bind de parámetros es el primer paso obligatorio:
// SEGURO - con mysqli y 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();
Con el parámetro vinculado vía bind_param, el valor enviado por el atacante nunca se interpreta como parte del comando SQL — siempre se trata como dato literal, sin importar el contenido.
Paso 3 — Corregir el formulario de búsqueda/ranking
Los formularios de búsqueda por nombre de personaje suelen usar LIKE con concatenación directa, un punto que a menudo se olvida corregir:
// VULNERABLE
$nome = $_GET['nome'];
$query = "SELECT * FROM Character WHERE Name LIKE '%$nome%'";
// SEGURO
$nome = $_GET['nome'];
$stmt = $pdo->prepare("SELECT * FROM Character WHERE Name LIKE :nome");
$stmt->execute(['nome' => '%' . $nome . '%']);
Nota que el comodín % se agrega en el valor del parámetro, no en la cadena de la consulta — esto preserva la protección del prepared statement.
Paso 4 — Manejar nombres de tabla y columna dinámicos con whitelist
Los prepared statements protegen valores, pero no protegen nombres de tabla o columna usados dinámicamente (por ejemplo, en un ranking que ordena por una columna elegida por el usuario). En ese caso, usa una whitelist explícita en lugar de bind:
$colunasPermitidas = ['Level', 'Resets', 'PkCount', 'Money'];
$ordem = $_GET['ordem'];
if (!in_array($ordem, $colunasPermitidas, true)) {
$ordem = 'Level'; // valor predeterminado seguro
}
$query = "SELECT * FROM Character ORDER BY $ordem DESC LIMIT 100";
Nunca aceptes el nombre de la columna directamente del request sin esta validación contra una lista cerrada de valores posibles.
Paso 5 — Validar y normalizar tipos antes que nada
Además del prepared statement, valida el tipo esperado de cada campo antes de usarlo, como capa extra de defensa:
$id = $_GET['id'];
if (!ctype_digit($id)) {
die('Parámetro inválido.');
}
$id = (int) $id;
Esto no reemplaza el prepared statement, pero reduce la superficie de ataque y evita que errores de la aplicación procesen tipos inesperados en otros puntos del código.
Paso 6 — Auditar la integración de pago/donación
Las páginas de donación frecuentemente reciben parámetros de retorno de pasarelas de pago (monto, referencia, estado) vía GET/POST y los usan directamente en actualizaciones de saldo. Garantiza que:
- El monto del crédito nunca provenga directamente del parámetro de la URL — confirma siempre el valor real con la API de la pasarela (webhook firmado), nunca confíes en lo que el navegador del usuario devuelve.
- La consulta de actualización de saldo del jugador use prepared statement con el valor validado del lado del servidor, no del request.
- Se mantengan logs de toda transacción de crédito para auditoría posterior.
Paso 7 — Probar la corrección
Después de corregir, prueba manualmente con payloads clásicos para confirmar que el comportamiento cambió:
| Payload de prueba | Resultado esperado tras la corrección |
|---|---|
' OR '1'='1 en el campo de usuario | El login falla normalmente (usuario no encontrado) |
'; DROP TABLE Character; -- | Error manejado o ninguna alteración — nunca ejecución del DROP |
admin' -- | El login falla (el comentario SQL no tiene efecto en el prepared statement) |
1 UNION SELECT memb__pwd FROM MEMB_INFO -- en el parámetro de búsqueda | La búsqueda retorna vacío o error manejado, sin filtrar datos de otra tabla |
Si cualquiera de estos payloads todavía altera el comportamiento esperado de la aplicación, la corrección no se aplicó correctamente en ese punto.
Paso 8 — Reducir la superficie con el principio del menor privilegio en la base de datos
Como capa adicional, garantiza que el usuario de base de datos usado por el panel PHP no tenga permiso de DROP, ALTER ni acceso a otras bases más allá de la necesaria:
CREATE USER 'painel_web'@'localhost' IDENTIFIED BY 'senha-forte-aqui';
GRANT SELECT, INSERT, UPDATE ON MuOnline.MEMB_INFO TO 'painel_web'@'localhost';
GRANT SELECT, INSERT, UPDATE ON MuOnline.Character TO 'painel_web'@'localhost';
-- Sin GRANT DROP, ALTER ni acceso a otras bases
FLUSH PRIVILEGES;
Incluso si una inyección pasa desapercibida en algún punto no auditado, este usuario restringido limita el daño posible.
Errores comunes y soluciones
| Síntoma | Causa probable | Solución |
|---|---|---|
El login acepta ' OR '1'='1 como usuario válido | Concatenación directa de cadena SQL | Migrar a prepared statement con bind_param |
| La búsqueda de ranking retorna datos de otras tablas | Falta de validación en la columna de ordenación dinámica | Restringir a una whitelist fija de columnas permitidas |
| El saldo de donación se altera sin pago real | El monto del crédito se confía a partir del request del navegador | Validar siempre vía webhook firmado de la pasarela |
El panel todavía usa mysql_query | Código legado nunca migrado | Migrar a mysqli/PDO con prepared statements |
El usuario de base de datos del panel puede hacer DROP TABLE | Permiso de base de datos excesivo | Restringir los grants al mínimo necesario (SELECT/INSERT/UPDATE) |
Lista de verificación de auditoría de SQL Injection
- Todos los formularios de login, recuperación de contraseña y búsqueda revisados.
- Todo uso de
mysql_query/concatenación directa migrado a prepared statements. - Nombres de columna/tabla dinámicos validados por whitelist, nunca por bind directo.
- Valores financieros de donación validados vía API/webhook, nunca vía parámetro del request.
- Pruebas manuales con payloads clásicos ejecutadas tras cada corrección.
- Usuario de base de datos del panel restringido al mínimo de permisos necesarios.
- Logs de acceso revisados en busca de payloads sospechosos ya utilizados en el pasado.
Con el panel protegido contra SQL Injection, el siguiente paso natural es revisar también el acceso administrativo que gestiona esa misma base de datos — consulta la guía de creación de servidor de MU Online para revisar la infraestructura completa detrás del panel.
Preguntas frecuentes
Mi panel es antiguo y usa mysql_query, ¿eso ya es un riesgo?
Sí. La extensión mysql_* fue eliminada de PHP a partir de la versión 7 y, aunque todavía funcione en versiones antiguas, no ofrece prepared statements nativos, lo que incentiva la concatenación directa de strings SQL — la principal causa de SQL Injection en paneles de MU. Migra a mysqli o PDO con prepared statements.
¿Los prepared statements son suficientes para eliminar el SQL Injection?
En la gran mayoría de los casos sí, siempre que se usen correctamente — es decir, todo dato proveniente del usuario (GET, POST, cookie, header) debe pasarse como parámetro bind, nunca concatenado en la cadena de la consulta. La excepción son los nombres de tabla/columna dinámicos, que requieren whitelist en lugar de bind.
¿Cómo sé si mi panel ya fue explotado por SQL Injection?
Busca patrones sospechosos en los logs de acceso del servidor web: parámetros de URL con UNION SELECT, ' OR '1'='1, --, o SLEEP(. También verifica si se crearon cuentas administrativas sin tu conocimiento o si aparecieron expuestos datos de otras cuentas.
¿Un WAF (Web Application Firewall) reemplaza la corrección del código?
No. Un WAF es una capa adicional de defensa que puede bloquear payloads conocidos, pero no corrige la vulnerabilidad — un atacante con un payload no catalogado aún puede pasar. Trata el WAF como una mitigación temporal mientras corriges el código fuente, nunca como solución definitiva.
¿Vale la pena reescribir todo el panel desde cero por esto?
Depende del tamaño del panel y del presupuesto. Para paneles pequeños y antiguos, generalmente es más rápido corregir punto por punto los formularios vulnerables con prepared statements que reescribir todo. Para paneles grandes con múltiples vulnerabilidades sistémicas, una reescritura gradual módulo por módulo suele ser más segura a mediano plazo.