Skip to content

The XZ Utils backdoor: anatomy of a multi-year supply-chain operation

· 22 min read
Copyright: MIT
Wordmark reading xz with a small orange ssh arrow bending into it, captioned CVE-2024-3094

A compression library is about as boring as software gets. xz takes bytes, makes them smaller, gives them back. It ships in the base install of nearly every Linux distribution, gets linked into nearly every package manager, and almost nobody thinks about it. That obscurity was the point. In late March 2024 a Microsoft engineer running a database benchmark noticed that his SSH logins had gotten slower by about half a second. He could have ignored it. Most people would have. Instead he pulled the thread, and what came out was a backdoor that had been built into the SSH daemon of half the servers on the internet through a dependency three steps removed from SSH itself.

The backdoor did not get there through a stolen credential or a memory-safety bug. It got there because a person, or a group operating as a person, spent more than two years becoming the trusted co-maintainer of the xz project, wore down a burned-out volunteer, took over releases, and then shipped a payload so carefully hidden that it lived only in the release tarball and never in the public git history. The attack failed by roughly two weeks. Had the backdoored version reached the stable branches of Debian and Fedora before anyone noticed, the cleanup would have been measured in years. This post traces the whole operation: the patient infiltration, the build-system trick that hid the payload in plain sight, the cryptographic lock that made the backdoor usable only by its author, and the accident that caught it.

The sections below walk through what xz is and why it sits where it does, the 2.5-year social-engineering campaign that handed control to “Jia Tan,” the multi-stage build-time injection that assembled the backdoor only inside distro package builds, the IFUNC hook into sshd and the Ed448-gated payload, the timing anomaly that exposed it, and the attribution questions that remain open. It closes with what actually changed afterward, which is less than you would hope.

What xz is, and the dependency chain nobody drew

xz is the command-line tool; the library it ships alongside is liblzma, an implementation of the LZMA and LZMA2 compression algorithms. If you have ever run tar -J, unpacked a .xz file, or installed a .deb or .rpm, liblzma did the decompression. It is foundational in the literal sense: it sits near the bottom of the stack and everything above it assumes it is trustworthy. The project was maintained, for most of its life, by one person. Lasse Collin wrote and shipped xz largely alone, on volunteer time, for years.

The path from a compression library to a remote-code-execution backdoor in SSH runs through a dependency that was never supposed to exist. OpenSSH does not link liblzma. It has no reason to. But several Linux distributions patch sshd to integrate with systemd, so that the service manager can track when the daemon is ready to accept connections. That patch makes sshd load libsystemd. And libsystemd, at the time, depended on liblzma, because systemd uses xz compression for its journal logs and other features. So the chain was sshd, then libsystemd, then liblzma, then the backdoor. Three hops, none of them obvious from inside the SSH codebase, and the last one a compression library that no SSH developer would ever think to audit.

The dependency chain that put a compression library inside SSH sshd distro-patched libsystemd systemd-notify liblzma xz / LZMA backdoor OpenSSH never links liblzma directly. The systemd patch is what builds the bridge. *The chain that made a backdoor in a compression library reachable from a pre-authentication SSH connection. None of these links is visible from inside the OpenSSH source tree.*

This is the structural lesson before any of the cleverness: the blast radius of a dependency is not the set of things that import it. It is the transitive closure of everything that imports anything that imports it, on every platform where the build wiring differs. The attacker understood that closure better than the maintainers of the projects inside it did.

2021 to 2024: how you become a maintainer

The account that became the public face of the attack was created on GitHub on 26 January 2021 under the name Jia Tan, username JiaT75. The first contributions to xz were forgettable by design. On 29 October 2021 the account sent its first patch to the xz-devel mailing list, adding a .editorconfig file. A month later, a second patch addressing reproducible-build issues. The first commit landed in the repository on 7 February 2022, authored as [email protected]. None of it touched anything sensitive. The point of the early work was not the code. It was the commit history.

Then the pressure started, and it did not come from Jia Tan.

