Keylogging on Linux, Part 1: From Kernel Key Handling to the X Server

How a key press travels through the kernel keyboard driver, the input subsystem, evdev and /dev/input/event nodes, then through the X server input pipeline and XKB keymap, and where loggers hook in.

48 min read
ibrahimsql
9,418 words

Keylogging on Linux, Part 1: From Kernel Key Handling to the X Server#

A key press on Linux travels a long way before it becomes a character in your terminal. It starts as an electrical signal in the keyboard, becomes a scancode, then a kernel keycode, then an input event on a device node, then an X event, and finally a keysym that your application turns into text.

Every stage of that path is a place where someone can listen. That is the whole idea behind keylogging on Linux: you do not need magic, you just need to sit on one of the stages and read what passes by.

This first part maps the path itself. Part 2 will build the actual loggers on top of this map.

Scope and ethics#

Everything here is for your own machines and lab VMs. Reading another user's keystrokes on a shared system without written permission is illegal in most places and a fast way to lose your job in all of them. The same knowledge is what you need to detect loggers on systems you defend, so keep it in that frame.

The full path at a glance#

key press | v [ keyboard controller ] IRQ1 + scancode (PS/2) or USB HID usage | v [ kernel driver: atkbd / usbhid ] converts scancode to keycode | v [ input subsystem core ] builds struct input_event | v [ evdev handler ] exposes /dev/input/eventN | +------------------> any process with read permission | (classic userspace logger spot) v [ X server input driver ] evdev or libinput | v [ XKB keymap ] keycode + group + level -> keysym | v [ X protocol KeyPress event ] delivered to the focused window | v [ application toolkit ] GTK/Qt turn keysym into text

Keep this diagram in your head. Every defensive and offensive trick in this series hangs on one of these boxes.

Stage 1: kernel key handling#

When you tap a key, the keyboard controller raises an interrupt. For a PS/2 keyboard that is IRQ1, handled by the atkbd driver. For a USB keyboard it is a HID report handled by usbhid. Both drivers do the same job in the end: turn hardware-specific scancodes into a single, hardware-independent keycode space.

The kernel keeps a translation table per input device. You can see it in action:

sudo showkey --scancodes # press a few keys, note the raw values, Ctrl+C to quit sudo showkey --keycodes # same keys, after the driver translation

A scancode says "the third key in the second row went down." A keycode says "the key we call KEY_Q went down." The kernel no longer cares whether the keyboard is PS/2, USB, or Bluetooth; everything above this layer speaks keycodes.

Relevant source files if you want to read them later:

  • drivers/input/keyboard/atkbd.c for the PS/2 path
  • drivers/hid/usbhid/ for USB keyboards
  • include/uapi/linux/input-event-codes.h for the keycode names

Stage 2: the input subsystem and evdev#

The input subsystem is the kernel's middle layer for mice, keyboards, touchpads and anything else that reports human input. Its event interface is called evdev, and it exposes one character device per device under /dev/input/:

cat /proc/bus/input/devices

Typical output, trimmed:

I: Bus=0011 Vendor=0001 Product=0001 Version=ab41 N: Name="AT Translated Set 2 keyboard" P: Phys=isa0060/serio0/input0 S: Sysfs=/devices/platform/i8042/serio0/input/input3 U: Uniq= H: Handlers=sysrq kbd event2 leds B: EV=120013

The Handlers= line tells you which /dev/input/eventN node belongs to your keyboard. Each event is a fixed-size struct:

struct input_event { struct timeval time; __u16 type; /* EV_KEY, EV_REL, EV_ABS, ... */ __u16 code; /* KEY_A, KEY_B, ... */ __s32 value; /* 0 release, 1 press, 2 repeat */ };

Three things to note:

  • type for keyboard keys is always EV_KEY.
  • value distinguishes press, release, and autorepeat. A logger that only counts presses halves its noise by ignoring value == 0.
  • Events arrive even if no window is focused. The kernel does not know or care which application is on screen.

An event also does not stand alone: the kernel emits a batch and ends it with SYN_REPORT (type == EV_SYN, code == SYN_REPORT). A KEY_CTRL press, then a KEY_A press, then SYN_REPORT means they all belong to the same physical moment. A realistic reader waits for the SYN_REPORT boundary before interpreting keys; otherwise a user holding Shift while pressing A looks like "Shift alone, then A alone" and the modifier state is lost.

Stage 3: reading /dev/input/event* yourself#

Reading raw events needs read permission on the node. On most distros that means root or membership in the input group. A minimal reader in C:

#include <stdio.h> #include <fcntl.h> #include <unistd.h> #include <linux/input.h> int main(void) { int fd = open("/dev/input/event2", O_RDONLY); struct input_event ev; while (read(fd, &ev, sizeof(ev)) == sizeof(ev)) { if (ev.type == EV_KEY && ev.value == 1) printf("keycode %u pressed\n", ev.code); } close(fd); return 0; }

Compile, run as root, type, and watch the keycodes stream by. That program is already 80 percent of a userspace keylogger. The missing 20 percent is a keycode-to-character table and a file to write into, both covered in part 2.

One more ioctl matters here: EVIOCGRAB. A process that grabs a device gets its events exclusively; the X server stops seeing them. Legitimate uses exist (screen lockers, virtual keyboards), but so do hostile ones, which is why a grab you did not start is a red flag.

Reader logic: how to consume the raw stream#

The minimal reader was deliberately left short. A production evdev consumer has to do four more things.

First, it asks the device for its identity and capabilities. With EVIOCGBIT you query which event types the device emits; for a keyboard you expect EV_KEY, EV_MSC, EV_LED, EV_SYN, while a mouse reports EV_REL. Among the many /dev/input/event* nodes, only the ones whose EV_KEY bit is present are really keyboards; power keys, webcam buttons, and built-in touchpads also live under event*.

Second, it maintains modifier state itself. The kernel does not hand you a "Shift is down" bit; it hands you press, release, and repeat events for KEY_LEFTSHIFT. The logger keeps a "currently held" matrix per modifier keycode and re-evaluates the whole matrix on every key press. Without that, you cannot see "Ctrl+Shift+K"; you only see "Ctrl down", "Shift down", "K down", which is a lossy view of the same stream.

Third, it separates value == 2 repeats. Autorepeat re-emits the held key on a timer. A user pressing a key for one long moment becomes "aaaaaaaaaaa" in a badly written logger. The common policy is to count repeats when profiling typing habits, but to treat only the first event when reproducing typed text.

Fourth, it merges several devices onto one timeline. External keyboard, internal keyboard, and sometimes a virtual device are open at the same time. A consumer that polls all file descriptors and keeps events ordered by their struct timeval stamp also preserves which device a keystroke came from. For detection, that ordering decision is a capability as well: once you scramble records, you lose track of which session you observed.

