QuietPolyfill, an NPM dropper that resurrected

QuietPolyfill: An NPM dropper that resurrected

TL;DR

Twenty npm packages published across two days in late July and early August 2026 present themselves as internal UI component libraries and shared backend utilities. None of them implement the functionality they advertise. Each ships a stub class, a README that describes the package as internal, and a file called _polyfill.js.

The interesting part is where that file is invoked from. It is not an install hook — the manifests declare no preinstall, install, or postinstall at all. Instead, the last line of index.js reads: try { require("./_polyfill"); } catch (_) {}. The payload runs the first time application code imports the package. Installing with --ignore-scripts, the standard advice for reducing supply-chain exposure at install time, does not prevent it.

What _polyfill.js does is retrieve a platform-specific executable from one of three Cloudflare Workers hosts, write it to a temporary directory under a randomised name, mark it executable, and launch it detached from the parent process. That executable is itself a loader: it enumerates endpoint-security products by process name, checks the current username against known analysis-environment accounts, patches the AMSI and ETW telemetry interfaces in memory, installs persistence under the name of a Windows audio component, and retrieves a third binary several megabytes in size. The third stage arrives encrypted and is decrypted and executed in memory rather than written to disk.

On 1 August, roughly four hours after the packages were confirmed malicious on manual review, nineteen of the twenty names were removed from npm by their own publisher in a twenty-five-second sequence. On 2 August one of those names was registered again — by a different account, carrying the same code. The delivery infrastructure was serving all three stages when this was written.

Packages20 npm names, ~74 published versions
Naming themesbeaver-ui-* (13), accounts-* (3), plus afisha-storybook-default, bcore-bravo-eslint-config, arbocrate-sla-prober-arbocrate-sla-prober-core, a.poltoradnev-package-c
TriggerModule import (require), not an npm lifecycle script
Stage 1_polyfill.js — retrieval and launch
Stage 2Statically-linked loader, UPX-packed on Linux; endpoint-security enumeration, AMSI/ETW patching, persistence
Stage 3beacon_linux.bin / beacon_mac.bin / update_win.exe, 4–13 MB; delivered encrypted, executed in memory
PlatformsLinux x64 and arm64, macOS universal, Windows x64
PublishersTwo accounts, one codebase
Status19 of 20 names unpublished by the operator; 1 republished and live; delivery infrastructure live

Attack anatomy

Stage 1 — the package

Every member ships the same three-file shape. index.js exports a class with an init() that sets a boolean, a version(), and a configure() that merges an options object. Nothing else. README.md gives an install line, a require line, and the sentence “Internal package. See CONTRIBUTING.md for guidelines” — CONTRIBUTING.md is not in the tarball.

_polyfill.js is a little under 5 KB and reads as ordinary infrastructure code. It opens with three environment checks:

if (process.env.DISABLE_TELEMETRY ||
    process.env.ANALYTICS_OPT_OUT ||
    process.env.DO_NOT_TRACK)
  return;

Those variables are conventional privacy controls. Here they gate whether a binary is downloaded and executed. A machine that sets DO_NOT_TRACK is skipped entirely.

Next comes a run-once stamp: a file at /tmp/.analytics_state, or analytics_state under %TEMP% on Windows, whose modification time is compared against a 20,357-second window. Within that window the module returns immediately, so repeated imports during a build produce one retrieval attempt rather than dozens.

The destination hosts are not present as literals. They are assembled at runtime from fragments:

["oob-worker.cf102-","baf.workers.d","ev"].join("") 

Three hosts are built this way and shuffled before use, so the order varies per execution. Retrieval is an HTTPS GET pinned to IPv4 with a User-Agent of node-fetch/2.6 — a plausible value for a Node process to emit. A response under 1,000 bytes is discarded and the next host is tried.

If all three fail, the module falls back to DNS. It issues a TXT query for c.<domain> to obtain a chunk count, then requests 0.<domain> through n.<domain> in batches of ten, concatenates the answers, and base64-decodes the result. This path carries the same executable over DNS TXT records, which survives environments where outbound HTTP is filtered but DNS resolution is not.

The retrieved bytes are written to /var/tmp/.cache_<8-hex> on Unix, or %TEMP%\dotnet_diag_<8-hex>.exe on Windows — a filename that reads as a .NET diagnostics artifact. On Unix the file is given mode 0755. Launch is spawn with detached: true, stdio: “ignore”, and unref(), so the child outlives the Node process that started it; on Windows the invocation adds windowsHide: true.

Two details complete the picture. A helper defined as const _l = (…a) => process.stderr.write(“”) is called at each step of the retrieval — it writes an empty string and discards its arguments, occupying the position where logging would sit. And every package ships an 81 KB file at lib/telemetry.js whose header comment describes an “Analytics SDK” with “HTTPS transport with endpoint rotation and retry” and “DNS-based service discovery for failover”. In the current build nothing requires it.

Stage 2 — the loader

The Linux x64 binary is a statically-linked ELF compressed with UPX 3.96. The macOS build is a universal binary covering x86_64 and arm64; the Windows build is a stripped PE32+ console executable. All four were retrieved and examined statically; none were executed.

The Windows build’s imports and string table describe its behaviour plainly. It resolves IsDebuggerPresent, CheckRemoteDebuggerPresent, and NtQueryInformationProcess, and reads SYSTEM\CurrentControlSet\Services\Disk\Enum — a registry path whose values identify virtual disk controllers. It calls GetDiskFreeSpaceExW and GlobalMemoryStatusEx, consistent with checking whether the host’s resources match a short-lived analysis instance.

It carries a list of usernames: CurrentUser, IT Admin, Hong Lee, TESTBOX, hapubws, milozs, timmy, admin, malware, sample, virus, environment. Several of these are artifacts of publicly documented analysis environments.

It also carries a list of endpoint-security products and their process names, enumerated through CreateToolhelp32Snapshot and Process32FirstW/Process32NextW:

  • CrowdStrike Falcon (csfalconservice, CSFalconContainer)
  • Carbon Black (cb.exe)
  • Cylance (cylancesvc.exe)
  • Bitdefender (bdagent.exe)
  • Kaspersky (avp.exe, kavfs.exe)
  • Trend Micro and Deep Security (coreServiceShell, ds_agent.exe)
  • FireEye/Trellix (xagt.exe)
  • Tanium (taniumclient.exe)
  • HitmanPro (hmpalert.exe)
  • Elastic (elastic-agent.exe, elastic-endpoint, filebeat.exe, winlogbeat.exe)
  • Wazuh and OSSEC (wazuh-agent.exe, ossec-agent.exe)
  • Check Point
  • Windows Defender

Two further symbols are present: AmsiScanBuffer, the entry point Windows exposes for in-memory content scanning, and EtwEventWrite, the function through which Event Tracing for Windows records events. Both are common targets for in-process modification that suppresses the telemetry those interfaces would otherwise produce.

Persistence appears twice on Windows. The binary is copied to %APPDATA%\Microsoft\Windows\Themes\SystemSoundsService.exe, and two mechanisms reference it: a value under SOFTWARE\Microsoft\Windows\CurrentVersion\Run, and a scheduled task registered as

schtasks /Create /F /SC ONLOGON
  /TN "\Microsoft\Windows\Multimedia\SystemSoundsService"
  /TR "\"%s\""
  /RL HIGHEST

The task path, task name, and executable name all mirror a genuine Windows audio component. /RL HIGHEST requests the highest available privilege level.

The macOS build follows the same pattern with platform-appropriate names: it writes a property list to ~/Library/LaunchAgents/com.apple.windowserver.helper.plist and places its executable at ~/.local/share/runtime/com.apple.runtime. It checks for /Applications/VMware Fusion.app and /Library/Application Support/VMware Tools.

