Skip to main content
Blog

Two bugs that only mattered together: hunting root in the Flatpak system helper

Published
Length
11 min read
Topics
flatpak / vulnerability-research / privilege-escalation / path-traversal

I went into the Flatpak system helper looking for one clean bug. I came out with two mediocre ones that were worthless on their own and dangerous together, a day lost to a theory that never had legs, and a small lesson in humility at the end when it turned out one of my two bugs was already somebody else’s.

The clean part of the story is a single input that reaches a root privileged g_mkdir_with_parents with no validation. If you just want that mechanism, I already wrote it up in the technical post. Everything around it, the chain, the dead end, the reveal, is the part I actually want to write down here. This is how the hunt went.

Where I started

flatpak-system-helper is a root-owned service on the system D-Bus. Your desktop client cannot write to /var/lib/flatpak on its own, so it asks the helper to install and update things for it. Any process that runs as root and takes orders from unprivileged callers is worth staring at, and Flatpak makes the boundary unusually legible: every method the helper exposes maps to a polkit action, and each action says out loud whether an active local user is allowed to proceed without a password.

That mapping was my whole map. If a method resolves to an action marked allow_active=yes, then every logged in user can call it with whatever arguments they like and no prompt appears. From the outside that is an unauthenticated call into code running as root. So the first pass was boring and mechanical: list the methods, read the polkit policy, and build a table of method to action to auth requirement. The methods that answered “no prompt” were the ones worth my time. Everything gated behind auth_admin I put aside, because a user who can already authenticate as an admin does not need a bug from me.

DeployAppstream came out of that table as one of the yes methods. It refreshes the appstream catalog, the metadata a software center reads to list apps. Nobody’s idea of a scary method. Which is usually where the good ones hide.

The bug fell out fast

The second pass was slower. For each no-prompt method, I traced every caller-supplied argument to wherever it touched the filesystem, a process, or a trust decision. The thing I was hunting was simple to describe and tedious to find: a string I control that reaches a path builder or a filename with no check in between.

DeployAppstream gave it up almost immediately, and it gave it up in the most irritating way possible. The handler validates neither of the two strings that become path components. The origin is saved by luck rather than by a check: it has to name a real OCI remote for the vulnerable branch to run at all, so a slash-laden origin just matches nothing and the path is never taken with it. The arch has no such luck. It flows into flatpak_build_file and then g_mkdir_with_parents, running as root, and flatpak_build_file resolves .. lexically. So a well-placed pile of ../ walks the root-owned mkdir straight out of the appstream tree.

I wrote the call, sent arch="../../../../../root/.ssh", got a D-Bus error back, and almost moved on thinking it had failed. Then I checked the disk.

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

There it was. The D-Bus error was just the OCI fetch dying after the fact; the directory had already been created before the helper ever touched the network. I had made root build a directory in /root as a normal user with no password. That is a real privilege boundary crossed, and my first honest reaction was not triumph. It was “so what.”

The “so what” problem

Directory creation is a weak primitive and I knew it. What can you really do with it? I can make root create empty folders wherever I want. I can be a nuisance with that. I can occupy a path something else expects, break a service, mess with ownership. It crosses the line but it does not hand me a shell. I wrote it up, sat with it, and did the thing you do with a weak primitive, which is keep it in your pocket and go looking for the second one it might unlock. A directory-creation bug is exactly the kind of thing that is boring alone and lethal the moment it meets a write.

So I went hunting for the write.

The dead end

Here is the day I would like back. The Deploy method takes a subpaths array, and those subpaths get built into destination paths during checkout. The theory wrote itself: a subpath of ../../../etc/cron.d traverses out of the deploy tree and I get a root write somewhere useful. I was pretty sure I had the second half of a chain.

I chased it into the OSTree checkout code and it fell apart, slowly, which is the worst way for a theory to fall apart. The subpath is resolved against the source tree, and the source tree is not the filesystem. It is a content-addressed Merkle structure, and its path resolver treats .. as a literal child name to look up, not as a parent reference. No committed directory contains a child that is literally named .., because the kernel will not let you make one, so the lookup just fails. And it fails at an existence check that runs before any destination directory ever gets created. The traversal never reaches the sink because the sink is never reached at all.

