TLP:CLEARSerie de estudio, defensiva y educativa. Continúa la Parte 0. Todo el YAML citado pertenece a **capa-rules de Mandiant** (Apache-2.0), commit `aed45e2`; se lee, no se construye nada.
Dónde estamos. En la Parte 0 vimos la anatomía de una regla —meta, features, la gramática lógica y los cuatro scopes— y el número que ordena la serie: capa-real, el motor file-scope que reimplementé en APT115, evalúa 609 de 1052 reglas (57,9 %) sin desensamblar. Esta parte recorre el primer eslabón del kill chain —lo que el malware hace antes de actuar— y usa esas reglas para dibujar, con ejemplos concretos, de qué lado de la frontera cae cada truco.

La táctica: no dejarse mirar#

Antes de robar, cifrar o persistir, el malware moderno resuelve un problema previo: sobrevivir al análisis. Da por hecho que lo van a detonar en un sandbox, abrir en un desensamblador y observar bajo un debugger, y trae contramedidas para las tres cosas. Ese repertorio —lo que MITRE agrupa bajo Defense Evasion— tiene cuatro movimientos recurrentes:

  1. Detectar el entorno de análisis. ¿Corro en una VM? ¿Hay herramientas de análisis instaladas? Si sí, me duermo o me apago.
  2. Detectar al depurador. ¿Alguien me está siguiendo paso a paso? Los trucos van del más burdo (IsDebuggerPresent) al más fino (cronometrarme a mí mismo, buscar breakpoints en mi propia memoria).
  3. Ocultar qué hago. No importar VirtualAlloc por su nombre, sino resolverlo en tiempo de ejecución —a veces por un hash del nombre— para que la tabla de imports no delate nada.
  4. Esconder el payload real. Llegar comprimido o cifrado (un packer), y desempacarse en memoria solo al ejecutar.

capa tiene reglas para los cuatro. Y son un banco de pruebas perfecto para la tesis de la serie, porque caen a los dos lados de la frontera: algunas se detectan con una cadena que el atacante no puede quitar; otras solo se ven en la forma de las instrucciones, invisibles a cualquier análisis que no reconstruya el código.

Detectar la VM: la victoria del file-scope#

Empezamos por el caso más limpio para un motor estático. Para saber si corre en VMware, el malware busca los rastros que el hipervisor deja por todas partes: nombres de driver (vmhgfs.sys), procesos (vmtoolsd.exe), rutas de registro, dispositivos (\\.\HGFS). La regla de capa que lo caza es, literalmente, un catálogo de esos rastros:

rule:
  meta:
    name: reference anti-VM strings targeting VMWare
    namespace: anti-analysis/anti-vm/vm-detection
    authors:
      - mehunhoff@google.com
      - "@johnk3r"
    scopes:
      static: file
      dynamic: file
    att&ck:
      - Defense Evasion::Virtualization/Sandbox Evasion::System Checks [T1497.001]
    mbc:
      - Anti-Behavioral Analysis::Virtual Machine Detection [B0009]
    references:
      - https://github.com/LordNoteworthy/al-khaser/blob/master/al-khaser/AntiVM/VMWare.cpp
    examples:
      - al-khaser_x86.exe_
  features:
    - or:
      - string: /VMWare/i
      - string: /vmtoolsd\.exe/i
      - string: /\\\\\.\\HGFS/i
      - string: /vmx86\.sys/i
      - string: /vmmemctl/i
        description: VMWare Guest Memory Controller Driver
      # ... ~40 cadenas en total

Regla anti-analysis/anti-vm/vm-detection/reference-anti-VM-strings-targeting-VMWare (recortada) — capa-rules (Mandiant, Apache-2.0).

Son unas cuarenta cadenas bajo un único or: basta una para coincidir. Dos cosas la hacen ejemplar. La primera es su referencia: apunta a al-khaser, el proyecto que cataloga técnicas de anti-análisis y que aparecerá una y otra vez en esta parte como la biblioteca de la que sale medio repertorio. La segunda es que su scope es file y sus hojas son todas cadenas: no hay que seguir ningún flujo, basta leer la tabla de cadenas del binario en reposo.

En capa-real: EXACTA — Es el caso canónico de detección file-scope. capa-real la evalúa idéntica a capa: extrae las cadenas del binario y busca las ~40 regex. Cero desensamblado, cero pérdida. Toda una clase de técnicas —detección de VM y de herramientas de análisis por sus artefactos de cadena, VMware, VBox, qemu, Wireshark, x64dbg…— cae de este lado y es donde un triage estático es tan bueno como capa.

Detectar al debugger: el otro lado de la frontera#

Cambiemos un solo eslabón de la cadena de razonamiento y el resultado se invierte. La detección de debuggers rara vez se apoya en cadenas: se apoya en medir y en inspeccionar el propio código. Dos ejemplos reales lo dejan claro.

El primero, cronometrarse a uno mismo. Bajo un debugger, el tiempo entre dos puntos del programa se dispara (el analista está leyendo, poniendo breakpoints). El malware llama a GetTickCount dos veces y compara la diferencia:

rule:
  meta:
    name: check for time delay via GetTickCount
    namespace: anti-analysis/anti-debugging/debugger-detection
    scopes:
      static: function
      dynamic: unsupported  # requires mnemonic features
    mbc:
      - Anti-Behavioral Analysis::Debugger Detection::Timing/Delay Check GetTickCount [B0001.032]
    examples:
      - Practical Malware Analysis Lab 16-03.exe_:0x4013d0
  features:
    - and:
      - count(api(kernel32.GetTickCount)): 2 or more
      - basic block:
        - and:
          - mnemonic: sub
          - mnemonic: cmp

Regla anti-analysis/anti-debugging/debugger-detection/check-for-time-delay-via-GetTickCount — capa-rules (Mandiant, Apache-2.0).

Fíjense en la precisión de la regla. GetTickCount a solas no dice nada —lo usa medio Windows para medir tiempos legítimos—. Lo que delata la intención es llamarlo dos o más veces (count(api(...)): 2 or more) y encontrar, en un mismo bloque básico, una resta (sub) seguida de una comparación (cmp): la firma aritmética de “calcular el delta y ver si es sospechosamente grande”. Esa segunda condición vive dentro de un basic block: y habla de mnemónicos, no de APIs.

El segundo ejemplo es aún más físico: buscar breakpoints de software en la propia memoria. Un breakpoint de software es el byte 0xCC (INT3) que el debugger escribe sobre la instrucción original. El malware escanea su propio código buscando ese byte:

  features:
    - and:
      - or:
        - instruction:
          - mnemonic: cmp
          - number: 0xCC = INT3
        - and:
          - description: INT3 (long form)
          - instruction:
            - mnemonic: cmp
            - number: 0xCD = INT3 (long form byte 1)
      - match: contain loop

Regla anti-analysis/anti-debugging/debugger-detection/check-for-software-breakpoints (recortada) — capa-rules (Mandiant, Apache-2.0). Referencia: al-khaser.

La hoja clave es instruction: [mnemonic: cmp, number: 0xCC]una instrucción cmp que compara contra el inmediato 0xCC. Eso no es una cadena ni un import: es una instrucción concreta con un operando concreto, dentro de un bucle que recorre el código.

En capa-real: FUERA DE ALCANCE (las dos) — Ninguna se puede evaluar sin desensamblar. La de timing exige ver sub+cmp en un mismo bloque básico —hay que haber identificado los bloques—; la de breakpoints exige encontrar una instrucción cmp con el inmediato 0xCC —hay que haber desensamblado para saber qué bytes son código y cuáles operandos—. Recordando la tabla de features de la Parte 0: mnemonic, instruction y number caen todas del lado que necesita el CFG. capa-real las descarta en el gate, en vez de fingir un veredicto. La detección de debuggers es, casi entera, terreno del desensamblado.

Por qué el anti-debug se resiste al file-scope. No es casualidad ni mala suerte del motor: es la naturaleza de la técnica. Detectar un debugger consiste en observar efectos dinámicos —el paso del tiempo, el contenido vivo de tu propia memoria, banderas del procesador, campos del PEB— que sencillamente no existen en el binario en reposo. Un fichero en disco no tiene un “tiempo transcurrido” ni un byte 0xCC inyectado; esos aparecen en ejecución. La regla solo puede capturar la maquinaria que los consulta, y esa maquinaria son instrucciones. Es la diferencia entre detectar la VM (deja artefactos estáticos: nombres) y detectar el debugger (mide fenómenos dinámicos: tiempo, memoria).

Ocultar los imports: resolución por hash#

