-
Notifications
You must be signed in to change notification settings - Fork 16
fix: resolve sudo and findmnt from trusted dirs to prevent PATH injection #70
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
leongdl
wants to merge
13
commits into
aws-deadline:mainline
Choose a base branch
from
leongdl:fix/path-injection-rce
base: mainline
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
42eea6f
fix: resolve sudo and findmnt from trusted dirs, not PATH
leongdl 16c62d2
fix: address CI failures and review findings on the trusted-path reso…
leongdl 433ab52
fix: use nested with statements for Python 3.8 compatibility
leongdl d0a1617
fix: repair Windows 3.13 assertion, hollow fixture, and added public API
leongdl 99c1011
docs: Reframe resolver comments around problem and solution
leongdl 9cdd7e8
fix: raise the error type callers of the VFS mount paths already handle
leongdl 4d82305
docs: State that VFS binary discovery is out of scope for the resolver
leongdl e11eed5
fix: alias the exception import so it stays out of the public API
leongdl e45b4d9
fix: Suppress three CodeQL false positives introduced by the resolver
leongdl f28cefe
fix: Remove the inert CodeQL suppressions, record the finding instead
leongdl bb357ae
fix: Stop logging the assembled VFS launch command
leongdl 6f09326
docs: Note that shell quoting in the launch command is out of scope
leongdl ae6e050
fix: Preflight findmnt and keep VFS pid entries on failed unmount
leongdl File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,145 @@ | ||
| # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. | ||
|
|
||
| """Resolution of system command names to absolute paths, without consulting PATH. | ||
|
|
||
| The problem: the VFS mount and unmount paths invoke ``sudo`` to act as the job | ||
| user, and query mount state with ``findmnt``. Invoking those by bare name resolves | ||
| them through ``PATH``, which makes the binary actually run depend on the search | ||
| path of whatever launched the process, for commands that cross a user boundary. | ||
|
|
||
| The solution: callers pass a bare name here and get back an absolute path found by | ||
| scanning a fixed list of trusted directories, so ``PATH`` plays no part. | ||
|
|
||
| Three properties make that work, and all three are easy to undo by accident: | ||
|
|
||
| * ``PATH`` is never read. Not directly, and not through :func:`shutil.which`, | ||
| which resolves via ``PATH`` and so would restore the original behaviour while | ||
| looking like a fix. | ||
| * Only paths under :data:`TRUSTED_SYSTEM_DIRECTORIES` are returned. A name | ||
| containing a path separator is rejected, because ``os.path.join`` would | ||
| otherwise let ``../../tmp/evil`` escape the directory being searched. | ||
| * A missing command raises. Returning the bare name as a fallback would put | ||
| resolution back on ``PATH`` while the code still read as though it did not. | ||
|
|
||
| A resolver rather than absolute-path literals, because the locations are not | ||
| universal: NixOS keeps the setuid ``sudo`` wrapper at ``/run/wrappers/bin/sudo``, | ||
| so a hardcoded ``/usr/bin/sudo`` would leave those hosts unable to mount at all. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import os as _os | ||
| from typing import Optional as _Optional, Tuple as _Tuple | ||
|
|
||
| __all__ = [ | ||
| "SystemCommandNotFoundError", | ||
| "TRUSTED_SYSTEM_DIRECTORIES", | ||
| "find_system_command", | ||
| "system_command_path", | ||
| ] | ||
|
|
||
|
|
||
| TRUSTED_SYSTEM_DIRECTORIES: _Tuple[str, ...] = ( | ||
| # Note for anyone adding a log statement that includes a resolved path: don't | ||
| # interpolate one into a string that gets logged. CodeQL treats this tuple as a | ||
| # sensitive-data source, so the resolved path taints whatever it is joined to, | ||
| # and logging that string is reported as clear-text logging of sensitive data. | ||
| # `vfs.build_launch_command` embeds a resolved path in the command it returns, | ||
| # which is why the launch logging there records the individual arguments rather | ||
| # than the assembled command. | ||
| # | ||
| # Ordered, deliberately. On NixOS the setuid `sudo` wrapper lives here and the | ||
| # /usr/bin copy is absent or not setuid, so this must be searched first. | ||
| "/run/wrappers/bin", | ||
| # ...and these two NixOS entries are a pair. /run/wrappers/bin holds only the | ||
| # setuid/setcap wrappers, so on NixOS it resolves `sudo` and nothing else: | ||
| # /usr/bin holds just `env`, /bin just `sh`, and the sbin directories are | ||
| # absent. `findmnt` lives in this symlink farm, which nixos-rebuild manages and | ||
| # root owns, so it is trust-equivalent to /usr/bin there. Without it the | ||
| # ordering above would resolve `sudo` and then fail on `findmnt`. | ||
| "/run/current-system/sw/bin", | ||
| "/usr/bin", | ||
| "/bin", | ||
| # sbin last: on non-usr-merged distributions some system commands exist only | ||
| # under /sbin. | ||
| "/usr/sbin", | ||
| "/sbin", | ||
| ) | ||
|
|
||
|
|
||
| class SystemCommandNotFoundError(Exception): | ||
| """A required system command was not present in any trusted directory. | ||
|
|
||
| Deliberately not a :class:`FileNotFoundError`, because ``vfs`` already uses that | ||
| type for something else. Three ``except FileNotFoundError`` blocks there mean | ||
| "the VFS pid file is missing", and one of them wraps a call chain that reaches | ||
| this resolver: ``kill_all_processes`` -> ``shutdown_libfuse_mount`` -> | ||
| ``wait_for_mount`` -> ``is_mount``. Inheriting from ``FileNotFoundError`` let a | ||
| resolution failure be reported as a missing pid file and skip the cleanup that | ||
| follows. | ||
|
|
||
| Callers in ``vfs`` translate this into :class:`VFSExecutableMissingError`, which | ||
| is the type their own callers already handle by falling back to a copy-based | ||
| sync. This differs from the sibling resolver in ``openjd-sessions``, where | ||
| inheriting from ``OSError`` is correct because its cancel path deliberately | ||
| catches ``OSError`` so a failed signal cannot unwind a cancelation. Same | ||
| problem, opposite answer, because the surrounding handlers differ. | ||
| """ | ||
|
|
||
|
|
||
| def _validate_command_name(name: str) -> None: | ||
| """Reject anything that is not a bare command name.""" | ||
| if not name: | ||
| raise ValueError("A system command name must not be empty.") | ||
| if name in (_os.curdir, _os.pardir): | ||
| raise ValueError(f"{name!r} is not a system command name.") | ||
| # Both separators are checked on both platforms. A backslash is a legal POSIX | ||
| # filename character, but no command resolved here contains one, and treating | ||
| # it as suspect keeps the check identical rather than subtly weaker on POSIX. | ||
| # The colon is rejected for the same reason, and it is not hypothetical: | ||
| # ntpath.join(r"C:\Windows\System32", "D:evil") == "D:evil". A drive-relative | ||
| # name discards the trusted prefix while containing no separator at all, so a | ||
| # separator-only check lets it through. posixpath joins it harmlessly, but the | ||
| # guard belongs here rather than depending on which os.path is loaded. | ||
| if "/" in name or "\\" in name or ":" in name: | ||
| raise ValueError( | ||
| f"A system command name must not contain a path separator or drive " | ||
| f"specifier, but got {name!r}." | ||
| ) | ||
|
|
||
|
|
||
| def _is_executable_file(path: str) -> bool: | ||
| return _os.path.isfile(path) and _os.access(path, _os.X_OK) | ||
|
|
||
|
|
||
| def find_system_command(name: str) -> _Optional[str]: | ||
| """Return the absolute path to ``name``, or ``None`` if it is not installed. | ||
|
|
||
| ``PATH`` is not consulted. Use this when the command's absence is tolerable; | ||
| use :func:`system_command_path` when it is required. | ||
|
|
||
| Raises: | ||
| ValueError: if ``name`` is not a bare command name. | ||
| """ | ||
| _validate_command_name(name) | ||
| for directory in TRUSTED_SYSTEM_DIRECTORIES: | ||
| candidate = _os.path.join(directory, name) | ||
| if _is_executable_file(candidate): | ||
| return candidate | ||
| return None | ||
|
|
||
|
|
||
| def system_command_path(name: str) -> str: | ||
| """Return the absolute path to ``name``. | ||
|
|
||
| Raises: | ||
| ValueError: if ``name`` is not a bare command name. | ||
| SystemCommandNotFoundError: if ``name`` is in no trusted directory. | ||
| """ | ||
| path = find_system_command(name) | ||
| if path is None: | ||
| raise SystemCommandNotFoundError( | ||
| f"Could not find the system command {name!r} in any trusted directory " | ||
| f"({', '.join(TRUSTED_SYSTEM_DIRECTORIES)}). PATH is deliberately not searched." | ||
| ) | ||
| return path | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Two things about the trusted-directory list worth a second look, since this module's entire value rests on the claim that these locations are trustworthy:
/run/wrappers/binis searched first on every platform, not just NixOS. On a non-NixOS host this directory normally does not exist, so the entry is inert — but it is unconditionally given priority over/usr/bin./runis a root-owned tmpfs on typical distributions, so exploiting it needs root already; still, giving highest precedence to a path that is non-standard on the vast majority of target hosts is the opposite of the "fixed list of trusted absolute directories" premise. Consider gating it (e.g. only prepend when/run/wrappers/bin/sudois a setuid file, or when a NixOS marker such as/etc/NIXOSis present), or at minimum documenting that the entry is expected to be absent elsewhere.No ownership or write-permission check on the resolved directory/file.
_is_executable_fileonly checksisfile+X_OK. A resolver whose stated purpose is defeating untrusted search paths would normally also refuse a candidate whose directory or file is group/world-writable, or not root-owned — otherwise a misconfigured/usr/local-style directory (or a symlink planted inside a searched directory) is trusted purely because of where it appears in the list. If that check is deliberately out of scope, saying so in the docstring alongside the three properties already listed would make the boundary explicit.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Both points correct, and both parked rather than fixed. On the ordering:
/run/wrappers/bindoes get priority on every platform though it is NixOS-specific, and it is normally absent, costing one stat. On trust:_is_executable_filechecks only isfile plus an execute bit, never root ownership or group and world writability, so membership is positional. Parked because reaching the exposure needs root or equivalent already, and an ownership check has the same failure shape as theX_OKcheck that already caused one regression in this series by testing permissions as the wrong user. Recorded so it is not lost, and the module docstring no longer implies a stronger guarantee than the code provides.