Skip to content
arielb57Public

About

Record X11 traffic and report which clients read your clipboard, when, and whether you pasted

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

xselaudit

Record X11 traffic and report which clients read your clipboard, when, and whether you pasted.

The problem

On X11, any client can ask the XFIXES extension to tell it whenever the clipboard changes owner, and then quietly request the contents with ConvertSelection. The server does not care whether a paste happened, so a password you copy is readable by every connected client that wants it. xtrace and x11trace will show you the requests, but only as a flat text dump: answering "did this app read CLIPBOARD without a paste?" means matching replies to requests by 16-bit sequence number, working out which dynamic opcode XFIXES got, following INCR transfers, and lining it all up against keyboard events by hand.

xselaudit does that matching for you. It records a session through a proxy display, rebuilds each client's protocol state from the raw bytes, and puts every selection read in one of four classes, with the evidence and the byte count.

How it works

 xselaudit record                         xselaudit analyze
 ┌──────────────┐   capture file   ┌─────────┐   ┌──────────┐   ┌─────────────┐   ┌──────────┐
 │ Unix-socket  │ ───────────────► │ wire.ts │──►│ state.ts │──►│ classify.ts │──►│ report   │
 │ proxy :N→:0  │  (conn, dir,     │ frames  │   │ seq/atom │   │ timeline +  │   │ text or  │
 └──────────────┘   time, bytes)   └─────────┘   │ /ext     │   │ classifier  │   │ JSON     │
                                                 └──────────┘   └─────────────┘   └──────────┘

Recording. record listens on /tmp/.X11-unix/XN (so clients use DISPLAY=:N) and forwards each connection to the real display. Every chunk is appended to the capture file as it arrives: record kind (open, client-to-server, server-to-client, close), connection number, a millisecond timestamp and the raw bytes. The proxy does not parse anything after the connection setup.

Framing (src/wire.ts). Two incremental decoders turn the byte streams back into frames. They buffer chunks in a queue and copy a frame only once all of it has arrived, so input split at any byte decodes identically, and feeding a 300 KB request one byte at a time stays linear.

  • Client stream: the setup prefix is parsed first. Its first byte (B or l) fixes the byte order for both directions, and the auth name and data fields are each padded to 4 bytes. Requests are then split on their 16-bit length field. A length of 0 means BIG-REQUESTS: a 32-bit length follows, and every later field moves 4 bytes to the right.
  • Server stream: the setup reply (success, failed or authenticate), then 32-byte packets. Replies (type 1) and GenericEvents (type 35, with or without the SendEvent bit) carry an extra length and can be longer. Errors and all other events are exactly 32 bytes. KeymapNotify has no sequence number.

Anything malformed produces a WireError carrying the stream offset where the bad frame starts. That connection stops decoding and the others carry on.

State (src/state.ts). Each connection keeps its own sequence counter. A reply only gives the low 16 bits, so the tracker considers every sent request whose number matches those bits and comes at or after the last one the server has visibly handled. It picks the first that fits: for a reply, a request that expects one and has not had it; for an error, a request with the error's major and minor opcode. This keeps matching right past wraparound, including when more than 65,536 requests go out between two server packets. What the replies reveal goes into tables shared by all connections (atom IDs and extension opcodes are server-wide):

  • InternAtom and GetAtomName replies map atom IDs to names (CLIPBOARD, TARGETS, INCR, ...).
  • QueryExtension replies map XFIXES and XInputExtension to their major opcode and first event code.

An opcode of 128 or above that no QueryExtension reply announced stays opaque. xselaudit never assumes XFIXES is at 138.

Timeline and classification (src/classify.ts). For each client it records SetSelectionOwner, XFixesSelectSelectionInput, delivered XFixesSelectionNotify events, ConvertSelection, SelectionRequest, SelectionNotify and the GetProperty calls of each transfer. Each ConvertSelection is classified the moment it is sent, using only what that client had received up to then:

class rule
self the client currently owns that selection. The owner window is attributed to a client through the resource-ID base and mask from each connection's setup reply.
user-initiated a KeyPress or ButtonPress (core, or XI2 once XInputExtension is known) reached the client within --window-ms (default 1000) before the request. Events carrying the SendEvent bit are ignored, since any client can forge them.
proactive an XFixesSelectionNotify for that same selection reached the client, and no input arrived after it.
unexplained none of the above.

The transfer is then followed: SelectionNotify gives the property (or None for a refusal), and the value bytes of each GetProperty reply on that window and property are summed. If the first reply has type INCR, its 4-byte value is kept as the size estimate and not counted. The following chunks are counted until the zero-length property that ends the transfer.

Worked example: a proactive reader caught in a synthetic session

xselaudit demo builds a session with the project's own encoder (src/encode.ts, written against the X11, XFIXES and XI2 specs): KeePassXC copies a password, a program called clipsnoop is watching the clipboard, and a terminal later pastes. Here is clipsnoop's side, as printed by xselaudit frames demo.xsc --conn 2 (abridged):

+0.180s  conn 2  C->S  #7 QueryExtension "XFIXES"
+0.180s  conn 2  S->C  reply to #7 QueryExtension: present major=138 first-event=87 first-error=140
+0.180s  conn 2  C->S  #8 XFixesQueryVersion 5.0
+0.180s  conn 2  C->S  #9 XFixesSelectSelectionInput CLIPBOARD on 0x000003a0 mask=1
+3.013s  conn 2  S->C  XFixesSelectionNotify CLIPBOARD owner=0x00400001 subtype=0
+3.016s  conn 2  C->S  #10 ConvertSelection CLIPBOARD -> TARGETS property=XSEL_DATA requestor=0x00600001
+3.019s  conn 2  S->C  SelectionNotify (SendEvent) CLIPBOARD -> TARGETS property=XSEL_DATA
+3.020s  conn 2  C->S  #11 GetProperty XSEL_DATA on 0x00600001 delete offset=0 length=536870911
+3.021s  conn 2  S->C  reply to #11 GetProperty: type=ATOM format=32 8 bytes, 0 after
+3.022s  conn 2  C->S  #12 ConvertSelection CLIPBOARD -> UTF8_STRING property=XSEL_DATA requestor=0x00600001
+3.025s  conn 2  S->C  SelectionNotify (SendEvent) CLIPBOARD -> UTF8_STRING property=XSEL_DATA
+3.026s  conn 2  C->S  #13 GetProperty XSEL_DATA on 0x00600001 delete offset=0 length=536870911
+3.027s  conn 2  S->C  reply to #13 GetProperty: type=UTF8_STRING format=8 29 bytes, 0 after

Reading it the way the analyzer does:

  1. Reply #7 says XFIXES is major opcode 138 with first event 87. Only after this does the request at opcode 138, minor 2, decode as XFixesSelectSelectionInput, and event code 87 as XFixesSelectionNotify.
  2. Owner window 0x00400001 falls in connection 1's resource range (base 0x00400000, mask 0x001fffff). That is KeePassXC, so clipsnoop is not the owner.
  3. At +3.013 s the server tells clipsnoop the owner changed. Three milliseconds later clipsnoop converts CLIPBOARD, and no KeyPress or ButtonPress was delivered to connection 2 in between. Both conversions are proactive.
  4. The GetProperty replies matched to #11 and #13 carry 8 and 29 bytes. The 29 bytes are the password.

Seven seconds later clipsnoop reads a 614 KB image the same way over INCR. The terminal's paste, 28 ms after a KeyPress, comes out user-initiated.

Install and usage

Requires Node.js 22 or later. There are no runtime dependencies; npm install fetches the TypeScript and ESLint toolchain and builds dist/.

git clone https://github.com/arielb57/xselaudit.git
cd xselaudit
npm install
npm test

Try the analyzer without an X server:

$ node bin/xselaudit.js demo demo.xsc
wrote synthetic capture demo.xsc (1,236,409 bytes)
next: xselaudit analyze demo.xsc

$ node bin/xselaudit.js analyze demo.xsc
xselaudit report
  capture   demo.xsc (synthetic), 5 connections, 17.240 s, started 2026-01-15T09:30:00.000Z
  window    input up to 1000 ms before a ConvertSelection counts as user-initiated

Selection reads (7)
  at        client        selection  target       class           outcome            bytes  evidence
  +3.016s   #2 clipsnoop  CLIPBOARD  TARGETS      proactive       complete               8  XFixesSelectionNotify 3 ms before, no input since
  +3.022s   #2 clipsnoop  CLIPBOARD  UTF8_STRING  proactive       complete              29  XFixesSelectionNotify 9 ms before, no input since
  +7.236s   #3 xterm      CLIPBOARD  TARGETS      user-initiated  complete               8  KeyPress 28 ms before
  +7.242s   #3 xterm      CLIPBOARD  UTF8_STRING  user-initiated  complete              29  KeyPress 34 ms before
  +10.289s  #4 gimp       CLIPBOARD  TARGETS      self            complete               8  client owns CLIPBOARD
  +10.297s  #2 clipsnoop  CLIPBOARD  image/png    proactive       complete (INCR)  614,400  XFixesSelectionNotify 8 ms before, no input since
  +16.724s  #5 xclip      CLIPBOARD  UTF8_STRING  unexplained     refused                0  no input seen; no owner-change notification

Clients
  #1 keepassxc  owned CLIPBOARD; no selection reads
  #2 clipsnoop  watches CLIPBOARD via XFIXES; 3 reads: 3 proactive; 614,437 bytes
  #3 xterm      2 reads: 2 user-initiated; 37 bytes
  #4 gimp       owned CLIPBOARD; 1 read: 1 self; 8 bytes
  #5 xclip      1 read: 1 unexplained; 0 bytes

Verdict
  #2 clipsnoop requested a selection with no paste to explain it: 3 proactive, 0 unexplained, 614,437 bytes received.
  #5 xclip requested a selection with no paste to explain it: 0 proactive, 1 unexplained, 0 bytes received.

Clients are labelled by the WM_CLASS they set on their windows, and by _NET_WM_PID when present.

Other analyzer options:

node bin/xselaudit.js analyze demo.xsc --timeline      # add each client's selection timeline
node bin/xselaudit.js analyze demo.xsc --json          # the full report as JSON
node bin/xselaudit.js analyze demo.xsc --window-ms 250 # stricter notion of "just pressed a key"
node bin/xselaudit.js frames demo.xsc --conn 2         # every decoded frame of one connection

analyze exits 0 when it printed a report, 2 when it printed a report but some stream could not be decoded to the end (the problems are listed with their byte positions), and 1 for usage or file errors.

Recording a real session (Linux, X11)

node bin/xselaudit.js record -o session.xsc     # listens as :9, forwards to $DISPLAY
DISPLAY=:9 some-app &                           # in another terminal: run what you want to audit
# ... use the desktop, then Ctrl+C the recorder
node bin/xselaudit.js analyze session.xsc

Options: --display :N picks the proxy display, --upstream DISPLAY picks the real one, --listen PATH listens on an arbitrary socket path, and --no-auth-rewrite forwards clients' own credentials untouched. The recorder prints its progress on stderr. This is its real output from a run against a stand-in server on localhost:99; the development machine has no X server:

$ node bin/xselaudit.js record --listen check.sock --upstream localhost:99 -o check.xsc
auth: no cookie found for the upstream display; clients' own credentials are forwarded
recording to check.xsc
listening on check.sock, forwarding to localhost:99
press Ctrl+C to stop
connection 1 opened
connection 1 closed: client closed the connection
^Cinterrupted: recorded 1 connection(s), 32 bytes from clients, 180 bytes from the server
check.xsc

Capture files contain everything the recorded clients exchanged, including clipboard contents. They are created with mode 0600. Treat them like the secrets they hold.

Results

There is no benchmark; the project makes no performance claim. Its correctness claim rests on the test suite (npm test, 112 tests on Node 24). Every test drives the decoder with sessions from the project's own encoder, so no X server is needed. What they check:

  • Round trip. Random request, reply, error, event, KeymapNotify and GenericEvent streams, some requests in BIG-REQUESTS form, decode to exactly the frames that were encoded (fields, bytes and offsets), in both byte orders.
  • Chunk independence. The same streams split at every possible offset, at 300 random sets of multi-split points, and one byte at a time, give identical frames. At the analysis level, re-splitting and coalescing capture records gives an identical report.
  • BIG-REQUESTS. A 300,001-byte ChangeProperty decodes with its payload intact, including splits inside the 8-byte extended header, and the request after it starts at the right offset.
  • Sequence matching. 70,000 requests with replies, and 66,000 with errors standing in for every third reply, all match correctly. So does a reply sent after 65,540 no-reply requests, which pick the request that expects it.
  • GenericEvent framing. Events whose extra length grows from 0 to 196 bytes do not shift the replies and events that follow.
  • XFIXES opcode learning. The same session with XFIXES at opcode 138 and at 150 produces the same report. Without the QueryExtension exchange, or with RANDR answering at 138, opcode-138 requests are not read as XFIXES.
  • Classifier scenarios. A paste after Ctrl+V is user-initiated. Notify-then-convert is proactive. An owner converting its own selection is self. A 1,000,003-byte INCR transfer reports exactly 1,000,003 bytes. Also covered: the window edge (inclusive), input between notify and read, forged SendEvent input, XI2 KeyPress vs raw events, refusals, split GetProperty reads, and ownership changes.
  • Damage. Truncated or corrupt streams and capture files give positioned errors. 400 random mutations of frame streams and 150 of a full capture never throw anything else.
  • Proxy. The proxy is tested against a stand-in X server on a Unix socket. It forwards bytes in both directions unchanged, substitutes the Xauthority cookie, keeps the cookie out of the capture, refuses to replace a non-socket file or a live socket, and removes a stale one.

Mutation checks were run by hand during development: removing the GenericEvent length handling, the BIG-REQUESTS length arithmetic, the INCR marker exclusion, the forged-input filter, the "no input since the notification" rule, the self exclusion, or the inclusive window edge each made tests fail.

Design notes

Matching replies by candidates, not by a FIFO. The obvious design keeps a queue of requests that expect replies and pops one for each reply. That breaks in two common cases. First, an error can replace a reply or arrive for a request that never expects one. Second, extension requests whose reply behaviour we cannot know (any extension other than XFIXES and BIG-REQUESTS) would have to be guessed, and one wrong guess shifts every later match. Instead, the tracker uses the one invariant the server guarantees: it handles a client's requests in order. A packet with 16-bit sequence s must refer to some sent request numbered n ≥ (last handled) with n ≡ s (mod 65536). Usually there is exactly one candidate. When there are several, the reply-or-error distinction and the error's opcode pick the right one. The cost is keeping a small record per unanswered request, not just per reply-expecting request, which is dropped as soon as a later server packet proves it was handled.

Learned, never assumed. Extension opcodes are handed out at server start, so XFIXES at 138 on one machine may be RANDR on another. Hard-coding 138 would make the tool report watchers that do not exist. xselaudit decodes XFIXES and XI2 only after it has seen the QueryExtension reply. The trade-off: a capture that starts after a client connected cannot be interpreted for that client. Since record is a proxy, every client it sees connects through it from the first byte, so this is not a problem in practice.

Classify at request time, with ties going to the user. Each read is judged with only the events the client had received when it sent the request. Later input cannot excuse it, and later notifications cannot condemn it. When both rules apply (a key press 50 ms before, and a notification after the key press), the read counts as user-initiated. That is the rule order in the spec, and it keeps false alarms down for someone who copies in one window and immediately pastes in another. A patient attacker who waits for your typing could hide inside the window; the report still records the notification timing for anyone who wants to look.

The cookie problem. A client connecting to :9 finds no Xauthority entry for display 9, so it sends no credentials, and the real server refuses it. The proxy therefore substitutes the upstream display's MIT-MAGIC-COOKIE-1 into the connection setup. That makes the proxy socket as powerful as your display, which is why it is created with mode 0600. The recorded copy of the setup has the cookie bytes zeroed at their original length, so framing is intact and no capture file carries a credential.

Limitations

  • Only proxied clients are visible. Clients connected directly to the real display are not recorded. When such a client owns a selection, its owner window shows up unattributed. Native Wayland clients are out of scope entirely.
  • Never run against a live X server in this project's tests. Correctness is proven against an encoder written from the protocol specs, so a misreading of a spec would be shared by encoder and decoder. The proxy has been exercised only against stand-in servers.
  • Timing heuristics, not intent. "User-initiated" means input reached the client shortly before the read; it does not prove the input was a paste. Input injected through XTEST (e.g. xdotool) is delivered by the server as real input and cannot be told apart. Clipboard managers are proactive readers by design and will be flagged. Touch events, key releases and XI2 raw events do not count as input.
  • Byte counts cover the standard transfer. Bytes are counted from GetProperty replies on the property named in SelectionNotify. The MULTIPLE target is not expanded, and INCR is recognized only if some recorded client interned the INCR atom.
  • Selective decoding. Only the requests, replies and events needed for selection auditing are decoded field by field. frames names every core request and event but is not a replacement for xtrace.
  • Protocol shortcuts. A request length of 0 is always read as BIG-REQUESTS, without checking that the client enabled the extension. The authenticate setup status is treated as final.
  • Authorization. Cookie substitution supports MIT-MAGIC-COOKIE-1 entries of the Local or Wild family. XDM-AUTHORIZATION-1 and cookies for TCP displays are not looked up; use --no-auth-rewrite and xauth yourself in those cases.
  • Timestamps are the proxy's arrival times, not the X server's timestamps.
  • Tested platforms. Development and tests ran on macOS; CI runs on Ubuntu. record needs a writable /tmp/.X11-unix (or --listen), and XQuartz's launchd socket has not been tried.

License

MIT. See LICENSE.

About

Record X11 traffic and report which clients read your clipboard, when, and whether you pasted

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages