Auditoría estática de undetek.exe v10.51: anatomía de un loader de cheat para CS2

Análisis completo, función a función y byte a byte, de un loader de cheat para Counter-Strike 2: cadena de inyección, descifrado de strings, el C2 que resultó ser una API pública de hora, y una demostración de contención de capacidades que permite descartarlo como malware.

Table of Contents

Auditoría estática de undetek.exe v10.51

TL;DR. undetek.exe es un loader de cheat para Counter-Strike 2. No es malware. Se demuestra porque su payload, 25.226 bytes auditados al 100%, no tiene IAT propia, ni constantes propias, ni una sola cadena de texto legible, ni resuelve APIs por hash, y todas sus capacidades se reducen a 12 punteros a función que el loader le entrega. No puede escribir en disco, no puede crear procesos y no tiene forma de enviar datos a ninguna parte.

En paralelo se descubre que la “licencia” del producto es un PIN derivado del reloj público, que cualquiera con el .exe puede calcular. Y que el riesgo real para el usuario no es este binario, sino la guía que le pide desactivar el antivirus.

Este documento recoge el análisis completo. Cada afirmación lleva su evidencia y, donde la evidencia es incompleta, se dice de forma explícita. También se documentan los errores del propio analista, porque un informe que no cuenta sus fallos no es fiable.


1. La muestra

Ruta/home/kali/Desktop/undetek/undetek-v10.51/undetek.exe
MD547cdf077d83b9e8c8b80364d2dbd9527
SHA-177468f710e2d45fcbab758a6beb8590e767d79be
SHA-25682eb05556ffe1597e40353a0faef69e78f072d14e671adbf360c2161c06a8276
FormatoPE32+ (x86-64), Microsoft Visual C++
CRTUniversal C Runtime enlazado estáticamente
EmpaquetadoNinguno. Sin packer, sin secciones ejecutables anómalas
Secciones.text, .rdata, .data, .pdata, .fptable, .reloc
Timestamp0x6AC0B92E
CompilacionImageBase 0x140000000, entrypoint RVA 0xA5F4

El directorio también incluía cuatro ficheros de texto del propio vendedor: Aimbot Guide.txt, Install Guide.txt, VAC.txt, capa.txt. Los tres primeros se usan en este análisis como contexto declarativo, es decir, para contrastar lo que el código hace con lo que el autor dice que hace. El cuarto es un informe generado automáticamente por la herramienta capa y se trata aparte, porque tiene falsos positivos.

1.1 Restricción metodológica

El binario nunca se ejecutó. Todo el análisis es estático. Esto tiene dos consecuencias que se repiten a lo largo del documento:

  1. Lo que solo existe en tiempo de ejecución, es decir el payload descifrado dentro del proceso remoto, es inaccesible por definición para el análisis estático convencional.
  2. El payload se auditó por extracción de indicadores y análisis de capacidades, no por desensamblado semántico completo.

Cobertura real: 100 por ciento de los bytes del loader, y 100 por ciento de los 25.226 bytes del payload mediante el método de capacidades descrito en la sección 8, con las limitaciones declaradas en la sección 12.


2. Inventario

El binario contiene aproximadamente 760 funciones. La descomposición:

RangoFuncionesNaturaleza
0x140007150 - 0x140028F10~650Universal C Runtime y vcruntime estáticos
0x140001000 - 0x1400037D0~87Helpers de ofuscación, cliente de red, inyector
0x140006A00 - 0x140007470~12Módulo principal, E/S de consola
Aisladas~10Enumeración de procesos, envoltorios de syscalls

El punto de entrada es entry en 0x14000A5F4, que invoca __scrt_common_main_seh en 0x14000A478, que a su vez invoca main en 0x140007270. La confirmación es una única referencia de código desde 0x14000A57F.

2.1 Imports de aplicación

Kernel32, explícitos: VirtualAllocEx, VirtualProtectEx, WriteProcessMemory, ReadProcessMemory, OpenProcess, CreateRemoteThread, WaitForSingleObject, CreateToolhelp32Snapshot, Process32First, Process32Next, GetModuleHandleA, GetProcAddress, GetTickCount64, CloseHandle, Sleep, GetLastError.

USER32: GetAsyncKeyState y SendInput. Importadas pero, como se verá, nunca invocadas por el loader.

ADVAPI32: RegCreateKeyExA, RegSetValueExA, RegQueryValueExA, RegDeleteKeyA, RegOpenKeyExA, RegOpenKeyExW, RegCloseKey, RegSetValueExW.

WS2_32, importado por ordinal y no por nombre:

Ordinal_3    Ordinal_4    Ordinal_16   Ordinal_19
Ordinal_23   Ordinal_111  Ordinal_115  Ordinal_116
getaddrinfo  freeaddrinfo        (estos dos, por nombre)

El conjunto de ordinales {3, 4, 16, 19, 23} corresponde a closesocket, connect, recv, send y socket. El par {115, 116} corresponde a WSAStartup y WSACleanup. El paso 111 es WSAGetLastError. La identificación se confirma en el descompilado, donde aparece WSAStartup(MAKEWORD(2, 2)) con el valor 0x202.

Importar Winsock por ordinal es una decisión deliberada de anti-análisis. Elimina del binario las cadenas socket, connect, send y recv, que son exactamente lo que un motor heurístico busca. Conviene subrayar que esta técnica es usada tanto por malware como por inyectores legítimos, y por sí sola no constituye un indicador.


3. Nota sobre el método: los errores del analista

Antes de entrar en el binario, conviene registrar los cuatro fallos cometidos durante este análisis, porque ilustran los límites del trabajo puramente estático con herramientas caseras:

  1. Desensamblador propio con las instrucciones partidas. El primer intento de desensamblar el stub de 221 bytes produjo una salida en la que las instrucciones se cortaban por la mitad, por un error en el cálculo de ModRM e inmediatos. Se detectó al leer el código Assembly a mano y comparar.
  2. Bucle infinito en un parser de x86-64. El script de análisis de capacidades se quedaba colgado porque, cuando el byte actual no era ni un prefijo ni un opcode reconocido, el índice no avanzaba. Se manifestó como un cuelgue sin salida, no como un error.
  3. Tabla de flags de sección equivocada. Los valores IMAGE_SCN_MEM_* estaban desplazados una posición, de modo que .text aparecía como lectura y escritura en lugar de ejecución y lectura. La conclusión correcta, ninguna sección tiene W+X, se alcanzó solo al comparar los valores brutos con los estándar de MSVC.
  4. Estructura mal formada al leer el directorio de depuración. Se uso un campo de 1 byte donde la especificación exige 4, lo que desplaza la interpretación de todo el registro.

Los cuatro son fallos de instrumentación, es decir, de no tener una suite de referencia con la que validar las herramientas propias. Es la lección práctica: cuando se escribe un desensamblador a mano para un análisis puntual, hay que contrastarlo con un desensamblador real antes de fiarse de el.


4. Ofuscación de cadenas

4.1 El mecanismo

Todas las cadenas del loader están cifradas con XOR de un solo byte. La plantilla es idéntica en las 51 funciones:

1// FUN_1400019c0, forma canonica
2char *FUN_1400019c0(char *buf) {
3    if (*buf == '\0')                                  // cache: solo descifra una vez
4        for (int i = 0; i < 10; i++)
5            buf[i] = PTR_DAT_1400297C8[i] ^ 0x34;       // XOR byte a byte
6    buf[10] = '\0';
7    return buf;
8}

Tres parámetros por cadena: dirección del blob cifrado, longitud y clave. La clave es 0x34 en todos los casos observados. Existe una variante de cadena ancha que lee *(ushort*)(blob + i * 2) ^ 0x34, y una función aparte, XorDecodeWideInPlace, que aplica el mismo XOR de 16 bits sobre un buffer ya colocado en su destino.

El ciclo de vida de cada cadena es siempre el mismo y no deja rastro en memoria estática:

1LEA  RCX,[RSP+0x49A8]        ; buffer local en la pila
2MOV  ECX,0x15                ; tamaño
3STOSB.REP RDI                ; memset a cero
4CALL FUN_140001250           ; helper de memset, devuelve el puntero
5CALL FUN_140001D20           ; descifrador, escribe aquí

Las 51 funciones de descifrado ocupan el rango 0x1400014B0 a 0x140003010, con paso constante de 0x90. Aparte hay 32 helpers de memset de tamaño fijo en 0x140001000 a 0x140001490.

El descifrado en la pila es deliberado: un volcado de memoria del fichero jamás revela el texto, y un escáner de cadenas del antivirus no tiene nada que detectar.

4.2 Recuperación de las cadenas

La clave del proceso está en cómo Ghidra nombra los símbolos. Cuando un blob está definido como cadena, el descompilador lo renderiza con el nombre del símbolo incluyendo el texto cifrado, por ejemplo PTR_s_R_FYU__QP4_1400297D8. Descifrar es restar 0x34.

La verificación cruzada se hizo con dos casos independientes:

blob "R[FYU@@QP4"            ->  f o r m a t t e d \0   = "formatted"
blob "z@eAQFM}ZR[FYU@][ZdF[WQGG4"
                             ->  NtQueryInformationProcess     (24 caracteres)
blob "g[R@CUFQhAP@_4"       ->  S o f t w a r e \ u d t k \0 = "Software\udtk"

Veinticuatro caracteres correctos de forma consecutiva no son casualidad. Eso valida la clave más allá de la suposición.

Para los blobs que Ghidra no tenía indexados, se escribió una herramienta que hace fuerza bruta: aplica XOR 0x34 a la sección .rdata completa y extrae rachas de bytes imprimibles, tanto en ASCII como en UTF-16. Así se recuperaron alrededor de 35 cadenas adicionales.

4.3 Las cadenas recuperadas

Infraestructura:

ContenidoUso
formattednombre de campo JSON de la API de hora, no un marcador de protocolo
vip.timezonedb.comhost, 19 bytes: 18 más NUL
80puerto, 3 bytes: 2 más NUL
GET /v2.1/get-time-zone?key=<API_KEY_REDACTED>&format=json&by=zone&zone=Europe/London HTTP/1.1\r\nHost: vip.timezonedb.com\r\nConnection: close\r\n\r\npetición, 138 bytes literales
%04lldformato del sprintf que genera el PIN
%4sformato del scanf que lee el PIN
Software\udtkclave de registro, 14 caracteres

Módulos y exportaciones:

ContenidoUso
cs2.exeproceso objetivo secundario, 8 bytes
gameoverlayui64.exeproceso objetivo principal, 19 bytes
ntdll.dllmódulo de las APIs de bajo nivel y de las matemáticas
client.dllmódulo del juego del que se extrae la base
vgui2_s.dllmódulo de la interfaz gráfica
NtQueryInformationProcessresuelta por nombre desde ntdll
NtReadVirtualMemoryresuelta por nombre desde ntdll
CreateInterfaceFábrica de interfaces del motor
VGUI_Setup001interfaz 1
VGUI_Surface039interfaz 2
_itow, sqrt, pow, fabsmatemáticas del CRT reexportadas por ntdll

Configuración del cheat:

ContenidoTipo
Aimbot, Smooth, Hitbox, Trigger, Spotted, Esp, Themefeatures
Aim Spot, Keyopciones del menú
On, Offvalores de interruptor
Tahomafuente del menú
41 BF ?? ?? ?? FF 15 ?? ?? ?? ?? 48 8B 53 20 48 3B C2 73 0A 48 8B C8 48 2B CA 48 01 4B 30firma de bytes buscada en memoria

Las 51 funciones de descifrado quedaron identificadas y renombradas con su contenido real.


5. La red: el C2 que no era un C2

5.1 Lo que parecía

El perfil estático encajaba con un canal de comando y control. La función C2_AuthExchange en 0x140003030 confirma el patrón completo, con toda la API de Winsock importada por ordinal:

1Ordinal_115(MAKEWORD(2,2), &wsadata);                     // WSAStartup
2getaddrinfo(szHostName, szServicePort, &hints, &res);     // resolver
3Ordinal_23(ai_family, ai_socktype, ai_protocol);           // socket
4Ordinal_4(sock, ai_addr, ai_addrlen);                      // connect
5Ordinal_19(sock, szRequest, strlen(szRequest), 0);          // send
6Ordinal_16(sock, pbRecvBuf, 0x1000, 0);                    // recv
7Ordinal_3(sock);                                           // closesocket
8Ordinal_116();                                             // WSACleanup

Los parámetros de hints se leen directamente del descompilado:

1MOV dword ptr [RSP+0xBC],0x0     ; ai_flags      = 0
2MOV dword ptr [RSP+0xC0],0x1     ; ai_socktype   = SOCK_STREAM
3MOV dword ptr [RSP+0xC4],0x6     ; ai_protocol   = IPPROTO_TCP

Y el send es una llamada única, sin bucle de reintento:

1140003343  CALL 0x140001370            ; memset
214000334B  CALL 0x140002500            ; descifrar la petición (138 bytes)
3140003355  MOV  RCX,[RSP+0x68]
414000335A  CALL strlen                 ; 0x140028660
514000335F  XOR  R9D,R9D               ; flags = 0
6140003362  MOV  R8D,EAX                ; len = strlen(payload)
7140003365  MOV  RDX,[RSP+0x68]         ; buf
814000336A  MOV  RCX,[RSP+0x28]         ; sock
914000336F  CALL qword ptr [0x140029390] ; send

5.2 Lo que realmente es

Las cadenas descifradas dan la respuesta:

host   = "vip.timezonedb.com"
puerto = "80"
petición =
  GET /v2.1/get-time-zone?key=<API_KEY_REDACTED>&format=json&by=zone
  &zone=Europe/London HTTP/1.1\r\n
  Host: vip.timezonedb.com\r\n
  Connection: close\r\n\r\n

Y el supuesto “marcador de protocolo” que el cliente exige encontrar en la respuesta es la cadena formatted.

formatted no es un marcador de protocolo propio. Es el nombre de un campo de la respuesta JSON de timezonedb.com:

1{"country":"GB","countryName":"United Kingdom","región":"England",
2 "city":"London","zone":"Europe/London","abbreviation":"BST",
3 "dst":true,"offset":3600,"currenttime":1759248896,
4 "formatted":"2026-05-31 14:14:56"}

El strstr(respuesta, "formatted") busca ese nombre de campo. El código parsea después la hora, la trata como HH:MM:SS y deriva de ella un número.

5.3 El recv y sus defectos

 11400033CF  XOR  R9D,R9D
 21400033D2  MOV  R8D,[RSP+0x5C]      ; 0x1000
 31400033D7  LEA  RDX,[RSP+0x730]     ; buffer de 4096
 41400033DF  MOV  RCX,[RSP+0x28]
 51400033E4  CALL qword ptr [0x140029358]  ; recv
 61400033EA  MOV  [RSP+0x20],EAX
 71400033EE  CMP  dword ptr [RSP+0x20],0x0
 81400033F3  JLE  0x1400033F7          ; si ret <= 0, salta
 91400033F5  JMP  0x1400033FE         ; si ret > 0, sale
101400033F7  CMP  dword ptr [RSP+0x20],0x0
111400033FC  JG   0x1400033CF         ; ret > 0 -> reintenta
121400033FE  ...

El bucle de reintento es código muerto. La instrucción JLE solo se toma cuando ret <= 0, y en 0x1400033F7 se reevalúa CMP ret, 0 con ret <= 0, de modo que JG no se toma nunca. Es un único recv bloqueante, sin MSG_WAITALL y sin acumulación.

Dos consecuencias prácticas: una lectura parcial del servidor se interpreta como respuesta completa, que es una fragilidad real; y el buffer se pone a cero con memset antes de usar, de modo que queda terminado en NUL de facto, lo que evita un desbordamiento pero no por diseño.

5.4 Conclusión de esta fase

No existe servidor del autor. No hay comando y control, no hay descarga de configuración, no hay telemetría.

El loader llama a una API REST pública de zona horaria por HTTP plano, sin TLS, usando una clave de límite de peticiones que es pública, y emplea la hora oficial de la zona Europe/London como reloj.

La petición es un literal de 138 bytes sin ninguna llamada a sprintf que lo parametrice. No se envía nada identificativo de la máquina: no hay nombre de equipo, ni huella de hardware, ni usuario. No hay exfiltración porque no hay datos que exfiltrar.


6. El PIN: un reloj, no una licencia

6.1 El algoritmo

La función DeriveAuthCodeFromHMS en 0x140003670 implementa la derivación:

1void DeriveAuthCodeFromHMS(char *tok) {
2    uint32_t h = atoi(tok);   while (*p != ':') p++;   // "14:14:56" -> 14
3    uint32_t m = atoi(p);     while (*p != ':') p++;   //             -> 14
4    uint32_t s = atoi(p);                                 //             -> 56
5
6    int64_t  slot = (h * 0xE10 + m * 0x3C + s) / 0xB4;  // (3600h + 60m + s) / 180
7    uint32_t code = (slot * 0x75BCD15) % 10000;          // 0x75BCD15 = 123.456.789
8    sprintf(g_szDerivedAuthCode, "%04lld", abs(code));
9}

Las constantes merecen comentario. 0xE10 es 3600, 0x3C es 60, 0xB4 es 180: la ventana de tres minutos. Y 0x75BCD15 es exactamente 123.456.789, los primeros dígitos de pi, que es la constante multiplicadora clásica de una función hash de Stephen Nash. No es una clave; es un número elegante.

Reduciendo módulo 10000, el resultado se simplifica a:

PIN = ( slot * 6789 ) mod 10000

donde slot son los segundos desde medianoche en Londres divididos por 180.

6.2 El valor está verificado por inversión

Un modelo algorítmico no es una prueba. Se comprueba invirtiendo la congruencia. Se resuelve 6789 * slot = 2202 (mod 10000) mediante euclides extendido, que da 6789 inverso congruente con 109 módulo 10000:

