TLP:CLEARSerie de estudio, defensiva y educativa. Continúa la Parte 1. Todo el YAML citado pertenece a **capa-rules de Mandiant** (Apache-2.0), commit `aed45e2`; se lee, no se construye nada. Los veredictos «En capa-real» se verificaron contra el pack real que embarca APT115.
Dónde estamos. La Parte 1 cerró con un adelanto: en cuanto una capacidad deja de ser «está esta API» y pasa a ser «esta API con esta constante, aquí», cruza la frontera de lo que se puede leer sin desensamblar. Esta parte es ese principio llevado al ejemplo más didáctico del ruleset. Inyección y persistencia son, casi enteras, técnicas compuestas: no basta con que aparezca una API, hace falta que varias evidencias coincidan en el mismo sitio. Vamos a ver por qué eso las manda, una tras otra, al lado difícil —y la excepción quirúrgica que confirma la regla—.

La táctica: ejecutar donde no te miran, y volver tras el reinicio#

Dos objetivos distintos, un mismo problema de detección.

Inyección de código (Process Injection, T1055 ) es hacer que otro proceso ejecute tu código. Le da al malware dos cosas: sigilo (el código malicioso corre bajo un proceso legítimo, explorer.exe, svchost.exe) y acceso (hereda los privilegios y la confianza de la víctima). La forma clásica tiene tres pasos: reservar memoria en el proceso destino, escribir tu payload en ella, y arrancar un hilo que lo ejecute.

Persistencia (Persistence, táctica TA0003) es sobrevivir al reinicio. El sistema ofrece decenas de ganchos legítimos de auto-arranque —claves del registro, servicios, tareas programadas, carpetas de inicio— y el malware secuestra uno para que el SO lo relance por él.

Lo que ambas comparten, y lo que las hace un caso de estudio perfecto, es que ninguna se reduce a una sola evidencia. “Importa VirtualAlloc” no es inyección —lo hace medio Windows—. “Contiene la cadena de la clave Run” no es persistencia —podría ser un manual—. La señal está en la combinación, y la combinación es justo lo que exige mirar dentro de una función.

inject thread, línea por línea#

Esta es, probablemente, la regla más didáctica de todo capa-rules. Su ejemplo canónico es el Lab 12-01 de Practical Malware Analysis, el ejercicio con el que se enseña inyección de hilos. Entera:

rule:
  meta:
    name: inject thread
    namespace: host-interaction/process/inject
    scopes:
      static: function
      dynamic: span of calls
    att&ck:
      - Defense Evasion::Process Injection::Thread Execution Hijacking [T1055.003]
      - Defense Evasion::Reflective Code Loading [T1620]
    examples:
      - Practical Malware Analysis Lab 12-01.exe_:0x4010D0
  features:
    - and:
      - or:
        - match: allocate or change RWX memory
        - match: allocate or change RW memory
      - match: write process memory
      - match: create thread
      - optional:
        - or:
          - match: host-interaction/process/create
          - match: open process
          - number: 0x3000 = MEM_COMMIT or MEM_RESERVE

Regla host-interaction/process/inject/inject-thread — capa-rules (Mandiant, Apache-2.0).

La raíz es un and de tres requisitos obligatorios más un optional. Y los tres obligatorios son, todos, match: a otras reglas —es la composición que vimos en la Parte 0—. Para entender el veredicto hay que seguir la cascada. write process memory y create thread son las dos primitivas fáciles; empecemos por ellas.

Los ingredientes que sí se ven#

write process memory es, por dentro, un or limpio de APIs:

rule:
  meta:
    name: write process memory
    scopes:
      static: instruction
  features:
    - or:
      - api: kernel32.WriteProcessMemory
      - api: ntdll.NtWriteVirtualMemory
      - api: ntdll.ZwWriteVirtualMemory
      - api: NtWow64WriteVirtualMemory64

Regla lib write-process-memory — capa-rules (Mandiant, Apache-2.0).

Nada más: si el binario importa WriteProcessMemory (o una de sus variantes de ntdll), la primitiva está. create thread es igual de directa —un or de CreateThread, CreateRemoteThread, NtCreateThreadEx, _beginthreadex, y sus equivalentes de Linux (pthread_create) y .NET (Thread::Start)—. Ambas son imports, evidencia de fichero.

write process memory y create thread: APROXIMADAScapa-real las evalúa las dos, degradando el scope: no confirma que la llamada ocurra en tal instrucción o bloque, pero sí que la API está en la tabla de imports. Los ingredientes de la inyección se ven.

El ingrediente que no se ve — y arrastra a toda la regla#

El primer requisito obligatorio de inject thread es el que rompe todo. allocate or change RWX memory —reservar o rehabilitar memoria como ejecutable y escribible a la vez (RWX), el sello de “voy a meter código y correrlo”— se modela así:

rule:
  meta:
    name: allocate or change RWX memory
    scopes:
      static: basic block
  features:
    - or:
      - basic block:
        - and:
          - or:
            - match: allocate memory
            - match: change memory protection
          - or:
            - number: 0x40 = PAGE_EXECUTE_READWRITE
            - instruction:
              - mnemonic: lea
              - offset: 0x40 = PAGE_EXECUTE_READWRITE

Regla host-interaction/process/inject/allocate-or-change-RWX-memory (recortada) — capa-rules (Mandiant, Apache-2.0).

Aquí está el corazón del asunto. La regla no pide «llama a VirtualAlloc»; pide «llama a VirtualAlloc (o cambia la protección) con la constante 0x40, y las dos cosas en el mismo bloque básico». Ese 0x40 es PAGE_EXECUTE_READWRITE: la bandera que convierte una reserva de memoria corriente en una reserva sospechosa. Y 0x40, como vimos en la Parte 1, es un number —un operando inmediato dentro de una instrucción—, no una cadena ni un import. Para verlo hay que desensamblar. Además, el basic block: exige que la asignación y la bandera coincidan en el mismo tramo de código: un acoplamiento que, por definición, no existe en el binario en reposo.

allocate or change RWX memory: FUERA DE ALCANCE — Y como es un requisito obligatorio de inject thread (primer miembro del and raíz), su imposibilidad se propaga hacia arriba:

inject thread: FUERA DE ALCANCE
flowchart TD
    IT["inject thread
(and)"] --> RWX["allocate or change
RWX memory"] IT --> WPM["write process memory"] IT --> CT["create thread"] RWX --> N["number: 0x40
= PAGE_EXECUTE_READWRITE"] N -.->|inmediato → exige disasm| HARD["FUERA DE ALCANCE"] HARD ==>|arrastra la regla entera| IT WPM -.->|import| OK1["aproximable"] CT -.->|import| OK2["aproximable"]

Este es el resultado más instructivo de la serie hasta aquí: capa-real reconoce los ingredientes de la inyección —la escritura en memoria de proceso, la creación de hilo— pero no la técnica compuesta. Ve la harina y los huevos; no puede confirmar el pastel. Y no por una carencia del motor, sino porque la evidencia que distingue una inyección de un uso legítimo de esas APIs —la bandera RWX, y que todo ocurra junto— vive en un plano que el análisis estático no alcanza. (Lo mismo ocurre con spawn thread to RWX shellcode, que hace match: allocate or change RWX memory y por tanto hereda el mismo destino: fuera de alcance.)

inject APC: el contraste quirúrgico#

Ahora la lección se vuelve fina. Otra técnica de inyección, APC (Asynchronous Procedure Call, T1055.004 ), es conceptualmente hermana de la anterior: escribir en otro proceso y encolar una rutina para que se ejecute. Uno esperaría el mismo veredicto. Miren la regla:

rule:
  meta:
    name: inject APC
    namespace: host-interaction/process/inject
    scopes:
      static: function
    att&ck:
      - Defense Evasion::Process Injection::Asynchronous Procedure Call [T1055.004]
  features:
    - and:
      - or:
        - match: write process memory
        - match: create or open section object
        - api: kernel32.MapViewOfFile
      - or:
        - api: kernel32.QueueUserAPC
        - api: ntdll.NtQueueApcThread
        - api: ntdll.NtQueueApcThreadEx
      - optional:
        - or:
          - number: 0x1fffff = THREAD_ALL_ACCESS
          - api: kernel32.CreateProcess
          - api: kernel32.OpenProcess

Regla host-interaction/process/inject/inject-APC (recortada) — capa-rules (Mandiant, Apache-2.0).

Los dos primeros requisitos obligatorios son or de APIs (write process memory / MapViewOfFile, y QueueUserAPC / NtQueueApcThread): todos imports, todos file-scope. ¿Y la constante dura? Ahí está la clave: number: 0x1fffff (THREAD_ALL_ACCESS) —el equivalente al 0x40 de antes— vive bajo optional. Y optional, como definimos en la Parte 0, es descriptivo: documenta lo que suele acompañar a la técnica, pero no afecta al match.

Cuando capa-real compila esta regla, poda la rama optional —incluido su number imposible— y se queda con dos or de APIs, ambos evaluables. El requisito difícil no era obligatorio; era una nota al pie.

inject APC: APROXIMADA

El contraste es exacto y vale como moraleja de toda la parte:

inject thread y inject APC son casi la misma técnica. La diferencia entre “fuera de alcance” y “aproximable” no está en lo que hacen, sino en dónde la regla pone su requisito duro: en inject thread, el number: 0x40 es obligatorio y hunde la regla; en inject APC, el number: 0x1fffff está bajo optional y se poda sin consecuencias. Un operador de la gramática —optional— decide de qué lado de la frontera cae la capacidad.

Es la demostración más limpia de por qué en esta serie no basta con clasificar reglas por su táctica: dos reglas de la misma carpeta, host-interaction/process/inject, con nombres casi iguales, caen a lados opuestos por un detalle de estructura. Leer capa bien es leer el árbol, no el título.

Persistencia: cuando la ruta no basta#

Cruzamos a la persistencia, y reaparece el mismo principio con otra cara. Tomemos la técnica más común: escribir tu binario en la clave Run del registro para que Windows lo lance en cada inicio de sesión. Ejemplo canónico, el Lab 06-03 del PMA:

rule:
  meta:
    name: persist via Run registry key
    namespace: persistence/registry/run
    scopes:
      static: function
    att&ck:
      - Persistence::Boot or Logon Autostart Execution::Registry Run Keys / Startup Folder [T1547.001]
    mbc:
      - Persistence::Registry Run Keys / Startup Folder [F0012]
  features:
    - and:
      - or:
        - match: set registry value
        - number: 0x80000001 = HKEY_CURRENT_USER
        - number: 0x80000002 = HKEY_LOCAL_MACHINE
      - or:
        - string: /Software\\Microsoft\\Windows\\CurrentVersion\\Run/i
        - string: /Software\\Microsoft\\Windows\\CurrentVersion\\Policies\\Explorer\\Run/i
        - string: /System\\(ControlSet\d{3}|CurrentControlSet)\\Control\\Session Manager\\BootExecute/i

Regla persistence/registry/run/persist-via-Run-registry-key (recortada) — capa-rules (Mandiant, Apache-2.0).

Léanla como un and de dos mitades. La segunda es un or de cadenas: las rutas de las claves de auto-arranque. Eso, a solas, es file-scope puro —capa-real ve esas cadenas sin problema—. Pero la primera mitad exige la evidencia de escritura: o match: set registry value, o una de las constantes HKEY_* (que son number, inmediatos → fuera de alcance). Y set registry value, aunque se apoye en APIs como RegSetValueEx, capa la modela acoplando la llamada con la clave que se abre y escribe —de nuevo, coincidencia dentro de una función—.

persist via Run registry key: FUERA DE ALCANCE — La regla no se conforma con «el binario menciona la clave Run»; exige «el binario escribe en la clave Run», y el acoplamiento entre la ruta y la primitiva de escritura, en la misma función, es lo que el file-scope no puede confirmar.

Y aquí está el matiz más honesto de toda la serie, que conviene no esquivar:

Precisión contra cobertura, en una regla. Una versión ingenua de este motor —la heurística capa-lite que APT115 tenía antes— marcaba persistencia con solo ver la cadena de la clave Run. Cazaba más (cobertura), pero con más falsos positivos: cualquier binario que mencione la ruta —un instalador, una utilidad de limpieza, el propio antivirus— disparaba la alerta. El capa real exige acoplar la ruta con la prueba de escritura, y por eso es más preciso. Pero ese acoplamiento es exactamente lo que un motor file-scope no puede verificar. Conclusión incómoda y verdadera: capa-real cede cobertura precisamente donde ganaría precisión. Prefiere no afirmar la persistencia antes que afirmarla sobre una evidencia parcial. Es la misma disyuntiva que atraviesa la serie Nightmare Eclipse —cazar la clase de comportamiento sin inflar el veredicto—, aplicada al detalle de una sola regla.

El resto de la familia de persistencia se comporta igual, y por la misma razón. Persistir vía servicio de Windows (T1543.003 ) acopla CreateService con la constante SERVICE_AUTO_START (number: 2) en un bloque, o bien la creación de proceso con las cadenas sc create / New-Service. Programar una tarea (T1053.005 ) acopla la ejecución de schtasks /create (o Register-ScheduledTask) con la evidencia de que se está lanzando un proceso. En los dos casos, la cadena delatora (sc, schtasks) es file-scope, pero la regla exige además la prueba de que se ejecuta, y ese acoplamiento cae del lado difícil.

persist via Windows service · schedule task via schtasks: FUERA DE ALCANCE

El mapa de la frontera, segunda entrega#

CapacidadLa evidencia que la distingueEn capa-real
Escribir en memoria de procesoImport (WriteProcessMemory)Aproximada
Crear hiloImport (CreateThread…)Aproximada
Inyección por APCAPIs obligatorias; el number duro va en optionalAproximada
Reservar/cambiar memoria RWXnumber: 0x40 en un bloqueFuera de alcance
Inyección de hiloArrastrada por el requisito RWXFuera de alcance
Persistir vía clave RunRuta (file) acoplada a la escrituraFuera de alcance
Persistir vía servicio / tareaCadena (file) acoplada a la ejecuciónFuera de alcance

El patrón de esta parte es nítido y complementa el de la anterior. En la Parte 1 vimos que el file-scope pierde lo que solo existe como forma o valor dentro de una instrucción (cripto, hashes, anti-debug). Aquí vemos la otra mitad: pierde también lo compuesto —lo que exige que dos evidencias, cada una visible por separado, coincidan en el mismo sitio—. La inyección y la persistencia son casi todas de este tipo: sus ingredientes están a la vista, pero la receta que los une no. Por eso son, de todas las tácticas, la zona donde capa-real más cede.

Para el defensor#

Qué llevarse a la práctica.

  • Los imports de inyección son un indicio, no una conclusión. WriteProcessMemory + CreateRemoteThread + VirtualAllocEx en la misma tabla de imports es el trío clásico de inyección y merece triage, pero —igual que capa-real— tu análisis estático no puede confirmar que se usen juntos y con PAGE_EXECUTE_READWRITE. Para eso hace falta un sandbox (ver la llamada con el flag 0x40) o desensamblar. Trátalo como “candidato a inyección”, no como “inyecta”.
  • La constante 0x40 es oro en dinámico. Lo que el file-scope no ve, la telemetría de ejecución sí: una llamada a VirtualAllocEx/VirtualProtect con flProtect = 0x40 (RWX) contra otro proceso es una de las señales de inyección de mayor fidelidad y bajísimo ruido. Es justo el eslabón que capa modela y que el estático pierde: cázalo en tu EDR.
  • Para persistencia, no te quedes en la cadena. Ver la ruta de la clave Run o schtasks en un binario es barato pero ruidoso (medio software legítimo las menciona). La señal de calidad es la acción: la escritura efectiva en ...\CurrentVersion\Run, la creación de un servicio SERVICE_AUTO_START, el alta de una tarea. Correlaciona la cadena con el evento de escritura/creación —Sysmon EventID 13 (registro), 12/7045 (servicio)— y habrás reconstruido, en detección, justo el acoplamiento que la regla de capa exige y que el estático no puede.

En la Parte 3 seguimos el kill chain hacia la acción sobre el objetivo: evasión de defensas, acceso a credenciales y descubrimiento. Ahí el file-scope recupera terreno —el robo de secretos en la nube y buena parte del reconocimiento se detectan por rutas de fichero e imports— y volveremos a ver la frontera moverse a favor del análisis estático.

Referencias#