In the spring and summer of 2022 a set of personas appeared on the mailing list whose entire function was to make Collin’s position as sole maintainer feel untenable. A “Jigar Kumar” sent a run of emails between April and June complaining about the pace of the project and pushing for changes to be merged faster. One of them carried the line that became the epigraph for the whole episode: patches that should be obvious “spend years on this mailing list,” with a maintainer who was “not interested.” A “Dennis Ens” questioned whether the project was being maintained at all. The complaints were pointed at Collin and the proposed solution was always the same shape: bring on another maintainer. Jia Tan, conveniently, had been doing helpful off-list work and was available.

Collin, by his own account, was not in a good place to resist. He had said publicly that he had limited time and that unpaid open-source maintenance carried mental-health costs, after a long stretch of doing it alone. In a May 2022 message he noted that Jia Tan had been helping him off-list and “might have a bigger role in the future.” By late June he described Jia Tan as “practically a co-maintainer already.” The campaign worked on exactly the pressure point you would target if you wanted to. A solo volunteer, tired, criticized by strangers for not moving fast enough, offered an eager helper. The reasonable human response and the catastrophic security response were the same response.

The 38-month operation, end to end 2021-01-26 JiaT75 account created 2022-04 to 06 Sock-puppet pressure campaign 2022-12-30 First commits with direct access 2023-03-18 First release as maintainer: 5.4.2 2024-02-24 5.6.0 ships with the backdoor 2024-03-09 5.6.1 ships a refined variant 2024-03-29 Freund discloses publicly *From account creation to public disclosure. The two-and-a-half-year ramp before the payload is the part that is hard to defend against.*

The formal handover followed on schedule. On 28 October 2022 Jia Tan was added to the Tukaani organization on GitHub. On 30 November 2022 Collin changed the project’s bug-report contact to an alias that reached both of them and updated the README to name both as maintainers. On 30 December 2022 Jia Tan merged a batch of commits directly, demonstrating commit access. Collin’s last solo release was 5.4.1 in January 2023. The first release cut by Jia Tan, 5.4.2, went out on 18 March 2023. By early 2024 the project website itself had been moved to infrastructure under the attacker’s control. The takeover was complete, and it had been complete for more than a year before the payload appeared. That gap is deliberate. Trust, once granted, stops being questioned, and the longer it sits unquestioned the safer it feels.

The pressure personas, it is worth noting, were not subtle in retrospect, but they were effective in the moment. There were several of them, they corroborated each other, and they all wanted the same outcome. The same playbook shows up across the npm and PyPI ecosystems, where the social attack precedes the code attack by months. It is the same shape as the event-stream takeover, where a maintainer handed an abandoned package to a stranger who asked nicely, and the difference between that incident and this one is mostly patience and craft.

The payload that lived only in the tarball

Here is the detail that makes xz different from a normal malicious commit: the backdoor was not in the source repository in any runnable form. If you had cloned the Tukaani git repo at the tagged 5.6.0 commit and read every line, you would not have found a working backdoor. The malicious logic only existed, assembled and active, in the release tarball that distributions actually download and build from.

This exploits a convention that almost every C project follows. The git repository contains the source, but the official release is a tarball generated by the autotools build system, and that tarball routinely contains generated files that are not checked into git, so that downstream builders do not need to run autoconf themselves. Reviewers look at git. Builders use the tarball. The two are assumed to match. The attacker put the trigger in the gap between them.

The entry point was a file called build-to-host.m4, an autoconf macro that was present in the 5.6.0 and 5.6.1 tarballs but not in the git tree. During the ./configure step of a build, this macro ran. Its job was to locate and decode the first stage of the payload, which was hidden inside the project’s own test corpus. xz, being a compression tool, has a directory full of test files that are supposed to be malformed or oddly compressed, because that is what a decompressor’s tests look like. Two of them were not test data. tests/files/bad-3-corrupt_lzma2.xz and tests/files/good-large_compressed.lzma carried the backdoor stages, disguised as corrupt inputs.

How the backdoor assembled itself during a distro package build stage 0 — configure build-to-host.m4 runs, greps test files for sentinel pattern tr-substitutes bytes, pipes to /bin/sh stage 1 — make patched Makefile extracts larger blob from good-large_*.lzma RC4-like awk decode yields malicious .o stage 2 — link object linked into liblzma.so crc64 ifunc resolver hijacked to run early hooks into sshd at load Only triggers on x86-64 glibc Linux, building a .deb or .rpm. Otherwise the build looks clean. *The assembly was staged so that no single artifact looked malicious in isolation. The git tree was clean; the tarball's m4 file was the only seam, and it only mattered on the exact build configurations the distributions used.*

The decoding chain was written to look like noise. The configure-stage script used common Unix tools in deliberately confusing ways: a sed invocation standing in for cat, a tr command performing a byte-for-byte substitution to unscramble the next stage, then xz -d to decompress and a pipe into the shell. That first stage patched the liblzma Makefile to add a step that, during make, extracted a much larger encrypted blob from the second test file and ran it through a custom RC4-like decryption routine implemented in awk. The output of that was a precompiled malicious object file. The build then linked that object into liblzma as if it belonged there.

The whole chain guarded itself. The injection only fired when the build was for x86-64, on a glibc Linux system, using GCC and the GNU linker, inside a Debian or RPM packaging environment. Build it any other way, on a BSD or with a different libc or for a different architecture, and the malicious path simply did not execute. That selectivity is what kept it quiet. The set of machines that would actually produce a backdoored binary was exactly the set the attacker cared about, and as narrow as possible beyond that. A curious developer building xz from source on their own laptop would, in most configurations, get a clean library and see nothing wrong.

There is an irony in the targeting condition worth sitting with. The backdoor checked that it was being built into a real distribution package before it would activate. The attacker did not want to backdoor your hobby build. They wanted to backdoor the official one, the signed one, the one with a chain of trust behind it that nobody re-examines.

The hook: IFUNC, the ifunc resolver, and the sshd intercept

Getting a malicious object linked into liblzma is not the same as getting it to run inside sshd. The bridge there was glibc’s indirect-function mechanism, IFUNC. IFUNC exists for a legitimate reason: it lets a library pick the fastest implementation of a function at load time based on the CPU. A library can ship three versions of a memcpy, mark the symbol as an ifunc, and supply a small resolver function that the dynamic linker calls during startup to choose which one this machine should use. xz used exactly this for its CRC routines, selecting a hardware-accelerated checksum if the processor supported it.

The backdoor hijacked that resolver. The injected object replaced the CRC ifunc resolver with code that ran the attacker’s setup logic. The timing matters here: ifunc resolvers run very early in process initialization, during dynamic linking, before the global offset table has been marked read-only. That window let the backdoor rewrite function pointers it should never have been able to touch. Once running inside the sshd process, it walked the loaded libraries, found OpenSSL’s RSA_public_decrypt, and redirected it to a hook the backdoor controlled. From that point every RSA verification sshd performed during authentication passed through attacker code first.

RSA_public_decrypt was a clever choice of target because of where it sits in the SSH authentication flow. It is called to verify a client’s signature during public-key auth, before the client has proven anything. So the hook ran pre-authentication. A connecting client did not need an account, a password, or a valid key. It needed to reach the authentication step, which any client can do, and present a crafted certificate whose contents the hook would inspect.

What happens when a client reaches the hooked RSA check client connects, sends crafted cert hooked RSA_public_ decrypt ChaCha20-decrypt the cert, split: Ed448 sig + command Ed448 signature valid for attacker key + this host? no: normal auth flow yes: system(command) *The hook decrypts the client's certificate, looks for a valid Ed448 signature over the command and the host's own key, and only then executes. A wrong or absent signature falls through to ordinary authentication, so the daemon behaves normally for everyone but the attacker.*