Tercer movimiento. Un binario que importa LoadLibraryA, GetProcAddress y VirtualAlloc grita “voy a cargar código y ejecutarlo”. Para callar ese grito, los loaders y el shellcode no importan esas funciones: las resuelven en tiempo de ejecución recorriendo las tablas de exports de las DLLs ya cargadas. Y para no dejar ni siquiera la cadena "LoadLibraryA" en el binario, comparan contra un hash del nombre. La variante clásica usa ROR13 (rotar 13 bits y sumar, byte a byte):

rule:
  meta:
    name: resolve function by hash
    namespace: linking/runtime-linking
    scopes:
      static: function
      dynamic: span of calls
    att&ck:
      - Defense Evasion::Obfuscated Files or Information::Indicator Removal from Tools [T1027.005]
    references:
      - https://www.mandiant.com/resources/precalculated-string-hashes-reverse-engineering-shellcode
  features:
    - or:
      - number: 0x6a4abc5b = ROR13(kernel32.dll)
      - number: 0x3cfa685d = ROR13(ntdll.dll)
      - number: 0xec0e4e8e = ROR13(LoadLibraryA)
      - number: 0x7c0dfcaa = ROR13(GetProcAddress)
      - number: 0x91afca54 = ROR13(VirtualAlloc)

Regla linking/runtime-linking/resolve-function-by-hash (recortada) — capa-rules (Mandiant, Apache-2.0).

La regla es un or de constantes numéricas: cada number es el hash ROR13 precalculado de un nombre de API o de DLL. El comentario = ROR13(LoadLibraryA) es documentación humana; para el motor, la hoja es number: 0xec0e4e8e. Si el binario contiene esa constante incrustada en una instrucción de comparación, casi con seguridad está resolviendo LoadLibraryA por hash.

En capa-real: FUERA DE ALCANCE — Y es un límite especialmente honesto, porque a primera vista parece evaluable: son constantes, ¿no se pueden buscar en los bytes del binario? No, y el motivo es preciso. Un number en capa no es “esta secuencia de bytes está en el fichero”: es “esta constante aparece como operando inmediato de una instrucción”. La diferencia importa: el valor 0xec0e4e8e podría aparecer por casualidad en una tabla de datos, en una imagen incrustada, en cualquier lado. Lo que hace señal es que sea el operando de un cmp o un mov —y eso, otra vez, exige haber desensamblado para saber qué es instrucción y qué es dato—. La ofuscación por hash gana precisamente porque convierte nombres (visibles) en números dentro de instrucciones (solo visibles tras desensamblar). Es uno de los ejemplos más didácticos de por qué capa necesita un CFG.

RC4 reconocido por su forma: el ejemplo estrella#

Y llegamos a la joya pedagógica del ruleset, la regla que mejor responde a “¿por qué capa desensambla en vez de buscar cadenas?”. El cifrado RC4 es omnipresente en malware (ofusca cadenas, cifra la config, protege el tráfico C2). Pero RC4 no usa ninguna API de Windows ni deja ninguna cadena: es un algoritmo de veinte líneas que el atacante compila dentro de su código. ¿Cómo se detecta algo que no importa nada ni contiene nada reconocible? Por la forma de sus instrucciones. Miren cómo capa modela la fase KSA (Key Scheduling Algorithm) de RC4:

rule:
  meta:
    name: encrypt data using RC4 KSA
    namespace: data-manipulation/encryption/rc4
    scopes:
      static: function
      dynamic: unsupported  # requires characteristic, mnemonic, Not features
    mbc:
      - Cryptography::Encrypt Data::RC4 [C0027.009]
      - Cryptography::Encryption Key::RC4 KSA [C0028.002]
  features:
    - or:
      - and:
        - basic block:
          - and:
            - description: initialize S
            - characteristic: tight loop
            - or:
              - number: 0xFF
              - number: 0x100
        - or:
          - description: modulo 256
          - basic block:
            - and:
              - description: modulo via zero-extended mov from 8-bit register
              - count(mnemonic(movzx)): 2 or more
              - not:
                - or:
                  - mnemonic: shl
                  - mnemonic: rol
                  - characteristic: nzxor
        - or:
          - mnemonic: div
          - mnemonic: idiv

Regla data-manipulation/encryption/rc4/encrypt-data-using-rc4-ksa (recortada) — capa-rules (Mandiant, Apache-2.0).

