Skip to main content
Writeups

CVE-2026-92162: Arch Path Traversal in the Flatpak System Helper

Published
Length
11 min read
Topics
flatpak / path-traversal / privilege-escalation / dbus / linux / cve

There is a moment in a lot of local privilege escalation bugs where you send a request as a normal user, no password, no prompt, and a process running as root does exactly what you told it to. This is the write up of one of those. The DeployAppstream method on the Flatpak system helper takes an architecture string from the caller, drops it into a filesystem path, and creates that path as root without ever checking what is in it. Feed it ../ and the root-owned directory lands wherever you point it.

It was assigned GHSA-v2gw-v9h5-9q4x and CVE-2026-92162. I found it as one half of a chain to root, and there is a separate post about how the hunt actually went, including the bug that turned out not to be mine. This one is about the mechanism: what the code does, why the slash gets through, and what it buys an attacker.

At a glance

FieldValue
ClassCWE-22, path traversal
Componentflatpak-system-helper, runs as root on the system D-Bus
ImpactActive local user creates root-owned directories at an arbitrary path
SeverityHigh (per the advisory)
PreconditionAt least one OCI remote configured (default on Fedora)
AdvisoryGHSA-v2gw-v9h5-9q4x
CVECVE-2026-92162

The caller needs one thing: an active local session. That is any user logged in at the machine’s console or a desktop seat. No admin rights, no sudo, no password prompt.

The system helper, and why it is the interesting part

flatpak-system-helper is a small service that runs as root and sits on the system bus. When you install or update an app system-wide, your unprivileged desktop client cannot write to /var/lib/flatpak itself, so it asks the helper to do it. The helper is the thing on the privileged side of that request, which is exactly what makes it worth reading closely.

What decides whether a given request needs a password is polkit. Every D-Bus method the helper exposes maps to a polkit action, and each action says whether an active local user gets to proceed on their own. Some actions demand auth_admin, which means a real authentication prompt. Others are marked allow_active=yes, which means any logged in user just does it. Those second ones are the attack surface, because from an attacker’s point of view they are unauthenticated calls into root-owned code.

DeployAppstream is one of the allow_active=yes methods. Its job is dull. Appstream is the catalog metadata that graphical software centers read to show you a grid of installable apps, and DeployAppstream refreshes that catalog for a given remote and a given architecture. Dull is good. Dull methods get less scrutiny.

What the handler checks, and what it forgets

handle_deploy_appstream() in system-helper/flatpak-system-helper.c pulls its arguments off the wire, including the origin (the remote name) and the arch. Neither of them is validated here. The handler checks that the optional local repo path exists and then gets to work; it never runs a name or arch validator on the two strings that are about to become path components.

The origin still ends up constrained, just not by a check. For the vulnerable branch to run at all, the origin has to name a real, configured OCI remote: the handler asks flatpak_dir_get_remote_oci() whether this remote is OCI, and a string stuffed with ../ matches no configured remote, so the OCI path is never taken with it. The arch gets no such accidental protection. It is used to build a path whether or not it names anything real, and it goes straight through.

For an OCI remote the handler takes the OCI branch, which routes into flatpak_dir_update_appstream_oci() over in common/flatpak-dir.c. That is where the arch string becomes a directory:

/* common/flatpak-dir.c, in flatpak_dir_update_appstream_oci() */
arch_dir = flatpak_build_file (flatpak_dir_get_path (self),
                               "appstream", remote, arch, NULL);

if (g_mkdir_with_parents (flatpak_file_get_path_cached (arch_dir), 0755) != 0)
  ...

The intended shape of that path is /var/lib/flatpak/appstream/<remote>/<arch>/. The problem is flatpak_build_file. It builds the GFile component by component and resolves each one lexically, which means a .. segment is treated as “go up a level,” not as a directory literally named ... So an arch of ../../../../../root/.ssh does not create a folder called .. five times. It walks up out of the appstream tree, up to the filesystem root, and back down into /root/.ssh, and then g_mkdir_with_parents creates the whole chain as root at mode 0755.

Two more details make it worse than a one-shot mkdir. First, that g_mkdir_with_parents runs before the helper ever talks to the network. The OCI index fetch happens later in the same function, so the directory is created whether or not the registry exists, is reachable, or serves anything valid. You do not need a working OCI server. You need the remote to be configured, and that is all. Second, the same function drops a root-owned lock file and an icons subdirectory under that escaped path, so the primitive is a small tree, not a single empty folder.

The maddening part is that Flatpak already has the right check sitting in the tree. flatpak_is_valid_arch() in common/flatpak-ref-utils.c restricts an architecture name to [A-Za-z0-9_], which rejects both . and / and kills any traversal on sight. It just was never called on this path. The matching predicate for remote names existed too, as a private helper in the same file’s orbit, and it also went uncalled here. The method reached the path builder without validating either string.

Reaching it with no prompt

