november 2026 · 4 min

Apt Checked the Package. Then It Wrote to It.

Nobody forged a signature in CVE-2026-109518. Apt verified a real package, and then its own retry logic overwrote it with something else, and installed that as root.

A verification covers the bytes until something can write to them again. Signing did its job here. The bytes it approved were gone by the time they mattered.

The Mechanism

Max Justicz found it, and Ubuntu's notice says it in one line: "APT incorrectly handled hard links when encountering redirects and retrying package downloads." The steps, as reported, since I couldn't open Debian's advisory and found no write-up from Justicz:

  1. An attacker on the network path, or running your mirror, uses redirects so two package downloads resolve to the same file. Apt hard-links them on disk.
  2. The attacker returns temporary errors, so apt retries and resumes.
  3. On resume, apt reopens the existing file and writes into it. That file is a hard link to a package that already passed verification, so the verified file changes in place.
  4. dpkg installs it, and the maintainer scripts run as root.

Retrying and resuming are reasonable on their own. The bug is that they sit between the check and the use.

Apt has been here before. Justicz's 2019 bug, CVE-2019-3462, injected fake protocol messages through a redirect's Location header. Same finder, same redirects. Back then, people in the HN thread argued plain HTTP was fine because signed metadata protects integrity. That argument covers the bytes at the moment they're checked, and nothing after.

Check It

dpkg-query -W -f='${Version}\n' apt
apt-config dump | grep -i retries
grep -rhE '^(deb|URIs:).*http://' /etc/apt/sources.list /etc/apt/sources.list.d/ 2>/dev/null

Compare the first line with Ubuntu's fixed versions (2.8.4ubuntu0.1 on 24.04, 3.2.1ubuntu0.1 on 26.04) or, as reported, 3.0.4 on Debian 13 and 2.6.2 on Debian 12.

Don't read the second line as a pass. Empty output means the setting isn't in your config, and Ubuntu says retries are on by default from 22.04. Reportedly that's 3 since apt 2.3.2, which covers Debian 12 and 13 too. Only an explicit 0 turns them off. Older releases are affected only if someone set it non-zero.

The third line lists your plain HTTP sources. On a default install, expect most of them to be there.

The Fix

Upgrading apt goes through apt, so turn retries off first. That's Ubuntu's own advice, and it covers both the on-path attacker and the malicious mirror:

sudo apt update
sudo apt install -o Acquire::Retries=0 --only-upgrade '?source-package(^apt$)'

The hosts you remember are the easy part. Your Dockerfiles are the rest: a pinned base image carries the old apt forever, and every apt-get install in a build runs it. Until your bases are rebuilt, do what this PR did and write Acquire::Retries "0" into /etc/apt/apt.conf.d/ before the first install. Then add a CI check on the apt version, because a mitigation in one Dockerfile doesn't reach the next one.

HTTPS helps, with a limit. Ubuntu says TLS protects against this, and it also says a malicious mirror can exploit it. A mirror terminating your TLS is still your mirror, so I read HTTPS as removing the on-path attacker only. Patching is what closes the mirror case.

The tradeoff: retries exist because mirrors flake, so 0 turns a blip into a failed build, and Ubuntu calls it temporary. Some mirrors don't offer HTTPS, and plain HTTP is what lets caching proxies work. No exploitation is reported so far.

What to Take From It

Preflight, which I designed to verify on the consumer's side, has this same limit. If you're writing anything that verifies then uses -- a package manager, a CI cache, an agent fetching a skill -- make the check the last thing that touches the bytes. Read-only, or re-hashed at the moment of use.

← all posts