-
Notifications
You must be signed in to change notification settings - Fork 79
Fix symlink escape vulnerabilities in safe_copy and log file reading (CWE-59) #1050
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
Merged
+96
−5
Merged
Changes from all commits
Commits
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
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 |
|---|---|---|
|
|
@@ -17,6 +17,7 @@ | |
| import errno | ||
| import os | ||
| import shutil | ||
| import stat | ||
| from pathlib import Path | ||
| from secrets import token_hex | ||
| from typing import Union | ||
|
|
@@ -26,17 +27,108 @@ | |
| from gprofiler.platform import is_windows | ||
| from gprofiler.utils import remove_path, run_process | ||
|
|
||
| # O_NOFOLLOW is always available on Linux (the target platform for this code). | ||
| # The getattr fallback to 0 covers non-Linux builds; on those platforms symlink | ||
| # protection in safe_read_text() is best-effort only. | ||
| _O_NOFOLLOW: int = getattr(os, "O_NOFOLLOW", 0) | ||
|
|
||
|
|
||
| def _is_symlink_lstat(path: str) -> bool: | ||
| """Check if path is a symlink without following it.""" | ||
| try: | ||
| return stat.S_ISLNK(os.lstat(path).st_mode) | ||
| except FileNotFoundError: | ||
| return False | ||
|
|
||
|
|
||
| def safe_copy(src: str, dst: str) -> None: | ||
| """ | ||
| Safely copies 'src' to 'dst'. Safely means that writing 'dst' is performed at a temporary location, | ||
| and the file is then moved, making the filesystem-level change atomic. | ||
|
|
||
| Security: Uses O_EXCL to atomically create the temp file, preventing symlink attacks where an | ||
| attacker plants a symlink to redirect writes to arbitrary locations. | ||
| """ | ||
| dst_tmp = f"{dst}.tmp" | ||
| shutil.copy(src, dst_tmp) | ||
|
|
||
| # Remove any leftover tmp file from a previous interrupted copy. | ||
| # unlink() removes symlinks themselves (not their targets), so this is safe even if dst_tmp | ||
| # is a symlink; the subsequent O_EXCL open then creates the file fresh. | ||
| try: | ||
| os.unlink(dst_tmp) | ||
| except FileNotFoundError: | ||
| pass # Normal case: no leftover file | ||
|
|
||
| # O_EXCL ensures atomic creation - fails if anything exists at dst_tmp (including symlinks). | ||
| # EEXIST means another process created the file after our delete - indicates a race or attack. | ||
| try: | ||
| fd = os.open(dst_tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644) | ||
| except FileExistsError: | ||
| raise Exception( | ||
| f"Refusing to copy: {dst_tmp} was created unexpectedly (possible race condition or symlink attack)" | ||
| ) | ||
| try: | ||
| dst_file = os.fdopen(fd, "wb") | ||
| except Exception: | ||
| os.close(fd) | ||
| try: | ||
| os.unlink(dst_tmp) | ||
| except OSError: | ||
| pass | ||
| raise | ||
| try: | ||
| with dst_file, open(src, "rb") as src_file: | ||
| shutil.copyfileobj(src_file, dst_file) | ||
| # Preserve source file permissions (e.g., executable bit) | ||
| shutil.copymode(src, dst_tmp) | ||
| except Exception: | ||
| try: | ||
| os.unlink(dst_tmp) | ||
| except OSError: | ||
| pass | ||
| raise | ||
|
|
||
| # Best-effort check: refuse if dst is currently a symlink. | ||
| # os.rename() replaces the destination atomically (it does not follow dst symlinks), so even | ||
| # if an attacker races to plant a symlink between this check and the rename, the symlink itself | ||
| # would be replaced rather than its target being overwritten. This check adds defence-in-depth. | ||
| if _is_symlink_lstat(dst): | ||
| os.unlink(dst_tmp) | ||
| raise Exception(f"Refusing to copy: destination {dst} is a symlink (security restriction)") | ||
|
Comment on lines
+95
to
+97
|
||
|
|
||
| os.rename(dst_tmp, dst) | ||
|
|
||
|
|
||
| def safe_read_text(path: str) -> str: | ||
| """ | ||
| Safely read text from a file, refusing to follow symlinks. | ||
|
|
||
| Uses O_NOFOLLOW so the kernel rejects symlinks atomically at open time (Linux). | ||
| On platforms without O_NOFOLLOW the flag falls back to 0 and the protection | ||
| is best-effort; the target platform for this code is Linux where O_NOFOLLOW | ||
| is always available. | ||
|
|
||
| Raises if path is a symlink. | ||
| """ | ||
| try: | ||
| # O_NOFOLLOW makes open() fail with ELOOP if the path is a symlink (Linux-specific behavior). | ||
| # On platforms without O_NOFOLLOW the flag is 0 and the call may follow symlinks; the target | ||
|
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. Nice hardening here. One follow-up to consider: when O_NOFOLLOW is unavailable, failing closed with a clear exception may be safer than falling back to 0, since fallback can silently weaken symlink protection on non-Linux platforms. |
||
| # platform for this code is Linux, so O_NOFOLLOW is always available. | ||
| fd = os.open(path, os.O_RDONLY | _O_NOFOLLOW) | ||
| except OSError as e: | ||
| if e.errno == errno.ELOOP: | ||
| raise Exception(f"Refusing to read {path}: symlinks are not allowed for security reasons") | ||
| raise | ||
|
|
||
| try: | ||
| f = os.fdopen(fd, "r") | ||
| except Exception: | ||
| os.close(fd) | ||
| raise | ||
| with f: | ||
| return f.read() | ||
|
|
||
|
|
||
| def is_rw_exec_dir(path: Path) -> bool: | ||
| """ | ||
| Is 'path' rw and exec? | ||
|
|
||
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.
Agree. Doing unlink/recreate in finally avoids skipping cleanup on exceptions and prevents persistent symlink-induced failures.