Device nodes are also unstable. As devices are plugged and unplugged, event2 becomes event3. Production readers therefore use the symlinks under /dev/input/by-id/:

ls -l /dev/input/by-id/

Those symlinks are named by hardware path and keep pointing at the same physical keyboard across re-plugs. A logger that opens a by-id path keeps working when /dev/input/eventN numbering changes, so detection rules should watch by-id opens separately.

Stage 4: the X server input pipeline#

Above the device nodes sits the X server. It reads the same evdev nodes through an input driver:

  • evdev driver: the older one, thin wrapper over kernel evdev.
  • libinput driver: the current default on most distros, shared with Wayland compositors.

Check which one your session uses:

grep -i "input driver" /var/log/Xorg.0.log # or, on modern systems: grep -ri "libinput\|evdev" ~/.local/share/xorg/Xorg.0.log | head

The X server receives keycodes from the driver and adds 8 to them for historical reasons (the X protocol reserved keycodes 1-7). So kernel keycode 24 (KEY_Q) becomes X keycode 32. This off-by-8 confuses everyone the first time they compare evtest output with xev output.

From there the pipeline splits:

  1. The X server builds a KeyPress/KeyRelease X event.
  2. It decides which window has input focus.
  3. It delivers the event to the client that owns that window.
  4. The client toolkit (GTK, Qt) runs its own input method layer on top.

XInput2 raw events: why they are the second hook point#

X11 exposes two event families. The classic core delivers KeyPress/KeyRelease and KeymapNotify per window path. XInput2 adds a "raw" pair, XI_RawKeyPress and XI_RawKeyRelease. Those raw events are captured before the server dispatches to any window, so a listening client does not have to care which window has focus.

Three properties matter:

  • The event is captured while the server is processing it; modifier and keymap translation are not yet applied. The listener must run its own translation.
  • A raw listener sees activity for every key the same X session receives, including modifiers and navigation keys; activity on windows the X server does not own is excluded.
  • The listener only needs access to the X display. No root, no /dev/input. This is why evtest/lsof-based detection misses it; what you need is a client sitting inside the same X session with an XI2 selection.

Quick look:

xinput test-xi2 --root

The output carries XIDeviceEvent records with a raw_event_code. That code matches the evdev code field, without the +8 offset. So XInput2 raw events hand you the same keycodes as the evdev reader, but with no privilege requirement; knowing the X session is enough.

That distinction drives a defensive asymmetry: auditd rules that watch /dev/input/event* opens cannot see an XInput2 listener. To approach one, you need to inspect the X client table, confirm XInputExtension via xdpyinfo -queryExtensions, and use tools that list which client registered which event mask. xinput list --long does not expose this directly, but a session owner can read client Schemata and event selections with xrestop-style tools.

XRecord plays the same role in older form: it asks the server to duplicate every event in flight. In practice XRecord and XIRawEvents cover the same ground, and the only wall that stops either is a compositor that hides events. Treat "X11 listeners are easy to detect" with caution: an evdev reader shows up in auditd, while an XI2 listener usually hides inside an ordinary X session.

Stage 5: XKB and the keymap#

Between the raw X keycode and the character you see sits the X Keyboard Extension, XKB. XKB holds a compiled keymap that answers: "given this keycode, the current shift level, and the active group, which keysym is this?"

Watch it live:

xev -event keyboard

Press a with and without Shift and compare state and keysym fields. Then try:

setxkbmap -print setxkbmap -query

The keymap has levels (plain, Shift, AltGr, Shift+AltGr) and groups (for layouts you switch between, like us/tr). The keysym is still not a character; it is a name like a, A, adiaeresis, or BackSpace. The client turns keysyms into text, which is why typing Turkish characters works even though the kernel never heard of the letter ğ.

Rebuilding a keymap from text rules:

setxkbmap -layout tr -variant f xmodmap -pke | head -20 # legacy per-keycode view

LD_PRELOAD shim: the third hook point and its limits#

LD_PRELOAD tells the dynamic loader to pull one library before the others. A shim redefines input functions from libX11 or the toolkit, such as XNextEvent, XINextEvent, and GTK/Qt equivalents. When the application calls the symbol, the shim runs first, records what it wants, and forwards the call to the real function through dlsym(RTLD_NEXT, ...). The skeleton looks like:

// inside the shim typedef int (*XNextEvent_t)(Display*, XEvent*); int XNextEvent(Display* d, XEvent* ev) { static XNextEvent_t real; if (!real) real = (XNextEvent_t)dlsym(RTLD_NEXT, "XNextEvent"); int r = real(d, ev); if (ev->type == KeyPress) record(ev); return r; }

That pattern collects one application's keystrokes with no X session or /dev/input privilege needed. It only works when three conditions hold:

  • The target is dynamically linked. A static binary does not consult LD_PRELOAD.
  • The target is not setuid/setgid. On those, AT_SECURE makes the loader ignore LD_PRELOAD, which explains why shimming /bin/su or sudo silently does nothing.
  • The attacker can arrange the preload in the victim's session: a line in the shell startup file, Environment= in a systemd unit, or a wrapper script.

Two quiet weaknesses remain. First, toolkits do not always call the plain X entry points. GTK reads through GdkDisplayX11 and XInput2; Qt goes through XCB/QPA. A shim that wraps only XNextEvent is a half-built one against modern applications. Second, some processes queue events and re-enter the loop; the shim must forward without mutating them, or the victim misbehaves and closes the event stream.

For detection, an LD_PRELOAD shim is often the quietest option because there is no new listening process; it rides inside another binary. To find it:

# shared libraries currently mapped by the process cat /proc/<pid>/maps | grep -E "\.so" | head # what LD_PRELOAD was set to on launch tr '\0' '\n' < /proc/<pid>/environ | grep -i preload # hardening flags that would block the preload grep -i preload /proc/<pid>/status | head

/proc/<pid>/environ reflects the launch environment, so clearing LD_PRELOAD after start hides the value; the durable traces are unknown .so paths in /proc/<pid>/maps and the search of shell startup files, /etc/ld.so.preload, and the user's .bashrc.

Where keyloggers attach#

Three classic hook points, matching three boxes in the diagram:

  1. Device node reader. Open /dev/input/eventN and read. Needs root, input group, or a logind ACL. Sees every keystroke on the machine, including the ones typed into other X sessions, consoles, and password prompts. Does not see XKB, so it must map keycodes to characters itself.

  2. X protocol snooper. Pick up XInput2 raw events. No root, only access to the X display. Sees only what goes through that X server, and because they are keycode streams, the character mapping work is on you again; XRecord is the older variant of the same idea.

  3. Library interposition. LD_PRELOAD a shim that wraps X event functions or toolkit input functions. Only sees one application's keys, but inherits its session and needs no extra privileges if the victim launches the app through a poisoned wrapper.

