capa-real es un motor que reimplementé en APT115 —mi herramienta de triage en el navegador— que evalúa un subconjunto del ruleset de capa sin desensamblar nada. Esta parte lo abre: cómo decide qué reglas puede evaluar, cómo las ejecuta, y qué aprende uno sobre los límites del análisis estático al construir la cosa en vez de solo describirla. Es el cierre de la tesis de la serie.Por qué reimplementar un motor que ya existe#
capa, el de Mandiant, ya funciona y funciona bien. Reescribir un subconjunto suyo no es para reemplazarlo: es para entenderlo, y para llevarlo a un sitio donde el original no llega. capa necesita vivisect para reconstruir el grafo de control de un binario —el CFG—; eso es un desensamblador completo, imposible de embarcar en una pestaña del navegador que analiza un fichero en file:// sin subir nada a ningún lado. APT115 es exactamente eso: un triage que corre offline, del lado del cliente, sin backend. La pregunta que lo motivó es la misma de toda la serie: ¿cuánto del análisis de capacidades se puede hacer sin el CFG? La única forma de responderla con números fue construir el motor y medir.
El resultado es capa-real: un compilador que corre una sola vez (al empaquetar la herramienta) y un evaluador que corre en el navegador. La cadena es corta y vale la pena verla entera:
flowchart LR
A["capa-rules
~1055 .yml"] -->|build-capa-pack.mjs
vendor-time| B["gate
clasifica cada regla"]
B -->|descarta hard| X["444 fuera de alcance"]
B -->|baja a forma compacta| C["pack JSON
609 reglas · 795 KB"]
C -->|carga lazy| D["evaluador en el navegador
capa-real.js"]
E["binario del usuario
imports · strings · secciones"] --> D
D --> F["capacidades + ATT&CK
exactas / aprox."]El compilador y su gate: qué se puede evaluar, y por qué tan poco#
El corazón de capa-real no es el evaluador —ese es un intérprete de árbol de veinte líneas—, sino el gate: la función que, en tiempo de compilación, decide si una regla es evaluable sin desensamblar. Toda la serie ha sido, en el fondo, un recorrido por las decisiones de esta función. Aquí está su núcleo, tal cual:
const FILE_OK = new Set(['api','import','string','substring','regex',
'bytes','format','os','arch','export','section']);
const HARD = new Set(['number','offset','mnemonic','operand',
'instruction','class','property','com']);
function classifyNode(node, seen, memo) {
let worst = 'file';
const bump = (s) => { if (rank(s) > rank(worst)) worst = s; };
for (const item of node) {
const key = Object.keys(item)[0], val = item[key];
if (key === 'and' || key === 'or' || key === 'not') bump(classifyNode(val, seen, memo));
else if (key === 'optional') continue; // ← la única poda
else if (key === 'match') bump(classifyRule(String(val), seen, memo));
else if (FILE_OK.has(key)) bump('file');
else bump('hard');
}
return worst;
}
scripts/build-capa-pack.mjs — APT115 (código propio).
Léanla con lo aprendido y encajará todo. Dos conjuntos parten el vocabulario de features de la Parte 0 en dos: FILE_OK son las que el triage extrae en reposo (imports, cadenas, bytes, formato, secciones, exports); HARD son las que exigen el CFG (number, offset, mnemonic, instruction…). Y la regla de combinación es la más conservadora posible: classifyNode devuelve el peor caso de todos sus hijos —bump sube el nivel, nunca lo baja—, y lo hace también dentro de un or.
Esto es sutil y es la clave de casi todos los veredictos de la serie. El gate no busca un camino evaluable; exige que todo el árbol lo sea. Si una sola hoja obligatoria —aunque sea una de las alternativas de un or— es HARD, la regla entera cae fuera de alcance. Por eso:
persist via Run registry key(Parte 2) es fuera de alcance aunque su rama de la ruta sea file-scope: la otra rama delandacopla la escritura, y ahí hay unnumber(HKEY_*) y unmatcha una regla hard. El peor caso manda.encrypt data using AES via WinAPI(Parte 4) es fuera de alcance aunque llame aCryptGenKey: elnumber: CALG_AESobligatorio basta para hundirla.inject thread(Parte 2) cae por suallocate or change RWX memory, que contienenumber: 0x40.
Y la única excepción —la que explica por qué inject APC y enumerate processes sí pasan— es esa línea else if (key === 'optional') continue;. El gate poda optional antes de clasificar, porque optional nunca afecta al match. Un requisito duro escondido bajo optional desaparece, y la regla se salva. Todo el contraste quirúrgico de la Parte 2 —dos reglas de inyección casi idénticas a lados opuestos de la frontera— se reduce, en el motor, a esa única línea.
capa-real es esa conservadura. Un motor menos honesto intentaría “aprovechar” la rama file-scope de una regla mixta y afirmar la capacidad sobre evidencia parcial —más cobertura, más falsos positivos—. capa-real hace lo contrario: si no puede evaluar la regla completa, la descarta y no dice nada. Es la misma ética que recorre Nightmare Eclipse —no inflar la confianza más allá de lo que la evidencia sostiene—. La consecuencia es un motor que cede cobertura para no perder precisión: cuando capa-real afirma una capacidad exacta, es tan fiable como capa; cuando calla, es porque de verdad no puede saberlo sin el CFG.Tras clasificar por features, una última línea produce el matiz exacta vs aproximada que etiqueta toda la serie:
if (base === 'file' && r.scope !== 'file') base = 'approx';
Si una regla usa solo features de fichero pero su scope estático es function o basic block, capa-real puede evaluarla —degradando el scope a «feature en cualquier parte»— pero no confirmar que las features concurren en la misma función. Eso es una aproximación. De ahí salen los tres cubos, verificables en el pack embarcado (static/apt115/data/capa-rules.js):
| Clasificación | Reglas | Qué significa |
|---|---|---|
| Exacta (file-scope puro) | 163 | Evaluada idéntica a capa |
| Aproximada (scope degradado) | 446 | Feature presente, sin confirmar la función |
| Fuera de alcance (necesita CFG) | 443 | Descartada por el gate |
| Evaluable | 609 / 1052 = 57,9 % |
El pack: bajar el YAML a algo ejecutable#
Las 609 reglas que pasan el gate no viajan como YAML —parsearlo en el navegador sería lento y pesado—. El compilador las baja (lowering) a un árbol compacto de arrays que el evaluador interpreta sin dependencias. La leyenda de esa forma, del propio código:
// node: ['&',[...]] ['|',[...]] ['!',node] ['#',N,[...]] ['m',name]
// ['a',api] ['s',substr] ['r',pat,flags] ['b',hex]
// ['f',fmt] ['o',os] ['c',arch] ['e',export] ['n',section]
src/triage/capa-real.js — APT115.
Así, la regla packed with UPX de la Parte 0 termina como ['|',[['&',[['f','pe'],['|',[['n','upx0'],['n','upx1']]]]], ...]]. El lowering aprovecha el paso para podar lo que el runtime no necesita —optional, description, comment— y para cortar los match: que apunten a reglas no evaluables. El pack resultante son ~795 KB de JSON que se cargan de forma perezosa (una etiqueta <script>, offline, sin CDN, segura en file://) solo cuando el usuario analiza un binario. Cada regla lleva su namespace, sus IDs de ATT&CK y MBC, y un flag x:1 si es aproximada.
El evaluador: un intérprete de árbol y dos gotchas#
Con el árbol ya compacto, evaluarlo es un recorrido recursivo directo. El núcleo cabe en una pantalla:
function evalNode(node, F, byName, cache, stack) {
switch (node[0]) {
case '&': return node[1].every((k) => evalNode(k, F, byName, cache, stack));
case '|': return node[1].some((k) => evalNode(k, F, byName, cache, stack));
case '!': return !evalNode(node[1], F, byName, cache, stack);
case '#': { let c = 0; for (const k of node[2]) if (evalNode(k, ...)) c++; return c >= node[1]; }
case 'm': return evalRule(node[1], F, byName, cache, stack); // composición
case 'a': return matchApi(F, node[1]);
case 'r': return matchRegex(F, node[1], node[2]);
case 'n': return F.sections.has(node[1]);
// ...
}
}
src/triage/capa-real.js — APT115.
F es la bolsa de features que el triage ya extrajo del binario: el conjunto de imports, las cadenas (crudas y en minúscula), los bytes, el formato, las secciones, los exports. Ninguna requiere desensamblar. El recorrido lleva un cache (memoización por nombre de regla, porque muchas se referencian entre sí vía match) y un stack (guarda anti-ciclo: dos reglas que se citaran mutuamente colgarían el motor). Es un evaluador de libro. Lo interesante no es el algoritmo, sino los dos detalles feos que solo aparecen cuando uno intenta que matchee de verdad.
Gotcha 1: la API calificada. capa nombra sus APIs con el módulo (kernel32.SetEndOfFile) o, en .NET, con la clase (System.Environment::GetCommandLineArgs). Pero la tabla de imports que extrae el triage guarda solo el nombre de la función (SetEndOfFile). Un match literal fallaría en cerca del 40 % de las hojas api del ruleset. La solución es probar el literal completo y la cola —lo que va tras :: o el último .—:
function apiCandidates(name) {
const lo = String(name).toLowerCase();
const cands = [lo];
if (lo.includes('::')) cands.push(lo.slice(lo.lastIndexOf('::') + 2));
else { const d = lo.lastIndexOf('.'); if (d >= 0) cands.push(lo.slice(d + 1)); }
// ...normaliza alias A/W, ordinales, etc.
}
src/triage/capa-real.js — APT115.
Es un detalle mundano y absolutamente crítico: sin él, el motor “funciona” en las pruebas simples y falla en silencio en casi la mitad de las reglas reales. Construir el intérprete enseña estas cosas que leer el formato nunca revela.
Gotcha 2: los exports de PE. La feature export: (la que hacía exacta a act as credential manager DLL en la Parte 3) depende de la tabla de exportaciones. El parser de PE de APT115 no la expone hoy, así que export: solo matchea en ELF y Mach-O. Es un límite honesto del motor, anotado en el propio código —no todo lo que el gate declara evaluable, el evaluador lo evalúa con la misma plenitud en todos los formatos—.
La disciplina del veredicto: no reinflar#
Queda la decisión de diseño que más me importa, y que conecta con el tema de fondo de Nightmare Eclipse: una capacidad detectada no sube el veredicto de malicia. Podría parecer natural que “más capacidades ofensivas = más peligroso”, pero es una trampa. Un binario empaquetado o un instalador legítimo puede matchear docenas de reglas; contarlas infla el score sin añadir certeza. capa-real lo evita: cada capacidad entra al veredicto como info —peso cero directo—, y solo un subconjunto «hot» genuinamente ofensivo pesa un poco, y capado:
const HOT = /(\/inject|\binject\b|\/keylog|\/screenshot|credential|\/c2(\/|$)|
^load-code|inhibit-system-recovery|cryptocurrency|\/ransom|anti-forensic)/;
src/triage/capa-real.js — APT115 (regex HOT, recortada).
capa-real mejora la descripción del binario —qué hace, con qué técnicas de ATT&CK—, no el número que dice si es malo. La distinción es la misma que la serie Nightmare hacía con el CVSS: una métrica que mide la cosa equivocada termina ordenando mal la atención. El recuento de capacidades es informativo, no acusatorio; el motor lo trata como tal.
Lo que se puede leer sin desensamblar#
Y así se responde, con un número y con sus razones, la pregunta que abrió la serie. El 57,9 % del ruleset de capa es evaluable sin un desensamblador. Traducido a comportamiento, y recogiendo las cuatro partes:
- Se lee bien (exacto o aproximado): la identidad del binario (compilador, packer), el robo de secretos por rutas y cadenas (credenciales cloud, contraseñas de navegador), el reconocimiento por API, la comunicación de red, el keylogging, las señales periféricas del ransomware. Todo lo que el malware nombra o importa para actuar.
- No se lee (necesita el CFG): la criptografía por algoritmo o por constante, la inyección y el hollowing, el tampering de AMSI/ETW, el anti-debug, la resolución de API por hash, la persistencia que acopla ruta y escritura. Todo lo que el malware hace en vivo o esconde dentro de una instrucción.
La frontera, resultó, no es caprichosa: separa lo que un binario declara de lo que un binario ejecuta. El análisis estático es una lectura de declaraciones —y es una lectura sorprendentemente rica, más de la mitad del catálogo de comportamientos de una de las mejores herramientas del oficio—. Pero hay un 44 % que solo confiesa en movimiento, y para ese, ni capa-real ni ningún triage estático sustituyen al desensamblador o al sandbox. Saber cuál de los dos mundos habita cada comportamiento —eso, y no una lista de firmas— es lo que capa-rules enseña a quien se toma el trabajo de leerlo regla por regla.
Referencias#
- capa (Mandiant/Google): https://github.com/mandiant/capa
- capa-rules (Apache-2.0): https://github.com/mandiant/capa-rules
- vivisect — el CFG en el que capa apoya lo que
capa-realno puede: https://github.com/vivisect/vivisect - APT115 — la herramienta donde vive
capa-real: manual de APT115 - El código de
capa-real:src/triage/capa-real.jsyscripts/build-capa-pack.mjsen el repositorio de APT115.