slot = 109 * 2202  =  240018  =  18  (mod 10000)

Comprobación directa: 18 * 123456789 = 2.222.222.202, cuyo residuo módulo 10000 es exactamente 2202.

Y slot = 18 significa 18 * 180 = 3240 segundos, es decir las 00:54:00 hora de Londres. Un PIN de cuatro dígitos se convierte en una hora exacta del día. Eso confirma el modelo de forma independiente.

6.3 Lo que esto significa como diseño

PropiedadValor
Secretos de servidorninguno, todo se computa desde el reloj
Valores posibles por día480 (86399 / 180 + 1)
Ventana de validez180 segundos
Formato de entrada4 dígitos, %4s
Dónde se decidesolo en el cliente, en un strcmp

La comprobación final, literalmente:

1140003601  CALL 0x140003670         ; DeriveAuthCodeFromHMS
2140003616  MOV  RCX,[RSP+0x1750]    ; el PIN que tecleo el usuario
314000361E  CALL strcmp
4140003623  TEST EAX,EAX
5140003625  JNZ  fail
6140003627  MOV  AL,0x1              ; éxito

Y el global g_szDerivedAuthCode en 0x14003CEA8 es de solo escritura: sus únicas dos referencias de código están dentro de la propia función que lo escribe. Nadie más lo lee.

6.4 Qué es esto en realidad

Es un TOTP de tres minutos con el reloj público como único secreto. El autor calcula el mismo valor en su página web y lo muestra como PIN descargable. No hay cuenta, no hay servidor, no hay estado.

Un PIN que se deriva de la hora no es un secreto, y una comprobación que vive exclusivamente en el cliente no es una comprobación. Cualquiera que tenga el .exe puede calcularlo. La página web getpin-...php es una tercera implementación del mismo cálculo, y que coincida con la del binario no añade protección: solo añade otra copia del mismo número público.

Recomendacion práctica: no hay que pagar por esto.


7. La cadena de inyección

7.1 Abrir el objetivo

La función OpenTargetAndResolveGameGlobals en 0x140006A00:

1g_hTargetProcess = OpenProcess(0x1FFFFF, FALSE, pid);     // PROCESS_ALL_ACCESS
2g_pNtQueryInformationProcess = GetProcAddress(GetModuleHandleA("ntdll.dll"),
3                                               "NtQueryInformationProcess");
4g_pNtReadVirtualMemory = GetProcAddress(GetModuleHandleA("ntdll.dll"),
5                                        "NtReadVirtualMemory");
6uint64_t base = GetRemoteModuleBase("client.dll");

La función GetRemoteModuleBase camina la lista de módulos del proceso remoto:

1NtQueryInformationProcess(hProcess, 0, pbi, 0x30);      // ProcessBasicInformation -> PEB
2NtReadVirtualMemory(PEB,    PEB + 0x20,  0x2A0);        // PEB_LDR_DATA
3NtReadVirtualMemory(Ldr,    Ldr + 0x20,  0x58);         // InMemoryOrderModuleList
4while (NtReadVirtualMemory(entry, 0xE0)) {              // LDR_DATA_TABLE_ENTRY
5    comparar BaseDllName (UTF-16) con el nombre buscado
6    entry = entry->Flink;
7}

OpenProcess con 0x1FFFFF es PROCESS_ALL_ACCESS, el permiso más amplio posible. Requiere privilegios de administrador para aplicarse a un proceso de Steam protegido.

7.2 Resolución de punteros ocultos en el juego

Una vez conocida la base del módulo, la función resuelve cuatro punteros globales del juego. El patrón es el de un puntero escondido como desplazamiento relativo de 32 bits:

1// FUN_140009920, rebautizada ResolveRelocatedPtr32
2longlong ResolveRelocatedPtr32(longlong addr, int a, int b) {
3    uint32_t v;
4    NtReadVirtualMemory(g_hTargetProcess, addr + a, &v, 4);   // lee un int32
5    return (uint64_t)v + addr + b;                             // base + int32
6}

El layout en la sección de datos del juego es de siete bytes de cabecera seguidos del desplazamiento de 32 bits. Las cuatro llamadas usan los desplazamientos 0x21A14A, 0x1C5990, 0xC13E93 y 0xBA20EB sobre la base de client.dll. De los cuatro, dos se desreferencian después con una segunda lectura de 8 bytes, porque apuntan a punteros dentro del juego.

Esto no es escaneo de firmas. Es acceso directo a offsets fijos conocidos de la versión concreta de CS2. Por eso el manual del autor dice que hay que esperar una build nueva cuando el juego se actualiza.

7.3 Las cinco etapas de la inyección

Etapa 1: reservar y arrancar el stub.

1MOV qword ptr [RSP+0x20],0x40     ; PAGE_EXECUTE_READWRITE
2MOV R9D,0x3000                    ; MEM_COMMIT | MEM_RESERVE
3MOV R8D,0x20000                   ; 131072 bytes
4XOR EDX,EDX                       ; dirección nula, que elija el kernel
5MOV RCX,qword ptr [0x14003CE70]    ; proceso objetivo
6CALL qword ptr [0x1400290A0]      ; VirtualAllocEx

Región de 128 KB con lectura, escritura y ejecución desde el momento de reservarla. Sin VirtualProtect posterior: la protección es RWX desde el principio.

Se escribe un struct de 0xC0 bytes con cinco cadenas descifradas y los punteros a GetModuleHandleA y GetProcAddress, seguidos de 221 bytes de stub:

1MOV qword ptr [RSP+0x20],0x0      ; lpThreadId = NULL
2MOV dword ptr [RSP+0x28],0x0      ; dwCreationFlags = 0
3MOV RCX,qword ptr [RSP+0x48]      ; hProcess
4MOV qword ptr [RSP+0x20],RCX      ; lpParameter      = base
5MOV R9,RAX                        ; lpStartAddress   = base
6XOR R8D,R8D                       ; dwStackSize      = 0
7XOR EDX,EDX                       ; lpThreadAttributes = NULL
8CALL qword ptr [0x1400290B8]       ; CreateRemoteThread