The contents of that certificate were where the cryptography came in, and this is the part that turns a backdoor into a private backdoor. The hook took attacker-supplied bytes and decrypted them with a hard-coded ChaCha20 key. The decrypted blob split into two parts: an Ed448 signature and a payload, which was a string of shell commands. The hook verified the signature against an Ed448 public key compiled into the backdoor. Only the attacker held the matching private key. Without a valid signature the hook did nothing unusual and the connection fell through to normal authentication. With one, the payload string was handed to system() and ran as root, before the user had authenticated at all.

The signature was bound to the target host’s own key, which means a command signed for one server could not be replayed against another. So even someone who captured a successful exploitation attempt on the wire could not reuse it elsewhere. This was not a backdoor anyone could walk through. It was a backdoor that only its author, holding one specific Ed448 private key, could open, and only on the specific machine each request was signed for. The choice of Ed448 over the far more common Ed25519 is one of several signs that whoever built this was not improvising.

The defensive shape here is worth naming directly. The backdoor turned a verification routine, code whose entire job is to check a signature, into a covert channel keyed by a different signature. The same primitive that protects the front door was repurposed to authenticate a hidden one. That pattern, hiding a trusted action inside an already-trusted code path, is the recurring move in modern supply-chain work, and it is why hash-pinning a dependency you can see does nothing against an attacker who controls what the dependency becomes. The defenses that work against a malicious script you can read, like Subresource Integrity for third-party JavaScript, all assume the bytes you audited are the bytes that run. xz broke that assumption at the build step.

The half-second that gave it away

The operation was caught by accident, by one person, paying attention to the wrong metric for the right reason.

Andres Freund is a PostgreSQL developer who, at the time, worked at Microsoft. He was doing performance work on Debian’s unstable branch, sid, which had picked up the backdoored xz. He noticed two things that did not fit. SSH logins were burning noticeably more CPU than they should, and a Valgrind run against the affected software was throwing errors it had not thrown before. He measured the login latency directly. Before the backdoored library, an SSH login took about 0.3 seconds of real time. After it, about 0.8. Roughly half a second, every login, for no visible reason.

That half-second was the cost of the backdoor doing its work inside the authentication path. The hooked code ran on every connection whether or not the attacker was the one connecting, because it had to inspect every certificate to find out. The overhead was the tell. A faster, lazier backdoor that did less work per connection might never have produced a measurable signal. This one was thorough, and its thoroughness cost CPU, and the CPU cost was visible to exactly the kind of person who profiles login latency for a living.

Freund did not stop at “that’s weird.” He traced where execution diverged between a system with the backdoor active and one without, using Intel Processor Trace through perf to find the exact point the two paths split, then gdb to set breakpoints before the divergence and step through. That work led him from the symptom in sshd back through the dependency chain to liblzma, and from liblzma to the conclusion that this was not a Debian packaging bug but something planted upstream. On 29 March 2024 he posted to the oss-security mailing list. The subject line was a plain statement that there was a backdoor in upstream xz/liblzma, and the body laid out what he had found and recommended upgrading, or rather downgrading, any potentially affected system immediately.

The timing of that post is the whole story in one number. The backdoored 5.6.x releases had reached the development and testing branches of several distributions, including Fedora Rawhide and Fedora 40 beta, Debian sid, openSUSE Tumbleweed, and Kali. They had not yet reached the stable releases that run on production servers. The attacker had been actively pushing distributions to pull the new version into stable, filing bugs and nudging maintainers in the days right before disclosure. Freund’s post landed in the window between the backdoor reaching testing and reaching stable. A few more weeks and it would have been in long-lived enterprise releases, on machines that stay deployed for years, and the timing anomaly would have been buried under far more eyes that were not looking for it.

