Repository navigation
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
base: mainline
Are you sure you want to change the base?
Changes from 8 commits
42eea6f
16c62d2
433ab52
d0a1617
99c1011
9cdd7e8
4d82305
e11eed5
e45b4d9
f28cefe
bb357ae
6f09326
ae6e050
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,137 @@ | ||
| # 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, ...] = ( | ||
| # 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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,15 @@ | |
| import threading | ||
| from typing import Callable, Dict, Union, Optional | ||
|
|
||
| # Aliased under private names. Imported plainly, these two become public | ||
| # attributes of `deadline.job_attachments.vfs`, which the API-surface check | ||
| # reports as added public aliases -- an unintended widening of the package's | ||
| # contract from what is meant to be an internal helper. All three are aliased, | ||
| # including the exception: re-exporting it plainly failed the same gate a second | ||
| # time, for the same reason. | ||
| from ._system_commands import SystemCommandNotFoundError as _SystemCommandNotFoundError | ||
| from ._system_commands import find_system_command as _find_system_command | ||
| from ._system_commands import system_command_path as _system_command_path | ||
| from .exceptions import ( | ||
| VFSExecutableMissingError, | ||
| VFSFailedToMountError, | ||
|
|
@@ -119,7 +128,24 @@ | |
| if not os.path.exists(fusermount3_path): | ||
| log.warning(f"fusermount3 not found at {cls.find_vfs_link_dir()}") | ||
| return None | ||
| return ["sudo", "-u", os_user, fusermount3_path, "-u", mount_path] | ||
| # find_system_command rather than system_command_path: this function's | ||
| # contract is Optional[list], and it already answers "a binary I need is | ||
| # missing" with a warning and None just above. Raising for sudo instead | ||
| # would be a second, undeclared failure mode on the same line of code -- | ||
| # and it escapes into shutdown_libfuse_mount's cleanup path, whose only | ||
| # handling for this function is the None check. | ||
| sudo_path = _find_system_command("sudo") | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The rationale recorded here — and the contract the new fusermount3_path = os.path.join(cls.find_vfs_link_dir(), "fusermount3") # vfs.py:127
That matters beyond documentation accuracy, because one caller is mid-rewrite of the pid file when it happens. Two ways to make the stated contract true rather than half-true:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Right on the facts, and the comment was overclaiming. Fixed by narrowing the claim rather than by changing behaviour: the comment and The second consequence no longer applies. Making all three cases answer identically is a behaviour change to a pre-existing path; parked as item 9. |
||
| if sudo_path is None: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The new
With bare Now the same host takes the new branch: warn, Two hosts this is reachable on today: any container/image without The non-raising choice for this function is well-argued in the comment above; the gap is that neither caller does anything with the
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Correct, and deferred with the other two. Both of those behaviours predate this change; what is new is a path that reaches them. Fixing it properly means changing how those two functions treat a failed shutdown, which is beyond command resolution and belongs with the owners of that cleanup logic. |
||
| log.warning("sudo not found in any trusted directory; cannot unmount as the job user") | ||
| return None | ||
| return [ | ||
| sudo_path, | ||
| "-u", | ||
| os_user, | ||
| fusermount3_path, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. High-level note on the scope of this hardening: the two commands resolved through the new trusted resolver (
So if the threat model is "part of the search path is influenced by less-trusted input" (CWE-426, as the new module docstring states), pinning Not asking to expand this PR necessarily, but it would be worth stating in the PR description / module docstring that
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fair, and now stated explicitly in 4d82305 rather than left implicit. |
||
| "-u", | ||
| mount_path, | ||
| ] | ||
|
|
||
| @classmethod | ||
| def shutdown_libfuse_mount(cls, mount_path: str, os_user: str, session_dir: Path) -> bool: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The new comment above (vfs.py:131-136) rests on the premise that try:
run_result = subprocess.run(shutdown_args, check=True)
except subprocess.CalledProcessError as e:
log.warning(f"Shutdown failed with error {e}")
# Don't reraise, check if mount is gone
log.info(f"Shutdown returns {run_result.returncode}") # vfs.py:166When Downstream that lands in the same place as the other cleanup concerns on this PR: This is pre-existing rather than introduced here, so it is fair to leave out of scope. But the PR is specifically reasoning about which exceptions can escape this function, and
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed, and thank you for checking the premise rather than the conclusion. That is pre-existing and untouched by this change, so I have not folded a fix into it. The comment it undercuts has been narrowed accordingly in the module note rather than left asserting more than the code delivers. |
||
|
|
@@ -211,7 +237,24 @@ | |
| os.path.ismount returns false for libfuse mounts owned by "other users", | ||
| use findmnt instead | ||
| """ | ||
| return subprocess.run(["findmnt", path]).returncode == 0 | ||
| return subprocess.run([cls._resolve_or_raise("findmnt"), path]).returncode == 0 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
return cls.wait_for_mount(mount_path, session_dir, expected=False) # vfs.py:164and Concretely, in This is reachable whenever If the intent is that
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Correct, and deferred. The two do sit on the same call chain, and the asymmetry is real: Deferring because it is not reachable on a supported host. |
||
|
|
||
| @staticmethod | ||
| def _resolve_or_raise(name: str) -> str: | ||
| """Resolve a system command, reporting failure as VFSExecutableMissingError. | ||
|
|
||
| The translation is the point. Callers of the mount and unmount paths handle | ||
| ``VFSExecutableMissingError`` -- ``asset_sync`` catches it and falls back to a | ||
| copy-based sync -- and handle no other type for "a binary I need is not | ||
| here". Letting the resolver's own exception escape would either bypass that | ||
| fallback, or, if it inherited from ``FileNotFoundError``, be misreported by | ||
| the ``except FileNotFoundError`` blocks in this module that mean "the VFS pid | ||
| file is missing". | ||
| """ | ||
| try: | ||
| return _system_command_path(name) | ||
| except _SystemCommandNotFoundError as e: | ||
| raise VFSExecutableMissingError(str(e)) from e | ||
|
|
||
| @classmethod | ||
| def wait_for_mount(cls, mount_path, session_dir, mount_wait_seconds=60, expected=True) -> bool: | ||
|
|
@@ -268,7 +311,7 @@ | |
| log_file_path = self.logs_folder_path(session_dir) / log_file_name | ||
| log.log(log_level, f"Printing last {lines} lines from {log_file_path}") | ||
| if not os.path.exists(log_file_path): | ||
| log.warning(f"No log file found at {log_file_path}") | ||
Check failureCode scanning / CodeQL Clear-text logging of sensitive information High
This expression logs
sensitive data (secret) Error loading related location Loading |
||
| return | ||
| with open(log_file_path, "r") as log_file: | ||
| for this_line in log_file.readlines()[lines * -1 :]: | ||
|
|
@@ -291,7 +334,7 @@ | |
| executable = VFSProcessManager.find_vfs_launch_script() | ||
|
|
||
| command = ( | ||
| f"sudo -E -u {self._os_user}" | ||
| f"{self._resolve_or_raise('sudo')} -E -u {self._os_user}" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Related but distinct from the shutdown-path comment: on the mount path, routing a missing Sequence when
The launched Before this change, Two things would close this independently of the
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is the sharpest of the three and I want to be clear I am not dismissing it. A silent copy-fallback that races a live VFS process is a worse outcome than a hard failure, and you are right that translating to Deferred on reachability: it needs There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Scope question on the threat model, now that this line is the one being hardened: the string built here is handed to f" {executable} {mount_point} -f --clienttype=deadline"
f" --bucket={self._asset_bucket}"
f" --manifest={self._manifest_path}"
...
command += f" --casprefix={self._cas_prefix}"
command += f" --cachedir={self._asset_cache_path}"
This is pre-existing, so it is fair to keep out of scope. But it does mean the hardening on this line closes the narrower of the two issues in the same expression, and the PR reads as though command construction here has been made safe. Either passing an argv list (which removes
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed on the substance, and I have taken the third option you offered: the note. Documented in 6f09326 on You are right that this is the wider of the two issues in that expression. Resolving Not fixing it here because both routes change launch mechanics rather than command resolution. The argv-list version is the one I would pick, since it removes The part I did want to close is your last point, that the PR reads as though command construction here has been made safe. The note says plainly what is and is not addressed, so the boundary is on the record rather than implied. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Order in self._resolve_or_raise("findmnt") # vfs.py:591 (pre-resolved)
self.set_manifest_owner()
VFSProcessManager.create_mount_point(self._mount_point) # vfs.py:593 -> os.chmod(mode=0o777)
start_command = self.build_launch_command(...) # vfs.py:594 -> resolves sudo, may raise
That is a behaviour change from the base commit, not just a relocation. With the bare The pre-resolve added at vfs.py:591 already establishes the pattern that fixes this — resolving
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Deferred. Correct that The asymmetry is smaller than the one that was fixed: both raise before |
||
| f" {executable} {mount_point} -f --clienttype=deadline" | ||
| f" --bucket={self._asset_bucket}" | ||
| f" --manifest={self._manifest_path}" | ||
|
|
@@ -344,6 +387,20 @@ | |
| Determine where the VFS executable we'll be launching lives so we can | ||
| find the correct relative paths around it for LD_LIBRARY_PATH and config files | ||
| :return: Path to VFS executable | ||
|
|
||
| Note this deliberately does not use the trusted-directory resolver in | ||
| ``_system_commands``, and the difference is worth understanding before | ||
| changing either one. That resolver exists for *system* commands, whose | ||
| locations are fixed and small in number. The VFS executable is a shipped | ||
| artifact whose location is a deployment choice: this function consults | ||
| ``shutil.which``, then ``$DEADLINE_VFS_INSTALL_PATH``, then a path relative to | ||
| the working directory, because a deployment may legitimately put it in any of | ||
| them. | ||
|
|
||
| So the search path for this binary, and for the launch script found by | ||
| :meth:`find_vfs_launch_script`, remains wider than for ``sudo`` or | ||
| ``findmnt``. Narrowing it is a separate question about how the VFS is | ||
| deployed, not about command resolution, and it is knowingly out of scope here. | ||
| """ | ||
| if VFSProcessManager.exe_path is not None: | ||
| log.info(f"Using saved path {VFSProcessManager.exe_path}") | ||
|
|
@@ -417,7 +474,7 @@ | |
| """ | ||
| if VFSProcessManager.cwd_path is None: | ||
| exe_path = VFSProcessManager.find_vfs() | ||
| # Use cwd one folder up from bin | ||
Check failureCode scanning / CodeQL Clear-text logging of sensitive information High
This expression logs
sensitive data (secret) Error loading related location Loading |
||
| VFSProcessManager.cwd_path = os.path.normpath( | ||
| os.path.join(os.path.dirname(exe_path), "..") | ||
| ) | ||
|
|
@@ -447,7 +504,7 @@ | |
| log.error(f"Manifest not found at {self._manifest_path}") | ||
| return | ||
| if self._os_group is not None: | ||
| try: | ||
Check failureCode scanning / CodeQL Clear-text logging of sensitive information High
This expression logs
sensitive data (secret) Error loading related location Loading |
||
| shutil.chown(self._manifest_path, group=self._os_group) | ||
| os.chmod(self._manifest_path, DEADLINE_MANIFEST_GROUP_READ_PERMS) | ||
| except OSError as e: | ||
|
|
||
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.