Skip to content

[MEDIUM] Disk analyzer (k4dirstat/baobab) runs the df-derived mount point through a shell on 'Analyse Diskspace' #50

Closed

Description

@Toqsick

Summary

Linux.openDiskSpaceAnalyzer() launches k4dirstat/baobab with the disk's
mount point as its only argument, but does not pass runInShell: false —
so the call goes through Linux.runCommandWithCustomArguments's default
runInShell: true and Process.run joins the argv with spaces into a
sh -c command line. A mount point containing a space is split into two
arguments (the analyzer opens the wrong path); a mount point containing shell
metacharacters (;, $(), backticks, &, |) is executed as shell code by
/bin/sh the moment the user clicks Analyse Diskspace for that disk.

The mount point is not a constant: it comes from df -h output parsed in
lib/linux/linux_filesystem.dart, and for removable media it is derived from
the volume label — external data chosen by whoever formatted the stick.

  • lib/services/linux.dart:2752 — runCommandWithCustomArguments("k4dirstat", [path])
  • lib/services/linux.dart:2755 — same, after the install-queue branch
  • lib/services/linux.dart:2770 — runCommandWithCustomArguments("baobab", [path])
  • lib/linux/linux_filesystem.dart:68 — mount point = match.group(6) of the df -h line
  • lib/layouts/disk_cleaner/clean_disk.dart:60 — Linux.openDiskSpaceAnalyzer(context, mountpoint)

Related: #42 covers the runInShell: true default at the execution layer and
enumerates other sinks (openfile:, openfolder:, openapp:, websearch, icon
loader); this sink was not in that list and is independently fixable.

Impact

