Kernel CVEs Are Leaking Faster Than You Patch: A Practical Kernel Update & Reboot Strategy for Linux Servers
Tuần này trên Hacker News có một tin khiến nhiều sysadmin giật mình: vài lỗ hổng Linux kernel bị vô tình gửi lên mailing list công khai trước khi có bản vá chính thức. Nghĩa là trong vài giờ đến vài ngày, attacker có thể đọc chi tiết lỗ hổng và viết exploit, còn server của bạn vẫn chạy kernel cũ. Thực tế còn tệ hơn thế. Rất nhiều team ở Việt Nam mà mình từng làm cùng có thói quen apt upgrade đều đặn nhưng không bao giờ reboot. Package kernel mới đã được cài, nhưng kernel đang chạy vẫn là bản từ 200 ngày trước. Bài này chia sẻ cách mình xử lý vấn đề đó: phát hiện server nào đang chạy kernel lỗi thời, quyết định khi nào dùng livepatch và khi nào reboot, rồi tự động hóa rolling reboot mà không gây downtime.
1. Cài kernel mới khác với chạy kernel mới
Đây là điểm nhiều người hiểu sai nhất. Khi apt hay dnf cài kernel mới, file vmlinuz mới nằm trong /boot, nhưng memory vẫn đang chạy kernel cũ cho tới lần boot tiếp theo. Userspace cũng vậy: update openssl xong mà không restart nginx thì process vẫn giữ thư viện cũ đã bị xóa trong memory.
Cách kiểm tra nhanh trên một máy:
# Kernel đang chạy
uname -r
# Kernel mới nhất đã cài (Debian/Ubuntu)
ls -1 /boot/vmlinuz-* | sort -V | tail -n1
# Ubuntu tạo file này khi có package yêu cầu reboot
cat /var/run/reboot-required 2>/dev/null && cat /var/run/reboot-required.pkgs
# RHEL/Rocky/Alma: dùng dnf-utils
needs-restarting -r; echo "exit=$?" # exit 1 = cần reboot
# Tool tốt nhất cho Debian/Ubuntu: needrestart (>= 3.6)
sudo apt install -y needrestart
sudo needrestart -b -r l
Output batch mode của needrestart rất dễ parse:
NEEDRESTART-VER: 3.6
NEEDRESTART-KCUR: 6.8.0-45-generic
NEEDRESTART-KEXP: 6.8.0-52-generic
NEEDRESTART-KSTA: 3
NEEDRESTART-SVC: nginx.service
Với KSTA: 1 là kernel đã mới nhất, 2 là có bản update cùng ABI, 3 là có version mới hơn và phải reboot. Ngoài ra bạn có thể xem /sys/devices/system/cpu/vulnerabilities/ để biết kernel đang chạy đã mitigate các lỗi CPU như Spectre hay Retbleed chưa.
2. Quét cả fleet bằng một script Python nhỏ
Kiểm tra từng máy bằng tay thì được 5 server, đến 50 server là bỏ cuộc. Script dưới đây SSH song song vào danh sách host, chạy needrestart và in ra máy nào đang chạy kernel cũ. Chỉ dùng stdlib, chạy được với Python 3.10+.
#!/usr/bin/env python3
import subprocess
from concurrent.futures import ThreadPoolExecutor
HOSTS = [h.strip() for h in open("hosts.txt") if h.strip()]
def check(host: str) -> dict:
cmd = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5",
host, "sudo needrestart -b -r l"]
try:
out = subprocess.run(cmd, capture_output=True, text=True,
timeout=60).stdout
except subprocess.TimeoutExpired:
return {"host": host, "error": "timeout"}
info = {"host": host, "services": []}
for line in out.splitlines():
key, _, val = line.partition(": ")
if key == "NEEDRESTART-KCUR":
info["running"] = val
elif key == "NEEDRESTART-KEXP":
info["expected"] = val
elif key == "NEEDRESTART-KSTA":
info["status"] = int(val)
elif key == "NEEDRESTART-SVC":
info["services"].append(val)
return info
with ThreadPoolExecutor(max_workers=20) as pool:
results = list(pool.map(check, HOSTS))
for r in sorted(results, key=lambda x: -x.get("status", 0)):
if "error" in r:
print(f"[??] {r['host']}: {r['error']}")
elif r.get("status") == 3:
print(f"[REBOOT] {r['host']}: {r['running']} -> {r['expected']}")
elif r["services"]:
print(f"[SVC] {r['host']}: restart {', '.join(r['services'])}")
```
Mình chạy script này bằng cron mỗi sáng thứ Hai, đẩy output vào channel chat của team. Thấy danh sách `[REBOOT]` dài ra là biết cần lên lịch maintenance.
## 3. Livepatch hay reboot?
Livepatch cho phép vá function trong kernel đang chạy mà không cần reboot. Các lựa chọn phổ biến hiện nay:
- **Ubuntu**: Canonical Livepatch, bật qua `sudo pro enable livepatch` (cần Ubuntu Pro, miễn phí cho tối đa 5 máy cá nhân).
- **RHEL 8/9 và các bản rebuild**: `kpatch` với `dnf install kpatch kpatch-dnf`.
- Kiểm tra trạng thái chung: `cat /sys/kernel/livepatch/*/enabled`.
Nhưng livepatch **không thay thế được reboot**. Nó chỉ vá được những lỗi có thể sửa bằng cách thay function. Lỗi liên quan đến thay đổi data structure, init code hay một số mitigation phần cứng thì vẫn phải reboot. Nguyên tắc của mình như sau:
```mermaid
flowchart TD
A[CVE kernel mới công bố] --> B{Có exploit public<br/>hoặc CVSS >= 7?}
B -- Không --> C[Gom vào đợt reboot định kỳ<br/>2-4 tuần/lần]
B -- Có --> D{Có livepatch?}
D -- Có --> E[Apply livepatch ngay]
E --> F[Vẫn reboot trong đợt kế tiếp]
D -- Không --> G[Rolling reboot khẩn cấp<br/>trong 24-48h]
C --> H[Verify bằng needrestart]
F --> H
G --> H
```
Điểm mấu chốt là **reboot phải trở thành việc nhàm chán**. Nếu team sợ reboot thì đó là dấu hiệu hệ thống thiếu redundancy, và đó mới là vấn đề thật cần giải quyết.
Để không vô tình reboot giữa giờ cao điểm, cấu hình `unattended-upgrades` chỉ cài package chứ không tự reboot:
```bash
# /etc/apt/apt.conf.d/50unattended-upgrades
Unattended-Upgrade::Automatic-Reboot "false";
Unattended-Upgrade::Remove-Unused-Kernel-Packages "true";
```
## 4. Rolling reboot không downtime
Với cluster Kubernetes, quy trình chuẩn là drain node, reboot, chờ node Ready, uncordon, rồi làm node tiếp theo. Nếu đã có kured (Kubernetes Reboot Daemon) thì nó tự làm việc này khi phát hiện `/var/run/reboot-required`. Còn nếu muốn kiểm soát bằng tay, script dưới đây là đủ:
```mermaid
sequenceDiagram
participant S as Script
participant K as kube-apiserver
participant N as Node
S->>K: kubectl drain node
K->>N: Evict pods (tôn trọng PDB)
S->>N: ssh sudo reboot
N-->>K: Node NotReady
N-->>K: Boot kernel mới, Node Ready
S->>K: kubectl uncordon node
S->>S: Sang node tiếp theo
```
```bash
#!/usr/bin/env bash
set -euo pipefail
for node in $(kubectl get nodes -o name | cut -d/ -f2); do
kver=$(ssh "$node" uname -r)
if ! ssh "$node" test -f /var/run/reboot-required; then
echo "[skip] $node ($kver)"; continue
fi
echo "[drain] $node ($kver)"
kubectl drain "$node" --ignore-daemonsets \
--delete-emptydir-data --timeout=300s
ssh "$node" 'sudo systemctl reboot' || true
sleep 30
kubectl wait --for=condition=Ready "node/$node" --timeout=600s
kubectl uncordon "$node"
echo "[done] $node -> $(ssh "$node" uname -r)"
sleep 60 # cho workload ổn định trước khi sang node khác
done
```
Vài lưu ý rút ra từ kinh nghiệm thực tế:
- **PodDisruptionBudget** là bắt buộc. Thiếu PDB thì `drain` có thể evict cùng lúc cả 3 replica của service.
- Database stateful (PostgreSQL, Redis primary) nên **failover thủ công trước**, đừng để drain tự xử lý.
- Server VM đơn lẻ không có cluster thì ít nhất phải có load balancer phía trước và tối thiểu 2 instance. Nếu không, mỗi lần reboot là một lần downtime.
## Kết luận
Kernel bug bị lộ sớm sẽ còn xảy ra. Từ năm 2024 kernel.org đã trở thành CNA và công bố hàng nghìn CVE mỗi năm, nên không thể đọc từng CVE rồi mới hành động. Thứ cứu bạn là **quy trình**, không phải phản xạ. Những việc nên làm ngay tuần này:
1. Cài `needrestart` (Debian/Ubuntu) hoặc `dnf-utils` (RHEL) trên mọi server, chạy `needrestart -b` để biết máy nào đang chạy kernel cũ.
2. Dựng script quét fleet như phần 2 và chạy bằng cron hàng tuần, gửi kết quả vào chat của team.
3. Bật livepatch cho các máy quan trọng để mua thêm thời gian khi có CVE nghiêm trọng, nhưng đừng coi nó là lý do để không reboot.
4. Đặt lịch rolling reboot định kỳ 2–4 tuần một lần, kèm PDB và health check. Một khi reboot đã thành thói quen thì patch khẩn cấp chỉ còn là chạy lại script.
5. Theo dõi kernel đang chạy (`uname -r`) bằng metric, ví dụ dùng `node_uname_info` của node_exporter, để dashboard cho thấy ngay node nào bị bỏ sót.
Server chạy kernel đã vá thì leak bao nhiêu lên mailing list cũng không ảnh hưởng tới bạn.
All Rights Reserved