Skip to content

Latest commit

 

History

History
68 lines (36 loc) · 18.8 KB

threat-model-elevation.md

File metadata and controls

68 lines (36 loc) · 18.8 KB

Threat model: root actions (Linux)

Root file operations offer delete, rename and paste (copy or move). It stays hidden in virtual locations and in AppImage/Flatpak, and unless pkexec, the native helper and its policy are installed at trusted system locations. AppImage/Flatpak neither install nor execute a host elevation helper.

Privilege boundary

The only elevated executable is /usr/lib/linuxfiles/files-elevation-helper, a small .NET 10 Native AOT console program. Native packages install it root-owned, mode 0755, with root-owned ancestors that are not writable by group/others. It has no setuid bit. The matching packaging/linux/io.github.memergamer.LinuxFiles.root-actions.policy requires auth_admin for an active session; other sessions are denied. Authorization is not retained (auth_admin_keep is never used).

Files executes the trusted absolute pkexec path with the fixed helper path followed by two arguments: a sanitized human-readable summary (operation, source count and first source, absolute target) and the SHA-256 hex digest of the exact UTF-8 stdin JSON. It sends the confirmed plan on stdin, closes stdin, and reads JSON results capped at 512 KiB. It never executes rm, cp, mv, a shell, commands from JSON, or programs found through PATH. The subprocess starts with a cleared environment and LANG=C. pkexec removes dangerous loader variables before launching the AOT executable; the helper requires effective uid 0 and a non-root PKEXEC_UID, then clears its environment, sets umask 0077 and changes cwd to /. Before opening paths, it recomputes the digest from the raw stdin bytes, strictly parses the request and checks the summary against the parsed request. Missing/mismatched arguments are refused. It takes no command-line switches and has no daemon, plugin loading, configuration files or test mode.

The polkit prompt is the authorization boundary against a compromised session. An in-app confirmation is useful UI, but malware in the same session can invoke pkexec directly, so that confirmation cannot authorize privileged execution. The policy explicitly states that authentication grants root file access to the listed paths and their contents, and includes $(command_line) and $(program). The summary is checked rather than trusted, and the digest binds all request bytes (including additional batch sources) to that invocation. Only the first source is named in the prompt; approving a batch authorizes the count and digest too. Administrators should approve only expected operations and targets; authentication agents must display the full policy message. Bidi, control and zero-width characters become visible escapes; paths are quoted as JSON without truncation. The summary is display data, never a shell command or authorization rule expression.

The supported minimum is polkit 0.105 (with distributor security fixes). Upstream's 0.105 pkexec manual source explicitly documents expansion of all three message variables for an annotated action. Its pkexec source joins argv with spaces, sets program and command_line details, and overrides polkit.message only for the default action, leaving our custom policy message intact. The current manual also documents expansion and exit codes 126 (dismissed) / 127 (not authorized or authorization error); both produce a dismissed/not-authorized result in Files. This is a supported minimum verified in source, not a claim about when expansion first appeared. The same holds in polkit 127: polkitd's get_localized_data_for_challenge runs expand_properties over the action message, and $(command_line) is the untruncated argv (unlike pkexec's own $(cmdline_short)).

Cancellation tries to stop pkexec, but a root child can reject killing with EPERM. Files drains bounded output and waits for exit without cancellation, then reports the actual helper result; it never labels running root work cancelled or failed. Even oversized output is drained after a denied kill before failure is reported. Unexpected top-level helper exceptions fail closed with exit 1 and a generic protocol error.

Frozen confirmation and protocol

ElevationPlanPreview copies the plan into read-only collections and shows the exact operation, absolute sources and target encoded as readable Unicode JSON (display only; the strict wire serializer is unchanged), together with the executable and the stdin marker. DisplaySanitizer escapes control, bidi and zero-width characters; oversized previews cannot be confirmed. Confirmation hands out that displayed plan once. Cancel, close and Escape do nothing; Cancel is the default button. Only one dialog can be open.

The application compares the complete serialized request with the displayed command data before sending it. Its path checks are UI preflight only. It never trusts unprivileged post-operation filesystem checks, including for destinations under /root.

The helper independently requires version 1, exactly the known JSON fields, unique property names, a known operation, 1–64 unique, non-overlapping sources, and normalized absolute paths with no NUL, empty components, dot components or root /. Delete/move/rename refuse / and top-level FHS sources /usr /etc /boot /bin /lib /lib32 /lib64 /libx32 /sbin /var /home /root /proc /sys /dev /run /srv /opt /mnt /media /tmp before opening paths. Copy/move refuse /proc, /sys and /dev targets and their descendants. Rename carries an absolute target in the same parent; copy/move cannot target the source or its descendants or introduce duplicate destination names. Input is strict UTF-8, at most 64 KiB, with bounded JSON depth. Unknown/missing fields, trailing data and unsupported versions fail closed. Results have one ordered entry per source; malformed, missing, duplicate or mismatched results can never become success in the app.

Root mode and process identity

A normal non-root launch exposes only Root actions → Open in terminal as root. Delete, Rename and Paste as root appear only after an explicit files --root launch (or the desktop entry's Open in Root Mode action), and only when the trusted native helper/policy are installed. Root mode keeps the GUI unprivileged; each helper action still previews its frozen plan and requests fresh polkit authentication. Every tab and the window title carry the localized (Root mode) suffix only when helper actions are allowed by the package/disable gates.

Each --root launch bypasses single-instance forwarding and opens a separate process/window, including when a normal instance or another root-mode instance already exists. Ordinary launches continue forwarding to the normal instance. New Window in a --root process passes --root to the new process. Mode is fixed for that process and is never transferred to an existing ordinary window; the root-mode process does not claim the normal instance name or FileManager1. --root is recognized only before -- and never as the operand of --select.

When geteuid() == 0 (including sudo files), tabs/title instead show (Running as root). Normal file operations already have root privileges; the helper actions and root-terminal item are hidden, and the elevation service itself refuses helper invocation for uid 0. The ordinary Open in terminal command remains available. This privileged GUI is outside the helper's restricted-operation boundary.

Before Uno, fonts, settings or application services initialize, every uid-0 launch resolves root's home through getpwuid_r(0) and replaces inherited HOME and all four XDG config/data/cache/state directories with root's own defaults, regardless of SUDO_UID. XDG_DATA_DIRS and XDG_CONFIG_DIRS are reset to the specification defaults /usr/local/share:/usr/share and /etc/xdg; thumbnailers, desktop-entry trust and service menus therefore derive their directories from these sanitized values. PATH is reset to /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin. Inherited TERMINAL, EDITOR, VISUAL, BROWSER, SHELL and XDG_CURRENT_DESKTOP are cleared, along with user runtime/session-bus, GVfs and TMPDIR overrides. The home and existing XDG directory ancestors must be root-owned real directories without group/other write access; unsafe or unavailable roots abort startup with a stderr diagnostic before settings are written. Thus a sudo-preserved user HOME/XDG environment cannot select the invoking user's settings directories. Administrator-controlled contents inside root's own home remain trusted configuration. Automated tests check the environment plan without changing the real process environment or writing root's settings.

AppImage, Flatpak and the existing explicit disable gates hide and refuse all elevation actions, including the root terminal, in every mode. Their desktop files omit Open in Root Mode, and --root shows no root-mode indicator when helper actions are disabled; it cannot enable packaged elevation. No sandbox exception is introduced.

Descriptor-relative execution

After authorization, the helper opens / once and walks every ancestor with single-component descriptor-relative opens. It prefers openat2 with RESOLVE_NO_SYMLINKS|RESOLVE_BENEATH. Only ENOSYS permits fallback to the per-component O_PATH|O_NOFOLLOW|O_DIRECTORY walk. Other errors fail closed. Each opened ancestor must be root/caller-owned and not group/other-writable, except root-owned sticky directories. No path operation follows a user-controlled symlink.

The complete source trees and destination directory are pinned before mutation. Every entry is checked with statx before and after opening, requiring identity, mode, uid, size, timestamps and mount id. Sources are regular files or directories only: symlinks (including inside trees), special files and mount crossings are refused. Tree walks stop at 4096 entries or depth 64; descriptor exhaustion and enumeration errors are failures. Invalid UTF-8 names are refused, rather than converted into different names. The helper checks source/destination containment by descriptor identity as well as by path.

  • Delete uses unlinkat relative to pinned parents, recursing only into pinned source directories. Identity and tree membership are checked again before deleting; absence is confirmed with a descriptor-relative statx lookup. Only ENOENT proves absence; permission/stat failures never do.
  • Rename uses renameat2(RENAME_NOREPLACE) with the pinned parent and plain leaf names. Collisions, including dangling symlinks, fail atomically. No unsafe rename fallback exists. The renamed identity and source absence are checked inside the helper.
  • Copy opens source files without following links, creates destination files with O_CREAT|O_EXCL and directories with mkdirat (existing names fail), and transfers bytes through descriptors. Copies start root-owned mode 0600, directories mode 0700. When the pinned destination directory is owned by PKEXEC_UID, only after byte/tree verification and before any move removes its originals, the helper applies fchown through each pinned output fd to PKEXEC_UID and its primary gid from getpwuid_r. Handover never widens access: files get at most the source mode & 0644 and directories the source mode & 0755 (a 0700 folder stays 0700). Group bits survive only when the source group equals the caller's primary group; otherwise they are cleared and the other bits are also limited to what the source group had, because former group members become "others". Group and other bits are cleared entirely when the source carries a POSIX access ACL (its mode group bits are only a mask) or when any directory from / to the source's parent does not let every non-root user search it: that requires owner, group and other search bits (a matching class never falls through to "other") and no access ACL. Ancestor ACLs are read through a read-only reopen of each pinned O_PATH descriptor, checked against its pinned identity; a failed reopen or ACL read counts as unreachable. This is deliberately narrower than strictly necessary for group-only paths. Multiply-linked files are refused before any output is created when handing over, because another link may alias data outside the approved tree. Metadata is rechecked and fsynced. Otherwise root ownership and 0600/0700 modes remain. Until handover, outputs are root-owned 0600/0700 even inside a caller-owned destination (and an inherited default ACL gets an empty mask), so the caller cannot read partial or unverified copies. At handover the inherited access ACL is removed before fchmod (ENODATA/EOPNOTSUPP mean none) and its absence is verified, so the new mode cannot activate inherited named entries; a handed-over folder keeps its inherited default ACL, which only shapes files the caller later creates; a failed handover leaves a root-owned 0700 parent around any already handed-over children. No original ownership, timestamps, xattrs (including file capabilities and security labels) or hardlink relationships are preserved. Setuid/setgid source trees are refused. Size and SHA-256 of source and the helper's own created output are verified; completed output and its destination parent are fsynced and its directory membership and metadata are checked again.
  • Move follows the same verified-copy path in the same authenticated helper process. It removes only the pinned originals, checking identity and file metadata again before removal. A skipped/colliding/failed/unverified copy never triggers source deletion. Each item reports its own result; failure does not erase another item's verified success.

Partial outputs remain in place and are reported as failures. Per-item errors carry only a fixed helper message, a generic not-found/access-denied text or an errno; they never echo entry names or raw name bytes from inside a tree, so a failure cannot list nested root-only names the caller was not shown. Every helper descriptor, including directory-listing duplicates (F_DUPFD_CLOEXEC), is close-on-exec, and the helper starts no child processes. The helper never performs recursive cleanup through an untrusted destination name. Partial deletion/move can leave a partially changed source tree; surviving originals remain, and the UI receives failure rather than success.

Races and residual limits

An ancestor replaced by a symlink during polkit authentication is refused. An ancestor renamed/replaced after opening cannot redirect the operation: it acts on the original pinned directory, even if its pathname now displays another object. Destination collisions never replace existing objects; copy verification concerns the helper-created inode, not an unrelated file that happens to exist.

Authentication authorizes the absolute plan after the prompt; the app does not attest every source inode before authentication. Concurrent modifications to regular file contents or directory entries are rejected when detected by identity, metadata, membership and byte checks. Linux has no atomic compare-inode-and-unlink primitive and no snapshot of concurrently writable file contents: a writer retaining access can still race a final check, and operations are not a transaction. No symlink race can escape the pinned parents, and no unexpected verification failure is accepted. For move, modifications detected before deletion preserve the affected original; undetectable writes after its final check remain a limitation. Users should quiesce writers before requesting privileged moves.

Root authentication intentionally permits destructive actions on root-owned data. An administrator already able to replace the installed executable/policy or perform privileged mount operations is outside this boundary. Kernel/glibc with statx mount identity and renameat2 support are required; missing required facilities disable/fail the operation rather than weakening checks.

Packaging and verification

AUR source packages AOT-publish the helper and install it and the policy at the fixed paths. Binary AUR packages use the helper in the release tarball. publish.sh includes it in elevation-helper/; AppImage/Flatpak explicitly remove it and set FILES_DISABLE_ROOT_ACTIONS=1 and add a .root-actions-disabled marker (the app also detects their runtime). install-local.sh --install-root-helper is explicit opt-in to a separate system installation through sudo; a default user-local install cannot supply a trusted helper. Policy/helper installation is never performed by the running app. The opt-in installer is equivalent to sudo make install: it executes administrator-trusted installer code and installs administrator-trusted build output, not a security boundary against a malicious checkout or build. It embeds the fixed policy, resolves destdir before checking for live /, and copies the helper through a no-follow regular-file descriptor to a private installer-owned temporary directory (root-owned for live installs) before checking its SHA-256 and ELF header and installing from that snapshot. The user-side wrapper supplies the pre-sudo helper digest; no helper is executed during installation. Non-root staging uses the same snapshot checks and never invokes sudo.

Non-root tests run HelperEngine directly against private temp trees, substituting the current uid for the elevated identity and the observed root-directory owner for root in uid-mapped sandboxes. Hooks exist only in that in-process API; stdin cannot enable them. Tests also cover request-byte/summary substitution, prompt sanitization and Unicode preview, protected directories, EPERM cancellation with an unprivileged fake process, caller/root destination modes, permission masking for every class (private folders, foreign groups, ACLs, unreachable sources and ACL-bearing ancestors), inherited default ACL removal when setfacl and filesystem ACLs are available, hard-link refusal, name-free error text, descriptor-based ownership during leaf replacement, generic exception handling and non-root installer staging/tampering/root aliases. Tests cover both openat2 and forced fallback, authentication-time and post-pin symlink swaps, source leaf races, nested copy/move, no-replace collisions, corrupt/modified copies, changed originals and partial batches. App tests cover protocol failures, forged plans, helper-only verification and packaging/trust gates. Real polkit authentication, root-only directories, mount races and distro package installation require a separate administrator review; automated tests never invoke pkexec or use the real display.

Not implemented

Open terminal as root and edit as root remain outside this helper's protocol. Symlink copying, timestamp/permission preservation, transactional rollback and concurrent-writer snapshots are unsupported.

The submenu also offers an interactive root terminal independently of helper installation, using the same packaging disable gate. Its broader root-shell authorization and terminal launch behavior are documented in threat-model-launching.md.