Hardening Linux Against n_gsm TTY GSM Multiplexer Privilege Escalation
Problem
The n_gsm kernel module implements the GSM 07.10 serial multiplexing line discipline. Its purpose is embedded and cellular: it splits one serial port into multiple logical channels, letting modems appear as several independent devices. On a typical server, container host, or cloud VM this functionality is entirely unused — but the module is present, auto-loadable, and regularly exploitable.
Privilege escalation via n_gsm is not a single CVE. It is a structural vulnerability class that has produced multiple independent bugs across kernel releases. The common pattern is always the same: an unprivileged process with access to any TTY character device opens a serial or virtual terminal, calls ioctl(fd, TIOCSETD, &ldisc) to attach the n_gsm line discipline, and then exercises race conditions or type-confusion bugs inside the GSM mux core to corrupt kernel memory and elevate to root.
Published examples include:
- CVE-2023-6546: use-after-free in
gsm_cleanup_mux()triggered when a racingclose()races with a DLCI teardown. Reachable from unprivileged namespaces on kernels 5.15–6.6. - CVE-2022-20565 (Android upstream): out-of-bounds write via malformed configuration ioctl, merged from the same subsystem.
- CVE-2024-36016: slab out-of-bounds read via
gsm_read()on a misconfigured mux, present in 6.1 LTS and fixed in 6.1.93.
Beyond concrete CVEs, n_gsm has been included as a reachable target in multiple LPE exploit chains developed in 2024–2025 by research groups targeting container-escape primitives. The subsystem’s age (written primarily in the 2000s), its dense ioctl surface, and its involvement of TTY layer locking make it a recurring source of kernel bugs.
On most Linux systems there is no workload that legitimately needs n_gsm. Servers, Kubernetes nodes, CI runners, and cloud instances have no GSM modem. The entire module can be blocked. Even on edge devices that do use cellular modems, access to the line discipline attachment ioctl can be restricted to privileged users, preventing unprivileged exploitation.
The secondary concern is that TTY device access is broader than most engineers assume. /dev/tty, /dev/ptmx, and the /dev/pts/* pseudo-terminal slaves are accessible to any process running in a user session. Container runtimes that mount /dev or that allow TTY=true in their pod spec expose these paths inside containers, making n_gsm reachable even from inside what appear to be well-sandboxed workloads.
Target systems: Linux kernel 5.10–6.12 on amd64 and arm64; Kubernetes nodes running containerd ≥1.6 or Docker ≥20.10 with default device mounts; any distribution shipping CONFIG_N_GSM=m (Debian, Ubuntu, RHEL 8/9, Amazon Linux 2023, Flatcar).
Threat Model
Adversary 1 — Container process with default device mount. Access level: unprivileged UID inside a container with /dev/ptmx or /dev/tty mounted (the default for kubectl exec, docker run -it, most Helm charts with tty: true). Objective: attach n_gsm line discipline via ioctl(TIOCSETD), trigger a kernel bug, pivot to host root and escape the container.
Adversary 2 — SSH user on a multi-tenant host. Access level: legitimate shell user, no sudo. Objective: obtain local root on a shared build server or jump host using a kernel LPE before moving laterally to secrets or credential stores.
Adversary 3 — CI pipeline code execution. Access level: code running inside a GitHub Actions self-hosted runner or GitLab Runner on a shared host. Objective: escape the runner sandbox to steal repository secrets, signing keys, or cloud metadata credentials from the host environment.
Adversary 4 — Kubernetes init container. Access level: init container with securityContext.privileged: false but without a Seccomp profile. Objective: use the ioctl syscall (unrestricted by default) to attach n_gsm and trigger a UAF before the main workload starts.
Without hardening, exploitation of n_gsm gives an attacker full kernel code execution. From that point: all container namespaces are compromised, all secrets in memory are accessible, and the node can be used as a pivot into the cluster. With hardening (module blocked + TIOCSETD ioctl blocked via Seccomp or LSM), the attack surface is removed entirely and no kernel bug in n_gsm is reachable regardless of future discoveries.
Configuration / Implementation
Step 1 — Verify whether n_gsm is currently loaded
# Check if the module is loaded
lsmod | grep n_gsm
# Check if it is auto-loadable
modinfo n_gsm 2>/dev/null | head -5
# Check which kernel config enabled it
grep CONFIG_N_GSM /boot/config-$(uname -r)
If modinfo returns output, the module exists on disk and can be loaded on demand by any process that calls ioctl(TIOCSETD) with N_GSM0710. The kernel auto-loads modules when an unknown line discipline number is referenced from userspace — no modprobe call is required.
Step 2 — Block the module via modprobe deny-list
The simplest and most effective control is to prevent module loading entirely.
# /etc/modprobe.d/harden-tty.conf
install n_gsm /bin/false
blacklist n_gsm
Apply immediately (does not require a reboot, but note that if the module is already loaded you must unload it):
cp /etc/modprobe.d/harden-tty.conf /etc/modprobe.d/harden-tty.conf
# Update initramfs so the deny-list persists across boots
update-initramfs -u # Debian/Ubuntu
dracut --force # RHEL/Fedora/Amazon Linux
Verify:
# Confirm the module is not loaded
lsmod | grep n_gsm # should return nothing
# Confirm loading is denied
sudo modprobe n_gsm 2>&1 # should return: "modprobe: ERROR: could not insert 'n_gsm': Operation not permitted"
Step 3 — Restrict via sysctl (defense-in-depth)
If the module deny-list cannot be applied (e.g., the module is built into the kernel as CONFIG_N_GSM=y on some embedded distros), use dev.tty.ldisc_autoload to prevent unprivileged auto-loading of line disciplines:
# /etc/sysctl.d/90-tty-hardening.conf
dev.tty.ldisc_autoload = 0
sysctl --system
# Verify
sysctl dev.tty.ldisc_autoload
# dev.tty.ldisc_autoload = 0
With ldisc_autoload = 0, attaching any non-standard line discipline (including N_GSM0710 = 21) requires CAP_SYS_MODULE, which is not available to unprivileged users or containers.
Note: this sysctl was introduced in kernel 5.1. On older kernels, only the module deny-list or Seccomp controls are effective.
Step 4 — Block ioctl via Seccomp (Kubernetes workloads)
For Kubernetes workloads, apply a Seccomp profile that restricts the ioctl call with TIOCSETD. Because Seccomp cannot filter on ioctl argument values natively, the practical control is to block ioctl on TTY file descriptors using a custom profile or to use a RuntimeDefault profile that removes the attack path indirectly.
For high-security workloads, use a custom profile:
{
"defaultAction": "SCMP_ACT_ERRNO",
"architectures": ["SCMP_ARCH_X86_64", "SCMP_ARCH_AARCH64"],
"syscalls": [
{
"names": ["read", "write", "close", "fstat", "mmap", "mprotect",
"munmap", "brk", "rt_sigaction", "rt_sigprocmask",
"exit", "exit_group", "openat", "getdents64", "newfstatat",
"pread64", "pwrite64", "lseek", "sendfile",
"socket", "connect", "accept", "sendto", "recvfrom",
"sendmsg", "recvmsg", "bind", "listen", "getsockname",
"getpeername", "setsockopt", "getsockopt",
"clone", "clone3", "execve", "wait4", "kill",
"uname", "fcntl", "getuid", "getgid", "geteuid",
"getegid", "getppid", "getpgrp", "setsid",
"prctl", "arch_prctl", "set_tid_address",
"set_robust_list", "futex", "nanosleep",
"clock_gettime", "clock_nanosleep",
"pipe2", "epoll_create1", "epoll_ctl", "epoll_wait",
"eventfd2", "signalfd4", "timerfd_create",
"timerfd_settime", "timerfd_gettime"],
"action": "SCMP_ACT_ALLOW"
}
]
}
This allowlist-style profile does not include ioctl, so no TTY line discipline manipulation is possible. Save it as /etc/seccomp/no-tty-ioctl.json and reference in a pod spec:
apiVersion: v1
kind: Pod
metadata:
name: hardened-app
annotations:
seccomp.security.alpha.kubernetes.io/pod: "localhost/no-tty-ioctl"
spec:
securityContext:
seccompProfile:
type: Localhost
localhostProfile: no-tty-ioctl.json
containers:
- name: app
image: your-app:latest
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 10000
capabilities:
drop: ["ALL"]
Step 5 — Apply on Kubernetes nodes via DaemonSet
Ensure the sysctl and modprobe deny-list are applied consistently across all nodes using a privileged DaemonSet:
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: tty-hardening
namespace: kube-system
spec:
selector:
matchLabels:
app: tty-hardening
template:
metadata:
labels:
app: tty-hardening
spec:
hostPID: true
hostIPC: false
hostNetwork: false
tolerations:
- operator: Exists
initContainers:
- name: apply-sysctl
image: busybox:1.36
securityContext:
privileged: true
command:
- sh
- -c
- |
sysctl -w dev.tty.ldisc_autoload=0
# Write modprobe deny-list to host filesystem
mkdir -p /host/etc/modprobe.d
echo -e 'install n_gsm /bin/false\nblacklist n_gsm' \
> /host/etc/modprobe.d/harden-tty.conf
volumeMounts:
- name: host-etc
mountPath: /host/etc
containers:
- name: pause
image: gcr.io/google_containers/pause:3.9
volumes:
- name: host-etc
hostPath:
path: /etc
type: Directory
Step 6 — Verify across the fleet
# On each node, confirm module is not loadable
ansible all -m shell -a "sudo modprobe n_gsm 2>&1; echo exit=$?"
# Confirm sysctl
ansible all -m shell -a "sysctl dev.tty.ldisc_autoload"
# Confirm lsmod is clean
ansible all -m shell -a "lsmod | grep n_gsm || echo NOT_LOADED"
Step 7 — Additional TTY hardening (related surface)
While blocking n_gsm, harden adjacent TTY-related attack surface:
# /etc/sysctl.d/90-tty-hardening.conf
# Prevent ldisc auto-load (blocks n_gsm and other unsafe disciplines)
dev.tty.ldisc_autoload = 0
# Restrict kernel pointer exposure (limits exploit info leak primitives)
kernel.kptr_restrict = 2
# Restrict dmesg to root (limits kernel address leaks)
kernel.dmesg_restrict = 1
# Restrict perf_event to root (limits side-channel primitives)
kernel.perf_event_paranoid = 3
Expected Behaviour
| Signal | Before hardening | After hardening |
|---|---|---|
lsmod | grep n_gsm |
Empty (module not yet loaded but auto-loadable) | Empty (module blocked; load attempt returns EPERM) |
modprobe n_gsm |
Module loads silently | ERROR: could not insert 'n_gsm': Operation not permitted |
sysctl dev.tty.ldisc_autoload |
1 |
0 |
ioctl TIOCSETD with N_GSM0710 from unprivileged process |
Succeeds, triggers module auto-load | Returns EPERM (ldisc_autoload=0) or ENOSYS (Seccomp) |
Container calling ioctl(fd, TIOCSETD, ...) |
Succeeds | Blocked by Seccomp profile → EPERM |
Verification snippet after applying all controls:
# Test from an unprivileged shell
python3 - <<'EOF'
import fcntl, os, termios
try:
fd = os.open('/dev/tty', os.O_RDWR | os.O_NOCTTY)
N_GSM0710 = 21
fcntl.ioctl(fd, termios.TIOCSETD, bytes([N_GSM0710]))
print("FAIL: ioctl succeeded — n_gsm is reachable")
except PermissionError as e:
print(f"PASS: ioctl blocked — {e}")
except OSError as e:
print(f"PASS: ioctl blocked — {e}")
EOF
Expected output: PASS: ioctl blocked — [Errno 1] Operation not permitted
Trade-offs
| Aspect | Benefit | Cost | Mitigation |
|---|---|---|---|
| Module deny-list | Eliminates entire n_gsm attack surface | Breaks legitimate GSM modem use | Only needed on servers/VMs; exclude embedded/edge nodes that use modems |
ldisc_autoload=0 |
Blocks unprivileged line discipline attachment globally | Breaks any userspace tool using custom line disciplines (ppp, slip, bluetooth serial) | Audit lsof | grep tty before enabling; these uses are rare on servers |
| Seccomp no-ioctl profile | Prevents exploitation even if module is loaded | Breaks workloads that use ioctl for legitimate purposes (terminal apps, serial tools) | Use RuntimeDefault profile for general workloads; no-ioctl only for stateless services |
| DaemonSet sysctl application | Fleet-wide consistent state | DaemonSet requires privileged containers in kube-system | Use node-level bootstrap scripts (cloud-init, Ansible) as alternative |
Failure Modes
| Failure | Symptom | Detection | Recovery |
|---|---|---|---|
| Application legitimately needs n_gsm | Service fails to start; dmesg shows TIOCSETD: Operation not permitted |
Check dmesg, application logs for EPERM on tty ioctl |
Exempt specific nodes from deny-list; document the use case |
| initramfs not updated after adding deny-list | Module loads after reboot despite modprobe.d config | lsmod | grep n_gsm after reboot shows module present |
Re-run update-initramfs -u or dracut --force; verify with cat /proc/modules |
Kernel built with CONFIG_N_GSM=y (built-in, not module) |
Module deny-list has no effect; modinfo returns “built-in” |
Check /boot/config-$(uname -r) | grep N_GSM |
Fall back to sysctl ldisc_autoload=0 and Seccomp controls |
| DaemonSet not scheduled on new nodes | New node joins cluster without hardening applied | Alert on node join events; check DaemonSet status with kubectl get ds -n kube-system |
Ensure DaemonSet toleration covers all node taints; use node bootstrap scripts as belt-and-suspenders |
| Seccomp profile blocks legitimate ioctl in workload | Application returns unexpected error on ioctl calls | strace -e ioctl shows EPERM; application logs show unexpected failures |
Switch to RuntimeDefault Seccomp which permits common ioctls while still blocking raw TIOCSETD on TTY |
Related Articles
- Linux User Namespace Security — user namespaces expand the set of processes that can trigger auto-loading of attack-surface modules like n_gsm
- Seccomp BPF Without Containers — applying Seccomp profiles directly to services without a container runtime
- Linux Unprivileged Namespace Restriction — restricting the
clone(CLONE_NEWUSER)call that makes many LPE chains reachable - Linux Kernel Module Hardening — deny-listing and signing requirements for the broader module attack surface
- Linux LPE Defence in Depth — layered controls that contain kernel privilege escalation even when individual patches are delayed