CVE-2024-3094 was assigned and rated 10.0 on the CVSS scale, the maximum. Within roughly a day the affected distributions reverted to safe versions, Debian rolling back to a 5.4 release, and the U.S. Cybersecurity and Infrastructure Security Agency issued an advisory recommending downgrade. GitHub suspended the JiaT75 account and disabled the Tukaani repositories, which complicated the immediate forensics because the malicious history was abruptly unavailable to researchers. Collin regained control of the project, stripped the attacker’s access and forwarding, and released 5.6.2 on 29 May 2024 with the backdoor removed.

Who was Jia Tan

The honest answer is that nobody has publicly named the person or group behind the operation, and the persona was almost certainly not what it claimed to be. What exists is circumstantial analysis, and it is worth treating it as exactly that.

The commit timestamps were the most-discussed evidence. Jia Tan’s commits carried timezone metadata consistent with UTC+8, which would place the author in roughly the China time zone. Several researchers immediately distrusted that signal, and they were right to. A timezone in a git commit is a configuration value, trivially set to anything. Analysts who looked closely, including Rhea Karty and Simon Henniger, found that some commits appeared to have been made with the clock set to an Eastern European zone, as if the author had forgotten to apply the usual UTC+8 disguise before committing. The bulk of the activity clustered in a clean roughly nine-to-five working window once you assumed a European, not Asian, time zone. The work also did not pause for major Chinese holidays and did pause around dates that fit a different calendar. The case for “the UTC+8 was a deliberate misdirection” is stronger than the case for taking it at face value.

The other strong inference is institutional rather than geographic. The operation ran for more than two years with patient, salaried-looking discipline. The cryptographic choices were sophisticated and defensive. The sock-puppet personas were coordinated. The targeting conditions were narrow and well-chosen. This is the profile of an organization with time and tradecraft, not a lone hobbyist. Some researchers floated specific state attributions, and one prominent suggestion pointed toward Russian intelligence patterns, but those remain unconfirmed speculation rather than findings. The defensible claim is the narrow one: this was a resourced, organized actor running a long-horizon operation, and the “Jia Tan” identity was a deliberately maintained fiction. Anything more specific than that is, as of now, not established.

What the persona got right is the uncomfortable part. It did not need a zero-day or a stolen key. It needed to be patient, helpful, and slightly more persistent than a tired volunteer could resist. The social attack is the one with no patch.

What actually changed

The reflexive lesson people drew was about code review, and that lesson is mostly wrong for this case. The malicious payload was never reviewable in the first place, because it was binary data masquerading as test fixtures, assembled by a build script that only ran on the targets that mattered, in a file that existed only in the release tarball and not in the source anyone would review. You could have read every commit to the xz git repository and approved all of them. The gap the attacker used was the one between what reviewers look at and what builders build.

The concrete changes since have been about closing that specific gap. There is renewed attention to reproducible builds, the property that the released artifact can be regenerated bit-for-bit from the public source, which would have exposed a tarball that contained anything the git tree did not. There is more scrutiny of the autotools-generated files that ship in tarballs, the exact category that hid build-to-host.m4. Distributions have moved, in places, toward building from version-control tags rather than maintainer-uploaded tarballs. And there is uncomfortable, ongoing conversation about the funding model that produced the vulnerability in the first place, because the social-engineering attack worked specifically because the maintainer was an unpaid, overextended, lone volunteer, and a great deal of the software the internet runs on is maintained exactly that way. That structural condition has not changed. The xz incident is frequently cited next to the Polyfill.io takeover and dependency-confusion as evidence that the soft underbelly of modern software is not the code but the trust around it, and of those, xz is the one where the trust was attacked most deliberately and most patiently.

The number that stays with me is two weeks. Not the CVSS 10.0, not the 38-month operation, not the cryptographic elegance of an Ed448-gated hook. Two weeks is roughly how close the backdoored release came to reaching the stable distributions that run production infrastructure, and the only reason it did not is that one person profiling database performance decided that a half-second of unexplained SSH latency was worth chasing to the bottom. The defense that worked was not a process, a scanner, or a policy. It was an engineer who refused to ignore a small, boring anomaly. That is not a control you can mandate, and it is not one the next operation can count on being there.


Sources & further reading

Further reading