xpra

Seccomp sandboxing

seccomp is a Linux kernel feature that restricts the set of system calls a thread is allowed to make. Xpra can use it to lock down the threads that are the first to process data coming from the network, so that a bug in a picture / video decoder or in the packet parser cannot easily be turned into something worse - executing a command, opening or deleting files, or opening new network connections.

This is a defence-in-depth measure: it does not replace authentication or encryption, it limits the damage a successful exploit of one of those threads could do.

Linux only (it has no effect on other platforms) and disabled by default.

What it protects

Xpra installs a separate, thread-local filter on each of the threads that handle untrusted input:

Filter Thread What it processes
decode picture decoding compressed image and video frames, window icons, cursors
parse network read raw socket bytes: decrypt → decompress → decode packets
rfb VNC client read RFB / VNC framebuffer updates (when connecting to a VNC server)

Each filter only affects the thread it is installed on - the rest of xpra keeps running normally. The same filters are available whether xpra is running as a client, a server or a proxy.

Enabling it

Use the --seccomp command line option:

xpra attach ssl://HOST:PORT/ --seccomp=default
--seccomp= Effect
no (default) no filtering
default enable all four filters with a non-fatal action: a blocked syscall fails with a permission error instead of killing anything
strict enable all four filters with a fatal action: a blocked syscall kills the whole process
a list enable only the listed threads, ie decode, parse, rfb, menu

The list form lets you pick individual filters and, optionally, an action per filter (see actions below):

# only sandbox the image decoding thread:
xpra attach ... --seccomp=decode

# sandbox decoding and network parsing:
xpra attach ... --seccomp=decode,parse

# decoding is fatal on violation, network parsing only fails the call:
xpra attach ... --seccomp=decode:kill,parse:errno

The recommended approach is to start with --seccomp=default (nothing is killed, violations simply fail) to confirm your normal workflow is unaffected, and only then switch to --seccomp=strict for full enforcement.

Actions

The action decides what happens when a sandboxed thread attempts a syscall that is not on its allow-list:

Action Behaviour
errno the call fails with a permission error - non-fatal, the safest to try first
kill the offending thread is killed
kill_process the whole xpra process is killed - the strongest enforcement
log the call is allowed but recorded in the kernel audit log - useful for tuning (see diagnosing)
allow the call is allowed - effectively disables the filter, for testing

--seccomp=default uses errno, --seccomp=strict uses kill_process.

Things to be aware of

Diagnosing and tuning

Different transports (TLS, QUIC, WebSocket) and platforms may need a slightly different set of syscalls. If a filter is too strict for your setup, two modes help you find the missing syscall:

To turn on xpra’s own logging of the filter installation, add -d seccomp.

Environment variables

The --seccomp option is a convenience wrapper: it sets a handful of environment variables early enough for the threads to pick them up. You can also set them directly for finer control - an explicit environment variable always takes precedence over the option:

Variable Purpose
XPRA_SECCOMP global on/off, enables all filters
XPRA_SECCOMP_DECODE enable/disable the decoding filter
XPRA_SECCOMP_PARSE enable/disable the network parse filter
XPRA_SECCOMP_RFB enable/disable the VNC client filter
XPRA_SECCOMP_MENU enable/disable the menu loading filter
XPRA_SECCOMP_DECODE_ACTION action for the decoding filter
XPRA_SECCOMP_PARSE_ACTION action for the parse filter
XPRA_SECCOMP_RFB_ACTION action for the VNC client filter
XPRA_SECCOMP_MENU_ACTION action for the menu loading filter
XPRA_MALLOC_PREWARM work around a glibc openat at thread exit (on by default - see the glibc malloc caveat in the technical details below)

Each *_ACTION accepts errno, kill, kill_thread, kill_process, log or allow (default: kill_process).


Technical details

The rest of this document describes how the filters are implemented and how the threads were audited. It is only relevant if you want to understand or extend the sandboxing - end users can stop reading here.

How it works

