libdrmtap captures screen framebuffers by reading the Linux kernel's DRM/KMS
scanout. Reading the active scanout of a session you are not the DRM master of
requires CAP_SYS_ADMIN. This document describes, accurately, how the privilege
is isolated, what the helper actually does, and what it does not do.
This document is intended to match the code in helper/drmtap-helper.c and
src/privilege_helper.c. If you find a claim here that the code does not
support, that is a bug in this document — please report it.
┌───────────────────────┐ ┌────────────────────────┐
│ YOUR APPLICATION │ │ drmtap-helper │
│ (unprivileged) │◄── socketpair ────►│ (CAP_SYS_ADMIN) │
│ │ │ │
│ - Uses libdrmtap API │◄── DMA-BUF fd ─────│ - Opens /dev/dri/card │
│ - Processes frames │ │ - drmModeGetFB2 │
│ - Encodes / transports│ │ - Exports DMA-BUF │
└───────────────────────┘ └────────────────────────┘
unprivileged keeps CAP_SYS_ADMIN for its
no network in the lib whole lifetime (see below)
The library (linked into your unprivileged application) fork()/execl()s the
helper, handing it one end of an anonymous socketpair(2) on fd 3
(HELPER_SOCKET_FD). The helper refuses to run if fd 3 is not a connected
socket, and checks the peer's credentials via SO_PEERCRED, refusing to
serve unless the peer uid matches its own — so it cannot be usefully invoked
standalone or hijacked by another user. All capture requests go over that
private inherited socket.
The privileged operation is drmModeGetFB2() to obtain the active scanout
framebuffer's GEM handles on a card node where the helper is not the DRM
master. The kernel gates that handle disclosure on
drm_is_current_master() || CAP_SYS_ADMIN. There is no narrower capability for
this specific operation:
CAP_DAC_OVERRIDEor thevideogroup only get you a device fd, not the handle-disclosure path.- A render node has no KMS/scanout access at all.
So CAP_SYS_ADMIN is what the kernel requires. We are upfront that it is broad
and overpowered for what the helper actually does with it. The mitigation is
structural (keep it out of the main process, confine the helper), not a
clever capability minimization. The helper never sets itself DRM master (it does
not call drmSetMaster), and right after opening the node it calls
drmDropMaster to release any master the kernel granted it implicitly.
A narrower kernel capability for scanout read-back would let us drop
CAP_SYS_ADMINentirely. We would welcome such a change.
At startup, in this order (main() in helper/drmtap-helper.c):
- Refuses to run standalone — validates that fd 3 is a connected socket and
that the peer uid (via
SO_PEERCRED) matches its own uid; exits otherwise. - Restricts the device path — the device comes solely from
argv[1](the path the library selected). The helper does not readDRM_DEVICEfrom the environment (0.4.14 hardening), and the library itself ignoresDRM_DEVICEwhen privileged (since 0.4.11), so an env var cannot redirect which device the privileged process opens. The still attacker-influenceableargv[1]is canonicalized withrealpath(), and the helper refuses any path that does not resolve under/dev/dri/before opening it. PR_SET_NO_NEW_PRIVS— set viaprctl, before seccomp; hard-fails if it cannot be set.- Opens the DRM device once, read-only —
open(..., O_RDONLY | O_CLOEXEC). Only read ioctls are issued (GetFB2/PrimeHandleToFD/SetClientCap), which all work on anO_RDONLYfd on the CAP_SYS_ADMIN path; the helper never becomes DRM master and never modifies KMS state. Opening happens before seccomp so the filter can forbidopen/openatoutright (the seccomp step below). - Drops any implicitly-granted DRM master — calls
drmDropMasterright after opening, so a boot-time capture process (started before the compositor holds master) cannot block a compositor from (re)acquiring master on a VT switch. It is a no-op unless the kernel had made the helper master implicitly. - Drops all capabilities except
CAP_SYS_ADMIN— via libcap (cap_set_proc). Hard-fails (refuses to serve) if it cannot. - Installs a seccomp filter —
seccomp_init(SCMP_ACT_KILL_PROCESS)(a default-KILL allowlist), allowing only:read, write, close, ioctl, sendto, sendmsg, recvfrom, mmap, munmap, brk, fstat, newfstatat, fcntl, exit_group, exit, rt_sigreturn, clock_gettime.ioctlis further restricted by request type to DRM ioctls only — a masked equality on the request's type byte ('d'), so a non-DRMioctlsuch asTIOCSTIconsole injection hits the default KILL.open/openatare deliberately NOT on the allowlist — the device fd was already opened in step 4 and the grab loop only ever reuses it, so a compromised helper cannot open arbitrary files even while it holdsCAP_SYS_ADMIN. Hard-fails if it cannot install. - Serves grab/cursor requests on the persistent fd in a loop.
The helper binary is also built with exploit-mitigation flags
(-fstack-protector-strong, _FORTIFY_SOURCE=2 on optimized builds, PIE, and
full RELRO via -z relro -z now). libcap and libseccomp are compiled in for the
packaged build; if either confinement step fails to take effect the helper
refuses to serve rather than run unconfined. Failures along this path emit
accurate, per-condition diagnostics to stderr (which hardening step failed and
why).
The wire protocol is tiny: three commands (CMD_GRAB, CMD_GET_CURSOR,
CMD_QUIT) in a fixed 16-byte command frame (helper_cmd_grab_t in
src/wire.h). The frame carries a magic + protocol version + type +
length header, and the helper rejects any frame whose magic, version or length
do not match its own build, or whose type is not one of the three commands, and
stops serving — so a stale helper or library binary fails closed rather than
misparsing a frame while holding CAP_SYS_ADMIN. Every field of the frame
originates from the peer and is validated before use (the magic/version/length/
type gate above). The only attacker-controllable payload field is a
u32 crtc_id, used purely as an equality filter against a plane's CRTC id — a
bad value yields "no active framebuffer", not memory unsafety. The request path
carries no path or fd field. Fd passing (SCM_RIGHTS) is one-directional,
helper → main only (the DMA-BUF export); the unprivileged side cannot hand
the privileged side a path or fd to open.
Before any size computation, the helper validates the framebuffer geometry
(pitch/height) reported by the kernel: it rejects a zero pitch/height and
caps pitch * height at one 8K BGRA frame (~126 MB). This guards against an
integer-overflow of size_t and keeps the u32 data_size put on the wire from
disagreeing with the payload actually sent, so a malformed scanout cannot drive
an oversized allocation or a short write.
The helper above is one trust boundary. The split-capture API is a second, independent one, for integrators that keep the GPU/EGL stack out of the privileged process entirely:
- The exporter side (
drmtap_open/drmtap_grab_desc) runs privileged and does only the minimal DRM work: pick the active CRTC, get its scanout framebuffer, export it as a DMA-BUF fd, and fill adrmtap_dmabuf_desc(geometry, per-plane offsets/pitches, modifier, HDR state). No EGL, no GPU driver on this side. - The converter side (
drmtap_open_render/drmtap_convert_dmabuf) runs unprivileged (a render node only, no KMS master, no helper), imports the DMA-BUF, EGL-detiles it and converts to linear RGBA. - The DMA-BUF fd is passed to the converter over the integrator's IPC
(
SCM_RIGHTS), together with the descriptor.
Because the descriptor and fd arrive over a process boundary,
drmtap_convert_dmabuf() treats them as untrusted IPC input, exactly like
the helper treats a command frame. The gates below run up front, before
either conversion path touches the fd — the EGL import runs first and would
otherwise hand the descriptor's width/height/stride/offset straight to
eglCreateImage with no size check, so they protect it as much as the CPU
mmap fallback:
- rejects
num_planes > 4, a zero/short stride (pitches[0] < width * bpp), and a frame whosestride * heightexceeds the same one-8K-frame cap (validate_fb_size, ~126 MB) used on the privileged side; - requires the fd to be a genuine DMA-BUF — a non-dma-buf fd is rejected (an
immutable dma-buf cannot be truncated mid-read, so it cannot be shrunk between
the check and the import or
mmap); - bounds the read against the buffer size —
offsets[0] + pitches[0] * heightmust fit the real fd size (vialseek, reliable across kernels unlikefstat, which reports 0 for a dma-buf before Linux 5.3) — and fails closed (returns-EINVAL) when the size cannot be determined.
A descriptor that claims a frame larger than its fd actually backs therefore
returns -EINVAL instead of faulting the converter with SIGBUS (a
denial-of-service on a malformed IPC message, fixed in 0.4.12 and covered by the
fuzz/fuzz_convert.c libFuzzer target). The converter never trusts the
descriptor's geometry against the real buffer.
Do not read the above as more locked down than it is:
- CAP_SYS_ADMIN is held for the helper's entire lifetime. Every frame
re-issues
drmModeGetFB2, so the capability cannot be dropped after init. The helper reduces to exactly one capability and keeps it for the whole session. It is not dropped after initialization. - The helper is dynamically linked (it links
libdrm,libcap,libseccomp, and EGL/GLES). It is not statically linked. On a setcap binary the dynamic loader ignoresLD_PRELOAD/LD_LIBRARY_PATHfor the secure-exec case, but this is the loader'sAT_SECUREbehavior, not something the helper itself enforces. - The seccomp filter narrows only
ioctl, and only by request type.ioctlis restricted to the DRM type — a masked equality on the request's type byte ('d'), so a non-DRMioctlsuch asTIOCSTIconsole injection is killed — but the fd argument itself is not filtered, and the other allowed syscalls have no per-argument narrowing at all. It otherwise blocks whole syscall classes (includingopen/openatentirely). - A world-executable setcap helper on a multi-user host is a real
consideration. Anyone who can execute the binary gets a process holding
CAP_SYS_ADMIN(confined by seccomp, and theSO_PEERCREDcheck stops them driving your session's socket, but it is still a privileged process they can spawn). The recommended packaging is therefore to install it ownedroot:<group>mode0750, so only members of that group can execute it. libdrmtap ships the hardened binary; this world-exec deployment decision is the integrator's (e.g. RustDesk's) to make, not something libdrmtap enforces. - The helper does not sanitize its environment (
clearenv), setPR_SET_DUMPABLE, detect/refuse containers, log to syslog, set rlimits, or emit desktop notifications. (Earlier versions of this document claimed some of these; they were not in the code and have been removed.)
| Threat | Protection |
|---|---|
| Unprivileged app reads the screen | Kernel blocks drmModeGetFB2 without DRM master / CAP_SYS_ADMIN |
| Helper exec'd standalone or by a different user | Refuses unless fd 3 is a connected socket whose SO_PEERCRED peer uid matches its own |
| Unprivileged side points the helper at an arbitrary device | Device path must canonicalize under /dev/dri/, and the node is opened O_RDONLY |
| Unprivileged side makes the helper open an arbitrary path/fd | The request protocol carries no path/fd; fd passing is helper -> main only; open/openat are not on the seccomp allowlist after init |
Compromised helper issues a non-DRM ioctl (e.g. TIOCSTI console injection) |
seccomp allows ioctl only for the DRM request type (masked equality on the type byte); any other ioctl hits SCMP_ACT_KILL_PROCESS |
| Malformed scanout geometry drives an oversized allocation / overflow | Geometry guard rejects zero/absurd pitch/height, capping at one 8K BGRA frame |
| Long-running helper exhausts kernel GEM handles / pins buffer objects | The helper closes the GEM handle drmModeGetFB2 mints on every grab and cursor path (helper_gem_close), so handle/BO use cannot grow unboundedly |
| Malformed convert descriptor faults the unprivileged converter (SIGBUS DoS) | drmtap_convert_dmabuf validates geometry, requires a genuine DMA-BUF, and bounds the read against the buffer size (lseek), failing closed on unknown size |
| Stale helper/library binary misparses the command stream | The command frame carries a magic + protocol version; the helper rejects a mismatched frame and stops serving |
| Helper gains new privileges via exec | PR_SET_NO_NEW_PRIVS |
| Helper runs unconfined if hardening fails | Hard-fails: refuses to serve if cap-drop or seccomp cannot be established |
| Third party intercepts frames or connects to the helper | Anonymous inherited socketpair, not discoverable, no listening socket |
| Replacing the helper binary | setcap xattrs are cleared by the kernel on file modification; re-applying needs root |
| Threat | Why |
|---|---|
| Root reads the screen | Root can do anything; by design on Linux |
| Admin installs a malicious helper | If the admin/root is compromised, the system already is |
| Physical access | Physical access is full access |
| Insecure transport by the app | Transport security is the application's responsibility |
| A bug in the helper while it holds CAP_SYS_ADMIN | The helper is small and seccomp-confined, but a memory-safety bug while holding CAP_SYS_ADMIN is the residual risk; the cap is not dropped after init |
This library deliberately reads below the Wayland compositor's per-application isolation. That is intentional for use cases where administrator control supersedes per-app isolation (servers with no logged-in user, headless management, kiosks, login-screen capture, CI). For interactive desktops where user consent matters, the portal/PipeWire path is the right tool — this is not a replacement for it. Integrators on multi-user desktops should pair capture with a user-visible indicator.
# Restrict who can execute the helper FIRST, so it is never world-executable
# while it already carries the capability (path varies by integration):
sudo chown root:rustdesk-capture /usr/lib/rustdesk/drmtap-helper
sudo chmod 0750 /usr/lib/rustdesk/drmtap-helper
# ...then an administrator consciously grants the capability to the binary:
sudo setcap cap_sys_admin+ep /usr/lib/rustdesk/drmtap-helper
# Audit: find capability-bearing binaries
sudo getcap -r / 2>/dev/null | grep drmtap
getcap /usr/lib/rustdesk/drmtap-helper
ps aux | grep drmtap-helper
# Remove the capability, or the binary
sudo setcap -r /usr/lib/rustdesk/drmtap-helper
sudo rm /usr/lib/rustdesk/drmtap-helperAn unprivileged user cannot grant the capability; only root/an admin can.
Please do not open a public issue for a vulnerability. Use GitHub's private vulnerability reporting (Security -> Report a vulnerability) with a description, reproduction, and impact.