CVE-2026-44713
Description
pam_usb provides hardware authentication for Linux using ordinary removable media. Prior to 0.8.7, src/tmux.c reads the user's $TMUX environment variable, splits it on commas, and interpolates the socket-path component directly into a shell command passed to popen(). Because the value is placed inside double-quotes without sanitisation, any value containing " terminates the quoted string and injects arbitrary shell syntax. popen() runs as root inside the PAM stack. This vulnerability is fixed in 0.8.7.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
pam_usb before 0.8.7 allows command injection via unsanitized $TMUX environment variable in tmux.c, leading to root RCE.
Vulnerability
In pam_usb versions prior to 0.8.7, the file src/tmux.c reads the user's $TMUX environment variable, splits it on commas, and interpolates the socket-path component into a shell command passed to popen(). The command is built via snprintf(cmd, len, "LC_ALL=C; tmux -S \"%s\" list-clients ...", tmux_socket_path); Because the value is placed inside double quotes without sanitization, any value containing a double quote (") terminates the quoted string and allows injection of arbitrary shell syntax. The popen() call runs as root inside the PAM stack. Additionally, usernames containing ERE metacharacters are passed unsanitized into regcomp(), causing compile errors or widened match patterns. Affected versions: pam_usb <= 0.8.6 (all versions with tmux remote-detection enabled). [1]
Exploitation
An attacker must be a local user configured to use pam_usb for sudo, su, or login. No USB device is required. The attacker sets the TMUX environment variable to a crafted value containing a double quote and shell commands, e.g., export TMUX='/tmp/s" $(cp /bin/bash /tmp/rootsh; chmod u+s /tmp/rootsh) #,0'. When the user runs a command that triggers PAM authentication (e.g., sudo ls), pam_usb executes the injected command as root, writing a setuid root shell to /tmp/rootsh. The exploit fires before env_reset takes effect. [1]
Impact
Successful exploitation gives the attacker a root shell without possessing the registered USB device. The attacker can then execute arbitrary commands with root privileges. The vulnerability leads to complete compromise of the system's confidentiality, integrity, and availability. [1]
Mitigation
The vulnerability is fixed in pam_usb version 0.8.7. The fix validates the $TMUX socket path and client ID using pusb_tmux_is_safe_socket_path() and pusb_tmux_is_numeric_id(), rejecting values containing shell metacharacters (", $, backtick, ;, |). Additionally, username matching now escapes ERE metacharacters via pusb_tmux_escape_for_regex() before regcomp(), and the w binary is invoked via its full path /usr/bin/w. Users should upgrade to 0.8.7 or later. No workaround is available for unpatched versions. [1]
AI Insight generated on May 27, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
2Patches
1014042451850[Security] Fix multiple advisories of various crititicallity - UPDATE ASAP!
8 files changed · +170 −39
src/conf.c+1 −1 modified@@ -216,7 +216,7 @@ int pusb_conf_parse( continue; } - strcpy(opts->device_list[currentDevice].name, device_list[currentDevice]); + snprintf(opts->device_list[currentDevice].name, sizeof(opts->device_list[currentDevice].name), "%s", device_list[currentDevice]); pusb_conf_parse_device(opts, doc, currentDevice, device_list[currentDevice]); }
src/device.c+15 −3 modified@@ -50,16 +50,28 @@ static int pusb_device_connected(t_pusb_options *opts, UDisksClient *udisks) if (udisks_object_peek_drive(object)) { drive = udisks_object_get_drive(object); - retval = strcmp(udisks_drive_get_serial(drive), opts->device_list[currentDevice].serial) == 0; + + const gchar *serial = udisks_drive_get_serial(drive); + if (!serial) + { + log_debug("Drive has no serial number, skipping.\n"); + g_object_unref(drive); + continue; + } + retval = strcmp(serial, opts->device_list[currentDevice].serial) == 0; if (strcmp(opts->device_list[currentDevice].vendor, "Generic") != 0) { - retval = retval && strcmp(udisks_drive_get_vendor(drive), opts->device_list[currentDevice].vendor) == 0; + const gchar *vendor = udisks_drive_get_vendor(drive); + retval = retval && vendor != NULL && + strcmp(vendor, opts->device_list[currentDevice].vendor) == 0; } if (strcmp(opts->device_list[currentDevice].model, "Generic") != 0) { - retval = retval && strcmp(udisks_drive_get_model(drive), opts->device_list[currentDevice].model) == 0; + const gchar *model = udisks_drive_get_model(drive); + retval = retval && model != NULL && + strcmp(model, opts->device_list[currentDevice].model) == 0; } g_object_unref(drive);
src/pad.c+52 −20 modified@@ -43,7 +43,7 @@ static int pusb_pad_build_device_path( struct stat sb; snprintf(path_devpad, sizeof(path_devpad), "%s/%s", mnt_point, opts->device_pad_directory); - if (stat(path_devpad, &sb) != 0) + if (lstat(path_devpad, &sb) != 0) { log_debug("Directory %s does not exist, creating it.\n", path_devpad); if (mkdir(path_devpad, S_IRUSR | S_IWUSR | S_IXUSR) != 0) @@ -52,6 +52,11 @@ static int pusb_pad_build_device_path( return 0; } } + else if (S_ISLNK(sb.st_mode)) + { + log_error("Device pad directory %s is a symlink, refusing to use it.\n", path_devpad); + return 0; + } snprintf( path_out, @@ -91,7 +96,7 @@ static int pusb_pad_build_system_path( user_ent->pw_dir, opts->system_pad_directory ); - if (stat(dir_path, &sb) != 0) + if (lstat(dir_path, &sb) != 0) { log_debug("Directory %s does not exist, creating one.\n", dir_path); if (mkdir(dir_path, S_IRUSR | S_IWUSR | S_IXUSR) != 0) @@ -107,6 +112,11 @@ static int pusb_pad_build_system_path( chmod(dir_path, S_IRUSR | S_IWUSR | S_IXUSR); } + else if (S_ISLNK(sb.st_mode)) + { + log_error("System pad directory %s is a symlink, refusing to use it.\n", dir_path); + return 0; + } /* change slashes in device name to underscores */ snprintf(device_name, sizeof(device_name), "%s", opts->device.name); while (*device_name_ptr) @@ -137,19 +147,26 @@ static FILE *pusb_pad_open_device( const char *mode ) { - FILE *f; char path[1024*5]; + int flags = (mode[0] == 'r') ? O_RDONLY : (O_WRONLY | O_CREAT | O_TRUNC); if (!pusb_pad_build_device_path(opts, mnt_point, user, path, sizeof(path))) { return NULL; } - f = fopen(path, mode); - if (!f) + int fd = open(path, flags | O_NOFOLLOW, 0600); + if (fd < 0) { log_debug("Cannot open device file: %s\n", strerror(errno)); return NULL; } + FILE *f = fdopen(fd, mode); + if (!f) + { + close(fd); + log_debug("Cannot fdopen device file: %s\n", strerror(errno)); + return NULL; + } return f; } @@ -159,19 +176,26 @@ static FILE *pusb_pad_open_system( const char *mode ) { - FILE *f; char path[1024*5]; + int flags = (mode[0] == 'r') ? O_RDONLY : (O_WRONLY | O_CREAT | O_TRUNC); if (!pusb_pad_build_system_path(opts, user, path, sizeof(path))) { return NULL; } - f = fopen(path, mode); - if (!f) + int fd = open(path, flags | O_NOFOLLOW, 0600); + if (fd < 0) { log_debug("Cannot open system file: %s\n", strerror(errno)); return NULL; } + FILE *f = fdopen(fd, mode); + if (!f) + { + close(fd); + log_debug("Cannot fdopen system file: %s\n", strerror(errno)); + return NULL; + } return f; } @@ -295,19 +319,27 @@ static int pusb_pad_update( generateRandom(magic, sizeof(magic)); - if (!(f_device = fopen(path_device_tmp, "w"))) { - log_error("Unable to create temp device pad: %s\n", strerror(errno)); - return 0; + int fd_dev = open(path_device_tmp, O_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW, 0600); + if (fd_dev < 0 || !(f_device = fdopen(fd_dev, "w"))) + { + if (fd_dev >= 0) close(fd_dev); + log_error("Unable to create temp device pad: %s\n", strerror(errno)); + return 0; + } } pusb_pad_protect(user, fileno(f_device)); - if (!(f_system = fopen(path_system_tmp, "w"))) { - log_error("Unable to create temp system pad: %s\n", strerror(errno)); - fclose(f_device); - unlink(path_device_tmp); - return 0; + int fd_sys = open(path_system_tmp, O_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW, 0600); + if (fd_sys < 0 || !(f_system = fdopen(fd_sys, "w"))) + { + if (fd_sys >= 0) close(fd_sys); + log_error("Unable to create temp system pad: %s\n", strerror(errno)); + fclose(f_device); + unlink(path_device_tmp); + return 0; + } } pusb_pad_protect(user, fileno(f_system)); @@ -403,19 +435,19 @@ static int pusb_pad_compare( } log_debug("Loading device pad...\n"); bytes_read = fread(magic_device, sizeof(char), sizeof(magic_device), f_device); - if (!bytes_read) + if (bytes_read != sizeof(magic_device)) { - log_error("Can't read device pad!\n"); + log_error("Device pad is incomplete (%zu/%zu bytes).\n", bytes_read, sizeof(magic_device)); fclose(f_system); fclose(f_device); return 0; } log_debug("Loading system pad...\n"); bytes_read = fread(magic_system, sizeof(char), sizeof(magic_system), f_system); - if (!bytes_read) + if (bytes_read != sizeof(magic_system)) { - log_error("Can't read system pad!\n"); + log_error("System pad is incomplete (%zu/%zu bytes).\n", bytes_read, sizeof(magic_system)); fclose(f_system); fclose(f_device); return 0;
src/tmux.c+63 −5 modified@@ -18,11 +18,52 @@ #include <stdio.h> #include <string.h> #include <stdlib.h> +#include <ctype.h> #include <regex.h> #include "log.h" #include "process.h" #include "mem.h" +/* Reject characters that could break out of shell double-quotes or inject commands. */ +static int pusb_tmux_is_safe_socket_path(const char *path) +{ + if (!path || *path == '\0') return 0; + for (const char *p = path; *p; p++) { + switch (*p) { + case '"': case '\'': case '`': case '$': case '\\': + case '\n': case '\r': case '\t': case '!': case ';': + case '&': case '|': case '<': case '>': case '(': + case ')': case '{': case '}': + return 0; + } + } + return 1; +} + +/* Client ID from TMUX must be purely numeric. */ +static int pusb_tmux_is_numeric_id(const char *s) +{ + if (!s || *s == '\0') return 0; + for (; *s; s++) { + if (!isdigit((unsigned char)*s)) return 0; + } + return 1; +} + +/* Escape ERE metacharacters in username so it matches literally in the regex. */ +static void pusb_tmux_escape_for_regex(const char *src, char *dst, size_t dstlen) +{ + const char *metachar = "\\.^$*+?{}[]|()"; + size_t j = 0; + for (size_t i = 0; src[i] && j + 2 < dstlen; i++) { + if (strchr(metachar, (int)src[i])) { + dst[j++] = '\\'; + } + dst[j++] = src[i]; + } + dst[j] = '\0'; +} + char *pusb_tmux_get_client_tty(pid_t env_pid) { char *tmux_details = getenv("TMUX"); @@ -49,6 +90,18 @@ char *pusb_tmux_get_client_tty(pid_t env_pid) char *tmux_socket_path = strtok(tmux_details, ","); log_debug(" Got tmux_socket_path: %s\n", tmux_socket_path); + if (!pusb_tmux_is_safe_socket_path(tmux_socket_path)) + { + log_error("TMUX socket path contains invalid characters, denying.\n"); + return NULL; + } + + if (!pusb_tmux_is_numeric_id(tmux_client_id)) + { + log_error("TMUX client ID is not numeric, denying.\n"); + return NULL; + } + size_t get_tmux_session_details_cmd_len = strlen(tmux_socket_path) + strlen(tmux_client_id) + 64; char *get_tmux_session_details_cmd = xmalloc(get_tmux_session_details_cmd_len); snprintf(get_tmux_session_details_cmd, get_tmux_session_details_cmd_len, "LC_ALL=C; tmux -S \"%s\" list-clients -t \"\\$%s\"", tmux_socket_path, tmux_client_id); @@ -82,8 +135,9 @@ char *pusb_tmux_get_client_tty(pid_t env_pid) log_debug(" Closing pipe for 'tmux list-clients' failed, this is quite a wtf...\n"); } - char *result = xmalloc(strlen(tmux_client_tty) + 1); - strcpy(result, tmux_client_tty); + size_t tty_len = strlen(tmux_client_tty); + char *result = xmalloc(tty_len + 1); + memcpy(result, tmux_client_tty, tty_len + 1); return result; } else @@ -107,24 +161,28 @@ int pusb_tmux_has_remote_clients(const char* username) char regex_raw[BUFSIZ]; char buf[BUFSIZ]; char msgbuf[100]; - char regex_tpl[2][BUFSIZ] = { + const char *regex_tpl[2] = { "(.+)([0-9]{1,3})\\.([0-9]{1,3})\\.([0-9]{1,3})\\.([0-9]{1,3})(.+)tmux(.+)", //v4 "(.+)([0-9A-Fa-f]{1,4}):([0-9A-Fa-f]{1,4}):([0-9A-Fa-f]{1,4}):([0-9A-Fa-f]{1,4})(.+)tmux(.+)" // v6 }; // ... yes, these allow invalid addresses. No, I don't care. This isn't about validation but detecting remote access. Good enough ¯\_(ツ)_/¯ + /* Max Linux username is 32 chars; doubled for escaping + null terminator = 65 bytes. */ + char escaped_username[65] = {0}; + pusb_tmux_escape_for_regex(username, escaped_username, sizeof(escaped_username)); + for (int i = 0; i <= 1; i++) { log_debug(" Checking for IPv%d connections...\n", (4 + (i * 2))); - if ((fp = popen("LC_ALL=C; w", "r")) == NULL) + if ((fp = popen("LC_ALL=C; /usr/bin/w", "r")) == NULL) { log_error("tmux detected, but couldn't get `w`. Denying since remote check for tmux impossible without it!\n"); return -1; } while (fgets(buf, BUFSIZ, fp) != NULL) { - snprintf(regex_raw, BUFSIZ, "%s%s", username, regex_tpl[i]); + snprintf(regex_raw, BUFSIZ, "%s%s", escaped_username, regex_tpl[i]); status = regcomp(®ex, regex_raw, REG_EXTENDED); if (status)
tools/pamusb-agent+14 −4 modified@@ -194,13 +194,22 @@ def userDeviceThread(user): else: logger.error('Ignoring empty command for user "%s".' % userName) + _DANGEROUS_ENV_VARS = { + 'LD_PRELOAD', 'LD_LIBRARY_PATH', 'LD_AUDIT', 'LD_DEBUG', + 'GLIBC_TUNABLES', 'PYTHONPATH', 'PERL5LIB', 'RUBYLIB', + 'IFS', 'CDPATH', 'ENV', 'BASH_ENV', + } + for henv in hotplug.findall('env'): if henv.text is not None: henv_var = re.sub(r'^(.*?)=.*$', '\\1', henv.text) henv_arg = re.sub(r'^.*?=(.*)$', '\\1', henv.text) if henv_var != '' and henv_arg != '': - henvs[henv_var] = henv_arg + if henv_var in _DANGEROUS_ENV_VARS: + logger.error('Rejecting dangerous env var "%s" for user "%s".' % (henv_var, userName)) + else: + henvs[henv_var] = henv_arg else: logger.error('Ignoring invalid command environment variable for user "%s".' % userName) else: @@ -269,9 +278,10 @@ def userDeviceThread(user): logger.info('Device "%s" has been inserted. Performing verification...' % deviceName) - cmdLine = "%s --debug --config=%s --service=pamusb-agent %s" % (options['check'], options['configFile'], userName) - logger.info('Executing "%s"' % cmdLine) - if not os.system(cmdLine): + checkArgs = [options['check'], '--debug', '--config=' + options['configFile'], '--service=pamusb-agent', userName] + logger.info('Executing "%s"' % ' '.join(checkArgs)) + checkResult = subprocess.run(checkArgs, capture_output=True) + if checkResult.returncode == 0: logger.info('Authentication succeeded. Unlocking user "%s"...' % userName) for l in events['unlock']:
tools/pamusb-conf+11 −1 modified@@ -17,6 +17,7 @@ import sys import os +import re import gi import subprocess import socket @@ -352,14 +353,23 @@ def resetPads(): if user.getAttribute('id') == options['resetPads']: # Device specific pad for user userDevice = user.getElementsByTagName('device')[0].firstChild.nodeValue + if '/' in userDevice or '..' in userDevice: + print('Invalid device ID in config (path traversal): %s' % userDevice) + sys.exit(1) padFiles.append('/home/%s/.pamusb/%s.pad' % (options['resetPads'], userDevice)) # User specific pad for device for details in devicesObj: if details.getAttribute('id') == userDevice: deviceUUID = details.getElementsByTagName('volume_uuid')[0].firstChild.nodeValue + if not re.match(r'^[0-9a-fA-F\-]+$', deviceUUID): + print('Invalid device UUID in config: %s' % deviceUUID) + sys.exit(1) try: - deviceMountPoint = subprocess.check_output(['mount | grep `readlink -f /dev/disk/by-uuid/%s` | awk \'{print $3}\'' % (deviceUUID)], shell=True) + deviceMountPoint = subprocess.check_output( + ['findmnt', '-n', '-o', 'TARGET', '--source', '/dev/disk/by-uuid/' + deviceUUID], + stderr=subprocess.DEVNULL + ) padFiles.append('%s/.pamusb/%s.%s.pad' % (deviceMountPoint.decode().strip(), options['resetPads'], socket.gethostname())) except subprocess.CalledProcessError as err: print('Could not get device mountpoint: ', err)
tools/pamusb-keyring-unlock-gnome+8 −4 modified@@ -39,11 +39,15 @@ else logger -p local0.notice -t ${0##*/}[$$] Killed existing gnome-keyring-instance. fi -# Read UNLOCK_PASSWORD from $KEYFILE -. ~/.pamusb/.keyring_unlock_password +# Read UNLOCK_PASSWORD from $KEYFILE via grep to avoid sourcing arbitrary shell +UNLOCK_PASSWORD=$(grep -oP '^UNLOCK_PASSWORD=\K.*' ~/.pamusb/.keyring_unlock_password) +if [ -z "$UNLOCK_PASSWORD" ]; then + logger -p local0.error -t ${0##*/}[$$] Could not read UNLOCK_PASSWORD from file. + exit 1 +fi -# Perform unlock -echo -n $UNLOCK_PASSWORD | gnome-keyring-daemon --daemonize --login \ +# Perform unlock — pass via stdin pipe so the password never appears as a process argument +printf '%s' "$UNLOCK_PASSWORD" | gnome-keyring-daemon --daemonize --login \ && gnome-keyring-daemon --start > /dev/null 2>&1; UNLOCKED=$? \ && logger -p local0.notice -t ${0##*/}[$$] Keyring unlocked. \ || logger -p local0.error -t ${0##*/}[$$] Keyring unlock failed.
tools/pamusb-pinentry+6 −1 modified@@ -16,6 +16,7 @@ # Street, Fifth Floor, Boston, MA 02110-1301 USA. import os +import sys import subprocess import getpass from dotenv import load_dotenv @@ -35,4 +36,8 @@ if (isAuthenticated.returncode == 0): exit() print("OK") else: - subprocess.run(fallbackPinentryApp) + if fallbackPinentryApp and os.path.isabs(fallbackPinentryApp) and os.path.isfile(fallbackPinentryApp) and os.access(fallbackPinentryApp, os.X_OK): + subprocess.run([fallbackPinentryApp]) + else: + sys.stderr.write('PINENTRY_FALLBACK_APP is not set or not a valid executable path, cannot fall back.\n') + sys.exit(1)
Vulnerability mechanics
Root cause
"Missing input sanitization of the $TMUX environment variable allows shell command injection via a double-quote character in the socket path component."
Attack vector
A local user who is configured to use pam_usb for authentication (e.g., via sudo, su, or login) can exploit this vulnerability without possessing the registered USB device. The attacker sets the $TMUX environment variable to a crafted value containing a double-quote and arbitrary shell commands, e.g., `export TMUX='/tmp/s" $(cp /bin/bash /tmp/rootsh; chmod u+s /tmp/rootsh) #,0'`. When PAM invokes pam_usb, `src/tmux.c` reads $TMUX, splits it on commas, and interpolates the socket-path component into a `popen()` call via `snprintf(cmd, len, "LC_ALL=C; tmux -S \"%s\" list-clients ...", tmux_socket_path)`. The injected double-quote terminates the quoted string, allowing arbitrary shell syntax to execute as root [ref_id=1].
Affected code
The vulnerable code is in `src/tmux.c`. The function reads the user's $TMUX environment variable, splits it on commas, and interpolates the socket-path component directly into a shell command passed to `popen()` without sanitization [ref_id=1].
What the fix does
The fix, committed in d9acb56, introduces `pusb_tmux_is_safe_socket_path()` and `pusb_tmux_is_numeric_id()` to validate the $TMUX socket path and client ID before any command is built. Values containing shell metacharacters (", $, backtick, ;, |) are rejected outright, preventing injection. Additionally, usernames are now escaped via `pusb_tmux_escape_for_regex()` before being passed to `regcomp()`, and the `w` binary is invoked via its full path `/usr/bin/w` to avoid PATH-based attacks [ref_id=1].
Preconditions
- configThe user must be configured to use pam_usb for authentication (e.g., via sudo, su, or login).
- inputThe attacker must have local shell access to set the $TMUX environment variable.
- authNo USB device is required — the exploit fires before env_reset takes effect.
Reproduction
Set the $TMUX environment variable to a crafted payload: `export TMUX='/tmp/s" $(cp /bin/bash /tmp/rootsh; chmod u+s /tmp/rootsh) #,0'`. Then run `sudo ls` (or any command that triggers PAM authentication via pam_usb). The PAM stack calls `popen()` as root, which writes a setuid root shell to `/tmp/rootsh`. Execute `/tmp/rootsh -p` to obtain a root shell [ref_id=1].
Generated on May 27, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
1News mentions
0No linked articles in our index yet.