Repository navigation
[MEDIUM] Root queue runner applies arbitrary env (incl. LD_PRELOAD/PATH) to root commands; approval dialog shows argv only #44
Description
Summary
The root command-queue runner executes each entry with an environment that the unprivileged app controls arbitrarily, and the confirmation dialog the user approves only shows the argv — never the environment:
additional/python/command_queue.py:71-75—build_environment()merges the queue'senvmap over the inherited environment:environment = dict(os.environ); environment.update(env)additional/python/command_queue.py:80-90—subprocess.Popen(build_argv(...), env=build_environment(env), user=uid)— foruid: 0entries this is a root process with a fully caller-controlled environmentadditional/python/run_multiple_commands.py:35-45— the runner validates types only (parse_command,command_queue.py:18-73); no environment key is restrictedlib/layouts/run_command_queue.dart:121— the approval table renderscommand.displayCommandonly (lib/models/linux_command.dart:55-70), which serializes argv and never mentionsenvironment
Two unguarded consequences for entries running as uid: 0:
LD_*injection. Python'ssubprocess.Popen(user=0)sets real and effective uid to the same value, so the kernel does not setAT_SECUREand glibc honorsLD_PRELOAD/LD_LIBRARY_PATH/LD_AUDITfrom the environment. An env entry like"LD_PRELOAD": "/tmp/evil.so"(shared object written by the unprivileged side) makes the root process load attacker code beforemain().PATHhijack.parse_command()does not requireargv[0]to be absolute, and the runner inheritsos.environ— so an entry{"argv": ["apt", ...], "env": {"PATH": "/tmp/evil"}}resolvesaptthrough attacker-controlledPATHas root.
Nothing in the flow the user sees (the RunCommandQueue table) reveals either. The queue format itself is fine — this is about what crosses the root boundary unfiltered, and about approval being given on incomplete information.
Impact
Defense-in-depth gap at the root boundary. Exploiting it requires the ability to push a crafted LinuxCommand into Linux.commandQueue (i.e. a compromised plugin/call path inside the app), after which the code runs as root while the user approves a queue that looks benign. Closing it removes a whole class of root escalation from any future command-queue consumer and makes the approval table honest.
Complexity
- Complexity: 2 (Easy)
Evidence
# additional/python/command_queue.py:71-75
def build_environment(env):
environment = dict(os.environ)
environment.update(env)
return environment# additional/python/command_queue.py:34-38 (argv[0] not required to be absolute)
argv = raw.get("argv")
if not isinstance(argv, list) or not argv:
raise QueueFormatError(f"Line {line_number} has no argv.")// lib/layouts/run_command_queue.dart:121 — env never displayed
command.displayCommand,Reproduce the AT_SECURE/LD_PRELOAD behavior locally (no root needed — demonstrates the loader honors LD_PRELOAD when uid == euid):
printf 'int init(void){return 0;} __attribute__((constructor)) static void c(void){}\n' > /tmp/noop.c && gcc -shared -fPIC -o /tmp/noop.so /tmp/noop.c && python3 -c "import os,subprocess; os.environ['LD_PRELOAD']='/tmp/noop.so'; subprocess.Popen(['id'], user=os.getuid())" && echo "loader accepted LD_PRELOAD for same-uid exec"Recommended Fix
- In
command_queue.build_environment(), strip dangerous variables before merging whenuid == 0(or always):LD_PRELOAD,LD_LIBRARY_PATH,LD_AUDIT,LD_LIBRARY_PATHvariants,BASH_ENV,ENV,IFS,SHELL, and anyPATHoverride for root entries (the queue convention already uses absolute argv paths —/usr/bin/apt,:3051in the Dart callers). - In
command_queue.parse_command(), requireargv[0]to be an absolute path whenuid == 0(raiseQueueFormatErrorotherwise). All existing Dart queue builders already pass absolute paths, so no behavior change. - In
lib/models/linux_command.dartdisplayCommand(or alongside the table row inrun_command_queue.dart), render non-emptyenvironmentmaps so the user approves what actually runs.
References
- CWE-426: Untrusted Search Path — https://cwe.mitre.org/data/definitions/426.html
- CWE-427: Uncontrolled Search Path Element — https://cwe.mitre.org/data/definitions/427.html
- glibc secure-execution mode (
AT_SECUREdisablesLD_PRELOADonly when uid ≠ euid) — https://man7.org/linux/man-pages/man8/ld.so.8.html - Python
subprocess.Popen(user=...)semantics — https://docs.python.org/3/library/subprocess.html
Validation
attacker_source: The env map of any LinuxCommand serialized into the queue file (~/.cache/linux_assistant_commands), written by the app process; also inherited os.environ for keys the queue does not override
missing_guard: build_environment() merges caller-supplied env over os.environ with no key filtering, and parse_command() does not require an absolute argv[0]; the approval UI renders argv only
sink_effect: A uid-0 queue entry runs with LD_PRELOAD/LD_LIBRARY_PATH (glibc honors them because Popen(user=0) yields uid == euid, so AT_SECURE is not set) or a PATH-hijacked non-absolute argv[0], executing attacker code as root while the confirmation dialog shows only the harmless-looking command line
preconditions: A crafted LinuxCommand reaches Linux.commandQueue (compromised or buggy code path inside the app) AND the user authorizes the RunCommandQueue polkit prompt; /tmp (or any root-readable path) must hold the payload .so
proof_anchors: additional/python/command_queue.py:71-75 "environment = dict(os.environ); environment.update(env)"; additional/python/command_queue.py:34-38 argv[0] accepted without absolute-path check; lib/layouts/run_command_queue.dart:121 "command.displayCommand"; lib/models/linux_command.dart:55-70 displayCommand serializes argv only
suggested_validation: python3 - <<'PY'
import json
line = json.dumps({"uid": 0, "argv": ["/bin/true"], "env": {"LD_PRELOAD": "/tmp/noop.so"}, "shell": False})
from command_queue import parse_command, build_environment
uid, argv, env, shell = parse_command(json.loads(line), 1)
merged = build_environment(env)
assert merged.get("LD_PRELOAD") == "/tmp/noop.so", "LD_PRELOAD passed through unfiltered"
print("LD_PRELOAD survives into the root process environment")
PY
Activity
Toqsick commentedon Sep 23, 2026
Umgesetzt in WP-S2, Commit
d8592a8(Branchhardening/0.8.x-browser-xdg, lokal — Push folgt mit der Freigabe zu #41):build_environment()verwirft bei uid 0LD_*-Variablen sowieBASH_ENV/ENV/IFS— aus der geerbten UND der Queue-eigenen Environment.parse_command()lehnt nicht-absolute argv[0] für Nicht-Shell-Entries ab; alle 15 bare-name-Sites der App auf absolute Pfade kanonisiert.displayCommand()zeigt die Environment alsenv KEY=VALUE …in der Kommandotabelle.Issue bleibt bis zum V0.8.2.5-Release offen.
fix(queue): strip linker env for root commands and make queue env vis…
Toqsick commentedon Oct 23, 2026
Abnahme — erledigt
Der Root-Queue-Env ist gehärtet und die Env sichtbar gemacht.
BASH_ENV/ENV/IFS-Strip für uid == 0additional/python/command_queue.py:78-95(build_environment) — strippt sowohl aus der Queue-Env als auch aus der geerbten Umgebung; für uid != euid bleibt alles erhalten (ld.soignoriert LD_* dort ohnehin)additional/python/command_queue.py:47-50—if not use_shell and not os.path.isabs(argv[0])→ Fehler mit Hinweis „queue an absolute path"additional/python/tests/test_command_queue.py:57-96— LD_PRELOAD wird bei uid==0 gefiltert, bei uid==1000 durchgelassend8592a8„fix(queue): strip linker env for root commands and make queue env visible" (Squash inadd73e3)Der Befund „PATH-Hijack über non-absolute argv[0] (empirisch bestätigt)" ist damit geschlossen:
Shell-Entries (
use_shell) bleiben ausgenommen, damit die Skript-Entries weiter funktionieren.Python-Gate: 58 Tests grün.
Nachweis:
docs/handoff/v0.8.2-xdg-browser-abschluss.md— folgt mit PR #154.