Each hook has a different cost, a different privilege requirement, and a different blind spot. Part 2 walks through building all three in a lab.

Permission boundaries: who can read what#

Input devices are a privilege surface with several distinct layers, and a single machine can produce different answers at each layer.

First, the node file modes:

ls -l /dev/input/event* /dev/input/by-id/

Most distros set owner root, group input, mode 0660. Anyone in input can read the keyboard without a password. That is how an accidentally-enrolled service account, a forgotten user, or a leftover kiosk profile becomes a silent listening surface.

Second, logind ACLs. While a session is active, the seat grants the session user an ACL on /dev/input/event* and /dev/uinput (for example u:user:ali:rw), and revokes it when the session ends. The model assumes that "the logged-in user may watch their own keyboard". On a production host, handing that ACL to a long-lived service account turns that service into a logger surface.

Third, capabilities. If a binary carries CAP_SYS_RAWIO or similar, a non-root process can open evdev nodes anyway. Walk the usual prefixes:

getcap -r /usr/bin /usr/local/bin 2>/dev/null

This is rare in 2026 but is the first answer to "why can this user read /dev/input/event0?" when group membership and ACL are clean.

Fourth, portal tokens. On Wayland sessions, the RemoteDesktop/libei flow grants a temporary input injection right with the user's explicit consent. The token dies with the portal session. You can spot them by the EIS socket files and portal artifacts in $XDG_RUNTIME_DIR:

ls -la $XDG_RUNTIME_DIR | grep -i -E "eis|remotedesktop|portal"

Defensive conclusion: do not scope the privilege audit to root-or-input. Add capabilities, logind ACLs, and portal session artifacts to the inventory.

The Wayland portal wall#

Wayland's stated goal is that clients cannot spy on each other. Input events go only to the client owning the focused window, and no client may observe what others receive. The protocol deliberately leaves no room for a "global input listener" API like XRecord or XI2 raw events. On a pure Wayland session, an X11-style listener channel simply does not exist.

The practical consequence is that a Wayland keylogger has to leave the protocol. The exits are:

  1. Read /dev/input/event*: needs logind ACL, input group, or root. This is the only unilateral path to listen without any consent surface.
  2. EVIOCGRAB the device: the compositor stops seeing keys, which also breaks focus for the real session; a strong detection signal.
  3. Inject via a virtual device and steer the victim process's focus: indirect, noisy, and usually the sign of a poorly built implant.

The RemoteDesktop portal is the "permitted hole" in the wall. Once consent is granted, the session can inject or sample input through libei. The libei EIS socket file appears under $XDG_RUNTIME_DIR and is only opened by processes holding the grant. This is the new legitimate surface to watch; a process may not touch the EIS socket without being in the session, but once the user clicks through the portal dialog, a virtual input device can exist for that session.

A comparison with the screen-capture portal helps. When a PipeWire screen share starts, the visible screen content can be relayed to another process; that can expose a password as a picture, but it does not carry the keystroke stream as a protocol. Do not conflate the two findings: screen share is visual bleed, keylogging is keystream bleed, and they need different detection questions and artifacts.

Practical checks on Wayland:

# who currently holds input nodes? sudo lsof /dev/input/event* 2>/dev/null # is the uinput path open? ls -l /dev/uinput # EIS/portal sockets present? ls -la $XDG_RUNTIME_DIR | grep -i -E "eis|remotedesktop|portal" # logind sessions to correlate ACLs loginctl list-sessions

On Wayland, any process that opens /dev/input/event* and is not an expected desktop service (libinput consumer, compositor, a portal helper for screen sharing) needs a justification. That question list is the Wayland answer to "who could be recording keystrokes".

Detection notes for defenders#

The same map tells you where to look:

# who is holding keyboard devices open right now? sudo lsof /dev/input/event* 2>/dev/null # which processes sit in the input group? getent group input # is an XRecord-style client connected? xdpyinfo -queryExtensions | grep -i record

