14 May 2024 · 6 min read

The tarball is not the repository

The xz backdoor's trigger was never in git, only in the signed release tarballs that distributions compiled. Build from the tag, and treat test files as code.

Andres Freund's message to the oss-security list on 29 March 2024 gave its finding in one line: "The upstream xz repository and the xz tarballs have been backdoored." That sentence names two objects, and the gap between them is the lesson. The code that switched the backdoor on, a doctored copy of build-to-host.m4, a standard gnulib macro file that recent versions of gettext pull in, was never in the xz git repository. It existed only in the release tarballs for 5.6.0 and 5.6.1, which the attacker built and signed and distributions compiled. The payload was in git, packed into two binary test files of the kind reviewers skip. "The code is open" was true and beside the point: the artefact that was built was not the artefact anyone reviewed. The rule I take from it is to build from the tag, regenerate what the tag lacks, and treat test fixtures and generated files as code.

Two downloads on one release page

One portion of the backdoor, Freund wrote, "is solely in the distributed tarballs". The line he pointed to in build-to-host.m4 "is not in the upstream source of build-to-host, nor is build-to-host used by xz in git." The release pages offered both kinds of download: the uploaded, signed tarballs carried the trigger, and the "source code" archives, which he thought "github generates directly from the repository contents", did not. Lasse Collin, the project's original maintainer, confirmed on his incident page that those tarballs "were created and signed by Jia Tan", the attacker's alias, and that the test files are harmless "without the trigger code that was never included in the Git repository."

Russ Cox's walkthrough of the script shows the macro, during configure, finding tests/files/bad-3-corrupt_lzma2.xz by a marker string, swapping some characters back with tr, decompressing it and piping it to a shell. That script carves a second one out of good-large_compressed.lzma. The second checks, among other things, for x86-64 Linux, gcc, the GNU linker, GNU indirect-function support and a Debian or RPM package build, and if they hold, edits the generated Makefile so that make links an object file hidden in the test data into liblzma. OpenSSH did not use liblzma directly, but Debian and several other distributions patched it to notify systemd through libsystemd, which depended on liblzma.

What was in git, what was only in the xz release tarball, and what a package build ran Left, three panels. Only in the signed release tarball: a generated configure script and the macros copied in at packaging, including a doctored m4/build-to-host.m4. In git at the tag, and so also in the tarball: the source code, configure.ac, and two binary test files, bad-3-corrupt_lzma2.xz and good-large_compressed.lzma, which hold the payload and which make check fed to xz by glob, like every other good-* and bad-* file. GitHub's source code archives, built from the tag by git archive, held no trigger. Right, the steps a Debian or RPM build ran: configure runs the macro; it greps the source tree for a marker; tr and xz -d turn the first test file into a script; that script carves a second script from the other test file; among other checks, it requires x86-64, gcc, GNU ld, GNU indirect-function support and a Debian or RPM build; if so, the Makefile is edited and make links the hidden object into liblzma, which patched sshd loaded through libsystemd. Arrows run from the macro and from each test file to the step that reads it. The trigger was only in the tarball; the payload was in git xz 5.6.0 and 5.6.1: what each artefact held, and what a package build executed Only in the signed release tarball generated configure, macros copied in m4/build-to-host.m4 doctored In git at the tag, and in the tarball source code and configure.ac bad-3-corrupt_lzma2.xz test file good-large_compressed.lzma test file payload; make check read both GitHub's "Source code" archives built from the tag by git archive, so they held no trigger What a Debian or RPM build ran configure runs the macro it greps the tree for a marker tr and xz -d turn it into a script that script carves a second from it x86-64, gcc, GNU ld, ifunc, Debian/RPM? Makefile edited; make links the hidden object into liblzma which patched sshd loaded via libsystemd yes
Source: Andres Freund's oss-security post of 29 March 2024 for what the tarball, the repository and GitHub's archives contained; GitHub's documentation on source code archives; Lasse Collin's review notes, last updated on 8 May 2024, for tests/test_files.sh picking up the test files by glob; Russ Cox's walkthrough of the script, posted on 2 April 2024 and updated on 3 April 2024, for the steps and the build conditions.

Why nobody compared them

None of this needed the tarball to look odd, because autotools tarballs are meant to differ from the repository. "Usually, the configure script and its support libraries are only added to the tarball distributions, not the source repository," Cox writes. "The xz distribution works this way too." His timeline judges the extra macro "not suspicious by itself", since many legitimate ones arrive the same way. Against the standard copy the changes were small: the serial number went from 3 to 30, the copyright line gained 2024, and a handful of other changes did not, in Cox's words, "look terribly out of place". The serial bump mattered: Guillem Jover found that the autoreconf -f -i in Debian's build left alone macro files with a higher serial than the installed copy, so the tarball's newer gettext.m4 survived to pull in the doctored file, which serial 30 would have kept through a gettext upgrade.

The signature was no help either, because it answered a different question. Arch Linux's first build recipe for 5.6.1 fetched xz-5.6.1.tar.gz and its .sig from the GitHub release and listed two valid signing keys, Lasse Collin's and Jia Tan's. A signature binds a file to a key. It says nothing about whether the file matches the repository, and here the key was the attacker's own.

Seventeen months to own the release

The campaign ran for more than two years, and Cox's timeline of the attack shows it aimed at the one role a review of git cannot audit. Jia Tan's first patch reached the xz-devel list on 29 October 2021. From April 2022, accounts that Cox thinks were probably fakes complained about Jia Tan's stalled patch, and by June demanded a new maintainer: "Progress will not happen until there is new maintainer," one wrote. Collin replied that this was "an unpaid hobby project", and on 29 June called Jia Tan "practically a co-maintainer already". Jia Tan was committing directly by 30 December 2022. Collin built his last release, 5.4.1, on 11 January 2023, and Jia Tan built a first, 5.4.2, on 18 March. From then on, the person producing the signed tarballs was the attacker.

On 23 February 2024 the payload went into git as test files, and the next day 5.6.0 was tagged and its tarball published with the extra macro. The object code caused Valgrind errors, so on 9 March Jia Tan replaced the test files, with a commit message explaining that "a constant seed was used to recreate these files", and shipped 5.6.1 the same day.

Timeline of the xz attack from the first patch in 2021 to the disclosure in March 2024 Two time axes, each drawn to scale. Top, October 2021 to March 2024: 29 October 2021, first patch; 22 April 2022, pressure emails begin; 29 June 2022, Collin calls Jia Tan practically a co-maintainer; 30 December 2022, Jia Tan commits directly; 11 January 2023, Collin's last release, 5.4.1; 18 March 2023, Jia Tan's first release, 5.4.2; 19 January 2024, Jia Tan controls the web page. A red band at the end marks the five weeks shown below. Bottom, 20 February to 31 March 2024: 23 February, payload committed in test files; 24 February, 5.6.0 tarball with the trigger; 26 February, Debian unstable takes 5.6.0; 9 March, the two test files replaced and 5.6.1; 25 March, a Debian bug asks for 5.6.1; 27 March, Debian takes 5.6.1; 28 March, reported privately; 29 March, public disclosure. Over two years of patience, then five weeks of attack Dates from Russ Cox's timeline, to scale on each axis access attack response Gaining control, October 2021 to January 2024 2022 2023 2024 zoomed below 29 Oct 2021 first patch 22 Apr 2022 pressure emails begin 29 Jun 2022 "practically a co-maintainer" 30 Dec 2022 commits directly 11 Jan 2023 Collin's last release, 5.4.1 18 Mar 2023 Jia Tan's first, 5.4.2 19 Jan 2024 controls the web page The attack and its discovery, 20 February to 31 March 2024 20 Feb 1 Mar 31 Mar 23 Feb payload in test files 24 Feb 5.6.0 tarball with trigger 26 Feb Debian unstable: 5.6.0 9 Mar files replaced, 5.6.1 25 Mar Debian bug asks for 5.6.1 27 Mar Debian takes 5.6.1 28 Mar reported privately 29 Mar public disclosure Linear in days: top axis 913 days, bottom 40 days, so a day below is about 23 times wider.
Source: dates from Russ Cox's timeline of the xz attack, posted on 1 April 2024 and updated on 3 April 2024.

Found by a stopwatch

It was not found by reading code. Freund was chasing "logins with ssh taking a lot of CPU, valgrind errors" on Debian sid; a failed login took 0.807 seconds instead of 0.299, and the slow step, he wrote, appeared to be the backdoor parsing symbol tables in memory. The damage was contained because the versions were new, and had "not yet widely been integrated by linux distributions, and where they have, mostly in pre-release versions." Debian's advisory lists its testing, unstable and experimental distributions, and Red Hat's alert lists Fedora 40 beta and Rawhide. The NVD entry for CVE-2024-3094 carries a CVSS base score of 10.0.

The rule

It has three parts, one for each gap.

Build from the tag. Fetch the source at the release tag and run the project's own bootstrap, autoreconf or autogen.sh, so every generated file comes from tools you installed, not from the maintainer's machine. Arch Linux did exactly this for xz on 28 March 2024, the day Freund privately warned distributions and the day before the public disclosure: its package switched from the tarball and signature to the git tag v5.6.1 and carried the message "improve reproducibility by running autogen.sh ourselves". That does not remove the payload, which was in git, but it leaves the payload inert, because the trigger never was.

Diff the tarball against the tag, and make the diff explain itself. A bare list of the files that exist only in the tarball would have shown build-to-host.m4 as one more macro added at packaging, which is Cox's point. The check that catches it is regenerating those files from the tag with the maintainer's tool versions, which the files record, and comparing contents, so a macro claiming to be the standard copy is compared with the standard copy.

Treat test fixtures and generated files as code. If the build can read a file, it is an input to the build, whatever directory it sits in. Freund wrote that the two files "were not even used for any 'tests' in 5.6.0", but make check fed every good-* and bad-* file to xz by glob, and 5.6.1 added a test aimed at one of them, so a rule that every fixture be tested would have passed both. Two checks would not have: a fixture described as generated must ship its generator, and a traced build that opens anything in tests/files/ outside make check fails. The README in tests/files said many of its files were made by hand, with no better source code "than the files themselves", which is exactly what an attacker wants in a hiding place.

What the rule does not fix

Building from the tag makes what runs the same as what can be read. It does not make anyone read it. The payload sat in git for five weeks, and an attacker with commit access can put a trigger in git too, dressed as plausibly as a version bump. What the rule changes is where that attacker has to work: every step becomes a public commit that anyone can diff and bisect, not a file that existed only on a release page. That is a smaller hiding place, and the only one review was ever going to cover.

"The code is open" is a statement about a repository. A build consumes whatever it is handed. The two are the same only when the builder makes them the same, and in March 2024 both Debian and Arch were building xz from the tarball.

Supply ChainOpen SourceRelease Engineering
All writing

Written by Mohd Shayan

Get new posts by email

Occasional essays on engineering, AI, and building for the people technology leaves behind.

One email per new post. Unsubscribe any time.

Subscribe with RSS