You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The TUI prompt resolves and displays the absolute path of each stage's argv[0] (src/tui.rs:104-115, using executor::which at src/executor.rs:75) so the operator can see which apt, which systemctl, which bash is about to run. After approval, the executor calls Command::new(&argv[0]) (src/executor.rs:205, :304, :314) — i.e., resolution happens again via $PATH, and even if it landed on the same name, the file behind that name could have been swapped between the operator's keypress and execve.
Threat model is narrow: an attacker who can write to /usr/bin already has root, and a $PATH controlled by a malicious caller is rejected by the env allowlist at src/server.rs. So this is defence-in-depth, not a critical bug. But the gap between "what the human read" and "what got exec'd" is exactly the kind of detail this project goes out of its way to shrink elsewhere (resolved-path display, env sanitisation, single-keypress prompt).
Proposal
After resolving the path for the TUI prompt, hold an O_PATH | O_CLOEXEC | O_NOFOLLOW file descriptor on the resolved binary for the lifetime of the request. At exec time, exec via /proc/self/fd/N (Linux's fexecve equivalent) — guaranteed to run the inode the human approved, not whatever $PATH lookup finds at exec time.
Sketch:
In src/executor.rs, add a pin_resolved(path: &Path) -> io::Result<OwnedFd> helper that opens with O_PATH | O_NOFOLLOW.
In the TUI path, pin each stage's argv[0] immediately after which returns. Store the FDs alongside the resolved path that's already shown.
In Command::new(&argv[0]), swap to Command::new(format!(\"/proc/self/fd/{}\", fd)) and set arg0 to the original name so the child still sees the expected argv[0].
If /proc is unavailable (extremely unusual, but containers/chroots), fall back to today's behaviour with a verbose-log warning.
Things to consider:
All three exec call sites must be updated: pkexec (src/executor.rs:104, 152), sudo (:182, :304), direct (:205, :314). Easy to miss the sudo branch.
For the sudo branches, the binary that gets pinned is the target command, not sudo itself. Pinning sudo is theatre — the kernel will resolve and exec the real binary inside sudo's own logic.
O_NOFOLLOW is important: which already canonicalises but a symlink-replacement race is the exact thing being defended against.
Cost: one extra FD per stage, released on request completion. Negligible.
Tests: a focused unit test that swaps a binary on disk between pin_resolved and exec, and asserts the original inode runs.
Gives the TUI a small additional honesty improvement: Resolves: could append the inode number, so two requests to the same path with different inodes are visually distinguishable in the audit log (Append-only audit log of approved requests and outcomes #6).
Problem
The TUI prompt resolves and displays the absolute path of each stage's
argv[0](src/tui.rs:104-115, usingexecutor::whichatsrc/executor.rs:75) so the operator can see whichapt, whichsystemctl, whichbashis about to run. After approval, the executor callsCommand::new(&argv[0])(src/executor.rs:205,:304,:314) — i.e., resolution happens again via$PATH, and even if it landed on the same name, the file behind that name could have been swapped between the operator's keypress andexecve.Threat model is narrow: an attacker who can write to
/usr/binalready has root, and a$PATHcontrolled by a malicious caller is rejected by the env allowlist atsrc/server.rs. So this is defence-in-depth, not a critical bug. But the gap between "what the human read" and "what got exec'd" is exactly the kind of detail this project goes out of its way to shrink elsewhere (resolved-path display, env sanitisation, single-keypress prompt).Proposal
After resolving the path for the TUI prompt, hold an
O_PATH | O_CLOEXEC | O_NOFOLLOWfile descriptor on the resolved binary for the lifetime of the request. At exec time, exec via/proc/self/fd/N(Linux'sfexecveequivalent) — guaranteed to run the inode the human approved, not whatever$PATHlookup finds at exec time.Sketch:
src/executor.rs, add apin_resolved(path: &Path) -> io::Result<OwnedFd>helper that opens withO_PATH | O_NOFOLLOW.argv[0]immediately afterwhichreturns. Store the FDs alongside the resolved path that's already shown.Command::new(&argv[0]), swap toCommand::new(format!(\"/proc/self/fd/{}\", fd))and setarg0to the original name so the child still sees the expectedargv[0]./procis unavailable (extremely unusual, but containers/chroots), fall back to today's behaviour with a verbose-log warning.Things to consider:
src/executor.rs:104, 152), sudo (:182, :304), direct (:205, :314). Easy to miss the sudo branch.sudoitself. Pinningsudois theatre — the kernel will resolve and exec the real binary inside sudo's own logic.O_NOFOLLOWis important:whichalready canonicalises but a symlink-replacement race is the exact thing being defended against.pin_resolvedand exec, and asserts the original inode runs.Resolves:could append the inode number, so two requests to the same path with different inodes are visually distinguishable in the audit log (Append-only audit log of approved requests and outcomes #6).