
1. 为什么现在还要用/etc/hosts.deny——一个被低估但依然锋利的系统级守门人很多人一提 Linux SSH 安全第一反应就是改端口、禁密码、配密钥、上 fail2ban、套 Cloudflare 或 WAF。这些都没错也确实该做。但当我去年在一台部署在公网边缘的 CentOS 7 路由管理节点上连续三天凌晨 3:17 分收到同一 IP 的第 47 次暴力 SSH 登录尝试时我顺手翻了下/var/log/secure发现它早在sshd进程启动前就已经被系统内核层面的 TCP Wrappers 拦在了门外——而拦它的就是那行写在/etc/hosts.deny里、看起来像古董一样朴素的sshd: 192.168.123.45。这不是怀旧是精准拦截。/etc/hosts.deny不是防火墙替代品也不是 fail2ban 的低配版它是 Linux 系统最底层的访问控制网关之一工作在 socket 层比 SSH 服务进程本身更早介入连接请求。它不消耗 CPU 去解析日志、不依赖 Python 解释器、不启动额外守护进程只要内核加载了tcp_wrappers模块绝大多数主流发行版默认启用它就静默运行毫秒级响应。对高频扫描类攻击它能直接让恶意连接连sshd的欢迎 banner 都看不到从源头减少日志污染、降低系统负载、规避部分基于 banner 指纹的自动化探测。关键词Linux、SSH 安全、hosts.deny、TCP Wrappers、系统级访问控制、暴力破解防护。这篇文章适合所有需要快速加固裸机或轻量 VPS 的运维人员、DevOps 工程师、以及正在备考 RHCE/LPIC-2 的考生——你不需要会写 Python 脚本也不必配置复杂的 iptables 规则链只要理解三行配置文件的逻辑就能为你的服务器加一道“物理门禁”。它解决的不是“如何构建企业级安全体系”这种宏大命题而是“当凌晨三点你被告警短信吵醒发现又是那个熟悉的 IP 在扫弱口令你能不能在 30 秒内让它永远连不上来”的具体问题。它不炫技但极可靠不智能但极确定。接下来我会带你从原理到实操把这把老式铜钥匙擦亮让它在现代服务器上继续咬合严丝合缝。2. TCP Wrappers 是什么——不是防火墙却是第一道无声的哨兵2.1 它的工作位置比 SSH 进程更早的“门房”要真正用好/etc/hosts.deny必须先扔掉一个常见误解它不是sshd自己读取并执行的配置。sshd本身并不解析这个文件。真正起作用的是TCP Wrappers—— 一个独立的、历史悠久的 Linux 访问控制库libwrap.so。它的设计哲学非常朴素在应用程序如sshd,vsftpd,xinetd托管的服务调用accept()接收一个新连接之前由系统动态链接库libwrap.so主动介入检查该连接的源 IP 和目标服务名再对照/etc/hosts.allow和/etc/hosts.deny两份白名单与黑名单做出放行或拒绝的决定。这个时机极其关键。我们画个简化的连接流程图纯文字描述无 mermaid客户端发起 TCP SYN → 服务器内核完成三次握手 → 内核将已建立的 socket 传递给 sshd 进程 ↓ libwrap.so 拦截此 socket 请求 ↓ 查询 /etc/hosts.allow优先匹配找到即停 ↓ 未匹配 → 查询 /etc/hosts.deny找到即拒绝不再往下 ↓ 均未匹配 → 默认放行即“默认允许”策略注意这个“默认允许”原则。很多初学者以为写了hosts.deny就万事大吉结果发现规则没生效根源就在这里TCP Wrappers 的决策逻辑是“先查 allow匹配则放行不匹配再查 deny匹配则拒绝都不匹配则放行”。它不像 iptables 那样有“默认 DROP”的概念。所以如果你只想拒绝特定 IP而其他所有 IP 都应正常访问那么/etc/hosts.allow里通常需要明确写上sshd: ALL否则hosts.deny的规则可能根本不会被触发。2.2 它能控制哪些服务——不是所有程序都买账TCP Wrappers 并非万能。它只对显式链接了libwrap.so库的程序有效。你可以用ldd命令快速验证一个服务是否支持ldd $(which sshd) | grep wrap # 正常输出类似libwrap.so.0 /lib64/libwrap.so.0 (0x00007f...) # 如果没有输出说明该 sshd 编译时未启用 TCP Wrappers 支持主流发行版中OpenSSHRHEL/CentOS 7/8、Debian 10/11、Ubuntu 18.04/20.04 默认编译时启用了--with-libwrapsshd支持。但 Ubuntu 22.04 和较新版本的 OpenSSH如 9.0上游 OpenSSH 社区已正式移除对libwrap的支持其sshd不再链接libwrap.so。这是重大变化意味着在这些系统上/etc/hosts.deny对sshd完全无效。你必须改用iptables/nftables或fail2ban。提示在动手配置前请务必确认你的系统和sshd版本是否支持。执行sshd -V 21 | grep -i wrap如果输出为空则不支持。别在不支持的系统上浪费时间调试hosts.deny。2.3 为什么它比 fail2ban 更“轻”——一次判断零开销fail2ban 的工作流是sshd记录失败登录 →fail2ban-server读取/var/log/auth.log→ 正则匹配失败模式 → 触发iptables命令添加临时封禁规则。这个过程涉及磁盘 I/O、日志解析、进程间通信、内核 netfilter 规则更新每次封禁都有毫秒级延迟且持续消耗资源。而 TCP Wrappers 的拦截发生在内存中libwrap.so加载规则后对每个新连接的判断就是一次哈希表查找IP 地址转为整数查预编译的规则树耗时在纳秒级别。它不产生任何日志除非你手动开启log_on_success/log_on_failure不修改内核网络栈不增加任何守护进程。对于一台只跑sshd和nginx的小 VPS启用hosts.deny几乎感知不到性能影响而 fail2ban 却可能在高并发扫描时因日志轮转和正则匹配拖慢整个系统。这就是它的核心价值用最低的系统成本换取最高确定性的早期拦截。它不是取代 fail2ban而是和它形成纵深防御hosts.deny拦住已知恶意 IP 的首次连接fail2ban 则负责动态学习和封禁新出现的扫描者。3./etc/hosts.deny的语法精解——从单 IP 到 CIDR 网段的实战写法3.1 最基础的拒绝一行一个直击要害假设你通过lastb或/var/log/secure发现恶意 IP 是203.0.113.42你想立刻禁止它。最简单的写法就是echo sshd: 203.0.113.42 /etc/hosts.deny这条语句的结构是服务名: 客户端地址。其中sshd是服务名必须与sshd进程注册给 TCP Wrappers 的名字一致通常是sshd不是ssh或openssh。203.0.113.42是 IPv4 地址精确匹配。执行后无需重启sshd规则立即生效。你可以用另一台机器ssh -o ConnectTimeout5 useryour-server-ip测试会立刻得到Connection refused而不是Permission denied。因为连接在到达sshd之前就被libwrap拒绝了TCP 层直接返回 RST 包。注意hosts.deny文件本身没有“重载”命令。修改后新连接会自动应用新规则。但已有连接如你当前的 SSH 会话不受影响这是设计使然。3.2 拒绝整个网段CIDR 表示法与通配符的正确用法单个 IP 拒绝太被动。攻击者往往使用僵尸网络IP 地址成片出现。这时就要用 CIDR无类别域间路由网段表示法。例如拒绝192.0.2.0/24整个 C 类网段echo sshd: 192.0.2.0/24 /etc/hosts.deny/24表示子网掩码255.255.255.0覆盖192.0.2.0到192.0.2.255共 256 个地址。这是最推荐、最清晰的网段写法。你可能在网上看到过sshd: 192.0.2.这种写法末尾带点。它利用了 TCP Wrappers 的“前缀匹配”特性意思是“所有以192.0.2.开头的 IP”效果等同于/24。但强烈不推荐。原因有二歧义性192.0.2.会被解释为192.0.2.0/24但192.0.2.123也会被匹配而192.0.2.1234非法 IP在某些旧版本解析器中可能出错。可读性差/24是标准网络术语任何网络工程师一眼看懂192.0.2.则像一个模糊的字符串匹配容易引发误判。同样避免使用sshd: ALL这种全局拒绝除非你明确想锁死所有 SSH。它会覆盖hosts.allow中的所有规则导致你把自己也关在外面。3.3 复杂场景多 IP、多服务、带注释的生产级配置一个真实的hosts.deny文件绝不会只有一行。它应该是一个有组织、可维护、带上下文的配置清单。以下是我在线上环境使用的模板已脱敏# [2023-10-15] 源自 AbuseIPDB 的高危扫描网段 sshd: 203.0.113.0/24 sshd: 198.51.100.128/25 # [2023-11-02] 本地测试环境误操作 IP sshd: 10.10.20.155 # [2024-01-10] 某云厂商已知恶意 AS自治系统出口网段 sshd: 192.0.2.192/28 sshd: 192.0.2.208/28 # [通用规则] 拒绝所有来自私有地址空间的 SSH 连接除非你明确需要 # 注此规则需确保 hosts.allow 中有明确的允许项否则会锁死所有连接 # sshd: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16关键实践技巧按时间戳和来源分类注释方便日后审计知道哪条规则是何时、因何加入的。用空行分隔逻辑区块提升可读性避免规则堆砌。把“可能误伤”的规则注释掉比如最后一条私有网段规则我把它注释掉了因为我的服务器需要接受来自内网管理网段的 SSH。只有当你 100% 确认不需要任何私有地址访问时才取消注释。拒绝多 IP 时不要写在同一行用逗号分隔sshd: 1.1.1.1, 2.2.2.2是错误的。TCP Wrappers 不支持这种语法。必须每行一个。3.4 高级技巧结合hosts.allow实现“白名单优先”策略前面提到默认策略是“允许”。但生产环境中更安全的做法是“默认拒绝仅允许白名单”。这就需要hosts.allow配合使用。一个典型的最小化hosts.allow配置如下# /etc/hosts.allow # 只允许来自公司办公网和运维跳板机的 SSH 访问 sshd: 203.0.113.100/32 sshd: 203.0.113.101/32 sshd: 198.51.100.0/24 # 允许 localhost用于本地脚本调用 sshd: 127.0.0.1 # 允许 IPv6 本地环回 sshd: [::1]然后在hosts.deny中写# /etc/hosts.deny # 默认拒绝所有其他 SSH 连接 sshd: ALL这样只有hosts.allow中明确列出的 IP 或网段才能连接sshd其余全部被拒。这是真正的“最小权限”模型。但请千万注意在写入sshd: ALL之前你必须确保至少有一个你当前正在使用的 IP 地址在hosts.allow中否则保存后你将立刻失去 SSH 连接只能通过 VNC 或物理控制台恢复。我建议的操作顺序是先编辑hosts.allow添加你的可信 IP。scp或rsync一份备份到本地scp /etc/hosts.allow backup_hosts.allow再编辑hosts.deny写入sshd: ALL。tail -f /var/log/messages监控然后从另一终端测试连接。一切正常后再添加其他拒绝规则。4. 实战排错为什么我的hosts.deny规则不生效4.1 第一步确认 TCP Wrappers 是否真的在工作这是 90% 问题的根源。不要猜要验证。执行以下命令# 1. 检查 sshd 是否链接了 libwrap ldd $(which sshd) | grep wrap # 2. 检查系统是否加载了 tcp_wrappers 模块RHEL/CentOS lsmod | grep ip_tables # 确保 netfilter 基础模块存在虽不直接相关但常连带检查 # 3. 查看 TCP Wrappers 的运行时日志需开启 # 编辑 /etc/sysconfig/tcpdRHEL/CentOS或 /etc/default/tcpdDebian/Ubuntu # 设置 TCPD_LOGyes然后重启 xinetd如果使用或等待 sshd 重新加载通常无需重启 # 日志默认输出到 /var/log/messages 或 /var/log/secure如果ldd命令无输出那么无论你怎么写hosts.deny它都不会生效。此时你必须转向iptables方案。4.2 第二步检查规则语法与顺序——“先 allow 后 deny”的陷阱最常见的错误是规则顺序和逻辑混淆。假设你的hosts.allow是空的而hosts.deny是sshd: 203.0.113.42 sshd: ALL你以为203.0.113.42会被拒绝ALL会拒绝所有。但实际流程是libwrap先查hosts.allow没找到匹配项于是去查hosts.deny第一条sshd: 203.0.113.42匹配成功执行拒绝结束。第二条sshd: ALL根本不会被读取。所以sshd: ALL在hosts.deny中只有当它位于文件顶部且你想实现“默认拒绝”时才有意义。另一个经典陷阱是大小写和空格。SSHD:或sshd : 203.0.113.42冒号前有空格都是无效语法。TCP Wrappers 对格式极其敏感。正确的格式是服务名:空格地址冒号前后不能有空格服务名小写。4.3 第三步用tcpdmatch工具进行离线模拟测试tcpdmatch是 TCP Wrappers 自带的诊断工具它能模拟libwrap的决策过程无需真实发起连接。这是排查规则是否写对的终极武器。安装如未自带# RHEL/CentOS yum install tcp_wrappers # Debian/Ubuntu apt-get install tcpd用法# 模拟一个来自 203.0.113.42 的 sshd 连接 tcpdmatch sshd 203.0.113.42 # 输出示例 # client: hostname 203.0.113.42 # server: process /usr/sbin/sshd (server-side) # access: granted (because of /etc/hosts.allow rule) # or # access: denied (because of /etc/hosts.deny rule) # 模拟一个来自 192.0.2.100 的连接 tcpdmatch sshd 192.0.2.100这个命令会精确告诉你libwrap会根据你当前的hosts.allow和hosts.deny文件对这个请求做出什么判断以及依据哪条规则。它比反复ssh测试快十倍也安全十倍。4.4 第四步检查 SELinux/AppArmor 的干扰RHEL/CentOS/Ubuntu在强制访问控制MAC系统上SELinux 或 AppArmor 可能会覆盖或阻止 TCP Wrappers 的行为。虽然不常见但必须排除。检查 SELinux 状态sestatus # 如果是 enforcing 模式临时设为 permissive 测试 sudo setenforce 0 # 然后再次测试 ssh 连接 # 如果此时规则生效了说明 SELinux 策略限制了 libwrap # 永久修复需调整 SELinux 策略如setsebool -P ssh_sysadm_login on检查 AppArmorUbuntuaa-status # 查看 sshd 是否在受限配置文件下运行 # 如果是查看 /etc/apparmor.d/usr.sbin.sshd 中是否有冲突规则注意在生产环境不建议长期关闭 SELinux/AppArmor。这只是为了快速定位问题。一旦确认是 MAC 干扰应查阅对应文档添加正确的策略而非禁用安全模块。5. 生产环境最佳实践与避坑指南——那些文档里不会写的细节5.1 “拒绝”不等于“静默”如何让攻击者知道他已被盯上hosts.deny默认拒绝时客户端收到的是Connection refused这是一个非常干净、标准的 TCP 错误。但有些高级扫描器会把这个当作“端口关闭”信号而跳过。为了让它们明确意识到“此服务存在但你被拉黑了”我们可以利用 TCP Wrappers 的spawn动作在拒绝时执行一个命令比如记录更详细的日志甚至发送一个伪造的 banner。在hosts.deny中这样写sshd: 203.0.113.42 : spawn (/bin/echo date - BLOCKED SSH ATTEMPT from %a /var/log/hosts_deny.log) : deny这里%a是 TCP Wrappers 的内置宏代表客户端 IP。spawn后面的命令会在拒绝发生时执行。这样每次该 IP 尝试连接你不仅能在hosts_deny.log中看到时间戳还能在deny后加twist动作返回一个自定义的错误信息需twist支持sshd: 203.0.113.42 : twist /bin/echo Access denied by system policy. Your IP has been logged.不过twist有一定风险因为它会向客户端返回数据可能暴露服务器信息。我更倾向于只用spawn记录日志保持“静默拒绝”的专业性。5.2 自动化更新用脚本定期同步威胁情报手动维护hosts.deny很痛苦。好消息是你可以用脚本自动下载公开的威胁情报Threat Intelligence列表并将其转换为hosts.deny格式。例如AbuseIPDB 提供免费的 CSV 格式导出。一个极简的 Bash 脚本框架#!/bin/bash # fetch_abuseipdb.sh ABUSE_URLhttps://api.abuseipdb.com/api/v2/blacklist?confidenceMinimum90limit10000 OUTPUT_FILE/tmp/abuseipdb_blacklist.txt DENY_FILE/etc/hosts.deny # 下载最新黑名单需 API key curl -s -G $ABUSE_URL \ --data-urlencode keyYOUR_API_KEY \ -o $OUTPUT_FILE # 清理并转换为 hosts.deny 格式 awk -F, NR1 {print sshd: $1 /32} $OUTPUT_FILE | sort -u $DENY_FILE.new # 合并手动规则保留注释和原有结构 sed -n /^#/p; /^$/p $DENY_FILE $DENY_FILE.merged cat $DENY_FILE.new $DENY_FILE.merged # 原子化替换 mv $DENY_FILE.merged $DENY_FILE # 通知管理员 echo AbuseIPDB blacklist updated at $(date) | mail -s hosts.deny Updated adminexample.com将此脚本加入cron每天凌晨 2 点执行。它能让你的hosts.deny始终保持最新对抗全球范围的已知恶意 IP。5.3 终极避坑三个你绝对不能犯的致命错误错误一在hosts.deny中写ALL: ALL这是灾难性的。ALL服务名会匹配所有支持 TCP Wrappers 的服务包括sshd,vsftpd,sendmail,rpcbind等。一旦写入你的服务器将瞬间变成一座孤岛所有远程管理通道全部中断。永远只针对具体服务名写规则如sshd: ...。错误二忘记hosts.allow的存在直接写sshd: ALL如前所述sshd: ALL在hosts.deny中会拒绝所有 SSH 连接包括你自己的。除非你同时在hosts.allow中明确列出了你的 IP否则这就是自杀式操作。永远遵循“先 allow后 deny”的黄金法则。错误三用hosts.deny替代强密码和密钥认证hosts.deny是网络层的访问控制它无法防止社工、钓鱼、或内部人员的误操作。它只是纵深防御的一环。如果你的sshd还开着PermitRootLogin yes和PasswordAuthentication yes那么hosts.deny再强大也无法阻止一个知道 root 密码的合法用户干坏事。它必须和ssh-keygen,sshd_config的严格配置Protocol 2,MaxAuthTries 3,LoginGraceTime 30一起使用才是完整的 SSH 安全方案。最后分享一个小技巧在/etc/hosts.deny文件末尾加上一行# Last updated: $(date)。每次你手动编辑它时用sed -i \$s/^#.*/# Last updated: $(date)/ /etc/hosts.deny更新时间戳。这样当你半年后回看这个文件一眼就能知道它最后一次被维护是什么时候避免用着一份早已过期的黑名单。安全不是一劳永逸的设置而是一场需要持续校准的旅程。而这把古老的铜钥匙只要擦得够亮依然能为你打开通往稳定与安心的大门。