Léanla como una descripción del algoritmo, porque eso es. RC4 inicializa un array S de 256 bytes: la regla busca un bucle apretado (characteristic: tight loop) que maneje la constante 0x100 (256) o 0xFF (255) —initialize S—. Luego hace aritmética módulo 256 en cada paso: la regla la reconoce por dos o más movzx (la forma habitual en que el compilador implementa “tomar el byte bajo”) y, para no confundirse con otro cifrado, exige que no haya shl, rol ni nzxor (not: sobre esas instrucciones). Y remata buscando un div/idiv para el módulo de la longitud de clave.

Ni una API. Ni una cadena. Ni un import. La regla entera está escrita en el vocabulario del desensamblado puro: characteristic, mnemonic, number como inmediato, conteos, negaciones. Su gemela, encrypt data using RC4 PRGA (la fase de generación de flujo), va aún más lejos y cuenta bloques básicos y llamadas salientes:

  features:
    - and:
      - count(characteristic(nzxor)): 1
      - or:
        - match: calculate modulo 256 via x86 assembly
        - count(mnemonic(movzx)): 4 or more
      - count(characteristic(calls from)): (0, 4)
      - count(basic blocks): (4, 50)
      - match: contain loop

Regla data-manipulation/encryption/rc4/encrypt-data-using-rc4-prga (recortada) — capa-rules (Mandiant, Apache-2.0).

count(basic blocks): (4, 50) —“entre 4 y 50 bloques básicos”, ni tan simple ni tan complejo— es una condición que ni siquiera se puede formular sin haber reconstruido el grafo de control de la función. El comentario en la regla original es honesto: “50 lo elegí por intuición”. Esto es artesanía de detección de bajo nivel.

En capa-real: FUERA DE ALCANCE (KSA y PRGA) — Imposible sin desensamblar, y por diseño. La criptografía por algoritmo —RC4, pero también AES por sus tablas/MixColumns, TEA/XTEA por sus constantes y desplazamientos, ChaCha por su patrón de rondas— se detecta por la estructura de las instrucciones, que es justo lo que el file-scope no ve. El contraste con la Parte 4 será elocuente: la criptografía por API (WinCrypt, BCrypt, CryptEncrypt) sí es aproximable, porque ahí la evidencia vuelve a ser un import. Un mismo objetivo —cifrar datos— cae de un lado o del otro según cómo se implemente. Esa es, en una regla, toda la tesis de la serie.

Cargar el payload: donde el file-scope vuelve a ganar (a medias)#

El último movimiento —resolver dinámicamente las APIs de carga y cambiar la protección de memoria para ejecutar el payload— es más matizado, y por eso interesante. La regla que modela la resolución dinámica en Windows es:

rule:
  meta:
    name: link function at runtime on Windows
    namespace: linking/runtime-linking
    scopes:
      static: instruction
      dynamic: call
    att&ck:
      - Execution::Shared Modules [T1129]
  features:
    - and:
      - os: windows
      - or:
        - api: kernel32.GetProcAddress
        - api: ntdll.LdrGetProcedureAddress
        - api: MmGetSystemRoutineAddress

Regla linking/runtime-linking/link-function-at-runtime-on-windows (recortada) — capa-rules (Mandiant, Apache-2.0).

Su scope estático es instruction —el más fino—, pero fíjense en las hojas: os: windows y un or de APIs importadas. Ambas son features de fichero. GetProcAddress aparece en la tabla de imports; os: windows se lee de la cabecera.

En capa-real: APROXIMADA — Aquí capa-real degrada el scope y acierta. La regla pide, en rigor, ver esta API en una instrucción concreta; capa-real no puede confirmar eso, pero sí puede confirmar que GetProcAddress está en los imports del binario. Pierde la precisión de “en esta instrucción” y gana la señal de “esta capacidad está presente”. Es exactamente el trato de la aproximación: cobertura a cambio de una afirmación más débil. Con un matiz honesto que conviene no maquillar —un binario que importa GetProcAddress no necesariamente lo usa para ofuscar; capa-real lo marca como posible, no como confirmado—.