Each filter is a per-thread seccomp BPF allow-list, built with libseccomp in the native helper xpra/seccomp/_native. The primitive install_filter(syscalls, action, masked_rules) sets PR_SET_NO_NEW_PRIVS, initialises the filter with the chosen default action, adds one SCMP_ACT_ALLOW rule per allowed syscall (optionally constrained by masked argument comparisons), and loads it. The Python side lives in xpra/seccomp/:

The --seccomp option is turned into the XPRA_SECCOMP* environment variables by parse_seccomp_option() / configure_seccomp() in xpra/scripts/main.py. This runs in run_mode right after configure_env, before the client / server object and its threads are created, and only sets variables that are not already defined. The list form deliberately does not set the global XPRA_SECCOMP flag: the decode filter reads it as its primary gate, so setting it would override the per-thread flags.

Thread inheritance. A seccomp filter is inherited by every thread created after it is loaded, so a filter also covers any worker thread its host thread spawns. To keep a handler out of the sandbox it must run on a thread that is not a descendant of the filtered thread - in practice, register its packet handler with main_thread=True so it is dispatched on the GLib main loop (which predates the parse thread’s filter). This is how the challenge handler stays unsandboxed: auth backends may fork/exec a helper (kerberos/gss/exec/u2f/pinentry) or read files, so _process_challenge runs on the main thread.

The server also starts its shared background worker during ServerCore.init(), before subsystem setup. Menu loading posts completion callbacks to this worker; creating it lazily from the filtered menu thread would make unrelated work inherit the menu policy.

Syscall lists

xpra/seccomp/draw.py defines a permissive BASE_SYSCALLS baseline. From it:

gi_import caveat: pygobject’s require_version() enumerates the typelib directories on disk on every call, even for a namespace that is already loaded. Any lazy gi_import("GLib") made from a handler (xpra/util/background_worker.py, xpra/notification/base.py, xpra/codecs/loader.py, …) would therefore hit openat on the parse thread and fail with Namespace GLib not available. gi_import (xpra/os_util.py) now skips require_version when that exact version has already been required, so the module comes from the import cache without touching the filesystem.

kill_process caveat: a blocked lazy import or dlopen is a SIGSYS process kill, not a catchable exception - log.trap_error cannot recover from it. This is why the pre-warm above matters, and why the debug file-dumps (get_save_to_file() in xpra/codecs/debug.py, get_save_window_icons() in xpra/client/subsystem/window/window_icon.py, get_save_cursors() in xpra/client/subsystem/cursor.py) are disabled under seccomp.

glibc malloc caveat: not every openat comes from xpra’s own code. glibc reads /proc/sys/vm/overcommit_memory - and caches the answer for the whole process - the first time it trims a thread’s malloc arena, and it does that when a thread exits (__malloc_arena_thread_freeres_int_free_maybe_trimheap_trimshrink_heapcheck_may_shrink_heap). If the first thread to reach that point is a filtered one, the read is blocked and the process is killed - not while decoding, but at shutdown, when the decode thread exits. It went unnoticed because the codec self-test allocates enough on the decode thread, before the filter, to trigger the read by luck. prewarm_malloc_arena() (xpra/client/subsystem/decode.py) now provokes it deterministically from a throwaway thread while the decode thread is still unfiltered. It only runs when a filter is actually going to be installed, and XPRA_MALLOC_PREWARM=0 turns it off (at the risk of that SIGSYS at shutdown) should it ever misbehave on a different libc.

The decode thread

The sandboxed decoding thread is not draw-specific: it is a shared worker owned by the decode subsystem (xpra/client/subsystem/decode.py), and any client subsystem can post work to it with add_decode_work(method, *args) (StubClientSubsystem). Its queue holds (callable, args) pairs, so the producer stays a trivial packet handler and the decoding itself happens on the filtered thread. Its consumers today:

