How Supply Chain Attacks Actually Work: The Anatomy of XZ, SolarWinds, and the npm Sagas
Most security failures are about someone getting in who should have been kept out. Supply chain attacks are the opposite: the attacker is invited, because the code they poisoned arrives wearing the credentials of something you already trust. The xz library on your system was installed by your distribution’s package manager from a signed release. SolarWinds Orion was delivered through the vendor’s own auto-update channel, signed with the vendor’s own certificate. The malicious npm package ran because you typed npm install and a postinstall hook did exactly what hooks are designed to do. In each case every signature checked out, every checksum matched, and every trust boundary held — because the attacker had already moved upstream of the boundary. Understanding these attacks means understanding the trust relationships that make modern software economically possible, and then watching, in forensic detail, how three of the most consequential incidents of the decade subverted them. Only then do the defenses make sense, because the wrong defenses are worse than none: they buy confidence without buying safety.
The Anatomy of Trust: Where the Chain Actually Lives
A “software supply chain” is not a metaphor for vague risk. It is a concrete sequence of handoffs, each with its own trust assumption, and an attacker only needs one of them.
SOURCE DEPENDENCIES BUILD DISTRIBUTION UPDATE
------ ------------ ----- ------------ ------
developer commits --> third-party libs --> compiler/CI --> registry/CDN --> auto-update
| | | | |
trust: the dev trust: the lib trust: the trust: the trust: the
wrote what they is what its name build produced artifact came update is
meant to implies the source from the build benign
| | | | |
[maintainer [typosquat, [SUNSPOT swaps [signed-but- [Orion auto-
hijack: XZ] dep confusion, source mid- malicious update ships
event-stream] compile] artifact] SUNBURST]
The recurring lesson reading across that diagram is that the attacks cluster in the middle and the right, not the left. We have spent twenty years hardening source: code review, branch protection, signed commits. We have spent comparatively little hardening the build and the transitive dependency graph, which is precisely where the expensive incidents land. A signature applied at distribution proves the artifact came from the expected place. It says nothing about whether the thing that built the artifact was honest, or whether the source that went in was the source the maintainer believed they wrote. That gap — authenticity without integrity of intent — is the structural flaw every case below exploits.
XZ Utils: A Backdoor Built Patiently Over Three Years
In late March 2024, a Microsoft engineer named Andres Freund noticed that SSH logins on a Debian testing box were taking about half a second longer than they should, and that valgrind was throwing odd errors in liblzma. Most people would have shrugged. He pulled the thread, and what he found was CVE-2024-3094: a deliberate, near-production backdoor in xz-utils versions 5.6.0 and 5.6.1 that had come within weeks of shipping in the stable releases of every major Linux distribution.
The mechanics are a masterclass in patience. The attacker operated a persona, “Jia Tan” (JiaT75), who began contributing to the xz project around 2021. xz was effectively maintained by one burned-out volunteer, Lasse Collin. Over two-plus years Jia Tan built a track record of legitimate commits, while sockpuppet accounts — “Jigar Kumar,” “Dennis Ens” — pressured Collin in the issue tracker to hand off more responsibility, citing the project’s slow pace and his unreliability. By 2023 Jia Tan was a co-maintainer with commit and release authority. The social engineering was the exploit; the code came later.
The payload itself was engineered to defeat exactly the review most people imagine protects them. It was not in the git repository. The git source was clean. The malicious logic lived in two binary blobs disguised as corrupted test fixtures under tests/files/, and the mechanism that assembled and injected them existed only in the release tarball — the .tar.gz that distributions actually build from — via a modified build-to-host.m4 autoconf macro that was never committed to git. When a distro ran ./configure and make against the tarball, the macro quietly extracted the blob, decompressed it, and patched it into the build. Anyone auditing GitHub saw nothing wrong. The divergence between the git tree and the shipped tarball was the entire trick.
At runtime the injected code used an IFUNC resolver to hook into the dynamic linker during the resolution of OpenSSH’s symbols. On Debian and Fedora, sshd links libsystemd, which pulls in liblzma. The backdoor replaced RSA_public_decrypt in the authentication path so that a remote attacker holding a specific Ed448 private key could execute arbitrary commands before authentication completed. A remote, pre-auth, root-capable backdoor in the most widely deployed SSH server on earth.
The honest part of this story is the worst part: it was caught by luck. Freund was running a beta distribution and happened to profile login latency. The backdoor had reached Fedora Rawhide, Debian sid/testing, openSUSE Tumbleweed, and Kali, but had not yet flowed into a single stable release. Had Jia Tan throttled the CPU cost of the hook to avoid the latency signature, the most serious open-source backdoor in history would likely have shipped to billions of devices.
SolarWinds and SUNBURST: Owning the Build, Not the Repo
If XZ shows a maintainer being subverted, the SolarWinds compromise — disclosed December 2020 and attributed to Russia’s SVR (APT29) — shows what happens when the build environment itself is owned. The attackers never needed to touch SolarWinds’ source repository in a way that would survive review. They compromised the machinery that turned source into shipped binaries.
A bespoke implant called SUNSPOT sat on a SolarWinds build server and watched for MSBuild.exe processes compiling the Orion product. At the exact moment a build of the targeted component began, SUNSPOT swapped a source file (InventoryManager.cs) for a malicious version, let the build proceed, then restored the original to cover its tracks. The output, SolarWinds.Orion.Core.BusinessLayer.dll, contained the SUNBURST backdoor — and was then signed with SolarWinds’ legitimate code-signing certificate, because from the build system’s perspective nothing was anomalous. The signature was valid. It proved the DLL came from SolarWinds. It could not prove that the source compiled was the source the developers wrote.
That trojanized DLL shipped through the normal Orion update channel in releases from roughly March to June 2020. Around 18,000 organizations installed it. SUNBURST then did something disciplined: it stayed dormant for up to two weeks, generated command-and-control domains as subdomains of avsvmcloud.com using a domain generation algorithm, blended its traffic with Orion’s legitimate “Improvement Program” telemetry, and checked the host for security tooling before acting. Of the 18,000 who got the backdoor, the operators selectively second-staged a far smaller set of high-value targets — government agencies and major enterprises — minimizing exposure and detection.
The structural lesson is the one that the entire SLSA framework was later built to address: code signing attests to origin, not to integrity of intent. Everyone downstream verified the signature and trusted it completely, because that is what signatures are for. The weak link was upstream of the signature, in a build environment that nobody was attesting to.
The npm Sagas: Typosquatting, Dependency Confusion, and Maintainer Handoff
The registry attacks are less cinematic and far more common, because the JavaScript and Python ecosystems hand attackers two gifts: enormous, deep dependency trees, and the ability to run arbitrary code at install time via lifecycle scripts. There is no single npm incident; there is a continuous saga with recurring shapes.
Typosquatting is the bluntest. Publish a package named one keystroke from a popular one — crossenv for cross-env, electron-native-notify for something legitimate — and harvest the installs of everyone who fat-fingers a name or copies a bad tutorial. The malicious behavior usually rides a postinstall hook that exfiltrates environment variables (which on a CI runner means tokens and secrets).
Dependency confusion, demonstrated by Alex Birsan in 2021, is more elegant. Many companies use internally-named private packages — say @acme/auth-utils or, on registries without scoping, just acme-auth-utils. If an attacker publishes a public package with the same name and a higher version number, default resolution logic at npm, pip, and others will often prefer the higher version from the public registry over the private one. Birsan got remote code execution inside Apple, Microsoft, Shopify, and dozens of others this way, purely by reserving public names that matched leaked internal ones, and collected six figures in bug bounties for it.
Maintainer handoff is the social-engineering variant, and event-stream (2018) is the canonical case. event-stream was a hugely popular package whose original author, Dominic Tarr, had lost interest. A stranger offered to maintain it; Tarr, reasonably, handed it over. The new maintainer published a release adding a dependency, flatmap-stream, that contained obfuscated code targeting a specific downstream application — the Copay bitcoin wallet — attempting to steal wallet keys. It was narrowly aimed and nearly invisible, surfacing only because a developer noticed the new transitive dependency.
The shape that ties the modern incidents together is token-stealing self-propagation. The 2024–2025 wave of npm worms operates by compromising one package, using its postinstall execution on developer and CI machines to steal long-lived npm and GitHub tokens, then using those tokens to publish infected versions of other packages the victim maintains — a chain reaction through the registry. The execution vector is the same lifecycle hook that has always been there; the propagation is what is new.
The Pattern That Repeats
| Incident | Layer attacked | Initial vector | What signing/checksums proved | How it was caught |
|---|---|---|---|---|
| XZ Utils (2024) | Maintainership + build | 3-year persona, payload in tarball-only build macro | Release was “authentic” from the project | Luck — 500 ms login latency |
| SolarWinds (2020) | Build environment | SUNSPOT swaps source during compile | DLL genuinely came from SolarWinds | Mandiant found it ~9 months later |
| Dependency confusion (2021) | Dependency resolution | Public name shadows private, higher version | N/A — pulled the “right” name | Researcher disclosure |
| event-stream (2018) | Maintainership | Maintainer handoff to attacker | Package was legitimately published | Developer noticed new transitive dep |
| npm token worms (2024–25) | Distribution + CI | Stolen long-lived tokens republish packages | Each infected release was validly signed | Anomaly detection, slow |
Read down the columns and five truths fall out. Trust is transitive and unverified — you vet your direct dependencies, maybe, and inherit hundreds of their dependencies on faith. Build systems are under-protected relative to source — we review commits and ignore the compiler’s environment. Signing proves who, not what — every one of these incidents passed signature verification. Install-time code execution is a gift to attackers — postinstall is RCE-as-a-feature. And the human is the softest layer — burnout, generosity, and a single keystroke are reliable exploits with no patch.
The Response: SBOM, SLSA, and Where They Stop
The industry’s answer is a stack of provenance tooling — software bills of materials, build attestations, and signing infrastructure — covered in depth in the companion piece on supply chain security tooling. The short version: an SBOM (via Syft, CycloneDX, or SPDX) enumerates exactly what is in your artifact, which is necessary for knowing whether you are exposed when the next liblzma lands. SLSA defines build-integrity levels, and provenance attestations (signed with Cosign/Sigstore) bind an artifact to the source commit and build instructions that produced it.
But it is worth being precise about what each would and would not have stopped, because the marketing blurs this.
- An SBOM would not have stopped XZ. The SBOM would have listed
liblzma 5.6.0— the malicious version — as a legitimate component. SBOMs tell you what is there, not whether it is honest. Their value is afterward: when the backdoor is disclosed, an SBOM lets you find every affected artifact in minutes instead of weeks. - SLSA provenance would have helped with SolarWinds, because the entire point of SLSA Level 3+ is a hardened, attested build that resists exactly the SUNSPOT-style source swap, and would have made the build environment’s integrity verifiable rather than assumed.
- The defense that actually kills the XZ class is reproducible builds plus build-from-source-not-tarball. The backdoor depended on the shipped tarball diverging from the git tree. If distributions built from the verified git source, or independently rebuilt and bit-for-bit compared the tarball against the source, the injected
build-to-host.m4macro would have produced a mismatch and screamed.
Provenance is necessary and worth deploying. It is not sufficient, and treating a green SLSA badge as proof of safety reproduces the exact false confidence that valid SolarWinds signatures created.
Defenses That Survive Contact With Real Codebases
The defenses that hold are mostly unglamorous and mostly about removing trust you did not need to extend. In rough order of return on effort:
Pin and hash-lock everything, and install from the lock. A lockfile with integrity hashes turns “give me the latest matching version” into “give me this exact byte sequence.” Use npm ci, not npm install, in CI so the lockfile is authoritative and a compromised “higher version” cannot slip in.
|
|
Disable lifecycle scripts by default. This single setting removes the most common execution vector — the postinstall hook — at the cost of occasionally having to allow-list a package that genuinely needs a native build step.
|
|
Defeat dependency confusion with scoping and reservation. Use a private registry or proxy (Verdaccio, Artifactory) configured so that internal scopes resolve only internally, and defensively reserve your internal names on the public registry so no one else can publish them.
# .npmrc — internal scope never resolves to the public registry
@acme:registry=https://npm.internal.acme.com/
//npm.internal.acme.com/:_authToken=${NPM_INTERNAL_TOKEN}
Kill long-lived tokens; use short-lived OIDC. The token-stealing worms propagate because long-lived publish tokens sit on CI runners. Publish from CI using OIDC-issued, short-lived credentials and npm provenance, which signs each release with a verifiable link back to the source commit and workflow.
|
|
Pin GitHub Actions to commit SHAs, not tags. The 2025 tj-actions/changed-files compromise mutated a tag to point at malicious code; everyone referencing @v4 was hit, everyone pinned to a SHA was not.
|
|
Verify provenance before you trust an artifact. When a publisher provides SLSA provenance, check it in your pipeline rather than admiring that it exists.
|
|
And the defense nobody markets: have fewer dependencies. Every package you add is a maintainer who can be socially engineered, a postinstall that can run, and a name an attacker can squat. The most resilient codebases are the ones with a dependency tree small enough that a human has actually looked at it. Auditing twenty direct dependencies is feasible; auditing two thousand transitive ones is theater.
Verdict
Supply chain attacks work because the modern software economy runs on borrowed trust, and that trust is overwhelmingly verified at the wrong layer. We sign artifacts and check signatures, which proves where something came from while saying nothing about whether the build was honest or the source was intended — exactly the gap XZ exploited by diverging the tarball from git, that SUNSPOT exploited by swapping source mid-compile, and that the npm ecosystem hands out for free through install-time code execution and resolver quirks. The provenance stack — SBOMs, SLSA, signed attestations — is real progress and worth deploying, but it is a flashlight, not a wall: an SBOM finds you fast after disclosure, and SLSA hardens the build, yet neither tells you a dependency is malicious before the world knows it is.
The defenses that actually survive contact with real codebases are humbler than the frameworks suggest. Pin and hash-lock and install from the lock. Turn off lifecycle scripts. Scope internal packages and reserve their names. Replace long-lived tokens with short-lived OIDC and signed provenance. Pin your CI actions to immutable SHAs. Build from verified source and reproduce it. And above all, shrink the dependency graph until a human can comprehend it — because the one thing every incident here shares is that somebody trusted code they had never actually looked at, delivered through a channel they assumed was safe. The attacker’s whole craft is being upstream of that assumption. The defense is to stop making it.
Sources
- Andres Freund’s original disclosure of the xz-utils backdoor (oss-security)
- CVE-2024-3094 — NVD entry
- CISA advisory on the XZ Utils backdoor
- Mandiant / CrowdStrike analysis of SUNSPOT and SUNBURST
- CISA Emergency Directive 21-01 — SolarWinds Orion
- Alex Birsan — “Dependency Confusion” research
- The event-stream incident postmortem (GitHub issue)
- SLSA — Supply-chain Levels for Software Artifacts
- npm package provenance documentation
- Reproducible Builds project
- Sigstore and Cosign documentation
Comments