Skip to content

Fix symlink escape vulnerabilities in safe_copy and log file reading - #1049

Closed
mlim19 wants to merge 9 commits into
masterfrom
fix-symlink-escape-vulnerability
Closed

Fix symlink escape vulnerabilities in safe_copy and log file reading#1049
mlim19 wants to merge 9 commits into
masterfrom
fix-symlink-escape-vulnerability

Conversation

@mlim19

@mlim19 mlim19 commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Harden safe_copy() to prevent symlink attacks on the write path by using O_EXCL for atomic temp file creation and adding symlink validation
  • Add safe_read_text() using O_NOFOLLOW to prevent symlink attacks on the read path
  • Use safe_read_text() in _read_ap_log() to protect against log file symlink attacks

Test plan

  • Verify safe_copy() raises exception when destination temp file is a symlink
  • Verify safe_copy() raises exception when final destination is a symlink
  • Verify safe_read_text() raises exception when path is a symlink
  • Verify normal Java profiling still works without symlinks present

🤖 Generated with Claude Code

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>
Copilot AI review requested due to automatic review settings July 23, 2026 01:15

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread gprofiler/utils/fs.py Outdated
Comment on lines +58 to +63
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:
Comment thread gprofiler/utils/fs.py Outdated
Comment on lines +49 to +54
# 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)
Comment thread gprofiler/utils/fs.py Outdated
Comment on lines +87 to +88
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>
Copilot AI review requested due to automatic review settings July 23, 2026 17:49

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Comment thread gprofiler/utils/fs.py Outdated
Comment on lines +60 to +64
# 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)
Comment thread gprofiler/utils/fs.py
Comment on lines +89 to +94
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
Comment thread gprofiler/utils/fs.py
Comment on lines 39 to 46
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.
"""
Copilot AI review requested due to automatic review settings July 24, 2026 02:27

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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}.tmp as long as it’s not a symlink. In an attacker-writable directory this can be abused (hardlink/DoS) and also makes concurrent safe_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 using O_NOFOLLOW when it’s unavailable (getattr(os, "O_NOFOLLOW", 0)), which reintroduces a TOCTOU window between the lstat check and open(). If this function is meant to provide symlink-attack protection, it should fail fast on platforms without O_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 via shutil.copymode(src, dst_tmp) after the write completes, using the temp path. If an attacker can replace dst_tmp between the copy and copymode, 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)"
        )

Comment thread gprofiler/utils/fs.py Outdated
Copilot AI review requested due to automatic review settings July 24, 2026 02:32

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 with O_EXCL, an attacker who can modify the destination directory could swap dst_tmp to a symlink between the data copy and copymode(), causing chmod to 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:

Comment thread tests/conftest.py Outdated
Comment on lines +437 to +440
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
Copilot AI review requested due to automatic review settings July 24, 2026 02:36

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread gprofiler/utils/fs.py
Comment on lines +127 to +133
try:
f = os.fdopen(fd, "r")
except Exception:
os.close(fd)
raise
with f:
return f.read()
Comment thread tests/conftest.py
Comment on lines +434 to +440
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
Copilot AI review requested due to automatic review settings July 24, 2026 02:40

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread gprofiler/utils/fs.py
Comment on lines +54 to +58
# 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)
@mlim19 mlim19 closed this Jul 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants