Is SSH Password Auth Really Off on Your Server? Hardening sshd_config and Proving It From the Outside
Tuần này, một security advisory của Alpine Linux về lỗi bypass password authentication trong OpenSSH-server lên top Hacker News. Thread đó có hàng trăm comment, và câu hỏi lặp lại nhiều nhất không phải về chi tiết lỗ hổng. Mọi người hỏi nhau: "Server của mình có đang bật password auth không?". Phần lớn trả lời "chắc là tắt rồi, mình sửa sshd_config từ lâu". Mình từng nghĩ y như vậy, cho đến khi audit lại vài con VPS và thấy 3/7 con vẫn nhận password. Lý do là một file drop-in do cloud-init tạo ra mà mình chưa bao giờ mở. Bài này chia sẻ cách mình harden sshd và quan trọng hơn là cách kiểm chứng từ bên ngoài rằng cấu hình thật sự có hiệu lực.
Vì sao bạn "đã tắt" mà nó vẫn bật
Điều nhiều người không biết: trong sshd_config, giá trị đầu tiên được đọc sẽ thắng (first match wins). Cách này ngược với phần lớn các file config khác, nơi dòng sau ghi đè dòng trước.
Từ OpenSSH 8.2 trở đi, Ubuntu và Debian đặt dòng Include /etc/ssh/sshd_config.d/*.conf ở đầu file sshd_config. Các file drop-in được đọc theo thứ tự alphabet, và chúng được đọc trước mọi thứ bạn viết bên dưới. Trên nhiều image cloud (AWS, DigitalOcean, Vultr...), cloud-init tạo sẵn file 50-cloud-init.conf với nội dung:
PasswordAuthentication yes Vậy là dòng PasswordAuthentication no bạn viết ở cuối sshd_config bị bỏ qua hoàn toàn, sshd cũng không báo lỗi gì.
flowchart TD A[sshd khởi động] --> B[Đọc /etc/ssh/sshd_config] B --> C[Gặp Include sshd_config.d/*.conf] C --> D["50-cloud-init.conf: PasswordAuthentication yes"] D --> E[Giá trị được chốt = yes] E --> F["Dòng 'PasswordAuthentication no' bên dưới bị bỏ qua"] F --> G[Server vẫn nhận password] Bài học rút ra: đừng tin nội dung file config, hãy tin vào effective config mà sshd thực sự dùng.
Viết config đúng và kiểm tra bằng sshd -T
Mình đặt toàn bộ hardening vào một file drop-in có prefix 00- để chắc chắn nó được đọc đầu tiên:
# Backup trước khi sửa sudo cp -a /etc/ssh /etc/ssh.bak.$(date +%F) sudo tee /etc/ssh/sshd_config.d/00-hardening.conf > /dev/null <<'EOF' PasswordAuthentication no KbdInteractiveAuthentication no PermitEmptyPasswords no PermitRootLogin prohibit-password PubkeyAuthentication yes AuthenticationMethods publickey MaxAuthTries 3 LoginGraceTime 20 X11Forwarding no EOF # Validate syntax (lệnh này không in gì nghĩa là OK) sudo sshd -t # Xem effective config - đây mới là sự thật sudo sshd -T | grep -Ei 'passwordauth|kbdinteractive|authenticationmethods|permitrootlogin' # Test với Match block cho một user/IP cụ thể sudo sshd -T -C user=deploy,host=example.com,addr=203.0.113.10 | grep -i passwordauth Vài điểm cần lưu ý:
-
KbdInteractiveAuthentication(tên cũ làChallengeResponseAuthentication) cũng có thể cho phép nhập password qua PAM. Chỉ tắtPasswordAuthenticationmà quên dòng này thì vẫn còn hở. -
AuthenticationMethods publickeylà lớp bảo vệ cuối: kể cả khi có dòng nào đó bật password, sshd vẫn yêu cầu public key. - Lệnh
sshd -T -Crất hữu ích nếu bạn cóMatchblock, vìMatch UserhoặcMatch Addresscó thể bật lại password cho một nhóm cụ thể mà bạn đã quên.
Sau đó reload service. Trên Ubuntu 22.10 trở lên, sshd chạy qua socket activation nên service tên là ssh. Trên Alpine thì dùng OpenRC:
# Ubuntu/Debian sudo systemctl reload ssh # Alpine sudo rc-service sshd reload Quy tắc sống còn: giữ nguyên session SSH hiện tại, mở thêm một terminal mới để test login. Session cũ không bị ngắt khi reload, nên nếu cấu hình hỏng bạn vẫn còn đường vào để sửa.
Kiểm chứng từ bên ngoài: hỏi thẳng server
sshd -T cho biết server định làm gì. Nhưng nếu phía trước có load balancer, bastion hoặc port forward tới một con khác, thì bạn cần hỏi trực tiếp endpoint mà attacker sẽ nhìn thấy.
Giao thức SSH có một mẹo khá hay: client gửi method none, server sẽ trả lời danh sách method mà nó chấp nhận.
sequenceDiagram participant C as Client (audit) participant S as sshd C->>S: Key exchange C->>S: USERAUTH_REQUEST method=none S-->>C: USERAUTH_FAILURE: publickey,password Note over C: Có "password" là có vấn đề C->>S: Disconnect (không gửi credential nào) Thử nhanh bằng tay:
ssh -v -o PreferredAuthentications=none -o PubkeyAuthentication=no \ -o StrictHostKeyChecking=accept-new nobody@your-server 2>&1 \ | grep 'Authentications that can continue' # Kết quả an toàn: debug1: Authentications that can continue: publickey Để audit hàng loạt server, mình dùng script Python nhỏ sau (Python 3.10+, không cần thư viện ngoài):
#!/usr/bin/env python3 """Audit SSH auth methods trên nhiều host. Không gửi credential nào.""" import re import subprocess import sys from concurrent.futures import ThreadPoolExecutor RISKY = {"password", "keyboard-interactive"} def probe(host: str) -> tuple[str, set[str] | None]: cmd = [ "ssh", "-v", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5", "-o", "PreferredAuthentications=none", "-o", "PubkeyAuthentication=no", "-o", "StrictHostKeyChecking=accept-new", f"audit-probe@{host}", "true", ] try: out = subprocess.run(cmd, capture_output=True, text=True, timeout=15).stderr except subprocess.TimeoutExpired: return host, None m = re.search(r"Authentications that can continue: (\S+)", out) return host, set(m.group(1).split(",")) if m else None def main(hosts: list[str]) -> int: bad = 0 with ThreadPoolExecutor(max_workers=10) as pool: for host, methods in pool.map(probe, hosts): if methods is None: print(f"[?] {host}: không kết nối được") elif methods & RISKY: bad += 1 print(f"[!!] {host}: {','.join(sorted(methods))}") else: print(f"[ok] {host}: {','.join(sorted(methods))}") return 1 if bad else 0 if __name__ == "__main__": hosts = [l.strip() for l in open(sys.argv[1]) if l.strip() and not l.startswith("#")] sys.exit(main(hosts)) Chạy bằng lệnh python3 ssh_audit_auth.py hosts.txt. Script trả về exit code 1 nếu có host nhận password, nên bạn có thể gắn thẳng vào cron hoặc một job CI chạy hằng đêm.
Nếu muốn audit sâu hơn (cipher, KEX, MAC yếu, version có CVE), dùng thêm ssh-audit (bản 3.x, cài bằng pip install ssh-audit) hoặc nmap --script ssh-auth-methods -p 22 host.
Những thứ hay bị quên
- Cập nhật OpenSSH ngay khi có advisory. Chạy
ssh -Vđể xem version. Trên Alpine dùngapk upgrade openssh-server, trên Debian/Ubuntu thì bậtunattended-upgradescho security repo. Hardening config không cứu được bạn nếu lỗi nằm trong chính code xử lý auth. - Image và template mới. Bạn sửa server cũ rồi, nhưng lần sau tạo VPS mới, cloud-init lại sinh file
50-cloud-init.conf. Hãy đưa file00-hardening.confvào Ansible, Terraform user-data hoặc Packer template. - Container có sshd. Một số image "dev box" cài sẵn sshd với password mặc định. Nếu container đó publish port 22 ra ngoài thì nó cũng là một server cần audit.
- Fail2ban không thay thế được việc tắt password. Nó chỉ làm brute-force chậm lại, còn với lỗi bypass auth thì hoàn toàn vô tác dụng.
Kết luận
Tắt password auth là việc ai cũng nghĩ mình đã làm, nhưng rất ít người kiểm chứng lại. Checklist mình áp dụng cho mọi server:
- Chạy
grep -r PasswordAuthentication /etc/ssh/để tìm mọi file drop-in đang ghi đè, đặc biệt là50-cloud-init.conf. - Đặt hardening trong
/etc/ssh/sshd_config.d/00-hardening.conf, nhớ tắt cảKbdInteractiveAuthenticationvà thêmAuthenticationMethods publickey. - Tin vào
sshd -Tchứ không tin vào file config. Dùng-C user=...,addr=...để test cácMatchblock. - Reload và giữ session cũ, test login ở terminal mới.
- Probe từ bên ngoài bằng method
none, chạy script audit định kỳ và cho nó fail khi phát hiệnpasswordhoặckeyboard-interactive. - Đưa cấu hình vào IaC để server mới sinh ra đã an toàn từ đầu, và cập nhật OpenSSH ngay khi có advisory.
Dành 15 phút làm hôm nay, bạn sẽ không phải hồi hộp mở thread HN ở lần advisory tiếp theo.