xz Backdoor Post-Mortem: Clean Repo, Poisoned Tarball
On 29 March 2024, a PostgreSQL developer named Andres Freund posted an alarm to the oss-security mailing list: SSH logins on Debian sid were suddenly eating all available CPU, and valgrind was reporting errors that had never been there before. The investigation led somewhere unexpected. It was not a compromised Debian package. The upstream xz project's release tarball was backdoored — and the source in the git repository was clean.
The counterintuitive part is exactly why this deserves a post-mortem: if you audit the source top to bottom, there is nothing wrong with it. The problem is that the source you audited and the file your build system actually consumed are not the same file.
This post has no affiliate links and recommends no paid product; it is a pure technical post-mortem. Every command and error message below comes from official advisories, distribution security announcements, or the project's own issue tracker. The only thing I tested myself is the matching logic of a detection script, and I mark that boundary explicitly — this machine has no liblzma to scan, so I have no real backdoored binary.
TL;DR: Two versions are affected, xz 5.6.0 and 5.6.1, tracked as CVE-2024-3094 with CVSS 3.1 base score 10.0 CRITICAL and CWE-506. Debian's rolled-back package version is 5.6.1+really5.4.5-1 — a +really in a version string is a signal to pay attention. The attack path is sshd → libsystemd → liblzma: the backdoor replaces ifunc resolvers to hijack RSA_public_decrypt@...plt, letting an attacker holding a specific private key run commands before authentication. The real second incident is on the fix side. 5.6.2 removed the backdoor, and on the same day it also shipped a tarball generated with libtool-2.5.0, whose bug breaks shared library builds on MIPS64 and silently makes --disable-static ineffective.
Who was affected: three distributions, three different official positions
There was no single global affected list at the time, because exposure depends on two conditions holding at once: 5.6.0 or 5.6.1 installed, and sshd going through systemd notification. Below are the dispositions as written in each distribution's own advisory, taken from primary sources rather than secondhand reporting.
| Distribution | Affected range | Official action | Source |
|---|---|---|---|
| RHEL (Enterprise) | Not affected | No action needed | Red Hat states "No versions of RHEL are affected" |
| Debian stable | Not affected | No action needed | DSA 5649-1: "no Debian stable versions are known to be affected" |
| Debian testing / unstable / experimental | 5.5.1alpha-0.1 (uploaded 2024-02-01) through 5.6.1-1 | Reverted to upstream 5.4.5 code, package versioned 5.6.1+really5.4.5-1 | DSA 5649-1 (2024-03-29) |
| Fedora 40 | Ships xz-libs-5.6.0-1.fc40.x86_64.rpm and xz-libs-5.6.0-2.fc40.x86_64.rpm | Downgrade to 5.4.x; advisory gives Bodhi update FEDORA-2024-d02c7bb266 | Red Hat advisory, updated 2024-03-30 |
| Fedora Rawhide | 5.6.0 and 5.6.1 | Officially told users to stop using it until the revert landed | Red Hat advisory |
| Arch Linux | xz 5.6.0-1 | Upgrade to 5.6.1-2 | Distribution advisory roundup |
| Alpine (edge) | 5.6.1-r0, 5.6.1-r1 | Upgrade to 5.6.1-r2 | Distribution advisory roundup |
Three details deserve to be pulled out on their own.
First, Fedora 40 was characterized as having the bad package without evidence of exploitation. Red Hat's 30 March update is careful about this: both xz-libs-5.6.0-1.fc40.x86_64.rpm and xz-libs-5.6.0-2.fc40.x86_64.rpm were poisoned, but "Fedora Linux 40 does not appear to be affected by the actual malware exploit." This middle state — poisoned package, exploit chain not proven — carries more information than a clean "hacked / not hacked" split.
Second, RHEL and Debian stable were both fine, but for different reasons. RHEL's advisory is simply categorical, while Debian's reasoning sits in the DSA: the compromised versions only ever landed in testing, unstable, and experimental. Shipping a stable branch was itself a defensive layer.
Third, Gentoo pulled the bad packages but was also unaffected, and its own issue explains why: Gentoo's OpenSSH is not patched for systemd-notify, and the backdoor depends on that link. I wrote a GitHub Actions supply chain post-mortem earlier, which covers pinning third-party action versions. That is dependency locking; this post is about the case where you did lock the version and it did not help, because the release artifact itself can disagree with the source repository. The troubleshooting actions are completely different.
Why auditing the source shows nothing: git and the release tarball are different files
This is the technical heart of the incident, and the reason it could hide for two years.
Freund pointed out in his report that one part of the backdoor exists only in the distributed tarballs. He gave the exact location — line 63 of build-to-host.m4 — and linked Debian's imported copy for comparison:
https://salsa.debian.org/debian/xz-utils/-/blob/debian/unstable/m4/build-to-host.m4?ref_type=heads#L63
His words: that line is not in the upstream build-to-host source, nor is build-to-host used by xz in git. "It is present in the tarballs released upstream, except for the 'source code' links." He specifically noted that the "source code" link on GitHub is generated directly from the repository contents, so the code you land on by clicking it is clean — which is precisely where an auditor is most likely to look.
Red Hat's advisory corroborates this from the other direction: the malicious code is obfuscated and included in full only in the download package; the git distribution lacks the M4 macro that triggers the build of the malicious code. The second-stage artifacts, however, are present in the git repository so they can be injected when the macro is present.
So the attack splits in two:
- The trigger (tarball only):
build-to-host.m4was modified to inject an obfuscated script at the end ofconfigure - The payload (in git):
tests/files/bad-3-corrupt_lzma2.xzandtests/files/good-large_compressed.lzma, two files that look like test fixtures, added by the first commitcf44e4b7f5dfdbf8c78aef377c10f71e274f63c0
Freund also pointed out that those two files "were not even used for any 'tests'" in 5.6.0. Their identity inside the repository is "harmless test fixture," which is exactly what makes them something any code reviewer would skip past.
Once de-obfuscated, the script rewrites $builddir/src/liblzma/Makefile, replacing what should have been a test target with a pipeline that decompresses and executes the payload. Freund quoted this fragment:
sed rpath $(am__test_dir) | $(am__dist_setup) >/dev/null 2>&1
After de-obfuscation it becomes a chain of head -c, tr, xz -d, and /bin/bash, ultimately reading injected code out of tests/files/good-large_compressed.lzma.
Where the backdoor attaches: a chain from libsystemd to sshd
Understanding this chain is what explains why most machines that did install 5.6.0 were never actually compromised.
The injected code replaces the ifunc resolvers crc32_resolve() and crc64_resolve() with a call to an injected _get_cpuid(). Per Freund's analysis, those functions get resolved during startup because sshd is built with -Wl,-z,now, so all symbols resolve early. On the first call the code just increments a completed variable and returns the normal cpuid result, which looks entirely benign.
The interesting part happens on the second resolution: the backdoor installs an audit hook into the dynamic linker and waits for RSA_public_decrypt@...plt to be resolved, then repoints its GOT entry at its own code. Freund's perf trace shows sshd jumping out of itself and into liblzma during a pubkey login:
sshd 1736357 [010] 714318.734008: 1 branches:uH: 5555555ded8c ssh_rsa_verify+0x49c (/usr/sbin/sshd) => 5555555612d0 RSA_public_decrypt@...+0x0 (/usr/sbin/sshd)
and then back into libcrypto to do the normal authentication:
sshd 1736357 [010] 714318.734009: 1 branches:uH: 7ffff7c137cd [unknown] (/usr/lib/x86_64-linux-gnu/liblzma.so.5.6.0) => 7ffff792a2b0 RSA_get0_key+0x0 (/usr/lib/x86_64-linux-gnu/libcrypto.so.3)
This runs in a pre-authentication context, which is where the severity comes from — not from what the code does. It is also why CVSS 3.1 came out as AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H: network reachable, no privileges, no user interaction, scope change.
Why most machines on 5.6.0 were never compromised
Freund listed the trigger conditions, and they are specific. All of these must hold simultaneously for the exploit path to be reached:
| Condition | Original wording |
|---|---|
| TERM not set | a) TERM environment variable is not set |
| argv[0] must be the sshd path | b) argv[0] needs to be /usr/sbin/sshd |
| LD_DEBUG and LD_PROFILE unset | c) LD_DEBUG, LD_PROFILE are not set |
| LANG must be set | d) LANG needs to be set |
| Debuggers get detected | e) Some debugging environments, like rr, appear to be detected. Plain gdb appears to be detected in some situations |
There is also a build-time gate: the target must be x86_64 linux-gnu, built with gcc and the GNU linker, and built as part of a Debian or RPM package build (test -f "$srcdir/debian/rules" or RPM_ARCH set to x86_64). He judged that last condition was likely added deliberately to make reproduction harder for investigators.
A discriminator you can run directly
Freund gave a very practical A/B experiment that shows the difference without needing to actually be compromised:
slow:
env -i LANG=C /usr/sbin/sshd -h
(about 0.5s on my older system)
fast:
env -i LANG=C TERM=foo /usr/sbin/sshd -h
env -i LANG=C LD_DEBUG=statistics /usr/sbin/sshd -h
(about 0.01s on the same system)
Same machine, only the environment changes, and the time drops from 0.5s to 0.01s. He also measured a real login, where real went from 0m0.299s to 0m0.807s. And LD_BIND_NOT=1 disables the backdoor — one reason being that the GOT only becomes read-only after -Wl,-z,relro.
That latency side effect is exactly what tipped Freund off in the first place. Put differently: the backdoor added anti-analysis countermeasures, and those countermeasures themselves created an observable performance signature.
What I actually tested: the matching logic of a detection script, and a false positive I hit myself
Let me draw the boundary first: I tested the matching logic of a detection script, not a real backdoor sample. This machine is Windows and has no scannable liblzma, so I do not have a genuinely poisoned binary, and I am not going to pretend otherwise.
What I did test: take the marker strings published in the public analysis, wrap them in a minimal scanner, and feed it constructed samples to see whether it misfires. I used three markers, all present in the primary analysis — yolAbejyiejuvnup=Evjtgvsh5okmkAvj (the backdoor's kill-switch environment variable name), Shutdown, and liblzma_la-crc64-fast (the injected artifact filename).
A constructed positive matched both expected markers correctly:
=== test 1: constructed POSITIVE sample ===
hit: ['yolAbejyiejuvnup=Evjtgvsh5okmkAvj', 'liblzma_la-crc64-fast']
A clean sample produced no false positive:
=== test 2: constructed CLEAN sample (ordinary liblzma names) ===
hit: [] (empty = no match)
But the third test is where I hit a trap, and it is the one I think is most worth putting in the article:
=== test 3: does unrelated "Shutdown" in a clean sample trigger it? ===
hit: ['Shutdown'] (bare token Shutdown causes a false positive)
My constructed sample contained systemd-shutdown-helper and SystemdShutdown. Neither is the backdoor, but the bare substring Shutdown matches. The public analysis does mention looking for this string in the .rodata section, and this test shows why: without boundary conditions, Shutdown pulls in ordinary systemd-related strings too. To make this criterion usable you need at least a case-distinguishing prefix, or restrict it to .rodata and require a simultaneous hit on the kill-switch variable name.
The fourth test was version parsing, which behaved stably:
=== test 4: version extraction from `xz --version` ===
'xz (XZ Utils) 5.6.1' -> 5.6.1
'xz (XZ Utils) 5.4.6' -> 5.4.6
The fifth test reproduced the Alpine judgment trap, which is the most practically useful part of the detection story:
=== test 5: reproducing the Alpine trap ===
Alpine edge fixed 5.6.1-r2: by package revision -> 5.6.1-r2 => SAFE
Alpine edge backdoored 5.6.1-r1: by package revision -> 5.6.1-r1 => VULNERABLE
bare `xz --version` gives the same value on both -> indistinguishable
On Alpine edge, xz --version prints 5.6.1 whether you have the backdoored 5.6.1-r1 or the fixed 5.6.1-r2, so judging on the upstream version alone gives you the wrong answer. You have to read the package revision:
apk list xz
apk list xz | grep xz
The rule is simple: revisions -r0 and -r1 are affected, -r2 and later are fixed.
Troubleshooting: three real failures and their fixes
All three of these are real failures recorded in official channels, not a "typical error" I made up to pad the article.
Error one: shared library build fails on MIPS64 after moving to 5.6.2
Symptom: building xz 5.6.2 on MIPS64 fails to produce a shared library, and --disable-static silently stops working — you thought you disabled the static library, but you did not.
The real clue in config.log (from project issue #142):
configure:9458: checking for file
configure:9479: found /usr/bin/file
configure:9491: result: :
Look at those three lines: /usr/bin/file was found, yet the result was assigned empty. Further down, the MIPS branch code that determines the ABI type depends on $FILECMD. With FILECMD empty, it only derives emul=elf, so the linker flag becomes ld -m elf, and that combination does not work at all:
configure:13624: checking whether the gcc linker (/usr/bin/ld -m elf) supports shared libraries
configure:14897: result: no
Cause: the 5.6.2 release tarball was generated with libtool-2.5.0, which was still an alpha release at the time and had a bug causing the generated configure to leave FILECMD unset. This is harmless on most architectures and amplified on MIPS64.
Fix: on 2024-06-19 the maintainer published xz-5213-547-562-libtool.patch, based on the upstream libtool fix commit, attached to the 5.6.2 release page. The same patch applies to 5.2.13, 5.4.7, and 5.6.2. The issue thread also offers a lighter workaround: pass FILECMD=file explicitly to configure.
I want to flag one line in the release notes that is easy to skim past: 5.2.13, 5.4.7, and 5.6.2 have a build system issue that prevents building of shared libraries on some systems like mips64. One patch covering three versions means this was not a regression that happened to ride along with the backdoor fix — it was a build system incident spanning three branches.
Error two: Buildroot musl static build fails with undefined reference to raise
Symptom: after Buildroot moved to xz 5.6.2, the musl static build fails and the linker reports:
libgcc.a(_dvmd_lnx.o): in function __aeabi_idiv0':
libgcc/config/arm/lib1funcs.S:1499:(.text+0x8): undefined reference to raise
Cause: the same missing FILECMD. Buildroot's commit message is blunt — without running autoreconf, -static in LDFLAGS is not passed down and linking behaves incorrectly. The commit also contains the sentence that matters most: the failure reproduces on 5.4.7 as well, but not on 5.4.6. So 5.4.7 and 5.6.2, both published the same day, share the same build defect — and CISA's advisory at the time recommended downgrading to "XZ Utils 5.4.6 Stable." That advice was correct for the incident, but an admin who bumped to 5.4.7 traded one pit for another.
Fix: add autoreconf to the Buildroot package definition:
XZ_AUTORECONF = YES
The upstream commit puts this in package/xz/xz.mk with the rationale that autoreconf fixes the musl static build failure.
Error three: `xz --version` shows 5.6.1 on Alpine even though the package is fixed
Symptom: on an Alpine edge machine the version output is 5.6.1, so the "upstream 5.6.0/5.6.1 are affected" rule flags it as critical — but the package is actually patched.
Cause: Alpine's package versions carry their own revision suffix. The fixed build is 5.6.1-r2, but xz --version prints only the upstream version, and both report 5.6.1.
Fix: never use xz --version for a security decision. Use the package manager:
apk list xz
5.6.1-r2 or a higher revision is safe. The same class of problem exists elsewhere: Debian went as far as renaming the package to 5.6.1+really5.4.5-1. When a version string contains suffixes like +really or -rN, that is itself a signal that the upstream version number cannot express the security state. Automation must parse the full version string; a startswith("5.6") check will misjudge.
What the fix actually changed, and why 5.6.x will not get updates anymore
5.6.2 shipped on 2024-05-29, and the first line of its release notes is Remove the backdoor (CVE-2024-3094). A few other changes matter for judging security posture:
- GNU ifunc support was removed. The official wording is unusually candid:
This is NOT done for security reasons even though the backdoor relied on this code. The performance benefits of IFUNC are too tiny in this project to make the extra complexity worth it.In other words the attack chain depended on ifunc, but removing it was routine simplification rather than hardening — do not cite this as evidence that "IFUNC is risky." - One known issue was deliberately left unfixed:
MSAN has a false positive in the CRC CLMUL code which also makes OSS Fuzz unhappy. Valgrind is smarter and doesn't complain.The maintainer decided against backporting a fix, reasoning that the old code is not actually wrong and silencing a false positive is not worth the regression risk. This matters for anyone reading incident reports: one of Freund's original clues was a valgrind error, and upstream states plainly that valgrind should not complain while MSAN does false-positive. - The CMake Landlock detection code had been sabotaged. The release note reads
Fix detection of Linux Landlock support. The detection code in CMakeLists.txt had been sabotaged.That points at the same conclusion again: the contamination was not limited to the tarball; build scripts in the repository were also modified. - 5.6.2 also updated maintainer info, retired the discontinued XZ logo, and removed PDF man pages from the source package.
For "which version should I be on today," the answer needs to be concrete. As of this post, the latest xz release is 5.8.4 (published 2026-09-09), and the release page states plainly that there will be no new 5.2.x, 5.4.x, or 5.6.x releases — the 5.6.x line ends at 5.6.4 from 2025-01-23. That means:
- If you are still on 5.4.6 or 5.6.4, you are on a branch that no longer receives updates. CISA's 2024 advice to downgrade to 5.4.6 was the right call at the time, but it is not a 2026 long-term answer.
- If you build on MIPS64, 5.2.13 / 5.4.7 / 5.6.2 all need
xz-5213-547-562-libtool.patchon top. - 5.6.3 (2024-10-01) carries a separate Windows command-line security fix involving non-ASCII characters being converted by best-fit mapping into lookalike characters, altering the meaning of a command line. Reported by Orange Tsai and the DEVCORE Research Team. It affects the command-line tools only; liblzma is not affected.
Four things to actually do today
Ordered from "figure out whether you have a problem" to "keep this class of problem out in the future":
Step one: query the package version, not the binary version. Use your distribution's package manager to get the full version string and compare it against the table above. Suffixes matter: +really and -rN mean the security state is encoded outside the upstream version.
Step two: confirm whether sshd really goes through systemd notification. If your OpenSSH does not link libsystemd, the chain is broken — Gentoo and Wolfi are exactly that case. ldd $(which sshd) | grep liblzma is the fastest way to rule yourself out.
Step three: if you must inspect the binary by hand, use markers with boundary conditions. At minimum require an exact match on the kill-switch variable yolAbejyiejuvnup=Evjtgvsh5okmkAvj, or a multi-marker hit inside .rodata. I demonstrated above that bare token matching false-positives, so do not paste a one-line grep Shutdown into production.
Step four, and the real legacy of this incident: fold the gap between release artifact and source repository into your routine checks. Concretely: pin reproducible builds, and have CI verify that build outputs correspond to a specific source commit. If your process leans on a GitHub release's "source code" link to audit code, understand that link points at repository content, not at the tarball your build actually consumes.
One last note on incident response: if you do need to handle a possibly affected machine, replacing the package is not enough on its own — restart sshd or reboot the host. After a downgrade the old code is still resident in process memory and stays there until restart. I covered the full cleanup ordering in a hands-on incident response and hijack cleanup writeup.
FAQ
Q: Does CVE-2024-3094 affect RHEL and Ubuntu?
A: Neither. Red Hat's advisory states outright that "No versions of Red Hat Enterprise Linux (RHEL) are affected by this CVE." On Ubuntu, the affected version only ever appeared in noble-proposed and was removed before moving into noble itself.
Q: How do I tell whether my machine was actually compromised?
A: Having 5.6.0/5.6.1 installed is not enough to decide. The official position is that under those conditions the exploit chain may or may not be reachable; Red Hat's characterization of Fedora 40 was "does not appear to be affected by the actual malware exploit, but downgrade anyway." What you need is SSH auth logs, unusual login patterns, unexpected child processes, and changes in authentication timing. CISA's advisory asks for a downgrade, a hunt for malicious activity, and a report of any positive findings.
Q: liblzma is linked by a huge number of programs, so am I exposed everywhere?
A: Do not infer that. The chain starts at sshd using systemd notification, which loads liblzma indirectly. Ordinary application-level use of liblzma — decompressing xz files as normal business logic — is not on this chain.
Q: Why does deleting those two test files fix it?
A: A community workaround circulated on Gentoo: patch the ebuild's src_unpack to remove m4/build-to-host.m4 plus the two test fixture files, and Gentoo's team lead Sam James confirmed it works on 5.6.1. Treat that as an emergency measure; the official path is upgrading to a fixed version.
Q: How much did this actually affect individual developers?
A: If you run a distribution stable branch and never manually installed xz 5.6.0/5.6.1, the impact is essentially zero. The genuinely exposed group is developers and operators who track rolling releases (Arch, Fedora Rawhide, Debian sid, Kali, openSUSE Tumbleweed) and use systemd-managed OpenSSH. The practical takeaway for individual machines is simply: do not run pre-release repositories on production boxes.
Q: Was the backdoor a compromise, or did the maintainer do it?
A: Freund's exact wording is that given the activity over several weeks, "the committer is either directly involved or there was some quite severe compromise of their system," while noting the latter looks less likely — because that committer had been actively discussing those "fixes" on various mailing lists. This post makes no further judgment about who; it only quotes the primary statement.
Q: Do I still need to care about CVE-2024-3094 in 2026?
A: The way you need to care has changed. Mainstream distributions have long been off the affected versions. What still lingers is old images and build caches: container images, CI caches, and offline installers built around 2024 may still carry that liblzma version, and such artifacts usually have no channel for tracking security updates. So the focus shifts from "does my system have it installed" to "do my images and build outputs have it." Also remember that 5.2.x, 5.4.x, and 5.6.x are all frozen — if you need long-term support you should plan a move to 5.8.x.
👉 Join Xiaomi MiMo Platform: Leading AI model platform with cost-effective inference
👉 Join Aliyun AI: Top AI products with exclusive coupons for business innovation
📌 This article was AI-assisted generated and human-reviewed | TechPassive — An AI-driven content testing site focused on real tool reviews
🔗 Recommended Tools
These are carefully selected tools. Using our affiliate links supports us to keep producing quality content: