Fix symlink escape vulnerabilities in safe_copy and log file reading - #1049
Fix symlink escape vulnerabilities in safe_copy and log file reading#1049mlim19 wants to merge 9 commits into
Conversation
Harden file operations to prevent container escape attacks (CWE-59): - safe_copy(): Use O_EXCL for atomic temp file creation, add symlink checks before writing to dst_tmp and renaming to dst - Add safe_read_text(): Read files using O_NOFOLLOW to refuse symlinks These changes prevent an attacker in a profiled container from: 1. Overwriting arbitrary host files via symlink at the libasyncProfiler.so copy destination 2. Exfiltrating host file contents via symlink at the async-profiler log path Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR hardens gProfiler’s filesystem operations against symlink-based attacks by introducing safer primitives for file copying and log reading, and integrating them into the Java async-profiler flow.
Changes:
- Reworked
safe_copy()to use atomic temp-file creation (O_EXCL) and added symlink checks. - Added
safe_read_text()to refuse following symlinks when reading files. - Updated Java async-profiler log reading to use
safe_read_text().
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| gprofiler/utils/fs.py | Adds symlink-aware helpers and updates atomic copy/read paths intended to mitigate symlink attacks. |
| gprofiler/profilers/java.py | Switches async-profiler log reading to the new symlink-safe read helper. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| fd = os.open(dst_tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644) | ||
| try: | ||
| with open(src, "rb") as src_file: | ||
| with os.fdopen(fd, "wb") as dst_file: | ||
| shutil.copyfileobj(src_file, dst_file) | ||
| except Exception: |
| # Remove existing tmp file if it's a regular file (from interrupted previous copy) | ||
| # If it's a symlink, refuse to proceed | ||
| if os.path.lexists(dst_tmp): | ||
| if _is_symlink_lstat(dst_tmp): | ||
| raise Exception(f"Refusing to copy to {dst_tmp}: path is a symlink") | ||
| os.unlink(dst_tmp) |
| try: | ||
| fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW) |
|
|
||
| log = Path(self._log_path_host) | ||
| ap_log = log.read_text() | ||
| ap_log = safe_read_text(self._log_path_host) |
…fe_copy - Wrap fd with os.fdopen() before opening src to prevent fd leak if open(src) fails - Add shutil.copymode() to preserve source file permissions (e.g., executable bit for libasyncProfiler.so) - Use getattr(os, "O_NOFOLLOW", 0) for portability on platforms where O_NOFOLLOW is not available Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (1)
gprofiler/utils/fs.py:58
- The temp file is created with mode 0o644, which can temporarily (or permanently, if copymode fails) widen permissions compared to the source. For security-sensitive copies, create the temp file with restrictive permissions (e.g. 0o600) and then apply the source mode explicitly.
fd = os.open(dst_tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)
| # Wrap fd first to prevent leak if open(src) fails | ||
| with os.fdopen(fd, "wb") as 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) |
| try: | ||
| fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) | ||
| except OSError as e: | ||
| if e.errno == errno.ELOOP: | ||
| raise Exception(f"Refusing to read {path}: path is a symlink") | ||
| raise |
| 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. | ||
| """ |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (3)
gprofiler/utils/fs.py:53
safe_copy()unlinks an existing${dst}.tmpas long as it’s not a symlink. In an attacker-writable directory this can be abused (hardlink/DoS) and also makes concurrentsafe_copy()calls to the same destination interfere with each other. Prefer using a unique temp filename and never deleting a pre-existing unknown path.
# Remove existing tmp file if it's a regular file (from interrupted previous copy)
# If it's a symlink, refuse to proceed
if os.path.lexists(dst_tmp):
if _is_symlink_lstat(dst_tmp):
raise Exception(f"Refusing to copy to {dst_tmp}: path is a symlink")
gprofiler/utils/fs.py:94
safe_read_text()silently falls back to not usingO_NOFOLLOWwhen it’s unavailable (getattr(os, "O_NOFOLLOW", 0)), which reintroduces a TOCTOU window between thelstatcheck andopen(). If this function is meant to provide symlink-attack protection, it should fail fast on platforms withoutO_NOFOLLOW(or clearly document that it’s best-effort).
raise Exception(f"Refusing to rename to {dst}: path is a symlink")
os.rename(dst_tmp, dst)
def safe_read_text(path: str) -> str:
gprofiler/utils/fs.py:64
safe_copy()sets permissions viashutil.copymode(src, dst_tmp)after the write completes, using the temp path. If an attacker can replacedst_tmpbetween the copy andcopymode, this can chmod an unintended target. Safer is to apply permissions via the open file descriptor (e.g.,os.fchmod) while the file is still open.
except FileExistsError:
raise Exception(
f"Refusing to copy: {dst_tmp} was created unexpectedly (possible race condition or symlink attack)"
)
…t lstat pre-check
…FOLLOW constant, clarify rename behavior
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
gprofiler/utils/fs.py:78
shutil.copymode(src, dst_tmp)can follow symlinks. Even though the temp file is created withO_EXCL, an attacker who can modify the destination directory could swapdst_tmpto a symlink between the data copy andcopymode(), causingchmodto affect an arbitrary target. Set the permissions via the already-open file descriptor (fchmod) to avoid this TOCTOU window entirely.
dst_file = os.fdopen(fd, "wb")
except Exception:
os.close(fd)
try:
| error_str = str(e) | ||
| if "Failed to fetch" in error_str or "Some index files failed to download" in error_str: | ||
| pytest.skip(f"Skipping: Docker image build failed due to unavailable package repository: {e}") | ||
| raise |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (2)
gprofiler/utils/fs.py:125
- safe_read_text() documents “best-effort” symlink protection on platforms without O_NOFOLLOW, but currently performs no symlink check when _O_NOFOLLOW == 0. Also, using O_CLOEXEC avoids leaking the fd to any execs between open() and close().
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:
gprofiler/utils/fs.py:61
- safe_copy()’s “leftover tmp cleanup” only rejects symlinks, but will attempt os.unlink() on any other existing file type. If an attacker (or a previous run) leaves a directory/FIFO/device at dst.tmp, this will raise with a confusing error (and can be used as a DoS). Consider explicitly rejecting non-regular files with a clear error message.
# 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
| try: | ||
| f = os.fdopen(fd, "r") | ||
| except Exception: | ||
| os.close(fd) | ||
| raise | ||
| with f: | ||
| return f.read() |
| except docker.errors.BuildError as e: | ||
| # Skip tests when Docker image build fails due to network issues (e.g., unavailable package repositories). | ||
| # This prevents transient network failures from turning into hard CI failures. | ||
| error_str = str(e) | ||
| if "Failed to fetch" in error_str or "Some index files failed to download" in error_str: | ||
| pytest.skip(f"Skipping: Docker image build failed due to unavailable package repository: {e}") | ||
| raise |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (3)
gprofiler/utils/fs.py:83
- shutil.copymode(src, dst_tmp) operates on the path after the file has been closed, which reintroduces a TOCTOU window: if an attacker (or a concurrent process) can replace dst_tmp with a symlink between close and copymode, copymode may chmod the symlink target. Apply the source mode via the already-open destination fd (os.fchmod) to keep the operation atomic w.r.t. the file you created with O_EXCL.
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)
gprofiler/utils/fs.py:121
- safe_read_text() documents that on platforms without O_NOFOLLOW it is “best-effort”, but the current implementation provides no fallback symlink check when _O_NOFOLLOW==0, so it will silently follow symlinks on those platforms. Add an lstat-based check in that case to match the documented behavior.
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
# 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
tests/conftest.py:443
- This PR is described as fixing symlink vulnerabilities in safe_copy/log reading, but it also changes test behavior by conditionally skipping Docker image build failures. That’s a significant, unrelated behavior change and should either be called out explicitly in the PR description/title or moved to a separate PR to keep the security fix reviewable.
try:
image = build_image(
docker_client, **application_docker_image_configs[image_name(runtime, application_image_tag)]
)
except docker.errors.BuildError as e:
# Skip tests when Docker image build fails due to network issues (e.g., unavailable package
# repositories). The APT "Failed to fetch" / "Some index files failed to download" messages
# appear in the build log stream entries, not in the top-level error reason.
# This prevents transient network failures from turning into hard CI failures.
network_error_markers = ("Failed to fetch", "Some index files failed to download")
build_log_text = " ".join(str(entry) for entry in e.build_log)
if any(marker in build_log_text for marker in network_error_markers):
pytest.skip(f"Skipping: Docker image build failed due to unavailable package repository: {e}")
raise
| # 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) |
Summary
safe_copy()to prevent symlink attacks on the write path by usingO_EXCLfor atomic temp file creation and adding symlink validationsafe_read_text()usingO_NOFOLLOWto prevent symlink attacks on the read pathsafe_read_text()in_read_ap_log()to protect against log file symlink attacksTest plan
safe_copy()raises exception when destination temp file is a symlinksafe_copy()raises exception when final destination is a symlinksafe_read_text()raises exception when path is a symlink🤖 Generated with Claude Code