Investigación original de RelevantSec
AnyDesk: análisis de la primera etapa de un goodware
Resumen
Hace poco analizamos AnyDesk 9.7.10 y descubrimos que su ejecutable de primera etapa emplea varias técnicas que solemos encontrar durante el análisis de malware:
- Recorrido de la PEB.
- Un directorio de importaciones vacío.
- Nombres de API cifrados.
- Resolución de exportaciones en tiempo de ejecución.
- Una imagen PE grande, cifrada y comprimida.
- Páginas de memoria convertidas en escribibles y ejecutables.
- Un cargador interno que aplica relocalizaciones y repara su propia IAT.
La lista podría describir un implante empaquetado, pero en este caso pertenece a un producto de acceso remoto correctamente firmado. Por eso, el archivo resulta un ejercicio útil de análisis de malware.
Seguiremos la pequeña primera etapa hasta que recupere la aplicación de 30 MB oculta en su interior. Introduciremos cada concepto cuando el cargador lo necesite.
Visión general
Cuando se descarga desde la página oficial de AnyDesk para Windows, el ejecutable firmado solo ocupa unos 8 MB. Su función es reconstruir un PE interno mucho mayor. A grandes rasgos, la ruta de inicio tiene dos etapas:
AnyDesk.exe firmado (8.343.992 bytes)
|
+-- Etapa 1: recorrido de la PEB + arranque mediante la EAT
| |
| +-- LCG-XOR descifra nombres y payload
| +-- decodificación LZMA + reversión del BCJ x86
| +-- copia cabeceras y secciones en la zona .itext reservada
|
+-- PE interno sin mapear (30.489.600 bytes)
|
+-- Etapa 2: relocalizaciones + importaciones + protecciones
+-- entrada en la aplicación AnyDesk
Las dos etapas se reparten el trabajo habitual del cargador de imágenes de Windows:
| Responsabilidad | Etapa externa | Etapa interna |
|---|---|---|
| Encontrar un módulo inicial sin importaciones | Sí | No |
| Resolver un pequeño conjunto de API nativas | Sí | Usa callbacks proporcionados |
| Descifrar y descomprimir el PE incrustado | Sí | No |
| Copiar cabeceras y secciones del PE según su RVA | Sí | No |
| Aplicar relocalizaciones de base | No | Sí |
| Cargar 23 dependencias | No | Sí |
| Resolver 767 importaciones y reparar la IAT | No | Sí |
| Establecer las protecciones finales de cada sección | No | Sí |
| Entrar en la aplicación real | No | Sí |
No se trata de una inyección reflectiva de DLL clásica, porque no interviene ningún proceso remoto. Es más preciso describirlo como un programa que reconstruye manualmente una imagen dentro de su propio espacio de direcciones.
Primera inspección
Antes de abrir un descompilador, comprobamos que todos estuviéramos observando la misma muestra:
| Propiedad | Valor observado |
|---|---|
| Versión del producto | AnyDesk 9.7.10 |
| Arquitectura | PE32 / x86 |
| Tamaño del archivo | 8.343.992 bytes |
| SHA-256 | 46872febd9684df716d392b457aef6611ae7b8716d2ece6bca30fb97271bce1d |
| Estado de Authenticode | Válido |
| Firmante | AnyDesk Software GmbH |
| Validez del certificado | Del 11 de febrero de 2026 al 13 de febrero de 2027 |
| Directorio de importaciones externo | Vacío |
Los nombres de funciones que aparecen a continuación son etiquetas que asignamos durante el análisis; el binario no contiene símbolos. También hemos eliminado de los fragmentos de ensamblador las direcciones específicas de la muestra, pero hemos conservado las instrucciones relevantes.
Con DiE ya podemos observar
propiedades habituales en un empaquetador: un directorio de importaciones
vacío, una sección .data de alta entropía y una sección .itext que ocupa
memoria, pero no contiene bytes en el archivo.
Una entropía alta no permite determinar por sí sola si los datos están
comprimidos, cifrados o si simplemente son inusuales. Sí indica dónde conviene
mirar. En esta muestra, casi todos los bytes de alta entropía del archivo están
en .data.
Dadas las funciones de AnyDesk y la poca cantidad de código visible, nuestra
hipótesis de trabajo es que .data contiene la aplicación real transformada de
algún modo.
La tabla de secciones respalda esa hipótesis:
| Sección | Tamaño virtual | Tamaño en archivo | Características |
|---|---|---|---|
.text |
0x296D |
0x2A00 |
0x60000020 |
.itext |
0x1D33C00 |
0 |
0xC0000080 |
.rdata |
0x434 |
0x600 |
0x40000040 |
.data |
0x7E8344 |
0x7E8000 |
0xC0000040 |
.rsrc |
0x4878 |
0x4A00 |
0x40000040 |
.reloc |
0x84 |
0x200 |
0x42000040 |
Esto nos da tres observaciones útiles:
.textsolo contiene unos 10 KB de código del cargador..datacontiene casi todos los bytes que ocupan espacio en disco..itextno ocupa bytes físicos en el archivo, pero solicita 30.620.672 bytes de espacio virtual inicializado a cero al mapear la imagen.
El punto de entrada está dentro de la pequeña sección .text. A partir de ahí,
podemos seguir el cargador en el mismo orden en que resuelve sus problemas.
Recorrido de la PEB
El primer problema aparece de inmediato: el ejecutable no tiene importaciones normales. Para entender por qué es importante, debemos desviarnos brevemente para ver cómo conecta Windows un programa con las funciones de una DLL.
Por qué importa la IAT
Un PE normal contiene un directorio de importaciones. Este indica a Windows qué DLL necesita el programa y qué funciones quiere utilizar de cada una. Para cada función importada, el cargador resuelve su dirección real y la escribe en la tabla de direcciones de importación (Import Address Table, IAT).
En el código fuente, un programa podría contener:
VirtualProtect(address, size, PAGE_EXECUTE_READWRITE, &old_protection);
En Windows de 32 bits, la llamada compilada suele tener conceptualmente este aspecto:
call dword ptr [__imp__VirtualProtect@16]
__imp__VirtualProtect@16 es una entrada de la IAT. Antes de que se inicie el
programa, Windows sustituye esa entrada por la dirección real de
VirtualProtect dentro de kernel32.dll.
Descriptor de importación del PE
-> "kernel32.dll"
-> "VirtualProtect"
-> Windows resuelve la exportación
-> Windows escribe su dirección en FirstThunk / la IAT
-> el programa llama a través de esa entrada
En este archivo de AnyDesk, el directorio de importaciones está vacío. No
existe ninguna entrada rellenada por el cargador para VirtualProtect,
GetProcAddress ni siquiera GetModuleHandleW. Esta primera etapa no tiene
funciones importadas normales que Windows pueda enlazar.
Esto crea un problema de arranque:
Sin importaciones -> no hay GetProcAddress
Sin GetProcAddress -> no hay una forma sencilla de resolver otra API
La solución consiste en utilizar información que Windows ya ha colocado en el proceso.
Siguiendo la PEB
Cada hilo tiene un bloque de entorno del hilo (Thread Environment Block, TEB). Cada proceso también tiene un bloque de entorno del proceso (Process Environment Block, PEB). Entre otras cosas, la PEB apunta a datos del cargador que contienen una lista enlazada de los módulos que ya están cargados en el proceso.
En Windows de 32 bits, el segmento FS permite que el código acceda a la TEB
actual. La función auxiliar de AnyDesk solo contiene dos instrucciones:
mov eax, dword ptr fs:[0x18] ; TEB actual
ret
La función que la invoca sigue el resto de la cadena:
call get_teb ; función auxiliar anterior
mov eax, [eax+0x30] ; TEB->ProcessEnvironmentBlock
mov esi, [eax+0x0C] ; PEB->Ldr
add esi, 0x0C ; &Ldr->InLoadOrderModuleList
mov edi, [esi] ; primera LIST_ENTRY (Flink)
Eso es el recorrido de la PEB en su forma más sencilla: seguir punteros a través de estructuras que Windows ya ha inicializado.
La misma lógica se puede escribir como pseudocódigo de estilo C. Se ha preparado deliberadamente para 32 bits y reproduce los desplazamientos que usa esta muestra:
static void *find_ntdll_base(void) {
uint32_t teb = __readfsdword(0x18);
uint32_t peb = *(uint32_t *)(teb + 0x30);
uint32_t ldr = *(uint32_t *)(peb + 0x0c);
uint32_t head = ldr + 0x0c;
// AnyDesk descifra este valor durante la ejecución.
wchar_t *wanted = decrypt_wide_name(
encrypted_ntdll, 10, 0x3114b604); // -> L"ntdll"
for (uint32_t entry = *(uint32_t *)head;
entry != head;
entry = *(uint32_t *)entry) {
wchar_t *base_name = *(wchar_t **)(entry + 0x30);
// Cinco caracteres UTF-16 = 10 bytes.
if (local_memcmp(base_name, wanted, 10) == 0)
return (void *)*(uint32_t *)(entry + 0x18);
}
return NULL;
}
AnyDesk tampoco importa memcmp para realizar esta comparación. La primera
etapa incluye su propia rutina pequeña de comparación de bytes.
La parte relevante del bucle real tiene este aspecto:
module_loop:
push 0x0A ; descifra 10 bytes
push encrypted_ntdll
push ntdll_seed
call decrypt_wide_name ; produce L"ntdll"
push 0x0A ; longitud de comparación
push eax ; nombre descifrado
push dword ptr [edi+0x30] ; BaseDllName.Buffer
call local_memcmp
test eax, eax
je module_found
mov edi, [edi] ; entry = entry->Flink
cmp edi, esi ; ¿hemos vuelto a la cabecera?
jne module_loop
module_found:
mov edi, [edi+0x18] ; entrada LDR -> DllBase
Por tanto, el objetivo es muy concreto: obtener la dirección base de
ntdll.dll sin llamar a una API importada y sin depender de una IAT.
Si quieres explorar las mismas estructuras de forma interactiva en WinDbg, el recorrido de la PEB de ired.team es un recurso complementario excelente. Muestra la PEB, los datos del cargador y las listas de módulos desde el punto de vista del depurador.
Los desplazamientos anteriores no forman parte de una API portable de Windows. Microsoft considera estas estructuras internas, y sus valores difieren entre procesos de 32 y 64 bits. Son correctos para esta muestra x86 y explican con precisión lo que hacen sus instrucciones.
Por qué es relevante
Observar fs:[0x18] no basta por sí solo para clasificar algo como malware. El
patrón importante es lo que ocurre a continuación:
leer la TEB
-> llegar a los datos del cargador en la PEB
-> recorrer los módulos cargados
-> recuperar la base de ntdll
-> tratar esa base como un PE
-> analizar sus exportaciones
Ahora conocemos la dirección de una DLL, pero aún no la de una función dentro de ella. Esto nos lleva de forma natural al siguiente paso.
Resolución de API
La contrapartida de una importación es una exportación. La tabla de direcciones de exportación (Export Address Table, EAT) de una DLL describe las funciones que esta pone a disposición de otro código.
Como AnyDesk todavía no puede llamar a GetProcAddress, la primera etapa
realiza la búsqueda por sí misma.
Recorrido de la EAT
Cuando edi contiene la base de ntdll, la primera etapa la analiza como un
PE:
mov eax, [edi+0x3C] ; DOS.e_lfanew
mov eax, [eax+edi+0x78] ; RVA del directorio de exportaciones
add eax, edi ; IMAGE_EXPORT_DIRECTORY *
mov ecx, [eax+0x1C] ; AddressOfFunctions
mov edx, [eax+0x20] ; AddressOfNames
mov ecx, [eax+0x24] ; AddressOfNameOrdinals
A continuación, recorre los nombres exportados hasta encontrar
LdrGetProcedureAddress. El nombre buscado se descifra justo antes de
compararlo:
mov esi, [edx+ebp*4] ; RVA del nombre
add esi, edi ; nombre exportado
call decrypt_ascii_name ; -> "LdrGetProcedureAddress"
call local_strcmp
...
movzx eax, word ptr [ordinals+ebp*2]
mov eax, [functions+eax*4]
add eax, edi ; dirección final de la función
La misma búsqueda resulta más fácil de leer en C simplificado:
static void *resolve_export(uint8_t *module, const char *wanted) {
IMAGE_DOS_HEADER *dos = (IMAGE_DOS_HEADER *)module;
IMAGE_NT_HEADERS32 *nt =
(IMAGE_NT_HEADERS32 *)(module + dos->e_lfanew);
DWORD export_rva =
nt->OptionalHeader
.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT]
.VirtualAddress;
IMAGE_EXPORT_DIRECTORY *exports =
(IMAGE_EXPORT_DIRECTORY *)(module + export_rva);
DWORD *names = (DWORD *)(module + exports->AddressOfNames);
WORD *ordinals =
(WORD *)(module + exports->AddressOfNameOrdinals);
DWORD *functions =
(DWORD *)(module + exports->AddressOfFunctions);
for (DWORD i = 0; i < exports->NumberOfNames; i++) {
char *name = (char *)(module + names[i]);
if (local_strcmp(name, wanted) == 0) {
WORD ordinal = ordinals[i];
return module + functions[ordinal];
}
}
return NULL;
}
Una vez disponible LdrGetProcedureAddress, el arranque se vuelve mucho más
sencillo. AnyDesk la utiliza para resolver el resto de su pequeña tabla de
ntdll, que incluye LdrLoadDll, NtProtectVirtualMemory y
NtMapViewOfSection. Después obtiene kernel32.dll y resuelve funciones como
HeapAlloc, GetProcessHeap, VirtualProtect y GetModuleHandleW.
recorrido de la PEB
-> base de ntdll
-> búsqueda manual en la EAT
-> LdrGetProcedureAddress
-> resto de funciones de ntdll
-> LdrLoadDll / kernel32
-> API de heap, línea de comandos, módulos y protecciones
Nombres cifrados
Es tentador llamar «API hashing» a cualquier resolver de este tipo, pero aquí sería incorrecto.
El API hashing guarda un entero fijo para cada función deseada. El resolver calcula el hash de cada nombre exportado y compara enteros. AnyDesk, en cambio, almacena bytes cifrados de forma reversible, los descifra y realiza comparaciones normales de cadenas.
Entre otros, recuperamos estos nombres:
ntdll
kernel32
LdrGetProcedureAddress
LdrLoadDll
NtProtectVirtualMemory
HeapAlloc
VirtualProtect
GetModuleHandleW
La diferencia es importante durante el análisis. El hashing nos obliga a reproducir un algoritmo de hash. Esta muestra nos obliga a reproducir un flujo de claves.
Descifrado de datos
Las funciones auxiliares que descifran nombres y el payload grande usan la misma construcción básica: un generador congruencial lineal, o LCG, produce un byte de clave cada vez y ese byte se combina con el texto cifrado mediante XOR.
state = state * 0x0019660D + 0x3C6EF35F
key = (state >> 12) & 0xFF
plain = cipher XOR key
La actualización del estado es determinista y cada semilla está presente en el código de la primera etapa.
El bucle del payload
Este es el bucle completo de descifrado del payload de la muestra, con las direcciones sustituidas por etiquetas:
push esi
mov edx, data_section ; inicio de .data en memoria
mov ecx, 0x0000727F ; semilla
mov esi, encrypted_size ; número de bytes cifrados
decrypt_loop:
imul ecx, ecx, 0x0019660D
add ecx, 0x3C6EF35F
mov eax, ecx
shr eax, 0x0C
xor byte ptr [edx], al
inc edx
sub esi, 1
jne decrypt_loop
pop esi
ret
A un nivel más alto, podría expresarse así:
static void decrypt_payload(uint8_t *data) {
uint32_t state = 0x727f;
for (uint32_t i = 0; i < 0x7e7f32; i++) {
state = state * 0x0019660d + 0x3c6ef35f;
data[i] ^= (uint8_t)(state >> 12);
}
}
Esto es ofuscación, no criptografía segura. El algoritmo, la semilla y el texto cifrado están presentes en el archivo, y no existe ninguna etiqueta de autenticación. Aun así, consigue varios efectos útiles para un empaquetador: las cadenas desaparecen, el payload deja de tener una cabecera reconocible y los análisis básicos de importaciones o firmas revelan poco sobre la aplicación real.
Descompresión de datos
Descifrar el bloque solo expone la siguiente capa: un flujo comprimido.
AnyDesk utiliza LZMA (algoritmo de cadena de Markov Lempel-Ziv), el algoritmo de compresión conocido sobre todo por 7-Zip. LZMA combina coincidencias en un diccionario con codificación por rangos. Es más lento y consume más memoria que formatos más simples, pero suele obtener una relación de compresión alta. Esto resulta útil cuando un proveedor quiere distribuir una aplicación grande dentro de un envoltorio mucho menor.
Se trata de un flujo LZMA1 sin procesar, no de un contenedor .7z o .xz. Un
contenedor normalmente incluiría metadatos descriptivos. Un decodificador de un
flujo sin procesar necesita, en cambio, las propiedades del modelo LZMA, el
tamaño del diccionario, los datos comprimidos y el tamaño de salida esperado.
En esta muestra, el flujo no tiene marcador de fin, por lo que el tamaño de
salida conocido también indica al decodificador cuándo debe detenerse.
Cómo conocemos las propiedades
Después del bucle XOR, el inicio de .data descifrado es:
5D 00 00 00 04
Esos cinco bytes siguen el formato esperado por el SDK de LZMA:
El primer byte empaqueta
lc,lpypb:textproperty = (pb * 5 + lp) * 9 + lc 0x5D = (2 * 5 + 0) * 9 + 3lccontrola cuántos bits altos del literal anterior aportan contexto,lpañade bits bajos de la posición al contexto del literal ypbcontrola el modelo de posición empleado para las coincidencias.Los cuatro bytes siguientes representan el tamaño del diccionario en little-endian. En este caso,
00 00 00 04equivale a 64 MiB.
| Campo | Valor |
|---|---|
lc, lp, pb |
3, 0, 2 |
| Tamaño del diccionario | 64 MiB |
| Bytes comprimidos después de las propiedades | 0x7E7F2D |
| Tamaño de salida esperado | 0x1D13C00 (30.489.600 bytes) |
Estos valores no son una suposición basada únicamente en los bytes. El código de la primera etapa descifra el payload, trata sus primeros cinco bytes como un bloque de propiedades, pasa el resto a su decodificador LZMA y reserva el tamaño esperado para los datos decodificados. El formato estándar de las propiedades, los argumentos del código que llama al decodificador y la decodificación correcta de 30.489.600 bytes coinciden.
La orquestación de la primera etapa muestra el orden directamente:
call decrypt_payload
mov ebx, decoded_size
call heap_alloc
...
call lzma_decode
...
push esi ; búfer decodificado
call x86_bcj_convert
; el PE está listo cuando la función retorna
El paso BCJ
La salida de LZMA todavía no es definitiva. AnyDesk también utiliza el conversor estándar de ramas x86 BCJ.
Las instrucciones x86 cercanas CALL y JMP, identificadas normalmente por
los opcodes E8 y E9, almacenan un desplazamiento con signo de 32 bits
relativo al final de la instrucción:
target = address_after_instruction + relative_displacement
relative_displacement = target - address_after_instruction
Esta representación es cómoda durante la ejecución, pero dificulta la compresión. Dos llamadas que llegan a la misma función suelen contener bytes de desplazamiento diferentes porque las instrucciones están en posiciones distintas. Por tanto, LZMA encuentra menos secuencias de bytes repetidas de las que sugeriría la estructura del código fuente.
Antes de comprimir, el codificador BCJ busca instrucciones E8 y E9 aptas y
convierte sus operandos relativos en valores normalizados según la posición.
Conceptualmente:
empaquetado: operando_normalizado = desplazamiento_relativo + posicion_final
desempaquetado: desplazamiento_relativo = operando_normalizado - posicion_final
El conversor real también comprueba los rangos de los operandos y sus bytes
altos para no modificar a ciegas cada byte E8 o E9 que encuentra. Los
valores normalizados hacen que las ramas hacia destinos relacionados se
parezcan más entre sí, lo que permite a LZMA encontrar mejores coincidencias.
BCJ no comprime nada por sí solo ni proporciona confidencialidad; es un filtro
reversible de preprocesamiento.
Tras decodificar LZMA, todavía hay que restaurar esos operandos normalizados o
el flujo de control del programa sería incorrecto. La rutina de esta muestra
coincide con el diseño x86_Convert del SDK de LZMA y se invoca con el
indicador de codificación a cero, lo que significa decodificar.
Obtención del PE real
Hay dos formas prácticas de recuperar el ejecutable interno.
La ruta estática sigue las transformaciones que acabamos de identificar:
bytes cifrados del payload en .data
-> LCG-XOR con la semilla 0x727F
-> lectura del bloque de propiedades LZMA de cinco bytes
-> decodificación LZMA sin procesar a 0x1D13C00 bytes
-> reversión de BCJ x86
-> archivo PE32 sin mapear
La ruta dinámica es más corta. Un depurador puede detenerse justo después de que retorne la rutina BCJ y volcar de la memoria el búfer de salida completo. Así se recupera el mismo archivo. Mencionamos esta posibilidad como método alternativo de verificación, sin convertir el artículo en una guía paso a paso para realizar el volcado.
El archivo recuperado se verifica así:
| Propiedad | Valor |
|---|---|
| Tamaño | 30.489.600 bytes |
| MD5 | c280dca9bf3927c8238854cf9dbc62e3 |
| SHA-1 | 5840c5218c0c31d82b1b4308a0979f384b76b75c |
| SHA-256 | 4117961150ab001e524a39be50250d221662f02b302c98e94248fc6ec450f282 |
Mapeo del PE
Recuperar un archivo PE y conseguir que pueda ejecutarse son tareas distintas.
Un archivo almacena las secciones de acuerdo con PointerToRawData; una imagen
mapeada las coloca en su VirtualAddress.
El ejecutable externo ya había reservado una región .itext grande,
inicializada a cero. La primera etapa cambia esa región a
PAGE_EXECUTE_READWRITE, valida las cabeceras recuperadas, copia las cabeceras
del PE y después copia cada sección en su posición virtual.
El código real de mapeo permite ver la transición:
push 0x40 ; PAGE_EXECUTE_READWRITE
push dword ptr [ebp+0x1C] ; tamaño de la región reservada
...
push edi ; destino .itext
call eax ; VirtualProtect resuelto
mov eax, 0x5A4D
cmp word ptr [edx], ax ; "MZ"
mov ecx, [edx+0x3C] ; e_lfanew
add ecx, edx ; cabeceras NT
cmp dword ptr [ecx], 0x4550 ; "PE\0\0"
push eax ; SizeOfHeaders
push edx ; PE sin mapear
push edi ; destino mapeado
call local_memcpy
push dword ptr [edi-0x04] ; SizeOfRawData
mov eax, [esi+0x0C] ; base del PE sin mapear
add eax, [edi] ; + PointerToRawData
push eax ; origen
mov eax, [edi-0x08] ; VirtualAddress
add eax, [esi+0x20] ; + base mapeada
push eax ; destino
call local_memcpy
Al final de esta función, .itext se parece a un PE mapeado, pero todavía no
está listo para ejecutarse. La primera etapa aún no ha:
- aplicado las relocalizaciones de base;
- cargado las dependencias de la imagen interna;
- resuelto sus importaciones;
- rellenado su IAT; ni
- aplicado las protecciones finales de cada sección.
Ese trabajo pertenece al cargador incluido dentro del PE recuperado.
La transición
La imagen interna exporta tres nombres útiles:
| Exportación | Función |
|---|---|
loader_main_thunk |
Puente hacia el inicio de la aplicación |
ldr_thunk_data |
Contexto compartido del cargador |
loader_entry_thread_thunk |
Entrada al cargador interno |
La primera etapa analiza la EAT interna para encontrar ldr_thunk_data. Rellena
esa estructura con la base mapeada, la línea de comandos, campos de estado,
datos de protección de páginas y los punteros a funciones nativas que resolvió
antes. Después resuelve y llama directamente a
loader_entry_thread_thunk.
Segunda etapa
El cargador interno termina ahora las tareas que normalmente realiza el cargador de PE de Windows.
Relocalizaciones
La imagen recuperada registra una base preferida en sus cabeceras PE, pero la
primera etapa la colocó dentro de la región .itext de la imagen externa. La
segunda etapa calcula la diferencia y recorre el directorio de relocalizaciones
de base, aplicando correcciones x86 IMAGE_REL_BASED_HIGHLOW.
Sin este paso, las direcciones absolutas compiladas para la base preferida apuntarían a posiciones de memoria incorrectas.
Reparación de importaciones
A diferencia del envoltorio externo, el PE interno tiene un directorio de importaciones normal: 23 DLL y 767 símbolos importados.
Para cada descriptor de importación, la segunda etapa:
- Carga o localiza la DLL solicitada mediante el callback
LdrLoadDllproporcionado. - Lee cada nombre u ordinal solicitado desde el thunk de búsqueda.
- Lo resuelve con
LdrGetProcedureAddress. - Escribe la dirección resultante en
FirstThunk, es decir, en la IAT.
Es el mismo enlace que Windows suele realizar antes de iniciar un ejecutable. La diferencia es que el cargador interno de AnyDesk lo hace manualmente.
La primera etapa lee las EAT para arrancar. La segunda escribe la IAT de la imagen interna para que sus puntos de llamada normales funcionen.
Protecciones finales
La región .itext completamente RWX resulta útil mientras se construye la
imagen. Finalmente, la segunda etapa convierte los indicadores de las
secciones PE en protecciones de página normales: código ejecutable y legible,
datos de solo lectura, y datos legibles y escribibles.
¿Por qué hacerlo así?
El análisis estático puede mostrar qué consigue el diseño, pero no qué requisito consideraron más importante sus desarrolladores.
Los resultados visibles son claros:
- La imagen interna de 30,49 MB se distribuye dentro de un ejecutable externo de 8,34 MB.
- AnyDesk sigue siendo un único archivo portable.
- El proveedor controla la transición entre el contenedor y la aplicación.
- Un análisis estático superficial ve un cargador diminuto en lugar del programa completo.
Son ventajas normales de ingeniería de producto con efectos secundarios que se parecen a la evasión de malware. Ambas afirmaciones pueden ser ciertas al mismo tiempo.
Conclusión
El ejecutable externo de AnyDesk es un contenedor y un cargador de arranque, no la aplicación completa.
Comienza sin importaciones normales, por lo que recorre la PEB para encontrar
ntdll. Analiza la EAT para resolver su primera API del cargador, construye el
resto de su tabla de API, descifra mediante LCG-XOR el flujo incrustado en
.data, lo expande con LZMA, revierte el filtro BCJ x86 y mapea el PE resultante
en .itext.
Después, el cargador interno aplica las relocalizaciones, resuelve 767 importaciones de 23 DLL, establece las protecciones finales y entra en la aplicación real.
La secuencia resulta familiar porque el malware utiliza los mismos componentes. La habilidad útil no consiste en memorizar qué técnicas son «malas», sino en poder explicar qué consigue cada instrucción, cómo una etapa crea las condiciones para la siguiente y qué dice sobre el archivo el conjunto de las evidencias.
Referencias
- ired.team, Exploring Process Environment Block.
- Microsoft, Formato PE.
- Microsoft, estructura PEB y estructura PEB_LDR_DATA.
- Microsoft, Información sobre la firma de archivos ejecutables.
- 7-Zip, SDK de LZMA y conversor BCJ x86.
- AnyDesk, página de descarga para Windows y registro de cambios para Windows.
- MITRE ATT&CK, Software Packing (T1027.002), Dynamic API Resolution (T1027.007), Embedded Payloads (T1027.009) y Encrypted/Encoded File (T1027.013).