Xpra Documentation

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). It is enabled by default on all Linux architectures, with the non-fatal errno action (--seccomp=default). Filtering requires Xpra’s native seccomp module, built with libseccomp.

Compared with an external tool that applies a single seccomp policy at process launch, Xpra can take command line options and configuration into account, and drop syscall permissions after each thread’s required initialization. For example, it can load and self-test decoders before blocking file access, or complete the VNC handshake before filtering the RFB thread. Each thread then gets a policy suited to its job, while other threads can still perform tasks such as file transfers and starting commands. A filter applied at launch must also allow the syscalls needed for startup, file transfers and starting commands.

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, notification icons
parse network read raw socket bytes: decrypt → decompress → decode packets
rfb VNC client read RFB / VNC framebuffer updates (when connecting to a VNC server)
menu server menu loading XDG menu data, desktop files and their icons (read-only file access)

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=strict
--seccomp= Effect
no no filtering (default on non-Linux platforms)
default (default on all Linux architectures) 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 confirm that your normal workflow is unaffected with --seccomp=default (nothing is killed, violations simply fail), and only then switch to --seccomp=strict for full enforcement.

When a filter blocks a system call with the non-fatal action, the feature that needed it fails, and xpra logs a warning naming the thread the first time it happens:

Warning: the seccomp filter of the 'parse' thread has probably blocked a system call
 some features may not work, use '--seccomp=no' to disable the filters

Please report it, so that it can be fixed.

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)
XPRA_MALLOC_ARENA_MAX work around a glibc openat when creating a new malloc arena (on by default - see the glibc malloc caveat 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() in xpra/config/validation.py and 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. For the same reason, a client that quits from a packet handler (ie: a command client like xpra version quitting from its hello handler) hops to the main thread before running its cleanup, which closes the connection and reaps child processes.

Signals. A signal sent to the process (SIGCHLD when a subprocess exits, SIGINT, …) is delivered to any thread that does not block it, and the C-level Python signal handler then runs on that thread: it writes to the signal wakeup fd, which a filter may not allow. So every filter is installed through xpra.seccomp.install_filter(), which first blocks all the asynchronous signals on that thread (the faults raised by the thread itself, SIGSEGV and friends, stay unblocked). Python only runs its signal handlers on the main thread anyway. Before this, --seccomp=strict killed the server as soon as a subprocess exited.

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 some files lazily - and caches the answer for the whole process - from whichever thread gets there first. If that is a filtered thread, the read is blocked and the process is killed. xpra/seccomp/glibc.py gets both reads out of the way, once per process, from install_filter() - before the first filter is installed:

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_draw → window.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).

Testing

tests/unittests/unit/seccomp/ checks the policies themselves. To find out what a real session needs, the integration tests can be run with every xpra server, client and command they start sandboxed:

XPRA_TEST_SECCOMP=strict python3 setup.py unittests

XPRA_TEST_SECCOMP is added as --seccomp=VALUE to the xpra command line (tests/unittests/unit/server_test_util.py), so the test runner itself is not filtered. With strict, a blocked syscall kills the process with SIGSYS, and the test failure says so. The seccomp leg of the GitHub test.yml workflow does exactly this.

Finding a blocked syscall

A process killed by a filter exits with code 159 (-31 from Python’s subprocess: SIGSYS, “Bad system call”), and nothing says which syscall was blocked: with kill_process there is no signal for strace to report, the kernel audit log needs root, and core dumps are usually not kept. These development notes describe what does work.

Re-run with the errno action. The blocked call then fails instead of killing the process, and strace -Z (only show failing syscalls) shows it as EPERM:

strace -f -qq -Z -o trace.log xpra version :100 --seccomp=default
grep EPERM trace.log

Most EPERMs in a server trace have nothing to do with seccomp and can be ignored: SO_RCVBUFFORCE / SO_SNDBUFFORCE, FUTEX_UNLOCK_PI, setpgid, mount, setresuid, and openat with O_NOATIME (from helper processes like xdg-desktop-portal). The remaining ones are usually all made by the same thread.

Find the filter. Enable one filter at a time to see which one kills the process: --seccomp=parse, --seccomp=decode, --seccomp=menu, --seccomp=rfb (or XPRA_TEST_SECCOMP=parse etc. for the tests).

Get the stack. strace -k prints a stack trace for each traced call, and -P limits tracing to the calls that use a given path:

strace -f -qq -k -e trace=openat -e status=failed -o trace.log xpra start ... --seccomp=default
strace -f -qq -k -P /proc/net/tcp -o trace.log xpra start ...

With -Z, strace may print a pointer instead of the path. If the pointer is the same on every run, it points to a constant string in a library: the low 12 bits of the address survive address space randomization, so they can be matched against the offsets of the strings in that library. This is how the glibc get_nprocs() read was identified (see the glibc malloc caveat above):

import re
data = open("/usr/lib64/libc.so.6", "rb").read()
for m in re.finditer(rb"/(proc|sys)/[ -~]{3,80}\x00", data):
    if m.start() & 0xfff in (0x140, 0xadf):
        print(hex(m.start()), m.group())

Pointers that change from run to run usually point to the Python heap: the call comes from Python code, ie: an open() - look for files read lazily by the code that runs on that thread.

Inside the tests. XPRA_COMMAND replaces the xpra command the tests run, so a wrapper script can run every server, client and command under strace. Name the script python3, otherwise the tests prepend python3 to it, and remove XPRA_COMMAND from its environment, otherwise the processes started by the server are traced twice:

#!/bin/sh
exec env -u XPRA_COMMAND strace -f -qq -Z -o /tmp/trace.$$ /usr/bin/python3 /usr/bin/xpra "$@"
XPRA_COMMAND=/path/to/python3 XPRA_TEST_SECCOMP=default \
    python3 tests/unittests/unit/server/server_sockets_test.py ServerSocketsTest.test_tcp_socket

Expect races. Some blocked calls come from code that runs on whichever thread gets there first (glibc’s lazy reads, first-time imports), so a test may only fail some of the time: run it in a loop until the call shows up in the trace.

Future work