Network capability spans three transports: WININET.dll for HTTPS with a https://%s:%d%s format string, WS2_32.dll sockets, and DNSAPI.dll with DnsQuery_A alongside a c.%s.%s format string — the same chunk-count query shape the first stage uses.

The description above is what the binary’s imports, strings, and registry paths show on inspection. Public multi-engine analysis of the same Windows sample adds detail that a static read of an import table cannot establish on its own, and is attributed here rather than restated as first-hand observation. That analysis reports that the AMSI and ETW references are not merely resolved but patched — AmsiScanBuffer in amsi.dll and EtwEventWrite in ntdll.dll — which suppresses in-memory content scanning and event tracing for the process. It reports the anti-analysis checks as timing-based in addition to CheckRemoteDebuggerPresent, and identifies the GetThreadContext call as a hardware-breakpoint check reading the debug registers. It also reports a single-instance mutex, Global\{7A3B9E1F-C2D4-4F8A-B5E6-D7A1F0C3E2B9}, and names SystemSoundsService as the Run-key value. That mutex value was not among the strings recovered here — the extraction filter used excluded braced uppercase-hex tokens — and it is recorded in the indicator table on the strength of that public analysis rather than direct observation.

Stage 3

The loader references a further set of paths on the same hosts. All were reachable during analysis: beacon_linux.bin at 5.3 MB, beacon_mac.bin at 13.3 MB, update_win.exe at 10.3 MB, and arm.bin at 4.3 MB. These were not retrieved, and their behaviour is unknown.

For the Windows path, public analysis of the loader describes how that third stage is handled: it is fetched over WinINet, arrives encrypted, and is decrypted in memory and executed reflectively — memory allocated, imports resolved, relocations processed, and control passed to a new thread via CreateThread. On that account the third stage is never written to the filesystem, so file-based detection and post-incident disk forensics have nothing to recover from it. It also means retrieving the bytes from the delivery host would yield ciphertext, not an analysable executable.

 

Timeline

All timestamps are UTC and come from npm registry metadata, which records package creation and unpublication independently of any observer.

When What
2026-07-31 09:25:05 → 09:29:56 Ten names created, each starting at version 0.0.1, roughly thirteen seconds apart.
2026-08-01 09:38 → 11:29 Existing names climb through inflated version numbers, 6.1.9 through 6.1.12, 9.9.9 through 9.9.12, 8.9.3 through 8.9.6, 5.4.6 through 5.4.9.
2026-08-01 13:04:14 → 13:04:18 Nine further beaver-ui-* names created in a four-second burst, each published directly at a 12.x version with no prior history.
2026-08-01 ~13:00–14:30 Versions classified as malicious on manual review.
2026-08-01 17:39:49 → 17:40:14 Nineteen of the twenty names unpublished in twenty-five seconds, sequentially, about 1.2 seconds apart.
2026-08-02 18:58:41 a.poltoradnev-package-c created again under a different maintainer account, version 0.0.2.
2026-08-02 19:20:28 Version 33.9.5 published to the same name.
2026-08-02 20:14 All three delivery hosts serving stages two and three; DNS fallback answering with chunk counts.

Two of these entries are worth dwelling on.

The 13:04 burst put nine packages on the registry in four seconds, each with a single version in the twelves and no 0.0.1 beneath it. Version numbers on this scale, on names one day old, are not describing a release history.

The unpublication sequence is equally mechanical: nineteen names, twenty-five seconds, even spacing. npm records these as ordinary unpublication events rather than the placeholder a registry-initiated removal leaves behind, which places the action with the publishing account rather than with the registry.

 

Indicators of compromise

Hosts and paths are defanged. Do not resolve or retrieve them from a production system.