The polkit action bound to DeployAppstream is org.freedesktop.Flatpak.appstream-update. In the shipped policy it reads:

org.freedesktop.Flatpak.appstream-update
  allow_active:   yes
  allow_inactive: auth_admin
  allow_any:      auth_admin

An active local session hits allow_active: yes and sails through. Inactive or remote sessions fall to auth_admin and get stopped, which is why the practical requirement is a local seat, the kind any desktop user has by default. You can confirm your own session counts with loginctl list-sessions.

The other precondition is that OCI branch. It only runs if the remote is an OCI registry, so the system needs at least one OCI remote configured. This is why the advisory calls out Fedora specifically: Fedora Workstation ships an OCI fedora remote (oci+https://registry.fedoraproject.org) out of the box, so a default Fedora install is exposed with zero setup. On distributions that ship no OCI remote, the bug is only reachable once one has been added, which is why it is rated lower there.

Triggering it

The call itself is unremarkable, which is the whole point. In Python over the system bus:

import dbus

bus = dbus.SystemBus()
helper = bus.get_object("org.freedesktop.Flatpak.SystemHelper",
                        "/org/freedesktop/Flatpak/SystemHelper")
iface = dbus.Interface(helper, "org.freedesktop.Flatpak.SystemHelper")

# repo_path="", flags=0, origin=<oci remote>, arch=<traversal>, installation=""
iface.DeployAppstream(b"", 0, "fedora", "../../../../../root/.ssh", "")

That returns a D-Bus error, and here is the trap: the error is a red herring. It is the OCI index fetch failing after the fact. The directory is already there:

$ sudo stat -c '%U:%G %n' /root/.ssh
root:root /root/.ssh

root:root, created by a call I made as an ordinary user with no password. The number of ../ segments is just bookkeeping. It tracks how deep the installation prefix is, so you add enough to climb to / and then name wherever you want to land.

Why a directory is not nothing

On its own, “I can make root create empty directories” sounds like a shrug, and that was my first reaction too. It is more than that even in isolation. You can plant a root-owned directory where a later privileged process expects to create a file and get tripped up by the existing ownership, you can occupy a path to break a service, and you can degrade a system quietly. The advisory frames it as an integrity and availability issue for that reason.

But the sharp version is composition. g_mkdir_with_parents creates parents. That verb, “creates parents,” is the thing that makes this bug the key to a second one.

There is a separate defect in Flatpak’s Deploy path, in how it writes extra-data files, where an attacker-controlled name is used as a filename with no path check. That one writes attacker-controlled content, which is the primitive you actually want. It has one structural limit: the routine it uses to write, g_file_replace_contents, does not create parent directories. It writes a temp file next to the destination and renames it, so the destination’s parent has to exist already. Plenty of useful targets have parents that exist on a stock system (/etc/cron.d, /etc/sudoers.d). Some of the best ones do not. /root/.ssh is absent until root makes it.

So look at the two side by side. One bug creates directories anywhere but writes no content you control. The other writes content you control but only where a directory already exists. Put them next to each other and the gap closes exactly:

  1. Call DeployAppstream with arch="../../../../../root/.ssh". The helper creates /root/.ssh as root.
  2. Drive the extra-data write to drop authorized_keys into it with your public key.
  3. ssh -i yourkey root@host.

The extra-data half has its own preconditions (an already-installed app from a GPG-verified remote, among others), and I walk through all of that in the story post. The point here is narrow: the arch traversal is the piece that supplies the missing parent directory, and that is what turns a write-where-a-dir-exists bug into a write-anywhere bug. The advisory nods at this when it says the vulnerability, combined with others fixed in the same release, leads to local root.

The fix

The remediation is as small as these things get, which is a little embarrassing for how long the gap sat there. Validate the arch before it is ever used to build a path, using the predicate the project already ships:

if (*arg_arch == 0 || !flatpak_is_valid_arch (arg_arch))
  return flatpak_invocation_return_error (invocation, ...,
             "Invalid architecture: %s", arg_arch);

One footnote from the advisory that will save someone an afternoon: the test coverage added alongside the fix trips up on very old Meson (0.61.2, the version in Ubuntu 22.04 “jammy”). Newer Meson is fine, and there is a workaround in PR #6768.

What to take from it

I do not want to end on a tidy list of maxims, because the real lesson is duller than a maxim. This trust boundary took two strings that both become path components and validated neither of them, even though the codebase already carried a predicate for each. The fix is what gives the shape away: it did not bolt on one missing check, it added validation for the origin and the arch together, because the problem was that the handler never validated its path-bound arguments as a class. It just trusted them and passed them down. A boundary that trusts the inputs nobody happened to worry about will keep growing gaps like this one for as long as new arguments get added. That is the shape of the bug, and it is worth more than the payload.

If you run Flatpak system-wide, especially on Fedora, update to 1.18.1 or later. If you are stuck on 1.16.x, the backport is there.