Los fallos de seguridad típicos de PHP y cómo evitarlos

Seguridad en PHP: los quince fallos que más se repiten en aplicaciones PHP, agrupados en tres bloques: lo que llega de fuera, la identidad y los secretos, y la configuración. Para cada uno, el patrón de código que hay que evitar, la forma segura comprobada con PHP 8.5 y cómo encontrarlo en tu propio proyecto.

PHP no es un lenguaje inseguro, pero es el más expuesto: está en una gran parte de las webs, y muchas aplicaciones se escribieron cuando las costumbres de hoy no existían. La buena noticia es que casi todos los fallos se repiten y casi todos se evitan con unas pocas reglas. Esta página los reúne. Cada uno tiene la misma estructura: qué es, el patrón de código que hay que evitar, la forma segura y cómo encontrarlo en un proyecto que ya existe.

Tres barreras para los datos que vienen de fuera: al entrar se validan con filter_var, tipos y listas de valores permitidos; al usarlos se separan del código con consultas preparadas, rutas fijas y sin pasar por el shell; al mostrarlos se escapan con htmlspecialchars según el contextoDónde se defiende cada dato que entraal entrarvalidarfilter_var, tipos, listasde valores permitidosal usarlosepararconsultas preparadas,rutas fijas, sin shellal mostrarloescaparhtmlspecialchars, segúnel contextoCasi todos los fallos de esta página se evitan con una de estas tres barreras.

Validar, separar y escapar: tres momentos, tres defensas distintas. Ninguna sustituye a las otras. Esquema: decodigo.com.

Si estás aprendiendo PHP, cada lección del curso tiene su recuadro «Ojo con la seguridad» con estos mismos fallos en contexto. Y recuerda que el código seguro no basta si PHP o las bibliotecas tienen fallos conocidos: de eso trata Versiones y parches.

Lo que llega de fuera

1. Inyección SQL

Ocurre cuando un dato externo se pega dentro del texto de una consulta SQL. Lo que debía ser un valor pasa a formar parte de la orden, y quien lo envía puede cambiar lo que hace la consulta: leer otras tablas, saltarse una comprobación o modificar datos. Lleva más de veinte años entre los fallos más graves de la web.

El patrón que hay que evitar

$sql = "SELECT * FROM usuarios WHERE email = '" . $_POST['email'] . "'";
$pdo->query($sql);

La forma segura

Consultas preparadas siempre: la consulta lleva marcadores y los datos viajan aparte. Los marcadores solo sirven para valores; los nombres de columnas o el sentido de un ORDER BY se eligen de una lista cerrada. Aquí, una columna desconocida no llega a la consulta: se ordena por nombre:

<?php
declare(strict_types=1);

$pdo = new PDO('sqlite::memory:', options: [PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]);
$pdo->exec('CREATE TABLE productos (nombre TEXT, precio INTEGER)');
$pdo->exec("INSERT INTO productos VALUES ('Café', 1250), ('Taza', 800), ('Té', 950)");

function listar(PDO $pdo, int $maximo, string $orden): array
{
    $columnas = ['nombre' => 'nombre', 'precio' => 'precio'];
    $columna = $columnas[$orden] ?? 'nombre';

    $consulta = $pdo->prepare("SELECT nombre, precio FROM productos WHERE precio <= :maximo ORDER BY $columna");
    $consulta->execute(['maximo' => $maximo]);
    return $consulta->fetchAll();
}

print_r(array_column(listar($pdo, 1300, 'precio'), 'nombre'));
print_r(array_column(listar($pdo, 1300, 'cualquier cosa'), 'nombre'));

Probar en el editor →

salidaArray
(
    [0] => Taza
    [1] => Té
    [2] => Café
)
Array
(
    [0] => Café
    [1] => Taza
    [2] => Té
)

Cómo encontrarlo en tu código: busca llamadas a query(, exec( o mysqli_query( cuyo argumento contenga $ o un punto de concatenación: grep -rnE "(query|exec|mysqli_query)\(.*(\$|\. )" src/. Cada resultado se revisa a mano.

2. XSS: escribir en la página lo que manda el visitante

Si un dato externo se escribe en el HTML sin escapar, el navegador lo interpreta como parte de la página, incluido código JavaScript. Ese código se ejecuta con la sesión del visitante, y puede leer sus datos o actuar en su nombre.

El patrón que hay que evitar

<p>Hola, <?= $_GET['nombre'] ?></p>
<input value="<?= $busqueda ?>">

La forma segura

Escapar al escribir, y según el lugar: no es lo mismo el texto de un párrafo, un atributo, un valor dentro de JavaScript o un parámetro de una URL. Con un texto inocente pero lleno de caracteres especiales se ve la diferencia:

<?php
declare(strict_types=1);

function e(string $s): string
{
    return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

$texto = 'O\'Brien & "Cía" <ventas>';

echo "HTML o atributo: ", e($texto), "\n";
echo "Dentro de JS:    ", json_encode($texto, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_THROW_ON_ERROR), "\n";
echo "En una URL:      ", 'buscar.php?q=' . rawurlencode($texto), "\n";

Probar en el editor →

salidaHTML o atributo: O&#039;Brien &amp; &quot;Cía&quot; &lt;ventas&gt;
Dentro de JS:    "O\u0027Brien \u0026 \u0022C\u00eda\u0022 \u003Cventas\u003E"
En una URL:      buscar.php?q=O%27Brien%20%26%20%22C%C3%ADa%22%20%3Cventas%3E

Para HTML y atributos, htmlspecialchars con ENT_QUOTES (escapa también la comilla simple). Para pasar datos a JavaScript, json_encode con las opciones JSON_HEX_*. Para parámetros de URL, rawurlencode. Y para enlaces cuyo destino lo escribe un usuario, comprueba además que empiece por https://, como en el reto de la lección 7. Una cabecera Content-Security-Policy (punto 15) añade una segunda barrera.

Cómo encontrarlo en tu código: cualquier echo, print o <?= que escriba una variable sin pasar por tu función de escape. Los motores de plantillas como Twig o Blade escapan por omisión, y es una buena razón para usarlos.

3. CSRF: peticiones que el usuario no quiso hacer

Mientras alguien tiene la sesión abierta en tu web, otra página que visite puede intentar que su navegador envíe un formulario a la tuya, y el navegador adjuntará su cookie de sesión. Tu aplicación no distingue esa petición de una legítima.

El patrón que hay que evitar

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    cambiarCorreo($_SESSION['usuario'], $_POST['email']);   // sin comprobar de dónde viene
}

La forma segura

Un token aleatorio por sesión, escondido en cada formulario y comprobado con hash_equals en cada POST que cambie algo, más la cookie de sesión con SameSite=Lax. Las acciones que cambian datos van siempre por POST, nunca por GET. El código completo, funcionando, está en la lección 7 y en el proyecto.

Cómo encontrarlo en tu código: formularios con method="post" sin un campo de token, y manejadores de POST que no lo comprueban. Si usas un framework, comprueba que su protección no esté desactivada en alguna ruta.

4. Inclusión de archivos

include y require ejecutan el archivo que se les indica. Si la ruta depende de un dato externo, quien lo envía puede elegir qué archivo del servidor se ejecuta o se muestra, por ejemplo uno de configuración con contraseñas. Con allow_url_include activado, podría ser incluso un archivo de otro servidor.

El patrón que hay que evitar

include 'paginas/' . $_GET['pagina'] . '.php';

La forma segura

Nunca construyas una ruta de include con datos de fuera: usa una lista cerrada que traduzca nombres a archivos.

<?php
declare(strict_types=1);

function rutaDePagina(string $nombre): ?string
{
    $paginas = [
        'inicio' => __DIR__ . '/paginas/inicio.php',
        'contacto' => __DIR__ . '/paginas/contacto.php',
    ];
    return $paginas[$nombre] ?? null;
}

foreach (['inicio', 'contacto', 'precios', ''] as $pedida) {
    $ruta = rutaDePagina($pedida);
    echo mb_str_pad("«{$pedida}»", 12), $ruta === null ? "→ 404" : "→ " . basename($ruta), "\n";
}

Probar en el editor →

salida«inicio»    → inicio.php
«contacto»  → contacto.php
«precios»   → 404
«»          → 404

Y en php.ini, allow_url_include = Off, que es el valor por omisión.

Cómo encontrarlo en tu código: grep -rnE "(include|require)(_once)?\s*\(?.*\$" src/: toda inclusión con una variable en la ruta merece una mirada.

5. Subida de archivos

Un formulario de subida recibe archivos que elige otra persona: el nombre, la extensión y el tipo que dice el navegador los decide ella. El riesgo grave es que un archivo con código PHP acabe en una carpeta pública donde el servidor lo ejecute.

El patrón que hay que evitar

move_uploaded_file($_FILES['foto']['tmp_name'], 'public/subidas/' . $_FILES['foto']['name']);

La forma segura

  • Comprueba el tipo real leyendo el contenido con finfo, no el que manda el navegador ni la extensión.
  • Limita el tamaño (upload_max_filesize y una comprobación propia).
  • Pon tú el nombre: uno aleatorio, con la extensión que corresponde al tipo detectado.
  • Guarda los archivos fuera de la carpeta pública y sírvelos con un script que compruebe permisos, o en una carpeta donde el servidor no ejecute PHP.
<?php
declare(strict_types=1);

const TIPOS = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp'];

function aceptarImagen(string $rutaTemporal, int $maximoBytes = 2_000_000): ?string
{
    if (filesize($rutaTemporal) > $maximoBytes) {
        return null;
    }
    $tipo = (new finfo(FILEINFO_MIME_TYPE))->file($rutaTemporal);
    $extension = TIPOS[$tipo] ?? null;
    return $extension === null ? null : bin2hex(random_bytes(16)) . '.' . $extension;
}

// Un PNG de verdad (1×1 píxel) y un archivo de texto que alguien llamó «foto.jpg»
$png = tempnam(sys_get_temp_dir(), 'sub');
file_put_contents($png, base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='));
$falso = tempnam(sys_get_temp_dir(), 'sub');
file_put_contents($falso, "esto no es una imagen\n");

foreach (['imagen PNG' => $png, '«foto.jpg» de texto' => $falso] as $que => $ruta) {
    $tipo = (new finfo(FILEINFO_MIME_TYPE))->file($ruta);
    $nombre = aceptarImagen($ruta);
    echo "$que: $tipo → ", $nombre === null ? 'rechazado' : 'aceptado', "\n";
}

Probar en el editor →

salidaimagen PNG: image/png → aceptado
«foto.jpg» de texto: text/plain → rechazado

El nombre aceptado es aleatorio en cada ejecución, por eso aquí solo mostramos si se acepta o no. En octubre de 2026, el fallo crítico PHP2Shell afectaba precisamente al procesamiento de las subidas: aunque tu código sea correcto, mantener PHP al día también protege esta puerta.

Cómo encontrarlo en tu código: move_uploaded_file y $_FILES: comprueba en cada uso de dónde sale el nombre, dónde se guarda y si se valida el contenido.

6. Deserialización de datos externos

unserialize reconstruye objetos a partir de un texto, y es el texto el que decide de qué clase son. Con datos externos, quien los envía puede crear objetos de clases de tu aplicación o de tus bibliotecas cuyos métodos mágicos (__destruct, __wakeup) hagan cosas que no esperabas. Se llama inyección de objetos, y ha afectado a muchos CMS y plugins.

El patrón que hay que evitar

$preferencias = unserialize($_COOKIE['prefs']);

La forma segura

Para datos que vienen de fuera, JSON: json_decode($texto, true, flags: JSON_THROW_ON_ERROR) solo produce arrays, textos, números y booleanos. Si no puedes evitar unserialize, pásale ['allowed_classes' => false]. La lección 5 lo muestra funcionando.

Cómo encontrarlo en tu código: grep -rn "unserialize(" src/. Cuidado también con las rutas que empiezan por phar://: algunas funciones de archivos deserializan los metadatos de esos archivos, así que nunca dejes que un dato externo elija el esquema de una ruta.

7. Órdenes del sistema

exec, system, shell_exec, passthru y las comillas invertidas pasan una orden al shell del sistema. Si esa orden incluye un dato externo, el shell puede interpretar parte del dato como otra orden.

El patrón que hay que evitar

exec("convert " . $_POST['archivo'] . " miniatura.png");

La forma segura

Lo mejor es no llamar al shell: casi siempre hay una función o una extensión de PHP que hace lo mismo. Si no la hay, proc_open acepta la orden como array (desde PHP 7.4): el programa recibe cada argumento tal cual, sin shell de por medio.

<?php
declare(strict_types=1);

$dir = sys_get_temp_dir() . '/demo-ordenes';
@mkdir($dir);
$nombre = "informe final; versión 2.txt";
file_put_contents("$dir/$nombre", "hola\n");

$proceso = proc_open(['wc', '-c', $nombre], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $tuberias, $dir);
echo stream_get_contents($tuberias[1]);
echo "código de salida: ", proc_close($proceso), "\n";
salida5 informe final; versión 2.txt
código de salida: 0

El nombre lleva un espacio y un punto y coma, que en un shell separarían argumentos y órdenes. Pasado en un array, wc lo recibe como un solo nombre de archivo. Si mantienes código antiguo que usa exec, cada dato externo debe pasar por escapeshellarg.

Cómo encontrarlo en tu código: grep -rnE "(exec|system|shell_exec|passthru|popen|proc_open)\(|\`" src/.

8. SSRF: peticiones que tu servidor hace por encargo

Si tu aplicación descarga una URL que le da el usuario (una imagen por dirección, la vista previa de un enlace, un webhook), quien la elige puede apuntar a direcciones que solo tu servidor alcanza: servicios internos, paneles de administración o los metadatos de la nube, que a veces contienen credenciales.

El patrón que hay que evitar

$imagen = file_get_contents($_POST['url']);

La forma segura

Acepta solo https, a ser posible solo una lista de dominios conocidos y, si tiene que ser cualquiera, resuelve el nombre y rechaza las direcciones privadas y reservadas antes de conectar. PHP 8.5 trae la extensión Uri, que analiza las URL siguiendo el estándar:

<?php
declare(strict_types=1);

function urlPermitida(string $texto, array $dominios): bool
{
    $uri = Uri\Rfc3986\Uri::parse($texto);
    if ($uri === null || $uri->getScheme() !== 'https') {
        return false;
    }
    return in_array($uri->getHost(), $dominios, true);
}

function ipPublica(string $ip): bool
{
    return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false;
}

$permitidos = ['imagenes.ejemplo.com'];
foreach (['https://imagenes.ejemplo.com/a.png', 'http://imagenes.ejemplo.com/a.png', 'https://otra.ejemplo.net/b.png', 'no es una url'] as $u) {
    echo str_pad($u, 38), urlPermitida($u, $permitidos) ? 'permitida' : 'rechazada', "\n";
}
foreach (['93.184.215.14', '10.0.0.5', '127.0.0.1', '169.254.169.254', '192.168.1.10'] as $ip) {
    echo str_pad($ip, 18), ipPublica($ip) ? 'pública' : 'privada o reservada', "\n";
}

Probar en el editor →

salidahttps://imagenes.ejemplo.com/a.png    permitida
http://imagenes.ejemplo.com/a.png     rechazada
https://otra.ejemplo.net/b.png        rechazada
no es una url                         rechazada
93.184.215.14     pública
10.0.0.5          privada o reservada
127.0.0.1         privada o reservada
169.254.169.254   privada o reservada
192.168.1.10      privada o reservada

La comprobación de la IP debe hacerse sobre la dirección a la que realmente te conectas, después de resolver el nombre, y sin seguir redirecciones automáticamente: una URL inofensiva puede redirigir a una dirección interna.

Cómo encontrarlo en tu código: file_get_contents, fopen, curl_init o un cliente HTTP como Guzzle con una URL que no sea fija.

9. XML con entidades externas (XXE)

XML permite declarar «entidades» que se sustituyen por el contenido de un archivo o de una URL. Un analizador que las procese al leer un XML externo puede acabar leyendo archivos del servidor.

El patrón que hay que evitar

$doc = new DOMDocument();
$doc->loadXML($xmlDelUsuario, LIBXML_NOENT | LIBXML_DTDLOAD);

La forma segura

Desde libxml 2.9, que es la que usa cualquier PHP actual, las entidades externas no se cargan por omisión. El peligro está en activarlas: no uses LIBXML_NOENT ni LIBXML_DTDLOAD con XML que no controlas, y si recibes datos de terceros, valora si JSON no te sirve igual.

Cómo encontrarlo en tu código: grep -rnE "LIBXML_(NOENT|DTDLOAD|DTDATTR)" src/.

Identidad, secretos y sesiones

10. Contraseñas guardadas mal

El patrón que hay que evitar

$guardada = md5($clave);           // o sha1(), o hash('sha256', ...)
if (md5($intento) == $guardada) { /* ... */ }

La forma segura

password_hash para guardar, password_verify para comprobar y password_needs_rehash para actualizar con los años. Los resúmenes rápidos como MD5 o SHA-256 se prueban por miles de millones por segundo, y sin sal dos usuarios con la misma contraseña tienen el mismo resumen. Exige contraseñas largas, limita los intentos de inicio de sesión y, en cuentas importantes, ofrece un segundo factor. Explicado y probado en la lección 7.

Cómo encontrarlo en tu código: grep -rnE "(md5|sha1|crypt)\(" src/ cerca de cualquier cosa que se llame password, clave o pass.

11. Sesiones mal configuradas

La cookie de sesión es la llave de la cuenta. Si un script de la página puede leerla, si viaja sin cifrar o si un atacante puede imponer un identificador que conoce antes de que la víctima inicie sesión, la cuenta queda expuesta.

La forma segura

session.use_strict_mode = 1
session.use_only_cookies = 1
session.cookie_httponly = 1
session.cookie_secure = 1
session.cookie_samesite = Lax

Y en el código, session_regenerate_id(true) justo después de iniciar sesión y al cambiar de privilegios, y session_destroy() más el borrado de la cookie al cerrarla. En la sesión guarda el id del usuario, no sus datos.

12. Comparaciones laxas y comparaciones que delatan

== convierte los tipos antes de comparar, y hay textos distintos que resultan «iguales», como vimos con los magic hashes en la lección 2. Además, una comparación normal termina en el primer carácter distinto, y medir cuánto tarda da pistas sobre cuántos caracteres acertó quien prueba.

El patrón que hay que evitar

if ($tokenRecibido == $tokenGuardado) { /* ... */ }
if (in_array($rol, $rolesPermitidos)) { /* ... */ }

La forma segura

=== en todas partes, in_array(..., true) con el modo estricto, y hash_equals para comparar tokens, firmas o cualquier secreto: tarda lo mismo acierte o falle.

13. Números aleatorios predecibles

rand, mt_rand, uniqid o microtime no sirven para nada que deba ser secreto: sus valores se pueden predecir o acotar. Para tokens, enlaces de recuperación de contraseña, nombres de archivo o códigos de verificación:

<?php
declare(strict_types=1);

$token = bin2hex(random_bytes(32));
$codigo = random_int(100_000, 999_999);
$r = new Random\Randomizer();
$clave = $r->getBytesFromString('abcdefghjkmnpqrstuvwxyz23456789', 12);

echo strlen($token), " caracteres de token, código de ", strlen((string) $codigo), " cifras, clave de ", strlen($clave), "\n";
var_dump(ctype_xdigit($token));

Probar en el editor →

salida64 caracteres de token, código de 6 cifras, clave de 12
bool(true)

Los tres usan el generador criptográfico del sistema. Aquí solo mostramos su forma, porque los valores cambian en cada ejecución.

Cómo encontrarlo en tu código: grep -rnE "(rand|mt_rand|uniqid|lcg_value)\(" src/ y revisa si alguno genera algo secreto.

Configuración y entorno

14. Un PHP de desarrollo en producción

Mostrar los errores al visitante revela rutas, consultas y a veces contraseñas; anunciar la versión de PHP ayuda a buscar fallos conocidos de esa versión. Este programa revisa la configuración con la que se ejecuta. Lo lanzamos dos veces: sin ningún php.ini, como en la imagen oficial de Docker, y con la plantilla de producción que trae PHP:

<?php
declare(strict_types=1);

$esperado = [
    'display_errors' => ['', '0', 'Off'],
    'log_errors' => ['1', 'On'],
    'expose_php' => ['', '0', 'Off'],
    'allow_url_include' => ['', '0', 'Off'],
    'session.use_strict_mode' => ['1', 'On'],
    'session.cookie_httponly' => ['1', 'On'],
];

foreach ($esperado as $opcion => $buenos) {
    $valor = (string) ini_get($opcion);
    $ok = in_array($valor, $buenos, true);
    printf("%s %-26s %s\n", $ok ? 'bien ' : 'REVISA', $opcion, $valor === '' ? '(vacío)' : $valor);
}
php revisar.php (configuración por omisión)REVISA display_errors             1
REVISA log_errors                 0
REVISA expose_php                 1
bien  allow_url_include          0
REVISA session.use_strict_mode    0
REVISA session.cookie_httponly    0
php -c php.ini-production revisar.phpbien  display_errors             (vacío)
bien  log_errors                 1
REVISA expose_php                 1
bien  allow_url_include          (vacío)
REVISA session.use_strict_mode    0
REVISA session.cookie_httponly    (vacío)

Sin ningún php.ini, PHP muestra los errores, no los guarda en ningún registro y anuncia su versión: es la configuración pensada para desarrollar. La plantilla php.ini-production que trae PHP corrige los errores, pero deja expose_php activado y no activa las opciones de sesión. En un servidor público, parte de esa plantilla y cambia esas tres líneas. Otras dos medidas: el código no debe poder escribirse por el usuario con el que corre PHP, y los secretos (contraseñas de bases de datos, claves de API) van en variables de entorno o en archivos fuera de la carpeta pública, nunca en el repositorio.

disable_functions y open_basedir no son un muro. Desactivar funciones como exec o limitar las carpetas con open_basedir estorba a un atacante, pero no lo detiene: a lo largo de los años se han publicado muchas formas de saltarse ambas. Son una capa más, no un sustituto de corregir el código y de aislar el servidor (un usuario sin privilegios, contenedores, permisos mínimos).

15. Funciones peligrosas y cabeceras que faltan

Algunas funciones convierten datos en código o en variables, y casi nunca hacen falta:

FunciónEl riesgoEn su lugar
eval()ejecuta un texto como código PHPnunca con datos externos; casi nunca en general
extract()convierte claves de un array en variables, y puede pisar las tuyasleer las claves que necesitas
$$nombrevariables variables: el nombre lo decide un datoun array asociativo
assert() con textoejecutaba el texto como código (eliminado en PHP 8)excepciones
preg_replace con /eevaluaba el reemplazo (eliminado en PHP 7)preg_replace_callback

Y unas cuantas cabeceras HTTP que el navegador entiende como instrucciones de seguridad. Se pueden enviar desde PHP con header() o, mejor, desde la configuración del servidor web:

CabeceraQué hace
Content-Security-Policydice de dónde puede cargar scripts la página; frena muchos XSS aunque se cuele uno
Strict-Transport-Securityobliga al navegador a usar siempre HTTPS con tu dominio
X-Content-Type-Options: nosniffimpide que el navegador «adivine» que un archivo es un script
Referrer-Policylimita qué parte de tu URL se envía a otras webs
frame-ancestors (en la CSP)impide que otra web meta la tuya en un marco para engañar al usuario

Cómo revisar un proyecto que ya existe

  1. Actualiza primero. PHP con soporte y dependencias sin avisos: composer audit (lección 6) y Versiones y parches.
  2. Pasa un analizador estático. PHPStan en un nivel alto encuentra datos mixed sin comprobar, como en el proyecto; algunas herramientas, como Psalm, hacen además análisis de contaminación (taint analysis): siguen cada dato externo hasta una consulta, un echo o un include.
  3. Busca los patrones de esta página con grep y revisa cada resultado a mano.
  4. Revisa la configuración del servidor con un programa como el del punto 14.
  5. Mira los registros. Si sospechas que alguien ya entró, nuestra guía Cómo saber si atacaron tu WordPress sirve para cualquier aplicación PHP.

Lista de comprobación

Comprobación
☐Todas las consultas SQL usan marcadores; columnas y orden salen de listas cerradas
☐Todo lo que se escribe en la página pasa por una función de escape adecuada al contexto
☐Cada formulario que cambia datos lleva un token CSRF comprobado con hash_equals
☐Ningún include ni ruta de archivo se construye con datos externos
☐Las subidas se validan con finfo, se renombran y se guardan fuera de la carpeta pública
☐Ningún dato externo llega a unserialize; se usa JSON
☐No se llama al shell, o se hace con proc_open y un array
☐Las URL que el servidor descarga se validan: https, dominios permitidos, sin IP privadas
☐Contraseñas con password_hash; inicio de sesión con límite de intentos
☐Cookie de sesión con HttpOnly, Secure y SameSite; identificador regenerado al entrar
☐Secretos con random_bytes o random_int, nunca con rand o uniqid
☐display_errors = Off, expose_php = Off y registros activados en producción
☐Solo la carpeta public/ es accesible desde la web
☐PHP con soporte de seguridad y composer audit sin avisos
☐Cabeceras de seguridad configuradas, empezando por HTTPS y Content-Security-Policy

El código de la forma segura de cada fallo se ejecutó con PHP 8.5.12 en un contenedor limpio, sin acceso a la red; las salidas están copiadas de esa ejecución. Los fragmentos marcados como «el patrón que hay que evitar» se muestran para reconocerlos y no se ejecutaron. Esta página explica los fallos para corregirlos, no cómo aprovecharlos.