Skip to content

cp TOCTOU: symlink swap bypasses no-dereference intent #10017

Closed

Description

@sylvestre

Component

cp

Description

uutils cp can be vulnerable to a classic TOCTOU as it decides whether the source is a symlink using lstat-style metadata, but later opens the source by path without O_NOFOLLOW.

An attacker who can mutate the source tree concurrently can replace a regular file with a symlink between the check and the open, causing cp to copy the symlink target’s contents.

Test / Reproduction Steps

Evidence via strace to make it easier:

  • In uutils, cp opens the source without O_NOFOLLOW:
    openat(AT_FDCWD, "/tmp/src/file", O_RDONLY|O_CLOEXEC) (no O_NOFOLLOW)
  • In GNU cp on Linux, the equivalent open includes O_NOFOLLOW:
    openat(AT_FDCWD, "/tmp/src/file", O_RDONLY|O_NOFOLLOW)

Preconditions: attacker can modify/rename entries under the source path while cp runs; victim runs cp with more read privilege than attacker; destination is attacker-readable.

  1. Create a source directory attacker controls and a destination.
  2. Run cp on a file inside the source while a second process repeatedly swaps that pathname between a regular file and a symlink to a sensitive target.
  3. Observe occasional copies of the symlink target’s contents in the destination when the swap lands between the metadata check and the open.

Activity

  1. denendaden commented on Feb 3, 2026

    @denendaden
    Contributor

    I can't seem to replicate this behavior for GNU cp. I am getting, for GNU cp:
    openat(AT_FDCWD, "/tmp/src/file", O_RDONLY)
    and for uutils cp:
    openat(AT_FDCWD, "/tmp/src/file", O_RDONLY|O_CLOEXEC)

  2. julian-klode commented on Feb 4, 2026

    @julian-klode
    Contributor

    Not sure what you are looking at but the file is opened multiple times in GNU CP:

    newfstatat(AT_FDCWD, "/tmp/src/file", {st_mode=S_IFREG|0664, st_size=0, ...}, AT_SYMLINK_NOFOLLOW) = 0
    openat(AT_FDCWD, "/tmp/src/file", O_RDONLY|O_NOFOLLOW) = 4
    fstat(4, {st_mode=S_IFREG|0664, st_size=0, ...}) = 0
    openat(3, "src/file", O_WRONLY|O_CREAT|O_EXCL, 0664) = 5
    ioctl(5, BTRFS_IOC_CLONE or FICLONE, 4) = -1 EOPNOTSUPP (Operation not supported)
    fstat(5, {st_mode=S_IFREG|0664, st_size=0, ...}) = 0
    

    (sorry target src/file is a bit ... weird)

    First stat file, then open it, then stat open file, then it tries to copy it. Presumably it compares the stat of the NOFOLLOW newfstatat with the opened file.

    For comparison, uutils opens the source file 4 times, never with NOFOLOW.

    openat(AT_FDCWD, "/tmp/src/file", O_RDONLY|O_CLOEXEC) = 4
    openat(AT_FDCWD, "/tmp/src/file", O_RDONLY|O_CLOEXEC) = 4
    openat(AT_FDCWD, "/tmp/src/file", O_RDONLY|O_CLOEXEC) = 4
    openat(AT_FDCWD, "/tmp/src/file", O_RDONLY|O_CLOEXEC) = 6
    
  3. denendaden commented on Feb 4, 2026

    @denendaden
    Contributor

    This is what I am seeing when I run strace cp /tmp/src/file /tmp/dest:

    newfstatat(AT_FDCWD, "/tmp/src/file", {st_mode=S_IFREG|0644, st_size=0, ...}, 0) = 0
    openat(AT_FDCWD, "/tmp/src/file", O_RDONLY) = 3
    fstat(3, {st_mode=S_IFREG|0644, st_size=0, ...}) = 0
    openat(AT_FDCWD, "/tmp/dest", O_WRONLY|O_CREAT|O_EXCL, 0644) = 4
    ioctl(4, BTRFS_IOC_CLONE or FICLONE, 3) = -1 EOPNOTSUPP (Operation not supported)
    fstat(4, {st_mode=S_IFREG|0644, st_size=0, ...}) = 0
    

    I've tried building GNU cp from source as well as copying different types of files to/from different locations. I'm not sure why it doesn't appear to be using NOFOLLOW on my system.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions