https://bugs.kde.org/show_bug.cgi?id=525766

            Bug ID: 525766
           Summary: PackageJobThread::installPackage() never checks
                    QTemporaryDir::isValid(); when the temp dir cannot be
                    created it runs QDir("/").removeRecursively() and
                    deletes the user's home directory
    Classification: Frameworks and Libraries
           Product: frameworks-kpackage
      Version First 6.30.0
       Reported In:
          Platform: Fedora RPMs
                OS: Linux
            Status: REPORTED
          Severity: grave
          Priority: NOR
         Component: default
          Assignee: [email protected]
          Reporter: [email protected]
  Target Milestone: ---

Created attachment 196171
  --> https://bugs.kde.org/attachment.cgi?id=196171&action=edit
QTest reproducer / regression test (fails on current code)

Disclaimer: After I lost my home directory due to this bug, I let Claude Fable
5.1 forensically analyze an image of the disk. It found this bug as the root
cause. Report written by Claude Fable 5.1. I can provide additional
files/information on demand.
----------------------------------

SUMMARY
KPackage's install path (PackageJob::install/update, kpackagetool6 -i, System
Settings "Install from
file" for KWin scripts, plasmoids, themes, ...) creates its extraction
directory with QTemporaryDir
and never checks isValid(). If the temporary directory cannot be created, the
extraction path becomes
"/" and the job's cleanup step runs QDir("/").removeRecursively() with the
privileges of the logged-in
user. Everything the user is allowed to delete is deleted: the home directory,
/tmp, /run/user/<uid>.
Reproduced with kf6-kpackage 6.30.0 (Fedora 44); the code is identical in
6.28.0 and 6.29.0.

STEPS TO REPRODUCE
(do this in a disposable VM or container, as an unprivileged user; it destroys
the user's files)
1. Make the temporary directory uncreatable, e.g.
       export TMPDIR=/nonexistent
   (a merely full /tmp does NOT trigger it: the directory is created and
extraction fails first)
2. cd ~ ; kpackagetool6 -t KWin/Script -i any.kwinscript
   or: System Settings > Window Management > KWin Scripts > Install from File
3. Wait. The process walks / for a long time (2.5 million files took ~13
minutes on the affected
   laptop, ~20 s in a small container) and ends with
       Error: Installation of any.kwinscript failed: Could not move package to
destination: ~/.local/share/kwin/scripts/<name>

OBSERVED RESULT
The user's home directory, /tmp contents and /run/user/<uid> (Wayland socket,
xauth file, ...) are
deleted. Under strace the walk starts at the root:
    unlink("/.profile")  = -1 EACCES
    unlink("/lib64")     = -1 EACCES
    unlink("/sbin")      = -1 EACCES
    ...
    unlink("/var/home/victim/Documents/CANARY") = 0
    unlink("/run/user/1001/CANARY-runtime")     = 0
    unlink("/tmp/victim-tmp/CANARY-tmp")        = 0
276,467 unlink() attempts, 25,028 rmdir(), 276,047 chmod() retries (6,627 of
them inside /proc) in the
container run. On an SELinux-enforcing system the chmod retries on user-owned
procfs directories show
up as AVC "denied { setattr } ... name="net" dev="proc" ...
comm="systemsettings"".

EXPECTED RESULT
The job fails with an error when no temporary directory is available. Nothing
outside the temporary
directory is ever removed.

ANALYSIS (src/kpackage/private/packagejobthread.cpp, v6.29.0 / v6.30.0)

    QTemporaryDir tempdir;                         // isValid() is never
checked
    ...
    archive = new KZip(src); ... archivedPackage = true;
    path = tempdir.path() + QLatin1Char('/');      // QTemporaryDir::path() is
"" when creation failed  ->  path == "/"
    source->copyTo(path);                          // return value ignored
    ...
    Package copyPackage = package;
    copyPackage.setPath(path);
    if (!copyPackage.isValid()) { ... return false; }   // passes, see below
    KPluginMetaData meta = copyPackage.metadata();
    QString pluginName = meta.pluginId().isEmpty() ? QFileInfo(src).baseName()
: meta.pluginId();
    ...
    const bool ok = copyFolder(path, targetName);  // copies "/" following
symlinks until the first failure
    removeFolder(path);                            // runs regardless of ok  ->
 QDir("/").removeRecursively()

Why the validity check does not catch it (src/kpackage/package.cpp,
Package::isValid()):
    // Minimal packages with no metadata *are* supposed to be possible
    // so if !metadata().isValid() go ahead
    ...
    const QString rootPath = d->tempRoot.isEmpty() ? d->path : d->tempRoot;
    if (rootPath.isEmpty()) { return false; }
    d->valid = true;
    for (... d->contents ...) { if (it.value().required &&
filePath(it.key()).isEmpty()) { d->valid = false; break; } }
For path "/" there is no metadata.json, so no metadata; the KWin/Script
structure
(kwin/src/plugins/kpackage/scripts/scripts.cpp, pathChanged()) only marks
"mainscript" as required
after reading X-Plasma-API from valid metadata, so nothing is required and
isValid() returns true.
pluginId() is empty, so the plugin name falls back to the archive's base name
and passes the
^[\w\-\.]+$ check. The job then reaches copyFolder("/", ...) and
removeFolder("/").

Qt's QDir::removeRecursively() (qtbase src/corelib/io/qdir.cpp) retries every
failed QFile::remove()
with QFile::setPermissions(..., WriteUser) -- that is the chmod storm inside
/proc.

REAL-WORLD IMPACT
2026-09-14, Fedora Kinoite 44 (compose 44.20260908.0; KPackage 6.29.x, KWin
6.7.x, Plasma 6.7):
installing a 1 KB, harmless KWin script via System Settings > KWin Scripts >
Install from File deleted
the user's entire home directory (2.5 million files) plus /run/user/1000 within
~13 minutes.
Forensic evidence available on request: 48 SELinux AVC denials from the
systemsettings pid
(setattr on /proc/<pid>/{net,task}, execute on pipes), btrfs generation history
showing the home
being deleted in readdir order, journal showing the Wayland socket
disappearing.

ATTACHED FILES

kpackage_tempdir_test.cpp
    QTest program that calls KPackage::PackageJob::update("KWin/Script",
<package>) exactly as the
    KWin Scripts KCM does. Two test functions: installControl() (working
TMPDIR, expects a clean
    install and intact canary files) and installWithUnusableTmpdir()
(TMPDIR=/nonexistent, expects
    the job to fail cleanly and the canary files under $HOME, /tmp and
/run/user/<uid> to survive).
    On current code the second function FAILS with the message "BUG REPRODUCED:
canary files under
    $HOME were deleted"; on fixed code both PASS, so it can serve as a
regression test.
    Safety interlocks: refuses to run as root, outside a container
(/run/.containerenv or
    /.dockerenv), or without KPKG_REPRO_ARMED=1, because on vulnerable code it
deletes every file the
    test user can delete. Requires a KWin/Script package structure plugin at
runtime (package kwin).
    Environment: KPKG_REPRO_PACKAGE=<path to any .kwinscript>.

CMakeLists.txt
    Build file for the test (Qt6 Core/Test, KF6Package, KF6CoreAddons, ECM):
        cmake -B b -S . && cmake --build b && KPKG_REPRO_ARMED=1
KPKG_REPRO_PACKAGE=... ./b/kpackage_tempdir_test

Containerfile
    Disposable Fedora 44 image (kf6-kpackage(-devel), kwin, qt6-qtbase-devel,
strace, build tools)
    with an unprivileged user "victim" (uid 1001, home /var/home/victim).
Records the exact package
    NVRs into /IMAGE-VERSIONS.txt.

run-in-container.sh
    Driver run as root inside that container. Seeds canary files (~/.config,
~/workspace, ~/Documents,
    ~/.local/share/containers with container-layer-style symlinks, /tmp,
/run/user/1001), then runs:
      N1  control:  kpackagetool6 -t KWin/Script -i pkg  with a working TMPDIR 
-> clean install, canaries intact
      N2  trigger:  same with TMPDIR=/nonexistent, under strace -f             
  -> all canaries deleted
      N3  full /tmp (64 KiB tmpfs)                                             
   -> NOT triggered (extraction fails first)
      N4  builds and runs the unit test above                                  
   -> "BUG REPRODUCED"
    Writes the strace summary (below) and a console log.

N2.fingerprint.txt
    Summary extracted from the strace of run N2: total unlink/rmdir/chmod
counts, the first unlink
    calls (showing the walk starts at "/"), the successful deletions of the
canaries in $HOME,
    /run/user/1001 and /tmp, and the per-top-level-directory distribution of
removal attempts.

avc-excerpt.txt
    Excerpt of the affected machine's audit log (hostname redacted): the 48
SELinux denials produced
    by the systemsettings process during the deletion (setattr on user-owned
procfs directories,
    execute on pipes), plus the journal lines showing the wallet,
/run/user/1000 and the home
    disappearing in sequence, and the btrfs generation history of the home
subvolume.

SUGGESTED FIX

    QTemporaryDir tempdir;
    if (!tempdir.isValid()) {
        d->errorMessage = i18n("Could not create a temporary directory: %1",
tempdir.errorString());
        return false;
    }
    ...
    if (!source->copyTo(path)) {
        d->errorMessage = i18n("Could not extract the package");
        return false;
    }

Additionally consider making removeFolder() refuse any path that is not
strictly below
tempdir.path() (or shorter than 2 characters), as defence in depth.

SOFTWARE/OS VERSIONS
Reproduced: Fedora 44, kf6-kpackage-6.30.0-1.fc44, kwin-6.7.5-1.fc44,
qt6-qtbase-6.11.2-2.fc44,
kf6-karchive-6.30.0-1.fc44. Affected system: Fedora Kinoite 44.20260908.0.

ADDITIONAL INFORMATION
I am the affected user and can re-run the reproduction against a patched build
on request.

-- 
You are receiving this mail because:
You are watching all bug changes.

Reply via email to