CVE-2026-48792
Description
pam_usb provides hardware authentication for Linux using ordinary removable media. Prior to 0.9.1, src/evdev.c silently ignores EACCES errors when opening /dev/input/event* nodes, causing pusb_has_virtual_input_device() to return 0 (no virtual devices found) even when every open() call failed due to insufficient permissions. The caller in src/local.c cannot distinguish a clean absence of virtual devices from a permission-denied scan, and acts on the false negative by continuing authentication without denying. This vulnerability is fixed in 0.9.1.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
pam_usb before 0.9.1 silently ignores EACCES when scanning /dev/input/event*, returning false negative for virtual device detection, risking authentication bypass during non-root testing.
Vulnerability
The vulnerability resides in src/evdev.c within the pusb_has_virtual_input_device() function. When opening /dev/input/event* nodes, the code silently ignores EACCES errors by using continue on any open() failure, returning 0 (no virtual devices) even when every open call failed due to insufficient permissions [1][2]. This affects pam_usb versions prior to 0.9.1. The caller in src/local.c cannot distinguish a clean absence of virtual devices from a permission-denied scan, leading to a false negative [1].
Exploitation
An attacker must execute pamusb-check or the PAM module without root privileges, or in a context where the process lacks read access to /dev/input/event* nodes (typically root:input permissions). No authentication or user interaction is required beyond running the check as a non-root user. The silent error handling causes the function to report no virtual devices, making the remote desktop check appear functional while it is actually non-functional [1][2].
Impact
Successful exploitation provides a false sense of security during configuration testing. In deployments where the PAM module runs with reduced privileges (unusual but possible via PAM configuration), the remote desktop check is silently disabled, potentially allowing authentication bypass if virtual input devices are present. This constitutes a protection mechanism failure (CWE-693) [2].
Mitigation
The fix is released in pam_usb version 0.9.1, which modifies pusb_has_virtual_input_device() to return a distinct sentinel value (-1) when all opens fail due to permission denial, enabling the caller to handle the inconclusive state [1][2]. Users should upgrade to 0.9.1 or later. No workarounds exist for unpatched versions; administrators should ensure pamusb-check is run as root for proper validation. The issue is not listed in the Known Exploited Vulnerabilities catalog.
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.
Patches
163f2bac1df75[Security] Distinguish EACCES from no virtual device in evdev scan (#360)
7 files changed · +109 −17
.claude/prompt-templates/handle-pr-feedback-loop.md+3 −3 modified@@ -1,7 +1,7 @@ -There are new comments in the PR for this branch. +There are new comments in the PR for this branch. Review them, fix, commit, push, resolve comments. -Post "/gemini review" in the PR after that and wait until gemini-code-assist has responded. Then fix the review. +Post "/gemini review" in the PR after that and wait until gemini-code-assist has responded. Then fix the review. -Repeat this until Gemini (or DevSkim, or Github Security, or the Maintainer) has nothing left to mock about. If comments are unclear, respond with a detailed request for clarification. +Repeat this until Gemini (or DevSkim, or github-advanced-security, or the Maintainer) has nothing left to mock about. If comments are unclear, respond with a detailed request for clarification.
.claude/prompt-templates/new-pr.md+6 −7 modified@@ -1,9 +1,8 @@ -Commit this, push it and create a new PR. +Commit this, push it and create a new PR. Wait till the CI has passed, if not: fix it. -Wait until gemini-code-assist has responded. Then get all comments. +When CI passed: + - Wait until gemini-code-assist has responded. Then get all comments. + - Review them, fix, commit, push, resolve comments. + - Post "/gemini review" in the PR after that and wait until gemini-code-assist has responded. Then address the feedback. -Review them, fix, commit, push, resolve comments. - -Post "/gemini review" in the PR after that and wait until gemini-code-assist has responded. Then address the feedback. - -Repeat this until Gemini (or DevSkim, or GitHub Security, or the Maintainer) has no further feedback. If comments are unclear, respond with a detailed request for clarification. +Repeat this until Gemini (or DevSkim, or github-advanced-security, or the Maintainer) has no further feedback. If comments are unclear, respond with a detailed request for clarification.
src/evdev.c+9 −3 modified@@ -17,6 +17,7 @@ #include <stdio.h> #include <string.h> +#include <errno.h> #include <dirent.h> #include <fcntl.h> #include <unistd.h> @@ -31,12 +32,14 @@ int pusb_has_virtual_input_device(const char *input_dir) DIR *d = opendir(input_dir); if (!d) { + int saved_errno = errno; log_debug(" Could not open %s for evdev scan\n", input_dir); - return 0; + return (saved_errno == EACCES || saved_errno == EPERM) ? -1 : 0; } char dev_path[PATH_MAX]; struct dirent *ent; + int permission_denied = 0; while ((ent = readdir(d)) != NULL) { if (strncmp(ent->d_name, "event", 5) != 0) @@ -45,8 +48,11 @@ int pusb_has_virtual_input_device(const char *input_dir) snprintf(dev_path, sizeof(dev_path), "%s/%s", input_dir, ent->d_name); int fd = open(dev_path, O_RDONLY | O_NONBLOCK | O_CLOEXEC); - if (fd < 0) + if (fd < 0) { + if (errno == EACCES || errno == EPERM) + permission_denied = 1; continue; + } struct libevdev *dev = NULL; int rc = libevdev_new_from_fd(fd, &dev); @@ -82,5 +88,5 @@ int pusb_has_virtual_input_device(const char *input_dir) } closedir(d); - return 0; + return permission_denied ? -1 : 0; }
src/evdev.h+7 −1 modified@@ -25,7 +25,13 @@ * - physical path is NULL or empty * - device has keyboard (EV_KEY) or pointer (EV_REL/EV_ABS) capabilities * - * Returns 1 if such a device is found, 0 otherwise. + * Returns: + * 1 — virtual input device found + * 0 — no virtual input device found + * -1 — scan inconclusive: at least one device could not be opened due to + * insufficient permissions (EACCES or EPERM, the latter returned by + * some LSMs such as AppArmor/SELinux); result should be treated as a + * warning, not a definitive negative * Production callers should pass "/dev/input". */ int pusb_has_virtual_input_device(const char *input_dir);
src/local.c+6 −1 modified@@ -352,9 +352,14 @@ int pusb_local_login(t_pusb_options *opts, const char *user, const char *service log_error("Active remote desktop service with connection detected, denying.\n"); return (0); } - if (pusb_has_virtual_input_device("/dev/input")) { + int evdev_result = pusb_has_virtual_input_device("/dev/input"); + if (evdev_result == 1) { log_error("Virtual input device detected (possible remote desktop tool), denying.\n"); return (0); + } else if (evdev_result == -1) { + log_error("Cannot check for virtual input devices (permission denied on /dev/input). " + "Run pamusb-check as root or add user to the 'input' group for reliable " + "remote desktop detection. If using AppArmor/SELinux, check policy.\n"); } }
tests/unit/c/evdev_test.c+67 −2 modified@@ -12,6 +12,7 @@ #include <stddef.h> #include <setjmp.h> #include <string.h> +#include <errno.h> #include <cmocka.h> #include <linux/input.h> @@ -21,10 +22,12 @@ typedef struct { const char *phys; unsigned int event_types; int init_fails; + int open_errno; } fake_device_t; extern fake_device_t g_mock_devices[]; extern int g_mock_device_count; +extern int g_opendir_errno; void fake_evdev_reset(void); /* Include source directly */ @@ -38,6 +41,14 @@ void fake_evdev_reset(void); g_mock_devices[idx].phys = (ph); \ g_mock_devices[idx].event_types = (et); \ g_mock_devices[idx].init_fails = 0; \ + g_mock_devices[idx].open_errno = 0; \ + g_mock_device_count = (idx) + 1; \ + } while(0) + +#define SETUP_DEVICE_EACCES(idx) \ + do { \ + memset(&g_mock_devices[idx], 0, sizeof(g_mock_devices[idx])); \ + g_mock_devices[idx].open_errno = EACCES; \ g_mock_device_count = (idx) + 1; \ } while(0) @@ -139,6 +150,55 @@ static void test_physical_before_virtual(void **state) assert_int_equal(1, pusb_has_virtual_input_device("/dev/input")); } +static void test_opendir_eacces_returns_inconclusive(void **state) +{ + (void)state; + /* opendir() itself fails with EACCES → should return -1, not 0 */ + g_opendir_errno = EACCES; + g_mock_device_count = 0; + assert_int_equal(-1, pusb_has_virtual_input_device("/dev/input")); +} + +static void test_opendir_eperm_returns_inconclusive(void **state) +{ + (void)state; + /* opendir() fails with EPERM (e.g. AppArmor/SELinux policy) → should return -1 */ + g_opendir_errno = EPERM; + g_mock_device_count = 0; + assert_int_equal(-1, pusb_has_virtual_input_device("/dev/input")); +} + +static void test_all_devices_eperm_returns_inconclusive(void **state) +{ + (void)state; + /* All opens fail with EPERM (LSM policy) → should return -1, not 0 */ + g_mock_devices[0].open_errno = EPERM; + g_mock_device_count = 1; + assert_int_equal(-1, pusb_has_virtual_input_device("/dev/input")); +} + +static void test_all_devices_eacces_returns_inconclusive(void **state) +{ + (void)state; + /* All opens fail with EACCES → should return -1, not 0 */ + SETUP_DEVICE_EACCES(0); + assert_int_equal(-1, pusb_has_virtual_input_device("/dev/input")); +} + +static void test_eacces_then_virtual_returns_found(void **state) +{ + (void)state; + /* First device EACCES, second is virtual keyboard → should return 1 */ + SETUP_DEVICE_EACCES(0); + g_mock_devices[1].bustype = BUS_VIRTUAL; + g_mock_devices[1].phys = NULL; + g_mock_devices[1].event_types = (1u << EV_KEY); + g_mock_devices[1].init_fails = 0; + g_mock_devices[1].open_errno = 0; + g_mock_device_count = 2; + assert_int_equal(1, pusb_has_virtual_input_device("/dev/input")); +} + int main(void) { const struct CMUnitTest tests[] = { @@ -150,8 +210,13 @@ int main(void) cmocka_unit_test_setup(test_virtual_no_input_capability, setup), cmocka_unit_test_setup(test_virtual_with_phys_path, setup), cmocka_unit_test_setup(test_virtual_with_empty_phys, setup), - cmocka_unit_test_setup(test_open_fails_skipped, setup), - cmocka_unit_test_setup(test_physical_before_virtual, setup), + cmocka_unit_test_setup(test_open_fails_skipped, setup), + cmocka_unit_test_setup(test_physical_before_virtual, setup), + cmocka_unit_test_setup(test_opendir_eacces_returns_inconclusive, setup), + cmocka_unit_test_setup(test_opendir_eperm_returns_inconclusive, setup), + cmocka_unit_test_setup(test_all_devices_eacces_returns_inconclusive, setup), + cmocka_unit_test_setup(test_all_devices_eperm_returns_inconclusive, setup), + cmocka_unit_test_setup(test_eacces_then_virtual_returns_found, setup), }; return cmocka_run_group_tests(tests, NULL, NULL); }
tests/unit/c/fake_libevdev.c+11 −0 modified@@ -29,15 +29,18 @@ typedef struct { const char *phys; /* NULL means no physical path */ unsigned int event_types; /* bitmask: bit EV_KEY, EV_REL, EV_ABS */ int init_fails; /* if non-zero, libevdev_new_from_fd returns -ENODEV */ + int open_errno; /* if non-zero, __wrap_open sets errno to this and returns -1 */ } fake_device_t; fake_device_t g_mock_devices[FAKE_EVDEV_MAX_DEVICES]; int g_mock_device_count = 0; +int g_opendir_errno = 0; void fake_evdev_reset(void) { memset(g_mock_devices, 0, sizeof(g_mock_devices)); g_mock_device_count = 0; + g_opendir_errno = 0; } /* ── Fake DIR / dirent for /dev/input ── */ @@ -104,6 +107,10 @@ DIR *__wrap_opendir(const char *name) { (void)name; g_mock_dirent_idx = 0; + if (g_opendir_errno != 0) { + errno = g_opendir_errno; + return NULL; + } if (g_mock_device_count == 0) return NULL; return (DIR *)&g_fake_dir_sentinel; @@ -141,6 +148,10 @@ int __wrap_open(const char *pathname, int flags, ...) errno = ENOENT; return -1; } + if (g_mock_devices[idx].open_errno != 0) { + errno = g_mock_devices[idx].open_errno; + return -1; + } return idx; /* use index as fd */ }
Vulnerability mechanics
Root cause
"Missing error handling for EACCES in src/evdev.c causes permission-denied open() failures to be silently ignored, making pusb_has_virtual_input_device() return a false negative."
Attack vector
An attacker with local non-root access (or any process not in the `input` group) triggers the bug by running `pamusb-check` or any PAM flow that invokes the evdev scan [ref_id=1][ref_id=2]. Because `/dev/input/event*` nodes are owned by `root:input` with permissions `crw-r-----`, every `open()` call fails with `EACCES` [ref_id=1][ref_id=2]. The loop silently continues past each failure, `pusb_has_virtual_input_device()` returns 0 ("no virtual devices"), and the caller in `src/local.c` treats this as a clean absence, allowing authentication to proceed without the remote-desktop check [ref_id=1][ref_id=2]. This constitutes a protection mechanism failure [CWE-693].
Affected code
The bug is in `src/evdev.c` lines 47-49, where `open()` failures on `/dev/input/event*` nodes are silently swallowed with `continue` [ref_id=1][ref_id=2]. The caller in `src/local.c:355` acts on the return value of `pusb_has_virtual_input_device()` but cannot distinguish a clean scan from a permission-denied scan [ref_id=1][ref_id=2].
What the fix does
The fix modifies `src/evdev.c` to track permission-denied errors by setting a `permission_denied` flag when `errno == EACCES` [ref_id=1][ref_id=2]. Instead of always returning 0, the function now returns -1 when all opens failed due to permission denial [ref_id=1][ref_id=2]. The caller in `src/local.c` handles this new sentinel by logging a warning that the check was inconclusive, rather than silently treating the scan as a clean absence of virtual devices [ref_id=1][ref_id=2]. The function comment in `src/evdev.h` is also updated to document the three return values (1, 0, -1) [ref_id=1].
Preconditions
- authAttacker must be a local non-root user not in the 'input' group, or the PAM module must be running with reduced privileges
- configThe system must have /dev/input/event* nodes with permissions crw-r----- owned by root:input
- inputThe attacker must trigger a PAM authentication flow or run pamusb-check that invokes pusb_has_virtual_input_device()
Generated on May 27, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
3News mentions
0No linked articles in our index yet.