1. 出站连接明明可控,为什么大多数服务器还是裸奔
先说个我一直很困惑的现象:很多团队在服务器上花大力气配安全组、配防火墙,入站规则写得密密麻麻,恨不得只留 80/443 和 SSH 端口,但出站连接几乎不设防。默认情况下,CentOS/RHEL 的防火墙只拦入站,不拦出站,也就是说一旦机器被种了木马、被拿到 shell,攻击者想往外传数据、想下载工具、想连回控制端,基本都是畅通无阻的。
出站连接控制的价值就在这儿:它不是为了拦住别人进来,而是为了拦住“这台机器主动出去”。它解决的是两种情况,一是机器已经被攻破后的横向移动和反向连接,二是机器上某些业务进程在偷偷往外发数据。如果你只做入站防护,等于只锁了前门,但后门和所有窗户都是敞开的。很多安全基线检查里也会明确要求“禁止服务器主动外联”,这在等保、PCI-DSS 这些标准里尤其常见。
这篇文章就是围绕 CentOS / RHEL 上如何把出站连接管起来展开的。我会从 firewalld 和 iptables 两条路线分别讲清楚,也会把默认拒绝出站、按目标 IP 拦截、按用户限制外联这些操作细节一并给你。内容适合两类人看:一是要加固服务器、应付合规检查的运维同学,二是刚接触防火墙、想搞明白 OUTPUT 链到底怎么玩的初学者。我会尽量讲得像做实验一样,每一条命令你都能直接拿到机器上去试。
这里先抛一个结论:出站拦截不是“把 OUTPUT 链设成 DROP”就完事了。难的不是那一条 DROP 规则,难的是在白名单里放行哪些必要流量,以及规则写错之后怎么快速定位问题。后面我踩过的坑,基本都集中在这两个地方。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 动手前先把家底盘清楚
2.1 先判断当前用的是 firewalld 还是 iptables
CentOS 6 默认是 iptables,CentOS 7 以后默认换成了 firewalld,但很多人在 CentOS 7/8 上仍然手动装回 iptables-services,或者干脆把 firewalld 停掉,自己用 iptables 命令灌规则。RHEL 8/9 的情况就更特殊一点,默认 firewalld 的后端是 nftables,你敲 iptables 命令也能用,但它其实是 nftables 的兼容层,规则展示方式和老版本不完全一样。
在开始改配置之前,先确认系统现在到底是谁在管网络过滤:
bash复制systemctl status firewalld
看输出如果是 active,后面就用 firewall-cmd。如果提示 Unit firewalld.service could not be found,或者状态是 inactive,那大概率是 iptables-services 或者裸的 nftables 在管。
再看一下当前 nftables 规则集,能帮助你了解现有的防火墙全家桶:
bash复制nft list ruleset
这一步不是多此一举。我见过不少机器,明明 firewalld 关了,但 nftables 里还留着 Docker 或 libvirt 塞进去的一大堆链;也见过 firewalld 开着的机器,却有人手动往 iptables 里加规则,两条通道同时在跑。你连底层是谁都不清楚,后面排查出站拦截不生效的时候,很容易被这种“双轨制”坑到怀疑人生。
2.2 规则备份和可回滚方案
修改防火墙规则之前,把当前规则完整导出来做备份,这是最便宜的一道保险。
firewalld 下可以直接导出所有永久配置目录,或者把当前运行时规则打出来:
bash复制mkdir -p /root/fw-backup
firewall-cmd --direct --get-all-rules > /root/fw-backup/direct-rules.txt
firewall-cmd --list-all > /root/fw-backup/zone-default.txt
iptables 模式下更简单,一行命令就把规则集存下来了:
bash复制iptables-save > /root/fw-backup/iptables.rules.$(date +%F_%H%M)
万一后面规则把自己锁死了,至少还能用备份文件恢复。尤其是你要在远程服务器上做默认拒绝出站这种高危操作,强烈建议先把当前 SSH 连接保持住,再用 tmux/screen 挂一个会话,或者准备一个定时任务来释放规则,给自己留一条后路。
2.3 一张表看明白 OUTPUT/FORWARD/INPUT 的关系
很多新手混淆出站和转发,我先给一个简单的对照表,后面所有操作都基于这个概念。
| 链 | 流量方向 | 典型场景 |
|---|---|---|
| INPUT | 进入本机的数据包 | 别人访问你的 80 端口,外部包进到本机进程 |
| OUTPUT | 本机进程主动发出去的数据包 | 你用 curl 访问外网、服务器主动连接数据库 |
| FORWARD | 经过本机但不住在本机的数据包 | Docker 容器访问外网、路由器转发流量 |
拦截出站连接,核心操作的是 OUTPUT 链。但要注意,如果这台机器上跑着 Docker,容器访问外网走的通常不是宿主机进程的 OUTPUT,而是 FORWARD 链。你单独配 OUTPUT 默认 DROP 并不会完全禁掉容器外联,还需要把 FORWARD 也管上,否则容器照样能通过 Docker 创建的转发规则偷偷跑出去。
3. firewalld 下拦截出站:直接规则是最稳的姿势
3.1 按目标 IP 精确拦截
如果你不想搞默认拒绝,只想拦住某个“异常外联目标”,firewalld 里的做法是用 direct 规则直接往 OUTPUT 链插入 DROP。
比如已知某个 IP 是恶意回连地址,要禁止本机任何进程访问它:
bash复制firewall-cmd --permanent --direct --add-rule ipv4 filter OUTPUT 0 -d 203.0.113.10 -j DROP
firewall-cmd --reload
这里 --direct 表示直接操作内核过滤规则,ipv4 指定协议族,filter 是表,OUTPUT 是链,0 是优先级。优先级数字越小越先执行,默认 DROP 这种兜底规则优先级要放后面,具体优先级分配在下面 3.2 里会讲。
也可以按网段整段拦,比如封锁某个 C 段:
bash复制firewall-cmd --permanent --direct --add-rule ipv4 filter OUTPUT 0 -d 203.0.113.0/24 -j DROP
按端口拦截也是一样的思路。比如禁止服务器主动向外发 SMTP 流量,防止被当成垃圾邮件跳板:
bash复制firewall-cmd --permanent --direct --add-rule ipv4 filter OUTPUT 0 -p tcp --dport 25 -j DROP
这种“目标明确”的拦截,不影响正常外联,业务风险最小,适合先快速止血。
3.2 默认拒绝出站的白名单写法
真正严格的做法是默认拒绝所有出站,然后只放行必要的目标地址和端口。这个玩法在 firewalld 里没有专门的命令,但 direct 规则完全够用。
先记住一个原则:所有放行规则必须排在 DROP 前面,尤其是回环、DNS、已建立的连接这几类,一旦顺序错乱,你设完 DROP 的那一刻,SSH 响应可能就回不来了。
下面是一套我在生产环境用过的完整规则序列,你可以根据实际业务调整:
bash复制# 1. 已建立的连接及其关联连接放行
firewall-cmd --permanent --direct --add-rule ipv4 filter OUTPUT 0 -m state --state ESTABLISHED,RELATED -j ACCEPT
# 2. 回环接口放行,本地进程之间通信不受影响
firewall-cmd --permanent --direct --add-rule ipv4 filter OUTPUT 1 -o lo -j ACCEPT
# 3. DNS 解析放行,否则服务器连域名都解析不了
firewall-cmd --permanent --direct --add-rule ipv4 filter OUTPUT 2 -p udp --dport 53 -j ACCEPT
firewall-cmd --permanent --direct --add-rule ipv4 filter OUTPUT 3 -p tcp --dport 53 -j ACCEPT
# 4. 允许访问本机网络使用的 NTP 服务
firewall-cmd --permanent --direct --add-rule ipv4 filter OUTPUT 4 -p udp --dport 123 -j ACCEPT
# 5. 允许 HTTP/HTTPS 出站,yum/dnf 更新和 curl 等操作需要
firewall-cmd --permanent --direct --add-rule ipv4 filter OUTPUT 5 -p tcp --dport 80 -j ACCEPT
firewall-cmd --permanent --direct --add-rule ipv4 filter OUTPUT 6 -p tcp --dport 443 -j ACCEPT
# 6. 如果业务需要连指定数据库或 API,再把对应目标 IP + 端口放行
firewall-cmd --permanent --direct --add-rule ipv4 filter OUTPUT 7 -d 192.168.10.20 -p tcp --dport 3306 -j ACCEPT
# 7. 最后兜底,其他所有出站一律丢弃
firewall-cmd --permanent --direct --add-rule ipv4 filter OUTPUT 8 -j DROP
firewall-cmd --reload
这套规则里最关键的一条是第 1 条。你可能会觉得奇怪,为什么默认拒绝出站了,还要放行“已建立的连接”?因为一个 TCP 连接建立之后,本机后续发送的 ACK、KeepAlive、数据包,在内核连接跟踪里都算 ESTABLISHED 状态。如果这条规则被漏掉,你会发现 SSH 能连上,但敲一个命令后响应可能断断续续,甚至连接直接卡死。连接跟踪是状态防火墙的灵魂,千万别凭感觉删掉它。
允许 HTTP/HTTPS 出站也要想清楚:如果你把 80/443 全放行,那恶意进程照样可以伪装成 HTTPS 流量外传数据。严格模式下,建议把第 5 条的端口放行改成“目标 IP + 端口”的组合,只允许访问信任的更新源和代码仓库。虽然维护白名单会麻烦一点,但安全性才是真提升了。
3.3 别忘了 IPv6
我在生产环境见过最典型的遗漏,就是只配了 IPv4 的 OUTPUT DROP,IPv6 一条没管。攻击者只要目标地址是 IPv6,或者你系统里启用了 IPv6 隧道,出站拦截就形同虚设。
firewalld 里 IPv6 的 direct 规则写法几乎一样,把 ipv4 换成 ipv6 即可:
bash复制firewall-cmd --permanent --direct --add-rule ipv6 filter OUTPUT 0 -m state --state ESTABLISHED,RELATED -j ACCEPT
firewall-cmd --permanent --direct --add-rule ipv6 filter OUTPUT 1 -o lo -j ACCEPT
firewall-cmd --permanent --direct --add-rule ipv6 filter OUTPUT 2 -j DROP
如果你这台机器根本不用 IPv6,更彻底的办法是直接在系统层禁用 IPv6。但要注意,禁用之前先确认业务和 Docker 网络没有依赖 IPv6,否则会出现莫名其妙的问题。
3.4 让规则永久生效
firewalld 的 direct 规则如果加了 --permanent,会进入永久配置,--reload 之后生效。但如果一开始没加,只敲了 firewall-cmd --direct --add-rule ...,那规则只在运行时存在,重启或 reload 就会消失。
一个比较稳妥的做法是:改完临时规则验证没问题后,再用 --runtime-to-permanent 把当前运行时规则写入永久配置:
bash复制firewall-cmd --runtime-to-permanent
不过我建议还是从一开始就明确加 --permanent,避免出现“明明测试时生效,重启后却回归原样”的尴尬。
4. iptables 直接操作的实战路线
4.1 只拦截特定出口,不碰其他流量
有些场景你不想用 firewalld,或者服务器上 firewalld 已经被禁用了,这时候直接用 iptables 更顺手。
按目标 IP 拦截一个具体地址:
bash复制iptables -A OUTPUT -d 203.0.113.10 -j DROP
按端口拦截:
bash复制iptables -A OUTPUT -p tcp --dport 25 -j DROP
iptables -A OUTPUT -p udp --dport 123 -j DROP
用 -A 追加规则,规则位置在链尾。如果你之前已经设置了默认 DROP,这些追加的规则可能永远走不到,因为默认策略只对不匹配任何规则的包生效。出现这种情况时,用 -I OUTPUT 1 插入到链首更可靠:
bash复制iptables -I OUTPUT 1 -d 203.0.113.10 -j DROP
4.2 默认 DROP 并保留必要通信
用 iptables 做严格出站限制,核心是修改 OUTPUT 链的默认策略。但强烈建议不要一上来就执行 iptables -P OUTPUT DROP,先把该放行的规则加好,最后再改默认策略。
推荐顺序如下:
bash复制# 先保证已建立的连接不受影响
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
# 回环放行
iptables -A OUTPUT -o lo -j ACCEPT
# DNS 放行
iptables -A OUTPUT -p udp --dport 53 -j ACCEPT
iptables -A OUTPUT -p tcp --dport 53 -j ACCEPT
# 放行业务需要的目标
iptables -A OUTPUT -d 10.0.0.0/8 -j ACCEPT
iptables -A OUTPUT -d 192.168.0.0/16 -j ACCEPT
# 最后把默认策略改成 DROP
iptables -P OUTPUT DROP
执行完最后一行,你会发现 curl 外网可能失败,但本地回环、内网网段、DNS 解析都正常,因为前面已经有 ACCEPT 规则匹配了。如果你不小心先改了默认策略,马上就出现“当前 SSH 弹不回任何字符”的恐怖体验,这时候不要慌,只要 SSH 会话还连着,立刻执行 iptables -P OUTPUT ACCEPT 还能救回来。
还有一点要特别留意:内网网段放行规则别写太宽。像 10.0.0.0/8 这种大网段,确实方便,但也意味着攻击者在内网里横向移动时,你的出站拦截根本拦不住。严格模式下更应该精确到具体业务 IP,而不是图省事放一个巨大网段。
4.3 iptables 规则保存与重启恢复的坑
CentOS 7 上如果直接用 iptables 命令加规则,这些规则不写盘,重启就没了。常见做法是安装 iptables-services:
bash复制yum install -y iptables-services
systemctl enable iptables
service iptables save
service iptables save 会把当前规则保存到 /etc/sysconfig/iptables,重启后由 iptables-services 自动加载。
但这里有个大坑:RHEL 8/9 的默认防火墙是 nftables 后端,如果你强行安装 iptables-services,并让它开机自启,容易和 firewalld 产生规则冲突。更常见的是,你在 firewalld 里设的规则和 iptables 文件里的规则各自为政,最后连你自己都搞不清谁在生效。
我的建议是:CentOS 7 老机器用 iptables-services 没问题,但 RHEL 8/9 上优先用 firewalld 的 direct 规则,或者直接学 nftables,不要图省事混着来。如果你想在 RHEL 8/9 上临时用 iptables 命令做测试,可以,但别想着靠 service iptables save 做永久化,那套机制在新版本里不是主流路径。
5. 更进一步:按用户/服务限制外联
5.1 用 owner 匹配做账号级限制
规则层面拦 IP 和端口已经能解决大部分问题,但有些场景你不想影响整台机器,只想限制某个用户或服务账户的外联能力。这时候可以用 iptables 的 owner 模块,它可以根据发出数据包的进程 UID 来匹配规则。
比如禁止 backup 用户主动外联:
bash复制iptables -A OUTPUT -m owner --uid-owner backup -j DROP
这里要特别提醒,如果这个 UID 同时跑着对外服务,那它的响应数据包也要穿越 OUTPUT 链,简单一条 DROP 可能连正常服务响应都拦掉了。所以实际使用时要先放行 ESTABLISHED、RELATED,再针对特定目标做限制:
bash复制iptables -A OUTPUT -m owner --uid-owner backup -m state --state ESTABLISHED,RELATED -j ACCEPT
iptables -A OUTPUT -m owner --uid-owner backup -d 10.10.10.20 -p tcp --dport 5432 -j ACCEPT
iptables -A OUTPUT -m owner --uid-owner backup -j DROP
owner 匹配的限制也很明显,它管不住 root 用户,因为 root 可以伪装成任意 UID,也管不住容器内部的进程,因为容器里的外联包在宿主机 OUTPUT 链上不一定带宿主机的 owner 信息。它适合做“非对抗性”的误操作防护,不适合作为安全边界。
5.2 systemd 的 IPAddressDeny 可能是更省心的选择
如果你的服务是用 systemd 管理的,还有一个更契合“业务角度”的做法:直接在 service 单元里限制网络访问,而不是在防火墙层面对 IP/端口做黑盒式拦截。
在 /etc/systemd/system/xxx.service.d/egress.conf 里写:
ini复制[Service]
IPAddressDeny=any
IPAddressAllow=192.168.1.0/24
IPAddressAllow=10.0.0.0/8
然后重新加载 systemd:
bash复制systemctl daemon-reload
systemctl restart xxx.service
IPAddressDeny=any 表示该服务进程所有网络包默认丢弃,IPAddressAllow 再按需放行内网网段。这个功能基于 BPF 实现,比 iptables 的 owner 匹配更贴近服务本身,也更容易被人理解:你想让这个服务访问哪些网段,一眼就能看明白。
不过这套方案只对 systemd 管理的服务进程有效,对那种直接 nohup 启动的进程不起作用,也管不住 root 启动后 fork 出来的会话。你可以把它和防火墙规则搭配使用:systemd 负责业务服务,防火墙兜底整个系统的出站白名单。
6. 验证、排障和我的实战经验
6.1 验证规则是否真的生效
配置完不要急着收工,要验证规则确实在起作用。最简单的验证方法,在规则配置完成后用 curl 测一下外网连通性:
bash复制curl -I --connect-timeout 3 https://example.com
如果你设了默认拒绝出站,这条命令应该超时。如果你只是按 IP 封锁了特定地址,可以这样测:
bash复制ip addr show
# 先看本机 IP,再用一个确定被封锁的测试 IP 去 ping
ping -c 2 203.0.113.10
不过要注意,很多服务器上 ICMP 可能被上层策略挡掉,ping 不通不代表 TCP 也不通。更可靠的测试是用 nc 或 curl 访问一个具体端口:
bash复制nc -vz 203.0.113.10 443
在测试过程中,随时查看规则的命中计数:
bash复制iptables -L OUTPUT -n -v --line-numbers
-v 会显示每个规则的包计数和字节计数。如果 DROP 规则的计数在增长,说明确实有流量被拦了。firewalld 环境可以这样看直接规则:
bash复制firewall-cmd --direct --get-all-rules
6.2 最常见的翻车现场
我在实际运维中见过太多出站拦截翻车的案例,挑几个典型的说。
第一个是 DNS 被拦,服务器陷入“有 IP 能通、有域名全挂”的状态。很多人严格模式把 DNS 放行漏了,以为只有访问网页才需要放行,结果所有依赖域名的操作全部失败。判断方法很简单:ping 8.8.8.8 通,但 curl https://example.com 报无法解析域名,基本就是 53 端口出了问题。
第二个是 NTP 被拦,时间同步中断。出站规则写得太狠,忘了放行 UDP 123,过几天你会发现服务器时间漂移,随后证书校验、日志时间、主从复制全跟着出问题。所以我在默认拒绝规则里特意加了 NTP 放行,别嫌多。
第三个是 IPv6 漏掉。服务器解析到一个 IPv6 地址,看起来不通,但它不是被 DROP,而是压根没有 IPv6 路由。更麻烦的是某些应用会优先尝试 IPv6,结果因为 IPv6 不通导致业务方误以为是你防火墙拦错了。所以做 IPv4 出站限制的同时,务必确认 IPv6 的规则是“放行”还是“拒绝”,不要留空白。
第四个是规则顺序错乱。iptables 的规则是自上而下匹配的,如果你把 -j DROP 写在白名单前面,那白名单永远不会被匹配到。用 --line-numbers 看规则顺序是最快的方式,必要时用 iptables -D 删掉错误规则,用 -I 重新插入到正确位置。
6.3 快速回滚与分阶段上线
出站拦截这种动作,最忌讳一次性上猛药。我的习惯是分三个节奏来走:
第一个阶段,只加“按目标 IP/端口拦截”的黑名单规则,不影响正常业务,观察一两天日志,确认没有业务进程在访问这些被拦目标。
第二个阶段,把规则改成“日志记录模式”,也就是不要直接 DROP,而是用 -j LOG --log-prefix "EGRESS-DROP: " 先记录所有本应被拦截的流量。跑一天,看日志里有没有业务关键流量,确认不会误伤后再切到 DROP。
第三个阶段,把规则切换为真正的 DROP,同时检查业务监控,看有没有异常超时或连接失败。
具体的 LOG 规则是这样:
bash复制iptables -A OUTPUT -j LOG --log-prefix "EGRESS-DROP: " --log-level warning
iptables -A OUTPUT -j DROP
日志会写到 /var/log/messages 或 journalctl 里,可以这样看:
bash复制journalctl -k | grep EGRESS-DROP
别小看这个缓冲阶段,我见过太多人直接把 OUTPUT 默认策略改成 DROP,然后发现监控告警、配置同步、日志上报全挂了,最后只能连夜回滚。先记录、再拦截,虽然多花一天时间,但能帮你把误伤的代价降到最低。
如果真出了紧急情况,需要立刻恢复,记住一条最直接的命令:
bash复制iptables -P OUTPUT ACCEPT
这条命令只针对 iptables 场景,且只解决默认策略导致的封锁。如果是 firewalld direct 规则里的 DROP 在捣乱,最快的方式是把对应规则删掉,或者直接 systemctl restart firewalld 让临时规则回归初始状态。
所有规则都验证没问题之后,把这个经验沉淀到你们的服务器初始化脚本或 Ansible playbook 里。下次新机器上线,直接套用这套出站白名单,比每次手工敲命令要稳妥得多。
我实际用下来还有一个体会:出站拦截的难点从来不是命令本身,而是你对自己的业务流量有多了解。你不知道服务器主动访问了哪些地址,就没办法写出安全又不误伤的白名单。所以做严格出站限制之前,我强烈建议先在机器上跑一段时间的连接审计:
bash复制ss -tnp | awk '{print $5}' | sort | uniq -c | sort -rn
这个命令能看当前所有外联连接的远程地址和数量统计,连续观察几天,你就能画出一张“这台机器到底在跟谁通信”的清单,然后再动手配规则,心里就有底了。