Y aquí aparece el primer contraste fino de la serie, que conviene mirar de cerca. El paso siguiente del loader —cambiar la protección de memoria a ejecutable, con VirtualProtectparece la misma clase de regla: su hoja principal también es un import. Uno esperaría, por analogía, otra APROXIMADA. Y sin embargo capa-real no incluye change memory protection en su subconjunto evaluable: la clasifica fuera de alcance. En capa-real: FUERA DE ALCANCE . La razón es que esa regla no es solo “importa VirtualProtect”: capa la modela a nivel de bloque básico, exigiendo que la llamada aparezca junto a la constante de protección correcta —el patrón que de verdad delata la intención—, y ese acoplamiento a nivel de bloque es lo que el file-scope no puede reconstruir. Es un adelanto exacto de la Parte 2: en cuanto una capacidad deja de ser “está esta API” y pasa a ser “esta API con esta constante, aquí”, cruza la frontera. La resolución dinámica cae del lado fácil; la manipulación de memoria que la sigue, del difícil.

Y el packer con el que empezó todo, packed with UPX (desarmado en la Parte 0), cae del lado bueno sin matices: section: UPX0 es file-scope puro. EXACTA . La familia entera de packers y compiladores por firma —UPX, VMProtect por secciones, Go, Nim, Rust, PyInstaller— es donde el file-scope es tan bueno como capa, y le dedicaremos el cierre de la Parte 4.

El mapa de la frontera, ya con datos#

Recorrido el primer eslabón, la frontera file-scope deja de ser una abstracción. Para las técnicas de anti-análisis y carga, cae así:

CapacidadSe detecta porEn capa-real
Detección de VM / herramientasCadenas (nombres de driver, procesos)Exacta
Packers por firma (UPX…)Secciones, formatoExacta
Resolución dinámica de APIImports (GetProcAddress)Aproximada
Cambio de protección de memoriaVirtualProtect con la constante de protección, en un bloqueFuera de alcance
Anti-debug por timingMnemónicos (sub+cmp)Fuera de alcance
Anti-debug por breakpointsInstrucción + inmediato 0xCCFuera de alcance
Resolución de API por hashConstante ROR13 en instrucciónFuera de alcance
Cripto por algoritmo (RC4…)characteristic + mnemonic + conteosFuera de alcance

Se dibuja un principio que se repetirá en cada parte: el file-scope caza lo que deja un artefacto nombrable —una cadena, un import, una sección— y pierde lo que solo existe como forma o como valor dentro de una instrucción. El atacante que quiere pasar desapercibido lo sabe, y por eso la evasión de gama alta (hash de imports, cripto artesanal, anti-debug por instrucciones) vive toda del lado difícil. No es una debilidad del motor: es el mapa de qué comportamientos son intrínsecamente estáticos y cuáles son intrínsecamente dinámicos.

Para el defensor#

Qué llevarse a la práctica.

  • Las cadenas de anti-VM/anti-análisis son detección barata y estable. El catálogo de al-khaser (nombres de driver de VMware/VBox, procesos de herramientas de análisis, rutas de registro) se traduce directo a reglas YARA de cadenas o a búsquedas en tu EDR. Un binario que referencia vmtoolsd.exe, vboxservice.exe y x64dbg.exe a la vez está declarando su intención de evadir sandbox: prioridad de triage alta, con muy pocos falsos positivos.
  • La ausencia de imports significativos es, en sí, una señal. Un ejecutable que apenas importa LoadLibraryA/GetProcAddress y nada más suele resolver el resto por hash en runtime —el patrón de loaders y shellcode—. La tabla de imports vacía o mínima es un olor, no una tranquilidad. (Es justo el perfil del cargador de Grandoreiro.)
  • Sé consciente de tu propia frontera. Igual que capa-real, tu pipeline de triage estático verá los packers y las cadenas de anti-VM, y no verá el RC4 artesanal ni el anti-debug por timing. Saber qué clase de comportamiento tu análisis estático no puede confirmar te dice cuándo hace falta sí o sí un sandbox o un desensamblado —el mismo criterio de precisión-vs-cobertura que atraviesa toda la serie Nightmare Eclipse—.

En la Parte 2 cruzamos de la evasión a la acción: inyección y persistencia. Desarmaremos inject thread línea por línea —el ejemplo canónico de por qué la técnica compuesta se le escapa al file-scope aunque sus ingredientes no— y veremos el contraste quirúrgico con inject APC, casi idéntica pero evaluable por un detalle de la gramática que ya conocemos: el operador optional.

Referencias#