Extra habits that pay off:

  • Keep an auditd watch on /dev/input/ open syscalls on sensitive hosts.
  • Treat unexpected members of the input group as findings, not trivia.
  • On Wayland, the compositor owns input and per-client isolation removes hook point 2 entirely; XWayland keeps some of it alive, so mixed sessions deserve a second look.
  • A grabbed device you did not grab (EVIOCGRAB by an unknown PID) is a strong signal.
  • For LD_PRELOAD shims, hunt unknown .so entries in /proc/*/maps and add /etc/ld.so.preload to surprise searches.
  • Treat any process touching /dev/uinput outside EIS/libei as a finding; it means an injection path.

A note on virtual devices#

The same subsystem also works in reverse. The uinput module lets a userspace process create a virtual input device and inject events into the kernel, which then flow through the exact pipeline you just read:

sudo modprobe uinput ls /dev/uinput

Testing tools, remote-desktop servers, and macro daemons rely on this. It also means "I only trust events from the real keyboard" is not a check the pipeline gives you for free: evdev events from uinput look identical to hardware events at the X server level. Keep that in mind when you design detection rules, and remember that injected input is how many lab setups simulate the logger's target without typing for hours.

How much did this pipeline change by 2026?#

The box names in the diagram still hold in 2026; what changed is what sits inside some boxes and who gets to cross them.

key press | v [ evdev / libinput ] libinput is the shared input library for | the X server and Wayland compositors; it | reads raw events and hides hardware details v [ compositor / X server ] the single authority, compositor on Wayland, | server on X11 | +--- classic: /dev/input/eventN reader (root or input group) | +--- X session: XRecord / XI2 raw snooping still works | +--- Wayland session: no global snooping in the protocol; | the only unilateral path is still reading /dev/input | +--- remote desktop: RemoteDesktop portal + libei opens a | consented virtual input session for the user v [ application ] receives events through wl_keyboard / wl_seat

Three practical points:

  • libinput is shared. The old evdev X driver has been replaced by libinput on mainstream distros. The same library serves X and Wayland, so keycode-to-keysym handling is consistent on both; most "works on X, breaks on Wayland" loggers trip over the privilege model, not over parsing.
  • The /dev/uinput path got narrower but did not disappear. systemd-uaccess rules hand uinput and input nodes to the active console user via ACLs instead of loose group membership. An authorised session can still push virtual devices to uinput through libei/EIS, and those events still look exactly like hardware at the compositor.
  • Portals created a new "legitimate listening" point. The RemoteDesktop portal grants input injection to a user session. For abuse purposes that carries the same power as uinput, but it opens with the user's consent and the compositor in the loop. In 2026, telling legitimate remote control apart from hostile injection means asking which session and which portal token holds the grant.

For defenders the conclusion is simple: the inventory problem around /dev/input/event* did not change; what changed is that services like libei and PipeWire also touch these nodes. PipeWire carries screen and microphone streams, not keystrokes; do not let a screen-capture finding get labelled as keylogging. When writing detection rules, add the new question: "which portal session and which grant scope is running this process?"

Choosing a hook point: a cost table#

Hook pointPrivilegeWhat it seesBlind spot
/dev/input/eventNroot, input group, or logind ACLEvery session, raw keycodes including password promptsNo XKB, so you map keycodes to characters yourself
X protocol snoop (XRecord/XI2)X display accessOnly what that X server carries, raw keycodesDoes not work on Wayland sessions, blind to compositor-owned windows
LD_PRELOAD shimInherits the target sessionOne application's textFails on static binaries, setuid binaries, and other users' sessions
Injection through RemoteDesktop/libeiPortal approvalSynthetic input, indistinguishable from real at evdevCannot touch unapproved sessions

A note on ownership#

Do not label any box in this map "safe" or "unsafe"; together they define a trust boundary. Reading /dev/input/eventN needs a privilege, but most day-to-day observation lives outside that node. The X server routes input to one client but still lets every client try the snoop extensions. The compositor pushes the logger out of the protocol, yet the logged-in user can still open their own event nodes. All three are realities sitting at the same table in 2026; none cancels the others. On a machine where nobody knows which session holds the keyring and uinput handles, a keylogger is still one line of code away.

Where to draw the line#

The line between owner and attacker got thinner by 2026: is the identity right, which session owns the node, and with what grants was it opened. The input group, uinput ACLs, portal tokens, and the presence of an X session all answer one question. When you write a detection rule, put that question list in the heading and use the map below for the technical layers.

Lab exercises#

  1. Find your keyboard in /proc/bus/input/devices and note its event node.
  2. Run sudo evtest /dev/input/eventN and press keys; map a few keycodes to input-event-codes.h names.
  3. Compile the C reader above and compare its output with evtest.
  4. Run xev and write down the X keycode for the same keys; confirm the +8 offset.
  5. Switch layouts with setxkbmap tr and watch how the same keycode yields a different keysym.
  6. Check lsof /dev/input/event* before and after starting a terminal, a browser, and a screen locker.
  7. Run xinput test-xi2 --root in parallel and match the keycode stream against evtest; note that raw codes have no +8 offset.
  8. Run getcap -r /usr/bin /usr/local/bin 2>/dev/null and list binaries carrying input-related capabilities; discuss how that changes the privilege story.
  9. Record /dev/input/by-id/, unplug and re-plug the external keyboard, and watch symlink names stay stable while eventN numbers change.
  10. On Wayland, run xinput test-xi2 --root and observe the failure; then boot an X11 session on the same machine and repeat the command.

What part 2 builds on this#

Part 2 turns this map into working code: a raw evdev reader with a full keycode table and modifier tracking, an X11 sniffer using the XInput2 raw events, and an LD_PRELOAD shim, each followed by the detection angle. If the pipeline in this part makes sense, the loggers in the next one will read like simple plumbing.

uinput and libei: the injection half of the story#

Where there is evdev, there is uinput: the same kernel client interface can create virtual input devices. /dev/uinput accepts an EVIOCGRAB-style grab and will happily synthesize key presses. On a desktop running a libinput-consuming Wayland compositor, uinput-created devices are picked up by libinput exactly like physical keyboards, so anything injected that way reaches the focused client with no special compositor permission. That is the actual counterpart of evdev keylogging: the two problems share one permission boundary.

Wayland adds a second path. Remote desktop applications (input emulation) are the legitimate use case for portals; libei is the protocol that runs between the portal and the compositor. A RemoteDesktop session participant receives a socket pair from the compositor and sends synthetic input events over that socket. The session comes from an explicit portal grant, so the injection is scoped and time-limited. Modern compositors also surface a desktop-shell indicator showing that a session is active.

Defenders get two concrete handles:

  • uinput node access: same group (input/video) rules as evdev, but frequently granted too broadly on DIY systems. Audit ls -l /dev/uinput and fd holders during an incident (/proc/*/fd, lsof).
  • Portal grants: the portal journal (journalctl --user -u xdg-desktop-portal*) records which app holds which capability, and the desktop settings panel should have a section listing active grants. If an unexpected app has a RemoteDesktop grant, the session is the thing to kill first.

Kernel input internals: evdev, uinput, and the input class#

Everything userspace sees in this subsystem hangs off three kernel structures: struct input_dev (the device itself), struct input_handler (how events leave the device), and struct input_handle (the binding between the two). You can meet all three in drivers/input/input.c.