Packet Enqueues Decodes
window-draw / eos WindowDraw._process_window_draw _do_drawwindow.draw_region()
window-icon WindowIcon._process_window_icon _decode_window_icon → Pillow
cursor / cursor-data CursorClient._process_cursor_data _decode_cursor_data → Pillow
notification-show NotificationClient._process_notification_show _decode_notification_icons → Pillow

Two rules bind a new consumer:

The result is bounced back to the UI thread with idle_add, and the target window is looked up then, not before enqueuing - a window destroyed mid-decode simply drops its icon.

Notification icons

Notifications are a special case, because their icons do not stay inside xpra: the backends hand them to the desktop notification daemon, as a file (NotifierBase.temp_icon_file) or as a GdkPixbuf (xpra/gtk/notifier.py), and the win32 and dbus backends decode them again themselves. Locking down the decode thread would not have helped: the server’s bytes were being parsed in four different places, three of them outside the sandbox and one outside the process.

So the icons are re-encoded rather than merely decoded. sanitize_icon_data() (xpra/notification/common.py) runs on the decode thread and turns each of the three server-controlled blobs - the icon field and the app-icon-data / image-data hints - into a PNG of xpra’s own making: pillow decodes the bytes (sniffing the real image type, so the encoding the server claims does not matter), the image is clamped to XPRA_NOTIFICATION_ICON_MAX_SIZE (256 by default) and re-encoded. What reaches the backends, and the notification daemon, is then an image xpra generated: the dimensions are the real ones rather than what the server asserted, and anything smuggled in the container (trailing bytes, ancillary chunks) is gone. Undecodable icons are dropped and the notification is still shown.

One consequence: notification-show now waits for its icons, so notification-close is posted through the same FIFO queue even though it has nothing to decode - otherwise a close could overtake the show it refers to and leave the notification stuck on screen.

Thread coverage

Four thread roles carry filters today: decode, parse, client-side rfb and menu loading. The rest of the thread inventory, and why each is or is not sandboxed:

Thread Untrusted input? Decision
decode Yes: compressed images/video, window icons, cursors Sandboxed (seccomp/draw.py)
network parse Yes: raw socket bytes Sandboxed, network sockets only (seccomp/parse.py)
RFB read (client) Yes: framebuffer parsing Sandboxed at steady state (seccomp/rfb.py)
menu loading Local XDG metadata and icons Sandboxed, read-only filesystem access (seccomp/menu.py)
QUIC/WebTransport asyncio Yes: UDP recv + TLS/HTTP3 Mostly covered indirectly - see below
RFB read (server) Yes: client input Out of scope - handlers drive the display server
HTTP handler Websocket upgrade only Left - see below
write / format No: outgoing packets only Not worth sandboxing
verify_auth Reads the hello packet Auth backends fork/exec helpers and read files by design - sandboxing would break authentication
handle-new-connection Sniffs a few bytes Setup-only, may go on to spawn (ssl/ssh upgrade) - low value

QUIC

For QUIC, the socket recvfrom + TLS decrypt + HTTP/3 demux happen in the aioquic event-loop thread (asyncio-thread), which pushes already-decoded stream bytes into read_queue (xpra/net/quic/connection.py). The parse thread then reads that queue and runs the normal decode/dispatch path - so the packet decode surface is covered by the parse filter. Only the UDP read and the QUIC/TLS framing run unfiltered. That thread also runs the whole aioquic/asyncio machinery (timers, crypto, cert access), so sandboxing it is not practical; the residual gap is narrow and documented rather than closed.

RFB read thread (client)

RFBClientProtocol reads the socket and does all the tight/zlib/gradient/ depalette/cursor byte-crunching inline, dispatching draw/challenge/ clipboard-token: jpeg goes to the already-sandboxed decode thread, challenge is main_thread=True (and dispatched before client-init anyway), and the heavy parsing is pure-Python + zlib.decompress.

The filter is not installed from thread-start: the RFB handshake and the VeNCrypt inline TLS upgrade (_upgrade_to_tls) run on this same read thread. Rather than audit every syscall OpenSSL’s handshake and the auth path might make, the filter is installed once we reach steady-state framebuffer parsing, in _parse_client_init just before self._packet_parser = self._parse_rfb_packet. By then TLS is up and auth is done; the write thread was already created on the first handshake send(), so it predates the filter and is unaffected.

Websocket upgrade

The HTTP/websocket upgrade is handled by Python’s stdlib http.server.BaseHTTPRequestHandler - text header parsing, no bespoke binary parser. Once upgraded, WS frames are unmasked and fed into the filtered parse thread, so the websocket data plane is already covered. The handler also serves the HTML5 client from disk (needs openat), so it could not be sandboxed anyway.

Packet handler audit

The parse thread runs packet handlers inline, so the filter also covers them. Handlers that do file I/O or spawn subprocesses are the ones that trip a fatal filter:

Handler(s) What it does Seccomp impact
open-url may spawn an opener via subprocess.Popen Moved to the UI/main thread (main_thread=True)
file-send, file-send-chunk open/write/unlink download files Moved to the file worker thread (see below)
file-ack-chunk compresses and sends the next outgoing chunk Moved to the file worker thread
file-data-response ACCEPT: hash/compress and send; OPEN: open a file/URL locally via subprocess.Popen Hash/compress → worker thread; OPEN’s subprocess.Popen → main thread
file-request (server) reads a requested file from disk, then sends it Moved to the file worker thread
print-file (server) forwards a print job to clients (hash/compress) Per-client send moved to the file worker thread
print-devices (server) configures virtual printers, spawns lpadmin Moved to the main thread (main_thread=True)
command-start (server) starts a new command via subprocess.Popen Moved to the main thread (main_thread=True)
control-request (server) runs a control command (may fork/exec, do file I/O) Moved to the main thread (main_thread=True)
shell-exec (server) runs arbitrary Python (exec) - gated by --shell Off by default; grants RCE when enabled (see below)
challenge starts the auth worker (may fork/exec, read files) Kept off-thread (main_thread=True)
audio-data / keepalive sequencing + delegation to the audio subprocess Safe on the parse thread
encoding-set, server-event, logging-event update client state / log Safe on the parse thread
ping immediate ping_echo response Kept on the parse thread deliberately

Handlers already moved off the parse thread (onto the UI/main thread) include open-url, notification-*, file-data-request, and the command-client display-* / shell-reply handlers. With these moves, no handler that spawns a subprocess or does file I/O runs inline on the parse thread - except shell-exec, which is off by default and, when enabled with --shell=yes, hands the client arbitrary code execution anyway (a far larger hole than any parse-thread syscall gap): do not enable it on a sandboxed deployment.

File worker thread

File transfers run their disk I/O (writing received files, reading files to send) and CPU-heavy work (compression, hashing) on a dedicated daemon thread (file-io, xpra.net.file_transfer.FileTransferHandler) rather than inline on the parse thread. The handlers hand their work to schedule_file_io(), which queues it for that thread:

The file-data-response OPEN fallback (open a file/URL at this end) instead uses GLib.idle_add to run its subprocess.Popen on the main thread, mirroring open-url - the worker thread stays a minimal disk/compression consumer and never spawns subprocesses.

Together this keeps openat / write / unlink / execve off the parse thread, which is what lets the parse allow-list drop file access (PARSE_SYSCALLS, above).

The thread is started on demand (on the first transfer that needs it) and, crucially, from the main thread via GLib.idle_add - never from the parse thread - because a thread inherits the seccomp filter of the thread that creates it, so a worker spawned by a filtered parse thread would itself be unable to touch the filesystem. On disconnect, stop_file_io_thread() queues an exit marker and joins the thread, so any in-flight write completes before teardown (XPRA_FILE_IO_JOIN_TIMEOUT, default 5s). Set XPRA_FILE_IO_THREAD=0 to run the work inline on the parse thread instead (the previous behaviour).

Future work