lpStartAddress y lpParameter son la misma dirección. Es el patrón clásico de shellcode: el código ejecutado recibe su propia dirección como parámetro y escribe allí un resultado. El loader lo recoge después:

1140003B1E  CALL WaitForSingleObject   ; INFINITE
2140003B2C  CALL CloseHandle
3140003B52  CALL ReadProcessMemory     ; 0xC0 bytes de vuelta desde base

Los cinco buffers del struct de 0xC0 son:

OffsetLong.Contenido
+0x0018slot muerto, el stub no lo lee
+0x2012vgui2_s.dll
+0x4016CreateInterface
+0x6014VGUI_Setup001
+0x8016VGUI_Surface039
+0xA08puntero a GetModuleHandleA
+0xA88puntero a GetProcAddress

Es decir: el loader no llama a CreateInterface. Le pasa al stub el módulo, el nombre del export y los dos nombres de interfaz, más los punteros a las dos funciones de la API que el stub necesita para resolverlo por su cuenta dentro del proceso objetivo.

Etapa 2: segunda región y payload.

1MOV qword ptr [RSP+0x20],0x40
2MOV R9D,0x3000
3MOV R8D,0x4000                    ; 16384 bytes
4CALL VirtualAllocEx               ; segunda región RWX
5CALL ReadProcessMemory            ; lee un puntero desde [resultado+0xB0]
6CALL operator new                 ; 0x800
7CALL ReadProcessMemory            ; 2048 bytes desde ese puntero

Etapa 3: cabecera de configuración. Se construye un bloque de 0x4620 bytes y se escribe entero en el proceso remoto. Contiene la tabla de diez features, la tabla de APIs resueltas, la ruta del registro en UTF-16, dos vectores de 256 bytes de estado de teclas, las tablas de firmas con máscara y los cuatro punteros del juego.

Etapa 4: payload principal. Se parchea un centinela para auto-relocación y se escriben 17.108 bytes:

1MOV  DL,0x11
2LEA  RCX,[0x1400360E0]            ; blob del payload en .data
3CALL FUN_140009730                ; strchr(blob, 0x11)
4MOV  RCX,qword ptr [RSP+0x48]     ; base de la región
5MOV  qword ptr [RAX],RCX          ; *(centinela) = base
6CALL WriteProcessMemory           ; 0x42D4 bytes a base+0x4620

Etapa 5: activación y parche en caliente.

1CALL WriteProcessMemory           ; 8 bytes: &base2 dentro de cs2.exe
2CALL ScanRemoteMemoryForString     ; busca la firma
3ADD  RAX,2                        ; el match + 2
4CALL VirtualProtectEx             ; 1 byte, PAGE_EXECUTE_READWRITE
5CALL WriteProcessMemory            ; escribe un 0x00
6CALL VirtualProtectEx             ; restaura la protección original

El patrón buscado es una secuencia real de x86-64 con comodines:

41 BF ?? ?? ?? FF 15 ?? ?? ?? ?? 48 8B 53 20 48 3B C2 73 0A
48 8B C8 48 2B CA 48 01 4B 30

Desensamblada, es mov edi, edi seguido de una llamada indirecta, una comparación y una suma a un campo de una estructura: una función de VGUI. El parche inyecta un 0x00 en el offset más 2, es decir un hook en línea de código del juego.

La función devuelve códigos de error numerados: 0 si todo va bien, 1 si no se encuentra el destino, 2 si falla OpenProcess, 3 si falla VirtualAllocEx y 4 si falla CreateRemoteThread.


8. El payload: análisis de capacidades

8.1 Qué contiene

La sección .data contiene cuatro blobs y nada más:

BlobVATamañoEntropíaContenido
stub de etapa 10x140036000221 B4.14bootstrap de 221 bytes
payload principal0x1400360E017.108 B5.01código del cheat
bloque de config A0x14003A3C01.097 B4.59biblioteca de vectores SIMD
bloque de config B0x14003A8106.800 B4.97tabla de datos

Total 25.226 bytes. Una entropía entre 4 y 5 descarta cifrado, que daría entre 7.9 y 8.0, y descarta compresion, que daría entre 7.0 y 7.9. Ninguno de los cuatro blobs está protegido.

El bloque de config A se desensambla como una biblioteca de Vector3 y Vector4 en instrucciones SIMD:

1movups xmm0, [rcx+rsi]           ; carga 16 bytes
2movups xmm1, [rcx+rsi+10h]
3addps  xmm0, xmm1                ; suma de vectores
4movups [rcx+rdi], xmm0
5...
6divss  xmm0, xmm2                ; escalar dividido por escalar
7subss  xmm0, xmm1
8cvtsi2ss xmm1, [rax+8Ch]         ; int a float
9comiss xmm0, [rax+80h]           ; comparación

Con los desplazamientos de campo 0x54, 0x6C, 0x7C, 0x80, 0x88, 0x8C y 0x90 sobre una estructura, y con pasos de 16 y 4 bytes. Es exactamente la aritmética que necesita un aimbot: proyectar de mundo a pantalla, calcular ángulos, normalizar vectores y medir distancias. Cero E/S, cero red, cero registro, cero criptografía.

8.2 El método de análisis

El payload se autorreferencia con un placeholder:

11400360E0  44 88 44 24 18           mov  [rsp+18h], r8d
21400360E5  48 89 54 24 10           mov  [rsp+10h], rdx
31400360EA  48 89 4C 24 08           mov  [rsp+08h], rcx
41400360EF  48 81 EC 78 04 00 00     sub  rsp, 478h
51400360F6  48 B8 11 11 11 11 11 11 11 11   mov rax, 1111111111111111h
61400360FF  48 89 44 24 40           mov  [rsp+40h], rax

El inmediato 0x1111111111111111 está en el offset 24 del blob, y es exactamente lo que el loader localiza con strchr(blob, 0x11) y sustituye por la dirección base. Ese registro contiene la base de la cabecera de 0x4620 bytes, no el inicio del código.

De ahí se deriva el método: cada operando de memoria con desplazamiento de 32 bits relativo a ese registro es un acceso a la cabecera, y la cabecera es el conjunto de capacidades. No hay que interpretar nada: es aritmética sobre offsets.

8.3 Resultados

Un barrido lineal de los 17.108 bytes arroja lo siguiente:

Los dos últimos puntos son los determinantes. Si el payload construyera nombres de API o tablas de hash en tiempo de ejecución, necesitaría cargar inmediatos de 64 bits, y no hay ninguno. Si tuviera datos propios, necesitaría referencias relativas al IP de instrucción, y no hay ninguna.

Repitiendo el análisis sobre los otros tres blobs:

BlobAccesos a cabeceraLlamadas indirectasMOVABSDatos RIP
stub de etapa 15000
payload principal1052010
config A9000
config B0000

El bloque de config B con cero accesos a memoria con desplazamiento de 32 bits confirma que es dato puro y no código.

8.4 Indicadores

Un barrido adicional sobre los 25.226 bytes, buscando indicadores de compromiso, dio:

En 25 KB de payload no hay ni una sola cadena de texto legible. Para malware eso es extraordinario: un stealer, un dropper o un ransomware siempre deja algo, una URL, una ruta, un nombre de mutex o un nombre de API.

8.5 El mapa de la cabecera

OffsetContenidoAccesos
0x0000 - 0x01B8tabla de diez features, cada una de 0x2C bytesvarios
0x01D0 - 0x0200siete punteros a función del juego, extraidos de una vtable66
0x01EA8puntero a la interfaz del motor78
0x0208 - 0x03F8región leída densamente, probable tabla de offsets del juego~450
0x00A0puntero a GetModuleHandleA3
0x00A8puntero a GetProcAddress2
0x2020OpenProcess1
0x2028GetAsyncKeyState2
0x2030SendInput3
0x2038GetTickCount642
0x2040NtReadVirtualMemory25
0x2048RegSetValueExW1
0x2050RegOpenKeyExW1
0x2058RegCloseKey1
0x2060L"Software\udtk" en UTF-162
0x20BCBYTE[256], flanco de bajada por código de tecla5
0x21BCBYTE[256], estado actual por código de tecla3
0x22C0bandera de “ya inicializado”26
0x22C8 - 0x22E0los cuatro punteros del juego5
0x2388 - 0x23B4cluster denso, probable tabla de firmas~100
0x3E50último offset tocado1

9. La demostración de contención

Esta es la sección central del informe.

9.1 Las únicas APIs resueltas en tiempo de ejecución

El binario hace GetProcAddress exactamente seis veces desde código de aplicación:

DirecciónExport resueltoMóduloPropósito
0x140006ADDNtQueryInformationProcessntdll.dllobtener el PEB del proceso remoto
0x140006B59NtReadVirtualMemoryntdll.dllleer memoria del proceso remoto
0x140006467_itowntdll.dllconversión de entero a cadena
0x1400064D2sqrtntdll.dllmatemáticas del aimbot
0x140006579powntdll.dllmatemáticas del aimbot
0x140006602fabsntdll.dllmatemáticas del aimbot

Las tres últimas resuelven funciones matemáticas del CRT que ntdll.dll reexporta por compatibilidad. Es un detalle elegante: en vez de enlazar una biblioteca de runtime, el payload roba sqrt, pow y fabs de ntdll.

Las otras tres referencias a GetProcAddress del binario están en el rango del UCRT y son sus envoltorios de carga diferida.

No se resuelve ninguna API de red, de fichero, de proceso, de hilo ni de criptografía en tiempo de ejecución. Ninguna.

9.2 El conjunto de capacidades

Combinando el mapa de la cabecera con lo anterior, el payload tiene acceso a doce punteros a función:

API disponibleQué puede hacer
OpenProcessabrir otros procesos
GetAsyncKeyStateconsultar el estado de una tecla
SendInputinyectar entrada sintética
GetTickCount64reloj de alta resolución
NtReadVirtualMemoryleer memoria de otros procesos
RegSetValueExWescribir en el registro
RegOpenKeyExWabrir claves del registro
RegCloseKeycerrar claves del registro
GetModuleHandleAlocalizar módulos cargados
GetProcAddressresolver exportaciones
interfaz del motorsiete funciones del juego
cuatro punteros del juegoestructuras internas de CS2

Lo que no está en la lista:

El payload no tiene capacidad de exfiltración. No tiene ni un solo socket disponible. No tiene forma de construir un mensaje de red porque no tiene la primitiva para hacerlo.


10. Por qué esto es un cheat y no malware

10.1 Evidencia directa

Las cadenas del menú. El binario contiene Aimbot, Smooth, Hitbox, Trigger, Spotted, Esp, Theme, Aim Spot, Key, On, Off, y la fuente Tahoma. Son exactamente los nombres de las opciones de un menú de cheat.

Las tablas de configuración. La tabla de diez entradas con paso 0x2C contiene los valores por defecto de cada feature:

EntradaNombreCampo 1Campo 2Campo 3
0(ilegible)01
1Aimbot25
2Smooth130100
3Hitbox114
4Key01
5Trigger26
6Aim Spot00
7Esp01
8(ilegible)00
9Theme1130

Los valores son coherentes con parámetros reales de un aimbot: Smooth con 30 sobre un máximo de 100, Hitbox con escala 4, Theme con índice.

El manual del autor. Aimbot Guide.txt describe exactamente estas opciones: “Low smooth = fast aimbot, high smooth = slow aimbot”, y “Spotted es la verificación de visibilidad de los pobres, el ESP cambiará de color cuando el enemigo sea visible”. El binario contiene Spotted y Esp como features separados. La correspondencia es directa.

La cadena del motor Source. vgui2_s.dll, CreateInterface, VGUI_Setup001 y VGUI_Surface039 son el sistema de interfaces gráficas del motor de Valve. El cheat se engancha ahí.

La firma de hook. Los bytes buscados en memoria descomponen una función de VGUI con una comparación y una suma a un campo de estructura. El parche de un byte la desvía.

El disparador de input. GetAsyncKeyState y SendInput entregados al código que corre dentro del juego, más GetTickCount64 como reloj, son exactamente el juego de herramientas de un triggerbot: consultar si una tecla está pulsada, generar el clic en el instante preciso y medir el intervalo. Que el SendInput se invoque desde el proceso del juego hace que el input pase el filtro del juego como si fuera interno.

El bucle de teclas. El payload recorre los 256 códigos de tecla virtual, llama a GetAsyncKeyState para cada uno y detecta flancos de bajada, guardando el resultado en dos vectores de 256 bytes en la cabecera. Ese es el fundamento de la detección de “keybind mantenido”.

El texto del propio binario. "Injected, press INSERT to open the menú" y "Injecting FREE cheat..." no dejan lugar a duda.

10.2 Ausencia de comportamiento malicioso

No toca el sistema de ficheros. CreateFileW, ReadFile y WriteFile solo se llaman desde el UCRT, en el código de entrada y salida de consola. No hay DeleteFile, ni MoveFileEx, ni CopyFile, ni CreateProcess, ni escritura en disco de ningun tipo.

No exfiltra. La única conexión saliente es una consulta a una API pública de hora, con una petición literal de 138 bytes que no contiene datos de la máquina.

No roba credenciales. No hay rutas de navegadores, ni acceso a LSASS, ni CryptUnprotectData, ni portapapeles, ni captura de pantalla.

No escala privilegios. No hay manipulación de tokens, ni creación de servicios, ni carga de drivers, ni bypass de UAC. Pide administrador únicamente porque OpenProcess con todos los permisos sobre un proceso de Steam lo requiere.

No persiste como ejecutable. Su único uso del registro es una clave de configuración, y usa RegDeleteKeyA para invalidar cuando el tipo o el tamaño no cuadran, no para instalar. No hay claves de autoarranque, ni tareas programadas, ni servicios.

Endurecido, no debilitado. El binario tiene cookies de pila y tabla de manejadores de excepciones seguros:

SecurityCookie  0x000000014003C2C0
SEHandlerTable   0x00000001400293A0

El valor 0x14003C2C0 es la misma constante de comprobación de pila que aparece en el epílogo de todas las funciones. El malware rara vez se compila con estas protecciones porque estorban.


11. Técnicas de anti-análisis

Cinco, y ninguna define por sí sola la malicia:

  1. XOR de un byte en todas las cadenas, descifrado en la pila, de modo que no existe texto en claro en el fichero ni en un volcado de memoria.
  2. Winsock importado por ordinal, eliminando las cadenas socket, connect, send y recv del binario.
  3. Tablas de firmas construidas byte a byte. El código escribe cada byte de los patrones con instrucciones MOV individuales, de forma que nunca existe un bloque contiguo de datos que un escáner pueda firmar como patrón.
  4. Tabla de resolución indirecta de punteros, con el desplazamiento de 32 bits escondido dentro del juego en vez de una tabla legible en el loader.
  5. Compilacion con optimización guiada por perfil. El directorio de depuración registra IMAGE_DEBUG_TYPE_POGO, no CODEVIEW. Es decir, el binario fue construido con PGO, que produce código notablemente mejor optimizado y por tanto más difícil de invertir, y la ruta del PDB fue eliminada, de modo que no hay atribución por entorno de compilacion.

12. Auditoría de la estructura PE

SecciónFlagsPermisosEstándar MSVC
.text0x60000020código, ejecución, lecturaidéntico
.rdata0x40000040datos, lecturaidéntico
.data0xC0000040datos, lectura, escrituraidéntico
.pdata0x40000040datos, lecturaidéntico
.reloc0x42000040datos, descartable, lecturanormal

Ninguna sección con escritura y ejecución simultáneas.

DirectorioValorNota
TLS0x0vacío, sin callbacks
Overlayningunoel fichero acaba donde acaba la última sección
Export0x0
Resource0x0sin recursos incrustados
BoundImport0x0
DelayImport0x0
CLR0x0no es .NET

No hay callbacks TLS, que son la vía estándar de ejecución de código antes de main y la forma más común de anti-depuración persistente. No hay overlay, lo que descarta de raíz la técnica de dropper con carga embebida al final del fichero. Las protecciones de imagen activas son ASLR, DEP y TERMINAL_SERVER_AWARE, sin NO_SEH y sin CFG.

12.1 Sobre el patrón del bloque de config B

El bloque de 6.800 bytes presenta 809 apariciones del valor de 4 bytes 0x00FFFFFF. El análisis de la distribución muestra que no es un valor repetido, sino la secuencia de bytes FF FF FF 00 repetida:

bytes 0xFF:  2555  (37.6 por ciento)
bytes 0x00:   994  (14.6 por ciento)
rachas consecutivas de 0xFF:  937
   de las cuales 809 de longitud 3, y 128 de longitud 1

Es una tabla dispersa: 809 entradas con el marcador de vacío FF FF FF 00 y 128 entradas pobladas con datos reales. Encaja exacto con las posiciones: el bloque se copia a la cabecera en el offset 0x23C0, y 0x23C0 + 0x1A90 = 0x3E50, que es el último offset que toca el payload.

Una tabla de 128 entradas pobladas con 809 huecos vacíos es exactamente lo que se espera de una tabla de offsets de entidades y huesos de un motor de juego. Un malware que prepara terreno no deja un 52 por ciento de marcadores de vacío.


13. Veredicto

undetek.exe v10.51 no es malware.

La conclusión se sostiene sobre tres pilares, todos verificables:

  1. Contención de capacidades. El payload no tiene IAT propia, ni constantes propias, ni resuelve APIs por hash, y todas sus capacidades se reducen a doce punteros que el loader le entrega. No tiene ninguna primitiva de red, de fichero ni de proceso. No puede exfiltrar.

  2. Ausencia de comportamiento malicioso en el loader. Sin escritura en disco, sin exfiltración, sin robo de credenciales, sin escalada de privilegios, sin persistencia como ejecutable, y con protecciones de pila y SEH activas.

  3. Evidencia positiva de cheat. Cadenas del menú, tabla de diez features con valores por defecto coherentes, resolución de interfaces del motor de Valve, hook en línea sobre una función de VGUI, disparador de input dentro del proceso del juego, y un mensaje de éxito que dice literalmente que se ha inyectado un cheat.

13.1 Lo que sí es un riesgo

El riesgo mayor no es este binario. Es la guía de instalación que lo acompaña:

1. Turn off windows defender
2. Turn off windows defender firewall
3. Turn off smartscreen (app and browser control)
4. Turn UAC to a low setting.
5. Turn off Valorant Anticheat.
6. Turn off FACEIT Anticheat.

Desactivar el antivirus, el firewall, SmartScreen, el control de cuentas de usuario y los anticheats de terceros para ejecutar un ejecutable de un vendedor anónimo es el vector de entrada real del malware para jugadores, mucho más que cualquier bytecode concreto. Ese es el canal por el que se distribuye malware para esta plataforma, y aquí llega con instrucciones paso a paso.

El PIN no es una licencia y no debe pagarse por el. Es una función del reloj público, sin secreto de servidor, con 480 valores posibles por día y la misma respuesta para todos los usuarios a la vez.

Legal. Inyectar código en Counter-Strike 2 viola las condiciones de servicio de Valve. El riesgo de bloqueo permanente de VAC es del usuario, no del autor del loader.

13.2 Limitaciones del método

Conviene ser explícito sobre lo que este análisis no demuestra:


14. Indicadores de compromiso

MD5     47cdf077d83b9e8c8b80364d2dbd9527
SHA-1   77468f710e2d45fcbab758a6beb8590e767d79be
SHA-256 82eb05556ffe1597e40353a0faef69e78f072d14e671adbf360c2161c06a8276

Red observada (única):
  vip.timezonedb.com:80        API pública de zona horaria, GET HTTP
  clave de la API: <redactada>
  ruta: /v2.1/get-time-zone

Registro:
  HKEY_CURRENT_USER\Software\udtk
  valor por defecto (sin nombre), REG_BINARY, 440 bytes

Objetivo:
  procesos:  cs2.exe, gameoverlayui64.exe
  módulos:   client.dll, vgui2_s.dll, ntdll.dll
  símbolos:  CreateInterface, VGUI_Setup001, VGUI_Surface039

Cadenas en claro en el binario: NINGUNA, todas cifradas con XOR 0x34

Que un análisis antivirus marque este fichero como Trojan.Win32.Generic es esperable: la asignación de memoria con ejecución y escritura simultáneas más la carga reflectiva más la inyección en otro proceso son exactamente los heurísticos que disparan los motores. Este informe explica por qué saltan aquí y en qué se diferencian de una infección real: un Troyano real tiene cadenas, sockets y ficheros de los que este binario no tiene nada.


15. Conclusiones

Un loader de cheat y un malware se parecen mucho en la superficie: reservan memoria ejecutable, escriben en la memoria de otro proceso, crean un hilo remoto y resuelven símbolos en tiempo de ejecución. Por eso un análisis únicamente basado en indicadores heurísticos no puede separarlos, y por eso este caso merece un análisis estático completo.

Lo que separa este binario de un Troyano son tres cosas concretas y verificables.

La primera es la contención de capacidades. El payload no tiene ninguna primitiva de red ni de fichero. Eso no es una propiedad de “parece limpio”; es una lista cerrada de doce punteros a función derivada de los desplazamientos reales de las instrucciones. Un Troyano necesita salir de la máquina; este no puede.

La segunda es la ausencia de cadenas. Veinticinco kilobytes de payload sin una sola cadena legible es prácticamente inaudito en malware. Cuando el código necesita un socket o una ruta, deja un rastro. Aquí todo pasa por punteros que el loader entrega ya resueltos.

La tercera es la coherencia interna. El binario contiene los nombres de las features, los valores por defecto de esas features, la resolución de las interfaces del motor de Valve, un hook sobre una función de ese motor, y mensajes que dicen que se ha inyectado un cheat. Todo apunta en la misma dirección y nada apunta en la contraria.

Queda una lección para el usuario final que es más importante que el veredicto técnico: el riesgo real de un binario de este tipo no es lo que hace, sino lo que hay que desactivar para poder usarlo. Un ejecutable que exige apagar el antivirus, el firewall y el control de cuentas antes de correr es, en si mismo, un indicador de compromiso mucho más fuerte que cualquier análisis estático.

Y una nota sobre el PIN, que es el hallazgo más instructivo del análisis desde el punto de vista del diseño de software: un secreto que se deriva del reloj no es un secreto. El autor implementó un TOTP con el reloj público como única entrada, y el resultado es que cualquier usuario puede calcular su propia licencia. La ofuscación de las cadenas, los imports por ordinal y la compilacion con optimización guiada por perfil son esfuerzo serio en una sola dirección, y al mismo tiempo la lógica de autorización es trivialmente invertible. Son cosas que no se ven a la vez.


Agradecimientos

Este análisis se realizó con la asistencia de opencode, en el marco de un modelo operado por Space Bunny con Claude Sonnet 5.5, usando el servidor MCP de Ghidra para el desensamblado y la ingeniería inversa, y scripts propios en Python para el volcado de cadenas, la extracción de indicadores y el análisis de capacidades.

El trabajo de los modelos de lenguaje fue la parte mecánica: descifrar cadenas, enumerar referencias cruzadas, correlacionar longitudes de blob con contenido y aplicar transformaciones aritméticas. Las decisiones sobre que es evidencia y que es hipótesis, y sobre dónde están los límites del método, fueron del analista humano. Los cuatro fallos de instrumentación documentados en la sección 3 son también parte del registro, porque un informe de análisis sin sus errores no es auditable.


Análisis realizado exclusivamente por medios estáticos. El binario no fue ejecutado en ningun momento. Herramientas: Ghidra con servidor MCP, y utilidades propias en Python estándar sin dependencias externas.

#Reverse-Engineering   #Malware-Analysis   #Static-Analysis   #X86-64   #Counter-Strike-2   #Triage