Supply Chain

@ctrl/tinycolor and the 40-Package npm Wave of September 2025

@ctrl/tinycolor versions 4.1.1 and 4.1.2 shipped a credential-stealing payload that propagated to 40+ packages with 2 million combined weekly downloads in under 24 hours.

Yukti Singhal
Threat Researcher
5 min read

@ctrl/tinycolor is a TypeScript port of the venerable tinycolor2 library — a tool every modern design system imports somewhere in its theming stack. It rarely makes news. On September 14, 2025, it became the seed package for one of the most disruptive npm supply chain events of the year: versions 4.1.1 and 4.1.2 were published to the registry within minutes of each other, each containing a credential exfiltration payload that then spread to more than 40 unrelated packages across multiple maintainers. By the time StepSecurity, Socket, and Wiz had finished cross-referencing IOCs three days later, the wave had become known as Shai-Hulud and the official count was 526 packages backdoored, including CrowdStrike's open-source falcon-shoelace and commitlint-config packages.

How was @ctrl/tinycolor chosen as the seed?

The package's maintainer, who also publishes @ctrl/deluge, @ctrl/qbittorrent, and roughly two dozen other npm modules under the @ctrl/ scope, fell to a phishing email impersonating npm support. The lure asked the maintainer to "verify your 2FA settings" via npmjs.help — a lookalike domain registered three days earlier through Namecheap. The form proxied to legitimate npm endpoints, captured the TOTP code, and used it to mint a long-lived access token before the page redirected back to the real site. Kodem's reconstruction places the token creation at 13:47 UTC on September 14 and the first malicious publish at 14:03 UTC, a 16-minute window that mirrors the parallel chalk/debug compromise the same week.

What was actually inside versions 4.1.1 and 4.1.2?

Each tarball contained a new file called bundle.js at the root, weighing in at 3.5 MB minified. Snyk's deobfuscation revealed three layered components: a credential collector that read process.env, ~/.npmrc, ~/.aws/credentials, and ~/.docker/config.json; a secret scanner that shelled out to trufflehog and gitleaks against /; and a propagator that authenticated to the npm registry as the compromised user and republished other packages the account could write to.

// Behavior reconstructed from bundle.js (redacted)
const targets = await npmRegistry.getPackagesByMaintainer(authedUser);
for (const pkg of targets) {
  const tarball = await downloadLatest(pkg.name);
  const manifest = JSON.parse(await readEntry(tarball, "package.json"));
  manifest.scripts = manifest.scripts || {};
  manifest.scripts.postinstall = "node bundle.js";
  await injectAndRepublish(tarball, manifest, "patch");
}

What other packages did it reach?

Socket's running IOC list captured 40 named victims in the first 24 hours and the curve flattened past 500 within a week. Notable entries included angulartics2, ngx-bootstrap 18.1.4, koa2-swagger-ui, rxnt-authentication, rxnt-healthchecks-nestjs, swc-plugin-component-annotate, and the @crowdstrike/* set covering commitlint config, falcon-shoelace, foundry-js, glide-core, and tailwind-toucan-base. Each victim was a downstream of a compromised maintainer rather than a typosquat — a meaningful distinction, because the IOC could not be defeated by name-similarity defenses alone.

How do I detect a compromised install?

Three high-signal indicators exist. First, look for a bundle.js in any installed package over 1 MB that did not exist in the previous version's tarball. Second, search lockfiles for the specific malicious versions: @ctrl/tinycolor@4.1.1, @ctrl/tinycolor@4.1.2, ngx-bootstrap@18.1.4. Third, the propagator creates a public repository named Shai-Hulud on every compromised GitHub account — a GitHub Enterprise audit log query for action:repo.create AND repository_name:Shai-Hulud will surface infected users immediately.

# Lockfile detection for known-bad versions
jq -r '.packages | to_entries[] | select(
  (.key=="node_modules/@ctrl/tinycolor" and (.value.version=="4.1.1" or .value.version=="4.1.2")) or
  (.key=="node_modules/ngx-bootstrap" and .value.version=="18.1.4")
) | .key + "@" + .value.version' package-lock.json

# Detect Shai-Hulud GitHub repos created in the blast window
gh api -H "Accept: application/vnd.github+json" \
  /search/repositories?q=Shai-Hulud+created:2025-09-14..2025-09-20 \
  --jq '.items[] | .full_name'

What were the second-order consequences?

Phoenix Security's incident report tracked 2,349 credentials harvested from 1,079 developer machines across the wave. More than 1,100 of those remained valid 48 hours after the public disclosure because the affected organizations did not have automated rotation tied to a leak feed. The most damaging second-order use was the conversion of 10,767 private GitHub repositories to public via the GitHub API, exposing 82,901 additional secrets — a downstream pattern previously seen only in the Nx s1ngularity attack three weeks earlier. CrowdStrike's compromised packages, while limited in usage, generated outsized headlines because the company is a security vendor and the IOCs landed in a CISA advisory the same day.

What did the maintainer and the registry do?

The legitimate maintainer regained access within five hours by working through npm's emergency security contact and rotating to a FIDO2 hardware key. Versions 4.1.1 and 4.1.2 of @ctrl/tinycolor were unpublished by npm staff on September 15, 2025 — npm's 24-hour unpublish window was extended administratively for this event because of the cross-account impact. The legitimate @ctrl/tinycolor 4.1.3 was published on September 16 as a clean re-release pointing at the same code as 4.1.0. npm's Sigstore-backed provenance flow, which generally available since October 2023, was not enabled for this package — a fact that GitHub used as the motivating example for its September 23 push to deprecate legacy publish tokens in favor of trusted publishing.

How Safeguard Helps

Safeguard's lockfile diff scanner flagged the malicious @ctrl/tinycolor@4.1.1 publish within 11 minutes of the IOC entering OSV — every project that pulled the bad version was surfaced with the path through the dependency tree visible, so triage moved straight to remediation. Postinstall script audits enforce a deny-by-default rule for tarballs that introduce new binary blobs over 1 MB, which would have blocked the bundle.js payload at install. Provenance verification rejects unsigned tarballs at the package-proxy layer, and the malicious-package feed sources from Socket, Snyk, ReversingLabs, and OSV so detection latency is measured in minutes, not days. Griffin AI cross-references compromised npm maintainers against your product portfolio and highlights every downstream package that shares an owner with a known-victim account — a critical second-order signal during waves where one phishing email becomes 40 backdoors before lunchtime.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.