Type Value
Delivery host oob-worker[.]cf99-9b3[.]workers[.]dev
Delivery host oob-worker[.]cf102-baf[.]workers[.]dev
Delivery host oob-worker[.]cf103-070[.]workers[.]dev
DNS transport tin[.]dl[.]well1[.]site (Linux x64), tina[.] (arm64), ldr[.] (macOS), win[.] (Windows) — chunk count at c.<domain>, data at <n>.<domain>
Stage-2 paths /pkg/package, /pkg/package-arm64, /pkg/loader_mac, /pkg/package.exe
Stage-3 paths /pkg/beacon_linux.bin, /pkg/beacon_mac.bin, /pkg/update_win.exe, /pkg/arm.bin
Drop path (Unix) /var/tmp/.cache_<8-hex>
Drop path (Windows) %TEMP%\dotnet_diag_<8-hex>.exe
Persistence (Windows) %APPDATA%\Microsoft\Windows\Themes\SystemSoundsService.exe; task \Microsoft\Windows\Multimedia\SystemSoundsService; Run-key value SOFTWARE\Microsoft\Windows\CurrentVersion\Run\SystemSoundsService
Mutex (Windows) Global\{7A3B9E1F-C2D4-4F8A-B5E6-D7A1F0C3E2B9}
Persistence (macOS) ~/Library/LaunchAgents/com.apple.windowserver.helper.plist; binary at ~/.local/share/runtime/com.apple.runtime
Run-once stamp /tmp/.analytics_state, %TEMP%\analytics_state (20,357 s window)
Request signature HTTPS GET, IPv4-pinned, User-Agent: node-fetch/2.6
Stage-1 file _polyfill.js, SHA-256 392dc9ec85042ce1796bfa9b8c7bf6e05c7710579aab8b7b1eb73788683883af
Unreferenced file lib/telemetry.js, SHA-256 2266c9b8e199daebe31a9afcb61d9b0d76706b63c92312fc8223d22de42e63c7
Stage 2 (Linux x64) SHA-256 7e486657f30594afda379b97030252a09a19fe8055e25c9e371544f59bd8e9e3
Stage 2 (Linux arm64) SHA-256 aafc37589c0d21fb729761a1579ca3b278b95bce152aacfa29243ffd27f5eeb7
Stage 2 (macOS) SHA-256 c214746c74cae8ece8bdaf69aa05da4db6ce013f9e77452d1eed1a002fd9ba00
Stage 2 (Windows) SHA-256 d632c2c0c7801c3da57cf849fe4b4ca8351d9a7fe73c31050bf5dbcc96a16791

Package names

beaver-ui-drawer beaver-ui-actions-button beaver-ui-card-large beaver-ui-date-range-picker beaver-ui-form beaver-ui-form-modal beaver-ui-form-object beaver-ui-grid beaver-ui-header beaver-ui-hooks beaver-ui-icon-lock beaver-ui-items-with-more beaver-ui-layout accounts-final-form accounts-loading-state accounts-timeline afisha-storybook-default bcore-bravo-eslint-config arbocrate-sla-prober-arbocrate-sla-prober-core a.poltoradnev-package-c

Attribution and observed behaviour

Two npm accounts are involved. The nineteen unpublished names were published by one account; a.poltoradnev-package-c was re-registered on 2 August under a different account name and a different email domain. The code is the same lineage in both, and the second registration carries the same delivery hosts, the same DNS fallback domains, the same drop-path construction, and the same run-once stamp.

The republished pair also shows how it was assembled. Versions 0.0.2 and 33.9.5 are byte-identical apart from the version string in package.json, and the index.js shipped as 0.0.2 declares const VERSION = “33.9.5”. Both artifacts were cut from one source tree and numbered afterwards. There is no 0.0.2 development history for the 0.0.2 release to represent.

The version numbering across the whole set follows the same logic. Package names in this campaign read as things that would exist inside an organisation rather than on a public registry: a UI component set, an ESLint configuration, an SLA prober, a package named after a person. When a build resolves a dependency by name and consults a public registry alongside a private one, the higher version number is the one that wins. A name created at 0.0.1 on Monday and carrying 12.9.6 on Tuesday is positioned for that comparison.