keyboard hardware | IRQ / USB interrupt v atkbd / usbhid (drivers/hid/*, drivers/input/keyboard/*) | input_event() v input core (input.c) -> struct input_dev, enum input_event_type | -> routes to each input_handler +----------------+--------------------+ | | | v v v input_kbd evdev handler uinput (tty driver) (evdev.c) (uinput.c) | | ^ v v | /dev/tty /dev/input/eventN write() from userspace This diagram is the whole kernel half of keylogging. evdev.c and uinput.c both live one layer below the device node and are the two reasons an unprivileged reader can understand the rest of the stack.

evdev.c builds the /dev/input/eventN nodes. Each open file descriptor gets its own event queue, its own key-state bitmap, and its own grab state. That is why a listener and libinput can read the same device at once without corrupting each other: the kernel keeps per-fd copies of every event. The queue overflow (EV_KEY floods, e.g. autorepeat during a cat keypress game) surfaces as a SYN_DROPPED followed by a full re-sync, not as silent data loss.

/* evdev consumer, bounded example */ struct input_event ev[64]; for (;;) { int n = read(fd, ev, sizeof(ev)); if (n <= 0) break; n /= sizeof(struct input_event); for (int i = 0; i < n; i++) { if (ev[i].type == EV_KEY) handle_key(ev[i].code, ev[i].value); if (ev[i].type == EV_SYN && ev[i].code == SYN_REPORT) flush(); } }

uinput.c is the mirror: a process calls UI_SET_EVBIT, UI_SET_KEYBIT, and UI_DEV_CREATE ioctls to register a virtual device, then writes struct input_event structs to inject events. Because a virtual keyboard enters the input core at the very same function that real drivers call, libinput, Xorg, and every evdev reader treat it like hardware. BUS_VIRTUAL in the bus field is visible to a curious defender via /proc/bus/input/devices, but nothing in the event stream marks "synthetic". Injected input cannot be told apart from hardware input without checking the device node's identity.

Security matrix: device layer#

SurfaceWho can read/writeWhat it seesDetection hook
/dev/input/event*root, input, seat ACLraw keycodes, all sessionslsof, auditd on open
/dev/uinputroot, input, seat ACLsynthetic devices allowednode listing, D-Bus seat grants
input_kbd (tty)root, tty layerconsole keystrokesauditd on tty open, showkey
evdev queue per fdwhoever holds the fdcopy of streamprocess /proc/<pid>/fd fan-out
sysrq via evdevrootmagic SysRq combinations/proc/sys/kernel/sysrq mask

libinput: one input library, several compositors#

Before libinput, Xorg and Wayland each parsed raw evdev differently. libinput made one library own the messy parts: quirks for cheap touchpads, tap-to-click, two-finger scroll, keyboard layouts, device hotplug, and the decision of which events belong to a shared "seat".

/dev/input/event* | v +-------------------------+ | libinput (libseat/logj)| | - device probing (udev)| | - event filtering | | - pointer/gesture logic | +-------------------------+ | libinput_event (keyboard_key, pointer_motion, device_added...) v +------------------+ +----------------------+ | Xorg (xserver) | | Wayland compositor | | libinput driver | | wlroots/wayfire etc. | +------------------+ +----------------------+

Command/description for inspecting libinput itself:

  • libinput list-devices: what libinput sees right now, per device, with capabilities and quirks applied.
  • sudo libinput debug-events: every event libinput hands to the compositor, with timestamps. This is the best seat-level mirror of what a logger reads from evdev.
  • libinput record session.yml: capture evdev traffic to replay later with libinput replay, handy for reproducing modifier edge cases in a lab.
  • libinput quirks list /dev/input/eventN: shows which udev hwdb quirk entries matched the device.

The seat is libinput's core abstraction. Every physical device is assigned to a seat, usually seat0. A seat owns the devices and the focus model together. Wayland's wl_seat global mirrors this directly: one seat gets one keyboard focus, one pointer focus, one touch focus. X11 never had a seat concept, which is why its permission model drifted into /dev/input group membership plus logind ACLs.

Detection implication: on a libinput based stack, the realistic loggers sit at one of three places. The evdev-shaped one (needs the same fd libinput has, so it fights libinput for grab semantics), the X11 protocol snoop on an XWayland server, or an injected LD_PRELOAD inside a toolkit. A process calling libinput debug-events in a background cron job produces the same observable artifacts as a keylogger: an extra open fd on every input node and a stream of keycodes. Watch for libinput tools where no one on the team runs them.

udev: permissions, rules, and the uaccess tag#

udev is where /dev/input modes become policy. A rule line is four parts: a match (SUBSYSTEM=="input"), an assignment (GROUP="input" or MODE="0660"), a tag (TAG+="uaccess"), and an action hook. The relevant paths:

/usr/lib/udev/rules.d/60-input-id.rules (device identity, ID_INPUT*) /usr/lib/udev/rules.d/73-seat-classes.rules /etc/udev/rules.d/*.rules (local overrides)

A minimal way to audit what udev would do to your keyboard:

udevadm info --path=/dev/input/event3 --query=all | grep -E "GROUP|MODE|TAGS|UDEV_" udevadm trigger --dry-run --verbose /dev/input/event3

TAG+="uaccess" is the mechanism that swaps group-based access for seat-based access. When a device carries the uaccess tag, logind grants an ACL entry to the active local session user on that seat, and revokes it when the session dies. On a seatd system without logind, the equivalent hook is udev rules that set GROUP="seat" plus the seatd helper that hands the fd to the compositor.

A defender's udev security matrix:

Rule choiceWho reads the keyboardRisk
MODE="0660", GROUP="input"every input memberleftover groups members become listening surfaces
TAG+="uaccess" onlyactive local session on that seathijacked session inherits the whole seat
bothmembers plus sessionwidest surface, most common DIY default
MODE="0600", no tagsroot + (via TakeDevice) one fdstrictest; breaks multi-user dev machines, expected tradeoff

Practical rule to keep in a security review: getent group input is no longer the whole story. Audit udevadm info output per node, and check loginctl seat-status seat0 for ACLs before blaming a specific group.

udev hwdb and libinput quirks#

The same udev database also feeds libinput its quirks. Touchpads and laptops need hwdb overrides for tap-to-click failures, mysterious double-clicks, and hotplug. Two security-relevant points.

  1. Quirks adjust event content. If an attacker wants to hide their injected keys inside normal typing, the "abnormal" path is a device that libinput applies a filtering quirk to. That is not a realistic hiding place, but it explains why libinput quirks list matters when you validate a synthetic device.
  2. An attacker adding a udev rule can change permissions without touching the node. Rules ship in three places (/etc/udev/rules.d, /run/udev/rules.d, /usr/lib/udev/rules.d), and udevinfo only shows the merged result. A directory watch on /etc/udev/rules.d is a reasonable detection primitive.

Wayfire, wlroots, and the seat model in compositors#

Compositors like Wayfire (wlroots-based) do not read /dev/input themselves. They take libinput's events, place them on a seat, and distribute them to the focused wl_surface. The visible part of the pipeline:

evdev -> libinput -> wlroots/seat -> wayfire shell | v wl_keyboard.enter / key / leave | v the client with keyboard focus

Wayfire config lives under ~/.config/wayfire.ini. Its input section defines keyboard layout, repeat rate, and focus behavior:

[input] layout = us,tr repeat_rate = 30 click_method = clickfinger

For a keylogger discussion the important thing is what must be true for a client-side logger to fire. On Wayland, a plain X client cannot open XInput2 raw events. It also cannot see other clients' keys. What it can do is read /dev/input/event* like any local user, which is the same privilege question you met before.

Wayfire's seat model means one thing for mixed sessions: if XWayland is running, X11 clients inside that session hold to the X11 rules, including XInput2 raw access to their own X server. So a Wayland login with XWayland enabled keeps a faint X11 hole open. Detection practice should include pgrep -a Xwayland and reviewing which apps run through it.

journald: the defensive glass case#

journald is not a keylogger, but it carries the evidence. Several places keep traces that survive a soft reboot:

journalctl -b # current boot journalctl -b -1 # previous boot journalctl _COMM=evtest _COMM=libinput journalctl --user _COMM=xdg-desktop-portal

journald writes to /var/log/journal (persistent) when Storage=persistent, or to /run/log/journal (RAM only) otherwise. A logger whose config deletes /var/log/journal or flips storage to none leaves two signatures: missing history across reboots, and an edited /etc/systemd/journald.conf. The stronger option is shipping to a remote journal (systemd-journal-remote) or an append-only host, because the local root logger can always edit local logs.

Also note journalctl --file /var/log/journal/*/*.journal for offline analysis and journalctl --since "..." --until "..." for cutting windows. For live defense, journald with --follow on a second terminal while you run evtest is enough to watch which device nodes get opened.

LD_PRELOAD vs LD_AUDIT vs loader hardening#

LD_PRELOAD is the loudest hook. Two less-obvious cousins do different things:

  • LD_AUDIT=./audit.so: loads an auditing library that receives loader callbacks. An audit module can observe symbol binding and call flow without overriding single functions, which makes it a stealthier injection point and also a stealthier detection source. Look for it in /proc/<pid>/environ like preload.
  • LD_LIBRARY_PATH=: precedence shift. An attacker drops a malicious libX11.so earlier in the search path and every new process picks it up. Same effect surface as preload, harder to see in environ-based scans because the path can live in the wrapper script.

Loader hardening flags that close the whole family at once:

readelf -d /usr/bin/target | grep -E "FLAGS|FLAGS_1" # expect BIND_NOW, NODELETE, ORIGIN for hardened builds grep -E "NoNewPrivs|CapEff|Seccomp" /proc/<pid>/status

NoNewPrivs: 1 plus a filter on open of /dev/input/* would kill the classic reader without knowing which logger variant it is. It is one of the few ways to make the defense version-independent.

XTest and XInput2: the X11 observation-injection pair#

XInput2 already gives the observation side. Its twin, XTest, gives the injection side. XTestFakeKeyEvent synthesizes virtual key presses that the X server delivers as if a real keyboard produced them. That is exactly what automation tools drive:

xdotool key ctrl+a ctrl+c # XTest

xdotool is legitimate on a lab box. The detection question is "which extension produced this event, and which device does the server think sent it?" In XI2 raw output, XTest-origin events show a device id that does not correspond to any real keyboard; in xinput list --short the XTEST devices appear as "Virtual core XTEST keyboard". A comparator rule: if evtest on every real keyboard shows no corresponding events but xev shows keypresses, the keys came from XTest, not from a hardware-level logger.

PropertyXI2 raw eventXTest injection
X server sees real keycodesnot applicableyes
compositor/kernel sees a deviceno (evdev reader only)no virtual device
shows in evtestnono
shows in xinput listlistener does notVirtual core XTEST
useful to defender asaudit of who listensproof of synthesis

systemd-logind: sessions, seats, and why they matter#

Logind is the layer that decides "which user currently gets the ACL on /dev/input/event*". Its three nouns are users, sessions, and seats.

user (uid) ---- seat0 ---- session (x11|wayland|tty) | | | +-- ACL on /dev/input, /dev/uinput +-- TakeDevice() D-Bus calls by the compositor

Command inventory:

  • loginctl list-sessions: who is on which session.
  • loginctl show-session <id>: Type, Class, Service, state.
  • loginctl seat-status seat0: which sessions this seat currently holds.
  • loginctl session-status <id>: CC, PAM config, ACL-relevant details.

A session grants the ACL only when it is active and seated on the same seat as the device. A logger running in a user's regular session inherits the ACL by default. A logger inside a separate tty session or over SSH does not, which is why the fork-via-ssh pattern quietly de-escalates a logger.

seatd: the same seat problem on non-systemd boxes#

seatd is a tiny daemon that hands seat ownership to the first process that asks and arbitrates the handoff. Its interface is one socket (/run/seatd-sock or wlroot's libseat), and its environment files set SEATD_SOCK, XDG_SEAT-like variables for the compositor.

cat /run/seatd-sock ls -l /run/seatd* 2>/dev/null grep -r "seatd" ~/.config/wayfire.ini ~/.config/sway/config 2>/dev/null

Wayfire, sway, and cage can all run with seatd instead of logind; the ACL story becomes "the compositor owns the seat's device nodes", not "the active session user gets ACL entries". That is a quieter security boundary: the compositor is the only process that can legitimately open every /dev/input/event*. On a logind-less seatd system, an unexpected fd to an input node is worth stopping and inspecting.

PolicyKit and portals: where delegated access lives#

polkit is the middleman between a service and a privileged action. For the input topic the rules usually govern the helpers around it:

ls /usr/share/polkit-1/actions | grep -E "login|freedesktop|portal" pkaction --action-id org.freedesktop.login1.manage-unit

A policy agent answers "user X on session S may do action A right now". The relevant ones for seat access are org.freedesktop.login1.manage-unit-style leases and the portal broker actions. Misconfigurations to watch: a rule granting org.freedesktop.login1.inhibit-handle-power-key:yes broadly, or a desktop file that registers a portal broker and never times out.

Portals sit one layer higher. On Wayland, clients ask xdg-desktop-portal for things like RemoteDesktop, ScreenCast, or InputCapture. The portal shows a consent dialog once, then hands out a libei socket. From there:

client app -> org.freedesktop.portal.RemoteDesktop.CreateSession -> SelectDevices(keyboard|pointer|touchscreen) -> Start -> EIS socket in $XDG_RUNTIME_DIR (libei) -> compositor receives synthetic or captured events

The kernel-level difference is that every portal-originated event arrives as a libei EIS stream, not as a raw /dev/input write. Defenders can audit granted portal sessions via the portal's own D-Bus properties (org.freedesktop.portal.RemoteDesktop.AvailableDeviceTypes, session paths under /org/freedesktop/portal/desktop/session/...). A RemoteDesktop session left open for days is the Wayland equivalent of an XTest daemon, with explicit user consent on record.

Security matrix: which hook survives which platform#

Platformevdev readerXInput2 rawXTestuinput devicelibei/RemoteDesktop
X11 (classic)yes, rootyes, sessionyesyes, rootyes, portal
XWayland inside Waylandyes, rootyes, X server sessionyesyes, rootyes, portal
Wayland sessionyes, root or ACLnonoyes, rootyes, portal
Wayland + seatd, no systemdyes, root or compositornonoyesyes, portal
Terminal-only (no display)yes, rootnonoyes, rootno

Reading list for the pipeline layers#

  • drivers/input/input.c: input core, event routing, handlers.
  • drivers/input/evdev.c: evdev open, queues, grab.
  • drivers/input/uinput.c: virtual device creation and event injection.
  • lib/libinput/src/evdev.c: libinput's udev probing and quirks application.
  • xserver/hw/xfree86/common/xf86Events.c: Xorg focus delivery and XTEST plumbing.
  • xserver/hw/xwayland: seat-to-keyboard mapping for X clients atop Wayland.

Lab matrix: one lab, ten checks#

#CheckCommand/description
1keyboard nodesls /dev/input/by-path/ description of node paths
2who sees the devicesudo lsof /dev/input/event* fd list
3libinput viewlibinput list-devices device inventory
4ACL stategetfacl /dev/input/event* seat grants
5logind sessionloginctl show-session self session type
6udev rule chainudevadm info --path=... --query=all per-node policy
7XTEST presencexinput list --short virtual device check
8libei sockets`ls $XDG_RUNTIME_DIR
9journal source of truth`journalctl -b
10uinput inventoryls -l /dev/uinput node permissions

Bounded example: a minimal seat-aware reader#

The reader below is the only new code in this section, bounded to a single event queue, one seat assumption, and no write path. It is shown to frame the earlier source discussion, not to be built on.

#include <libinput.h> int main(void) { struct libinput *li = libinput_path_create_context(NULL, NULL); while (struct libinput_event *ev = libinput_get_event(li)) { if (libinput_event_get_type(ev) == LIBINPUT_EVENT_KEYBOARD_KEY) { struct libinput_event_keyboard *kbd = libinput_event_get_keyboard_event(ev); printf("key %u %s\n", libinput_event_keyboard_get_key(kbd), libinput_event_keyboard_get_key_state(kbd) == LIBINPUT_KEY_STATE_PRESSED ? "down" : "up"); } libinput_event_destroy(ev); } libinput_unref(li); }

Putting the pieces into one detection walk#

Run the walk in order. It uses only read-only commands, and each step leaves a marker if someone else shares the machine.

1. ls -l /dev/input/* -> note GROUP/MODE per node 2. getfacl /dev/input/event* -> note seat ACLs 3. loginctl show-session self -> seat, type, active state 4. sudo lsof /dev/input/* -> every open fd, by pid 5. ls $XDG_RUNTIME_DIR -> eis sockets, wayland sockets 6. journalctl -b | grep -iE "input|libei|uinput" 7. xinput list --short -> virtual XTEST devices 8. pgrep -a Xwayland -> is XWayland bridge fresh?

When any two answers disagree, for example a seat ACL belongs to a session that no longer exists, the usual culprit is a leftover sudo service or a miswired udev rule. Each of those is a quieter path to the keyboard than a running logger.

Common questions, short answers#

Does evtest install a logger? No, it uses the same fd that libinput does; the difference is that evtest prints and quits, a logger would persist. Your detection rule should look for configuration, files, or services that call evtest-like APIs on a schedule.

Can a Wayland client see another client's keystrokes through xdg-desktop-portal? Only if a RemoteDesktop grant exists. The grant is per-app and the compositor shows an indicator while active. An answer of "no indicator, no RemoteDesktop session" is a meaningful negative.

Is uinput dangerous because it can fake keys? It can, but only after getting the node, which collapses to the same GROUP/ACL audit as evdev. If you lock down /dev/input/*, you have already reviewed /dev/uinput if you read the same rule block.

Do XInput2 raw events on a Wayland native app work? No. XI2 raw listeners only receive X server events, and only the X clients of that server. On Wayland, the equivalent client isolation is enforced by the protocol, so the snoop channel is absent, not hidden.

Lab Check: Watching Evdev Events#

The block below does not record data, nor does it map keys to text. It only lists kernel event types so that, to whom, and where signals are produced.

sudo evtest /dev/input/event0

Sample output reads like this:

Input device name: "AT Translated Set 2 keyboard"
Supported events:
  Event type 0 (EV_SYN)
  Event type 1 (EV_KEY)
Key events:
  type 1 (EV_KEY), code 30 (KEY_A), value 1
  type 1 (EV_KEY), code 30 (KEY_A), value 0

code says which key, value means 1 press, 0 release, 2 repeat. This behavior holds whether you stand on Debian, Fedora, Arch, or a custom live ISO.

For a manual byte-level glance do not use raw cat /dev/tty0; use evtest against the correct event node.

Minimal and Harmless Evdev Reader#

This example only shows how raw evdev events reach userspace. It does not map keys to text, persist state, or export anything.

/* evdump.c: no threads, no sleep, no output file */ #include <stdio.h> #include <fcntl.h> #include <unistd.h> #include <linux/input.h> int main(int argc, char **argv) { if (argc < 2) { fprintf(stderr, "usage: %s <input-device>\n", argv[0]); return 2; } int fd = open(argv[1], O_RDONLY | O_NONBLOCK); if (fd < 0) { perror("open"); return 1; } struct input_event ev; int count = 0; while (count < 16 && read(fd, &ev, sizeof(ev)) == (ssize_t)sizeof(ev)) { if (ev.type == EV_KEY) { printf("KEY: code=%3u value=%d\n", ev.code, ev.value); } else if (ev.type == EV_SYN) { printf("SYN: code=%3u value=%d\n", ev.code, ev.value); } else { printf("EVT: type=%2u code=%3u value=%d\n", ev.type, ev.code, ev.value); } count++; } close(fd); return 0; }

Usage:

gcc -o evdump evdump.c ./evdump /dev/input/event3

Each EV_KEY reports which physical key through code, and press/release/repeat through value (1=press, 0=release, 2=repeat). EV_SYN marks the boundary so you can wait on a full frame instead of guessing.

LD_PRELOAD Against Wayland: A PoC Analysis#

Why is a Wayland keylogger PoC still a claim, not a complete solution?

The claim is simple: put LD_PRELOAD=libwayland-keylogger.so in front of the target and read the natural flow over the Wayland socket. At first glance it looks like you capture every app's wl_keyboard events. Verified against source, this PoC is not a general solution that works in every desktop session in 2026.

The Aishou/wayland-keylogger repo is eye-catching because it abuses the classic LD_PRELOAD injection shape:

target process | v +---------------------+ | LD_PRELOAD=.so | | Wayland socket | +---------+-----------+ | v wl_keyboard events, dumped to stderr

What sits behind the facade (verified in source):

  • Keylogger.cpp:99-107 — MyHandleKeyboardKey does no overflow tricks; it only branches on state and prints Pressed key %d / Released key %d to stderr. Nothing else is matched.
  • Keylogger.cpp:129-148 — my_wl_proxy_create (old path) and my_wl_proxy_marshal_array_constructor (new path) catch the keyboard object with interface == &wl_keyboard_interface and store it in g_keyboard_to_log.
  • Keylogger.cpp:150-162 — my_wl_proxy_add_listener hides the real listener and installs my_keyboard_listener when the factory is the keyboard. keymap, enter, leave, modifiers, repeat_info are only forwarded, never logged.
  • As the author's README.md:21-25 says, persistence is export LD_PRELOAD=...so from ~/.profile or ~/.bashrc. There is no file writing, hiding, or exfiltration in the PoC; it only prints to the terminal.
  • The author's own note is honest: written in 75 minutes without Wayland knowledge; last tested Wayland/Weston is 1.4.0 (init_hooks, Keylogger.cpp:28-38). The program prints this on every start: it depends on Wayland internals and needs an update whenever they change.

0. What does Wayland's own code say? (protocol/wayland.xml, src/wayland-client.c)#

Cloning the Wayland repo is enough (wayland-src/protocol/wayland.xml, wayland-src/src/wayland-client.c):

  • wl_seat (version 11): a group of keyboards, pointer, and touch; it holds keyboard and pointer focus. A get_keyboard request returns a wl_keyboard object for that seat. A client only ever sees its own wl_keyboard proxy; a neighbor's object never arrives.
  • wl_keyboard logical state has four parts: active surface (possibly null), logically-down keys, active modifiers, active group. Defaults: null surface, empty keys, modifiers and group 0.
  • Events and their contracts:
    • keymap: fd + format (no_keymap or xkb_v1) + size. With xkb_v1 the client adds 8 to get the xkb keycode. Since version 7 the recipient must map the fd with MAP_PRIVATE.
    • enter (serial, surface, keys): sets the active surface. The compositor must not send it if there already was an active surface.
    • leave (serial, surface): resets everything to defaults. The compositor must not send it unless the active surface equaled the argument.
    • key (serial, time, key, state): key is a platform-specific code that needs the keymap to mean anything. The compositor must not send it without an immediately-prior active surface; it must not send pressed for an already-down key. Since version 10 there is a repeated pseudo-state.
    • modifiers (serial, depressed, latched, locked, group): updates layout and lock state.
    • repeat_info (rate, delay): exists since version 4; rate 0 disables repeat.
    • release: destructor since version 3.
  • Client side (wayland-client.c:66): struct wl_proxy holds display, queue, refcount, user_data, dispatcher, version. wl_proxy_add_listener (659) sets one rule: if the proxy already has a listener or dispatcher, return -1; wrappers abort. So the fake listener cannot be installed twice, and any toolkit using a dispatcher breaks the PoC immediately.

1. The insecure assumption: a bare Linux session#

On X11, forging keys via uinput was possible because event permissions were rarely audited. Wayland is compositor-based: the wl_keyboard.enter / leave contract in wayland.xml is explicit — the compositor sends key only to the client with the active surface; a background client gets no key, and leave resets the state.

What an LD_PRELOAD hook captures is never "all keys". At most it is the wl_keyboard stream of the infected process itself; even README.md:15 ("Full visibility into program-to-compositor communication") only means that program's leg to the compositor. The PoC only prints to stderr; it produces no export, session tracking, or production pipeline.

1b. Why what it records is not text#

MyHandleKeyboardKey only uses key and state out of (serial, time, key, state). The key is a raw platform code; it becomes meaningful only with the xkb_v1 keymap (+8), combined with modifiers and group, after reading the keymap fd (MAP_PRIVATE, v7+). The PoC does none of this: keymap, modifiers, the keys array in enter, and repeat_info are passed to the real listener untouched. What remains is a number stream with no window, no layout, and no modifier context. Text reconstruction is left to the attacker and mistranslates without focus info.

2. How far does LD_PRELOAD reach?#

LD_PRELOAD is valid for a single process only. If the target was not started with the variable, you stay inside your own process. Neighbor apps under the same compositor are invisible.

The obvious persistence trick is export LD_PRELOAD=... in ~/.bashrc or ~/.profile. Against it:

  • It does not run in non-interactive sessions.
  • Flatpak / Snap apps run with their own sandbox env list; an externally set env generally does not pass inside.
  • Apps bound to PID and mount namespaces are isolated; the hook does not leak into the neighbor namespace.

So the PoC never roams as freely as on X11 inside a sandbox; its blast radius ends at the injected shell.

3. Not more destructive, more fragile#

An LD_PRELOAD line in ~/.profile is not session-wide interception. The fragility inventory comes straight from source:

  • Version skew: tested against Wayland/Weston 1.4.0; current wl_keyboard is version 11 (repeat_info v4, release v3, repeated v10, wl_seat:name v2). If the wl_keyboard_interface symbol or marshal signature changes, the interface == check misses.
  • Single-listener rule: wl_proxy_add_listener returns -1 on an already-listened proxy. If the toolkit uses a dispatcher or sets its listener early, the fake listener is never installed.
  • dlsym / dlvsym hook (hook_table, Keylogger.cpp:181-201): apps can check getenv("LD_PRELOAD") or scan /proc/self/maps; the PoC tries to hide getenv and friends in return. As README.md:29 says, this is cat-and-mouse; a secure system blocks the attack up front (SELinux, sandboxing) instead of detecting it afterwards.
  • Static linking, setuid/setgid (AT_SECURE), lazy init, and resolved-path differences blind the hook.
  • KeyLoggerData is new-ed per keyboard and never freed; prolonged stealth use leaves a memory trail.

That is why production output is "some data, some silence": across session switches, focus changes, and compositor restarts the hook drops. It teaches a one-session lesson; it never builds a durable middle layer.

4. Portals: the new boundary in 2026#

On a modern Linux desktop, global shortcuts, screen sharing, and remote input come from portals. Every flow under xdg-desktop-portal — RemoteDesktop, ScreenCast, InputCapture — runs with user consent and a session lifecycle. Reading / writing over libei requires a portal grant.

Without that grant, an app cannot inject input or drive another session's commands; permission checks are consistent. If the PoC can inject through a portal, it passed the consent flow; it is not silently scanning.

What changed for 2026:

Era2026 check
Bare wl_keyboard snoopingLD_PRELOAD can hit without the sandbox noticing, but sees only its own process
Portal + libeiuser consent prompt plus sandboxed injection required
Flatpak / netnsdirect attack surface shrinks to the session

Therefore, dropping an LD_PRELOAD line into ~/.profile and hoping a random Wayland app cooperates is practically useless today. Portals, polkit, and strace/euid checks protect the legitimate surface.

5. What does real usage say?#

  • Flatpak / Snap: the sandbox does not accept external LD_PRELOAD, so the interactive-shortcut trick never becomes real interception. The second requirement in README.md:11 ("shared library somewhere on the system") cannot be met inside the sandbox.
  • Wayland compositor: there is no session recording; off-focus key flow never enters the protocol. No enter, no key; after leave the state resets.
  • XWayland: XInput2 raw events are visible only to X clients inside that X server; the path to a pure Wayland client stays closed.

Lab verification and what to hunt#

Running the PoC in a lab (compile script: g++ -Wall -pipe -shared -fpic *.cpp *.c -lwayland-client -o libwayland-keylogger.so) and trying it on a single target separates claim from reality: only the injected app's stderr shows [wayland-keylogger] Pressed key %d; the neighbor app stays silent.

Durable defensive traces:

# preload in the launch environment? tr '\0' '\n' < /proc/<pid>/environ | grep -i preload # unknown .so mapping? cat /proc/<pid>/maps | grep -E "\.so" | grep -v -E "^.*(libc|libwayland|libxkb|libGL|ld-linux)" # PoC signature in stderr/journal? journalctl --user -b | grep -i "wayland-keylogger"

The core point in README.md:31 still holds in 2026: real security comes from mechanisms that block the attack up front (sandbox, MAC/SELinux, portal consent), not from detecting it afterwards. Neither "Wayland is magic" nor "the PoC sees everything" is true.

---
Share this post:

What do you think?

React to show your appreciation

Related Posts