I did not want to believe it, so I spent longer than I should have trying to force a commit tree that contained a .. entry, which is a bit like trying to mail a letter to a house that cannot be built. Eventually I accepted the disproof. In hindsight it was worth the time, even though it felt like waste while I was in it. It ruled out a whole shape of attack on that method and pushed me back to the two primitives I already had, which is where the actual chain was hiding the whole time. A theory that is cleanly dead is a narrower search, and a narrower search is progress. It just does not feel like it at 2am.

The other write, and the moment they clicked

Back to the primitives. The write I needed was not in subpaths at all. It was in how Deploy handles extra-data files. There is a spot where a name taken from a commit’s own metadata is used directly as a filename, with the only checks being that the name matches its declared source, its size, and its sha256. None of those constrain path characters. So a name like ../../../../root/.ssh/authorized_keys escapes the checkout, and both the name and the content are attacker-controlled. That is the write primitive: arbitrary root-owned file, name and content mine.

It had one catch, and the catch is the whole reason the two bugs matter. The routine it writes with, g_file_replace_contents, does not create parent directories. It stages a temp file next to the destination and renames it into place, so the destination’s parent has to exist already. Some great targets have parents that exist by default. /etc/cron.d, /etc/sudoers.d, both there on a stock system. But the cleanest target of all, /root/.ssh/authorized_keys, has a parent that does not exist until root creates it.

And that is the sentence that made me sit up, because I had spent the previous day building a bug that creates directories as root wherever I want, including parents that do not normally exist.

The two limitations were the exact inverse of each other. The arch traversal creates directories but writes no content I control. The extra-data write controls content but only where a directory already exists. Each bug’s weakness is the other bug’s strength. So:

  1. DeployAppstream with the arch traversal makes root create /root/.ssh.
  2. The extra-data write drops authorized_keys into it, now that the parent exists, with my public key.
  3. ssh -i mykey root@host.

I built the full thing in a lab, watched it drop a key into /root/.ssh, and logged in as root from an account that started the day with nothing but a local session. Two weak bugs, one real one.

The twist

I wrote both up carefully, made the case that they were distinct defects with independent fixes, and reported them, quietly expecting two CVEs at the end of it. That is not how it went, and the way it did not go is the part I keep coming back to.

The arch traversal was new. It became GHSA-v2gw-v9h5-9q4x, credited to me, and later CVE-2026-92162. The extra-data write was not new. It was already on the maintainers’ radar and already being fixed, and it landed in the same 1.18.1 release under its own advisory, GHSA-fqx6-vh4p-42cg, that was not mine. My “second bug” was a bug I had rediscovered rather than discovered. The whole release was, it turned out, one big coordinated sweep of the trust boundary I had wandered into, and I had arrived in the middle of it holding one genuinely new finding and one that a lot of other people already knew about.

The first feeling was a small deflation. I thought I had a two-bug chain that was entirely mine and it was really a one-bug chain wearing a borrowed second half. But sitting with it, the interesting result was not diminished by the overlap. The composition was still the thing. On its own, the arch traversal reads as a modest integrity bug, a way to make root create directories, the kind of finding that gets a “High, sort of, in context” and a shrug. The reason it is worth caring about is what it does next to a write primitive: it removes the exact precondition that keeps that write from reaching the targets that matter. The advisory says as much when it notes that combined with other issues fixed in the same release, it leads to local root. The other issue in that sentence was the one I had found the hard way and then learned was already known. The chaining was real whether or not I was the first to see both halves.

What I actually took from it

Not a list of maxims. Two smaller things that felt true by the end.

The first is that a directory-creation bug deserves more respect than it gets. I nearly filed the arch traversal as a curiosity because it did not immediately spell root, and the thing that made it matter was a completely separate defect in a completely separate file. Nobody who reviewed the appstream code was thinking about the extra-data write, and nobody reviewing the extra-data write was thinking about who might create its missing parent. Composition risk lives in the seams between components, which is to say it belongs to nobody, which is to say it is easy for everybody to miss.

The second is that a dead end that cleanly disproves a theory is not lost time, even when it feels like the most lost time in the world. The subpath traversal that never worked is the reason I stopped looking for a fancy new write and went back to the two ordinary primitives I already had. The chain was never going to be a clever bug, just two dull ones that happened to fit, and the boring path to it was the correct one.

If you run Flatpak system-wide, update to 1.18.1 or later. If you want the mechanics of the arch traversal in detail, the root cause and the fix are in the technical write up.