The two publication waves also differ in a way that suggests iteration. The earlier code named its payload file setup.js and listed five delivery hosts; the current file is named _polyfill.js and lists three. The earlier version removed the dropped file five seconds after launch, leaving the process running from a deleted inode; the current version does not. The unreferenced lib/telemetry.js remains in the tarball across both, still shipped, no longer wired in.

One observation about the kill switches is worth stating precisely, because it cuts against a common reading. DISABLE_TELEMETRY, ANALYTICS_OPT_OUT, and DO_NOT_TRACK are honoured, and they are checked before anything else happens. A researcher whose analysis environment sets any of them observes a package that does nothing at all.

Impact and what defenders can do

The practical consequence of the import-time trigger is narrow and worth stating on its own. npm install –ignore-scripts prevents lifecycle scripts from running, and it is sound advice. It has no effect here, because this code does not use a lifecycle script. The payload runs when application code, a test run, a bundler, or a linter first imports the module. On a CI runner that installs and then immediately builds, the two are seconds apart.

For hunting, the durable signals are structural rather than literal. The hosts, the file name, and the drop paths will change; these shapes are harder to vary:

  • A module entry point whose final statement is a try { require(“./x”); } catch (_) {} around a file that is not part of the exported API. A package with a genuine polyfill imports it at the top and uses its result. Swallowing every error from a bottom-of-file import is not error handling.
  • Host strings assembled by .join(“”) from an array of fragments. Legitimate code has no reason to split a hostname across array elements.
  • A package whose declared dependencies are empty but which reaches for https, dns, and child_process at import time. Retrieval plus execution in a package that advertises a UI component is the whole finding.
  • Version numbers inconsistent with a package’s age. A name first published days ago at a double-digit major version, with no intervening releases, is describing something other than its development.
  • Writes to /var/tmp or %TEMP% followed by a chmod to 0755 and a detached spawn. The sequence is short, ordered, and rarely benign together.

On Windows hosts there are two cheap, high-confidence checks. The persistence artifacts are fixed strings and none of them belong on a clean system: a scheduled task at \Microsoft\Windows\Multimedia\SystemSoundsService, a Run-key value named SystemSoundsService, and an executable of that name under %APPDATA%\Microsoft\Windows\Themes\. The genuine Windows audio service does not live in any of those places. Second, the loader uses a fixed single-instance mutex, Global\{7A3B9E1F-C2D4-4F8A-B5E6-D7A1F0C3E2B9}; its presence on a host is an indicator, and because the loader treats an existing mutex as “already running”, pre-creating it is a viable short-term inoculation while remediation is scheduled.

Detection on the third stage is a harder problem and worth being clear about. On the account given by public analysis it is decrypted and mapped in memory rather than written to disk, so file-scanning and disk forensics will not see it. What remains observable is the loader’s own footprint — the persistence entries, the mutex, and outbound requests to the delivery hosts or the DNS-TXT domains.

For environments that resolve dependencies from both a private registry and a public one, the mitigation is unchanged by anything here: scope internal names so a public name cannot satisfy them, and pin resolution to the internal registry for those scopes. The version-inflation behaviour described above only works where a higher public version can win a name.

Finally, the unpublication is worth reading carefully rather than as an ending. Nineteen names left the registry in twenty-five seconds and one returned a day later under a new account, with the delivery infrastructure untouched throughout. Removal of packages is not removal of the operation; the hosts continued serving every stage while this was written. Any dependency tree that resolved one of these names between 31 July and 1 August should be treated as having had the opportunity to execute stage one, whether or not the name still exists.

References

sca-tools-software-composition-analysis-tools
Prioritize, remediate, and secure your software risks
Get your Free Account.
No credit card required.

Secure your Software Development and Delivery

with Xygeni Product Suite