Clicking "Analyse Diskspace" on a disk whose mount point carries shell
metacharacters executes attacker-influenced shell code as the desktop user.
The data source is third-party without being attacker-typed: for removable
volumes the mount directory name comes from the filesystem label
(/media/<user>/<label>/), which survives ; and $ sequences for several
filesystem types (udisks only excludes /). At minimum, every mount point
containing a space (/media/user/USB Stick/ — the exact case
parseDfOutput's comment says it was written to preserve) silently breaks:
the shell splits it into two arguments and the analyzer opens the wrong
directory. The queued fallback (LinuxCommand(argv: ["k4dirstat", "$path"]))
is argv-based and safe — only the three direct launches are affected.

Complexity

  • Complexity: 2 (Easy)

Evidence

// lib/services/linux.dart:2750-2756
static void openDiskSpaceAnalyzer(context, path) {
  if (currentenvironment.desktop == DESKTOPS.KDE) {
    runCommandWithCustomArguments("k4dirstat", [path]);      // no runInShell: false
    ensureApplicationInstallation(["k4dirstat"]);
    if (commandQueue.isEmpty) {
      runCommandWithCustomArguments("k4dirstat", [path]);    // no runInShell: false
// lib/linux/linux_filesystem.dart:56-69
final match = linePattern.firstMatch(line);
...
devices.add(DeviceInfo(source, size, ..., match.group(6)!, ...));  // mount point

Data-flow trace: df -h → LinuxFilesystem.parseDfOutput (group(6) =
everything after the fifth field, deliberately kept whole so
"/media/user/USB Stick" survives parsing) → CleanDiskPage.mountpoint
(lib/layouts/disk_cleaner/clean_disk.dart:60) → openDiskSpaceAnalyzer →
Process.run("/usr/bin/k4dirstat", ["/media/user/USB Stick/"], runInShell: true)
→ /bin/sh -c "k4dirstat /media/user/USB Stick/".

Reproduce the shell join without the app:

dart run - <<'DART'
import 'dart:io';
void main() {
  // Stands in for Process.run("k4dirstat", [mountpoint], runInShell: true)
  Process.run('echo', ['k4dirstat', '/media/user/x;touch /tmp/pwned/'],
      runInShell: true);
}
DART

Recommended Fix

In lib/services/linux.dart, pass runInShell: false to the three direct
launches in openDiskSpaceAnalyzer (:2752, :2755, :2770). This is safe
by the codebase's own documented precondition: expandCommand yields an
absolute /usr/bin/... path and the argument is a single argv element that
needs no shell handling. The same one-line change pattern is already applied
elsewhere (runPythonScript, runPrivilegedPythonScript,
linux.dart:59/62/1478/1501). While there, keep the queued LinuxCommand
branches as they are — they are already argv-based.

Optionally (follow-up, not required for this issue): consider a broader
grep-audit of remaining runCommandWithCustomArguments/runCommand call
sites without runInShell: false, tracked under #42.

References

Validation

  • attacker_source: mount point taken from df -h output; for removable media
    the mount directory name derives from the volume label set by whoever
    formatted the device (plus any fstab/network mount point)
  • missing_guard: no runInShell: false on the direct k4dirstat/baobab
    launches; the path argument reaches Process.run(..., runInShell: true),
    which joins the argv into a sh -c command line unquoted
  • sink_effect: shell code embedded in the mount point executes as the desktop
    user when the user clicks "Analyse Diskspace"; space-containing mount
    points split into two arguments and open the wrong directory
  • preconditions: a mounted filesystem whose mount point contains shell
    metacharacters (removable volume label, or a hand-written fstab entry) or a
    space; the user opens Disk Cleaner and clicks "Analyse Diskspace" on that
    disk's page
  • proof_anchors: lib/services/linux.dart:2752, lib/services/linux.dart:2755,
    lib/services/linux.dart:2770 (runCommandWithCustomArguments("k4dirstat", [path]) / ("baobab", [path]) without runInShell), data source
    lib/linux/linux_filesystem.dart:68, caller
    lib/layouts/disk_cleaner/clean_disk.dart:60
  • suggested_validation: grep -n 'k4dirstat\|baobab' lib/services/linux.dart | grep -n runCommandWithCustomArguments — then, after the fix, the same call sites must show runInShell: false; a behavioural check: bash -c 'set -- k4dirstat "/media/u/x;touch /tmp/pwned"; sh -c "k4dirstat $*"' demonstrates the unquoted shell join executing the metacharacter payload

Activity

  1. Toqsick commented on Sep 15, 2026

    @Toqsick
    OwnerAuthor

    Triage-Link 2026-09-15: Verdict „contested" — die Injection-Prämisse wurde empirisch entkräftet (Dart escapet argv per Single-Quote-Wrapping, POSIX; nachgetestet Dart 3.12.2). Severity ist damit Low pending RV-1 (#51): der Re-Verify über die gebaute App entscheidet final. Fix bleibt als Hygiene in WP-S3 (#56) eingeplant (drei Disk-Analyzer-Launches auf runInShell: false).

  2. added a commit that references this issue on Sep 24, 2026
  3. Toqsick commented on Oct 29, 2026

    @Toqsick
    OwnerAuthor

    Behoben — der Fix ist in main

    Stand: main @ d9321ef (PR #159, „WP-S3 + V0.8.6 Welle 1 (Q1, Q2, Q3, Q5, Q6) — zusammengefuehrt")
    Ursprungsbranch: feature/56-wp-s3-hygiene
    Nachweis: docs/handoff/v0.8.2-wp-s3-security-hygiene.md

    Was geändert wurde

    Der Disk-Analyzer startet nicht mehr ueber eine Shell, und der Aufruf erfolgt genau einmal — nach der abgeschlossenen Installationspruefung.

    Datei Änderung
    lib/services/linux.dart:2791-2810 openDiskSpaceAnalyzer: Installationspruefung zuerst und awaited, dann genau ein Start mit runInShell: false. context.mounted-Check nach dem Await.
    lib/layouts/disk_cleaner/clean_disk.dart:61 Der Aufrufer ist in unawaited(...) gewrappt (Lint unawaited_futures).

    Nebenbefund (behoben)

    Der alte Ablauf war:

    runCommandWithCustomArguments("k4dirstat", [path]);   // feuert UNBEDINGT
    ensureApplicationInstallation(["k4dirstat"]);            // danach erst der Check
    if (commandQueue.isEmpty) {
      runCommandWithCustomArguments("k4dirstat", [path]);   // und nochmal
    }

    Ein nicht installiertes Programm wurde also zweimal gestartet (beim ersten Mal erfolglos), und ensureApplicationInstallation lief nicht awaited — der Queue-Stand konnte daher gar nicht gefuellt sein, wenn er gelesen wurde. Beides ist behoben.

    Einordnung der Prämisse

    Der Befund im Issue beschreibt den Weg ueber sh -c als („argv space-joined unquoted") gedrueckt. Diese mechanische Praemisse wurde am 2026-10-23 empirisch widerlegt: Dart quotet jedes argv-Element einzeln, wenn es den sh -c-String baut (_getShellArguments() in sdk/lib/_internal/vm/bin/process_patch.dart). Gemessen mit einer Sonde, die die Argumentanzahl zaehlt: ein Pfad mit Leerzeichen kommt als genau ein argv-Element an, ;, $(...), Backticks, * und | bleiben inert.

    Der Fix bleibt trotzdem — er ist Hygiene, und er macht den Aufruf unabhaengig vom Default der Kommandoebene (siehe #56). Der globale Default-Flip auf runInShell: false liegt seit demselben Merge in main.

    Tests

    test/wp_s3_hygiene_test.dart, Gruppe „Der Disk-Analyzer startet nach der Installationspruefung" (2 Tests):

    • der Start erfolgt erst nach der abgeschlossenen Installationspruefung
    • kein Start, wenn das Programm fehlt

    Gates auf main

    Alle elf CI-Gates gruen, flutter test mit 297 Tests. Details im Nachweis-Doc docs/handoff/v0.8.2-wp-s3-security-hygiene.md.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions