
半夜两点服务器CPU突然飙到100%流量图撑满你迷迷糊糊爬起来看日志满屏都是GET /wp-login.php和一堆陌生域名刷过来的请求。查了一圈DNS解析记录没问题业务也正常问题出在你的Nginx服务器对“任何指向这个IP的域名”都来者不拒。早在半年前就该把这层安全配置加上。Nginx安全配置里最容易忽略、也最容易被人钻空子的一环就是“限制域名访问防止恶意IP解析”。简单说就是你的服务器只认你自己的域名其他域名、甚至直接拿IP访问的一律挡在门外。这篇文章我会把配置思路、实操步骤、验证方式、还有我踩过的坑从头到尾说一遍看完你直接照抄就能用。1. 为什么必须限制域名访问恶意IP解析是怎么回事1.1 “你家的门牌号外人随便挂”先把原理捋明白。互联网上每台服务器都有一个IP地址正常来说用户访问你的网站流程是输入www.example.comDNS把这个域名解析到你服务器的IP然后请求到达NginxNginx根据请求头里的Host字段决定把流量交给哪个server块处理。问题在于域名解析到你这个IP不需要经过你的同意。任何一个域名只要它的DNS记录指向了你的服务器IP访问者就能借道进入你的服务器。而Nginx默认配置里如果请求的域名匹配不上任何server_name就会落到默认server处理。如果你没有设置默认serverNginx会用第一个加载的server块来响应而这个server块通常就是你正常业务的那个意味着别人的域名也被当成你自己站点的一部分来响应了。这就是“恶意IP解析”攻击者把自己的恶意域名、钓鱼页面指向你的IP一方面借你的服务器带宽和资源做跳板另一方面如果搜索引擎收录了他那个域名脏水全泼在你身上。早年很多整站被挂黑页的案例源头就是这个。1.2 默认server块的“兜底”逻辑Nginx匹配server块是按优先级来的规则其实不复杂先按监听端口分再按server_name精确匹配、通配符匹配、正则匹配最后都没命中就落到default_server。如果配置里没显式指定default_serverNginx会默默把该端口第一个出现、或者配置文件排序最靠前的server块当作默认。这就有个隐患很多人一台服务器上挂多个站点随手写了一个server块压根没想过它会成为“接盘侠”。我在实际排查过一个客户的服务器日志里几十个陌生域名都在请求他的业务接口流量白白流失不说还被人拿去做了暴力破解跳板。原因就是他的第一个server块配置了proxy_pass到内网一台Tomcat而Tomcat又允许任意Host访问整个链路等于裸奔。2. 核心配置思路先拒后放只认自家域名2.1 兜底拦截默认server返回444配置逻辑一句话先写一个default_server把不合法的请求全拒掉再写你的正常站点。这样不管谁来只要Host不在你的白名单里直接吃闭门羹。Nginx有个很有用的状态码叫444它不是标准HTTP状态码是Nginx特有的作用简单粗暴直接断开连接不返回任何响应头和数据。这对恶意扫描器来说体验很好——对方等不到HTTP响应超时重试几次就放弃了。比返回403或404更省资源也更隐蔽。配置示例# 兜底拦截所有未匹配域名的请求在这里终结 server { listen 80 default_server; listen 443 ssl default_server; server_name _; # 注意443端口如果没有证书我这里留空有证书场景往下看3.2节 return 444; }这段配置的要点listen必须要带default_server参数否则你的80或443端口可能仍然把未知域名交给其他server块处理。443端口这块有个坑我后面单独说。2.2 正常站点域名白名单必须写全拦截server只是第一道关你正常业务的server块也要把域名写完整、写严格。不要只写一个主域名就完事子域名、备用域名、甚至以后要用的新域名现在就该列清楚。server { listen 80; server_name example.com www.example.com m.example.com; # 其他业务配置... } server { listen 443 ssl; server_name example.com www.example.com m.example.com; # SSL证书、其他业务配置... }有人会问写这么全有必要吗有。因为你只写了example.com攻击者用m.example.com来访问Nginx发现匹配不上就会落到default_server——这没问题会被拦截。但如果某个server块写得不严谨比如用了server_name example.com *.example.com而你的业务代码又没做域名校验那么随便造一个anything.example.com也能进业务页面。这就是为什么我建议能精确匹配就精确匹配少用泛解析。2.3 为什么是444而不是403或404我前期接触过很多类似需求早期我也习惯返回403。后来对比日志才发现返回403其实有信息泄露的风险——扫描器收到403立刻知道服务器存在、有安全策略、这个Host被拒了但IP活着反而会激起更猛烈的针对性扫描。返回404的话对方会以为域名解析错了走了也就走了但404毕竟消费了一部分连接和日志资源。444是其中成本最低的连接直接断掉Nginx不写任何业务日志访问日志里也就是一条极小记录对方得不到任何反馈。我把生产环境的拦截策略从403改成444之后恶意请求量降了大概30%剩下的都是攻击脚本没脾气地在反复重试。做安全配置越被动越省事越好。3. 实操落地方案从检查现状到完整配置3.1 先确认你现在的“接盘”状态动手前先摸摸底看看你的服务器现在到底在替多少人“接盘”。最直接的办法是查访问日志里有多少个不同的Host字段# 查看今天日志里请求的域名Top10 awk {print $1} /var/log/nginx/access.log | sort | uniq -c | sort -nr | head -20如果这个列表里有一堆你根本不认识的域名那你确实已经中招了。另外也可以用curl模拟测试提前看看Nginx到底把未知域名的请求交给了谁# 随便造一个域名解析到自己IP并指定Host看返回的是什么 curl -I -H Host: whatever-random-domain.com http://你的服务器IP/如果返回的是你正常站点的内容恭喜你当前就是默认接盘侠状态下面的配置马上就能派上用场。3.2 443端口的default_server坑证书从哪来完整配置里最尴尬的就是443端口。listen 443 ssl default_server要求这个server块必须带证书但你要拒绝的是未知域名它们根本没有任何一个证书和你匹配。怎么办我用的是自签名证书顶上反正目的只是让SSL握手能有个证书可用真到了业务校验那步还是会被规则拦下来。自签名证书生成命令openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \ -keyout /etc/nginx/ssl/default.key \ -out /etc/nginx/ssl/default.crt \ -subj /CNlocalhost -addext subjectAltNameDNS:localhost然后拦截server改成server { listen 80 default_server; listen 443 ssl default_server; server_name _; ssl_certificate /etc/nginx/ssl/default.crt; ssl_certificate_key /etc/nginx/ssl/default.key; return 444; }注意一个细节client发起的HTTPS握手是在HTTP请求之前完成的也就是说握手阶段Nginx根本看不到Host字段只能用证书来匹配。如果你对这个未知域名访问时跳出了证书错误但后续连接还是被444断开那就对了这套机制就是靠默认自签名证书兜底SSL层、靠server_name兜底应用层。3.3 完整配置实例一套可以直接抄的模板下面是一套我在生产环境实际在用的精简版配置你们可以直接参考调整# 1. 兜底拦截 server { listen 80 default_server; listen 443 ssl default_server; server_name _; ssl_certificate /etc/nginx/ssl/default.crt; ssl_certificate_key /etc/nginx/ssl/default.key; return 444; } # 2. HTTP正常站点只负责跳转HTTPS顺便兜底非443请求 server { listen 80; server_name example.com www.example.com m.example.com; return 301 https://www.example.com$request_uri; } # 3. HTTPS正常站点 server { listen 443 ssl; server_name example.com www.example.com m.example.com; ssl_certificate /etc/nginx/ssl/example.com.pem; ssl_certificate_key /etc/nginx/ssl/example.com.key; # 建议再加一层开启标准HTTP响应头 add_header X-Content-Type-Options nosniff always; location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } }这套配置里顺序也很重要拦截server放在最前面Nginx默认会把第一个server块当成该端口隐含的default_server虽然我显式写了default_server但放第一个更稳妥防止某些历史配置干扰。正常站点的server_name必须和拦截不重叠否则配置加载时会报conflicting server name错误别问我怎么知道的我改错过。3.4 重载与验证改完必须做的三件事改完配置执行nginx -t检查语法没问题再systemctl reload nginx。然后做三个验证# 1. 正常域名返回200 curl -I -H Host: example.com http://你的服务器IP/ # 2. 陌生域名直接断连444无法用curl -I直观看到看返回码curl提示 curl -v -H Host: evil-example.com http://你的服务器IP/ 21 | tail -20 # 3. 直接IP访问同样被拒 curl -v http://你的服务器IP/ 21 | tail -20如果你在curl的输出里看到类似Empty reply from server恭喜444生效了说明连接被Nginx切断这正是我们要的效果。日志里也会留下一条GET / HTTP/1.1 444用tail -f /var/log/nginx/access.log | grep 444 就能实时看到被拦截的请求动态。顺带一提如果你用了CDN千万别把CDN回源IP加进Host白名单——CDN回源时用的Host头仍然是你自己的域名不受这个配置影响你不需要为CDN网段改变Nginx逻辑真正要处理的是上行环节。4. 进阶加固让拦截更聪明日志更可控4.1 用map做域名白名单替代多个server_name如果你的站点特别多或者说你的需求是“一批域名放行、其余全拒”那配置多个server块会显得很低效、也难以维护。更优雅的写法是用map做白名单判断然后在入口层统一处理。下面是另一种我比较推荐的方案# 顶层http块里定义白名单映射 http { map $http_host $is_allowed { hostnames; default 0; example.com 1; www.example.com 1; m.example.com 1; api.example.com 1; } server { listen 80 default_server; listen 443 ssl default_server; server_name _; if ($is_allowed 0) { return 444; } # 这里写你的默认业务配置 } }这个思路适合中大型站点把域名校验从server_name匹配中抽离出来用变量控制流程逻辑统一又清晰。缺点是需要小心if规则在Nginx里的各种行为限制但用在return上没问题。实测下来这种map方案对几百个域名的场景维护起来要舒服很多。4.2 日志策略把恶意请求和正常请求分开被拦截的请求里其实有大情报但默认日志配置把所有记录搅在一起翻起来费劲。我的做法是单独开一个access_log专门记录被拒绝的请求server { listen 80 default_server; listen 443 ssl default_server; server_name _; ssl_certificate /etc/nginx/ssl/default.crt; ssl_certificate_key /etc/nginx/ssl/default.key; access_log /var/log/nginx/blocked.log; error_log /var/log/nginx/blocked_error.log; return 444; }然后每天例行扫一眼这个文件啥套路都能看出来。我曾经从一个被拦截的恶意域名顺藤摸瓜发现对方连续一周用不同子域名解析到我IP上来探测路径时间规律全是凌晨三点到五点。你要是没单独存日志这种规律很难发现。4.3 配合防火墙或云安全组做双保险Nginx层做域名校验防的是“已经到达应用层”的请求。但像上面那种高频探测应用层虽然不响应连接还是打满了TCP握手照样消耗系统资源所以Nginx层拦截只是第一道防线。我建议在更外围再加一道云安全组或防火墙只放行80/443端口其他端口一律关掉尤其是22端口只对管理IP白名单开放。另外还有一个思路值得考虑如果你的业务只有固定几个出口IP访问比如只给内部人员用的系统可以直接在Nginx加allow/deny规则。但对公网业务allow/deny不如域名校验来得实际因为用户IP是动态的。我自己更常用的组合是Nginx管域名白名单防火墙按端口来源IP分段管理两层配合覆盖不同维度。4.4 和反代、负载均衡一起用时的注意事项很多人配置正向反代或者负载均衡时会忽略一个关键点$host变量是从客户端请求头里拿的还是从你proxy_pass转发的上游拿的在Nginx里$http_host是客户端原始Host$host是处理后的Host。如果你在拦截server里用了$http_host做判断而前面又有一层代理把Host改掉了判断结果就可能失真。我举个实际场景你用了阿里云SLB或腾讯CLB客户端先经过负载均衡器再转发到后端的Nginx。有些负载均衡配置会默认把Host保留原样那没事但有些会重新写Host为你的域名导致后端Nginx接收到的Host变成了别名你的白名单如果没包含这个别名就会被误杀。遇到这种情况把负载均衡器转发过来的域名加进白名单或者干脆在后端Nginx的realip和host校验上下点功夫别只看客户端原始IP。5. 常见问题速查与避坑指南5.1 问题速查表现象可能原因解决方法未知域名请求到了业务页面没设default_server或业务server里server_name过宽增加兜底拦截server块收紧server_name精确白名单443端口未知域名能访问但有证书错误443的default_server用了业务证书为拦截server单独配自签名证书配置重载时报server_name冲突多个server块写了相同server_name把server_name整合到同一个server块或用map方案HTTPS正常页面访问变成444负载均衡器改了Host头把转发过来的Host别名加入白名单或调整LB的Host透传日志里有大量444但来源IP分散扫描器在批量探测配合防火墙封IP段或加限流规则5.2 几个容易踩的坑我帮你趟过了第一个坑是“自签名证书过期”。我给443拦截server配的自签名证书默认10年但有一次测试环境里同事把这个证书误用在正常站点上客户端直接报证书错误排查半天才反应过来。所以注意给这个自签名证书注释清楚用途别混用。第二个坑是“server_name _;不代表任意”。_作为server_name时语义是“我什么都不匹配只做兜底”它不是通配符。如果你在后面写了server_name *.example.com;同时又想用server_name _;拦截其他域名两个是可以共存的没问题但别指望server_name _;能同时匹配example.com它只能靠default_server身份兜底。想通配就明确写*.example.com。第三个坑是关于return 444的位置。如果你在location块里也写了规则有时候会被外层return 444覆盖有时候反过来取决于你是想全局拒绝还是针对部分路径拒绝。我的实践结论是全局拒就写在server块顶层按路径拒就写在location块里不要两边都写否则维护起来精神分裂。5.3 特定场景Tomcat等多应用服务器的接收方校验讲一个实战中很多人忽略的延伸点。我前面提到过有个客户Nginx挡好了但后端Tomcat还开着。因为Nginx是反向代理它转发请求给Tomcat时会带上Host头而Tomcat的Host过滤器如果没配它根本不知道客户端原本是谁Tomcat默认接受一切Host。这种情况下Nginx做了拦截但攻击者如果绕过Nginx直接访问Tomcat的8080端口照样能打进来。所以我的建议是后端应用服务器也要同步做Host校验。以Tomcat为例最简单的做法是在server.xml的Engine里配置DefaultHost让它对未知主机名返回错误或者给每个站点配置自己的Host即可。这个领域虽然不属于Nginx配置本身但整体安全链条缺它不可你们部署的时候一定要前后端一起管别只盯着Nginx。6. 这个配置后续能怎么演进做完域名白名单拦截之后安全这块的习惯还能继续沉淀。我个人建议下一步把日志分析自动化哪怕是写个简单的Shell脚本每天扫一遍blocked.log把请求量大的IP自动拉进防火墙黑名单。有条件的话再接个告警比如连续一个小时内某个IP被拒次数超过阈值就发个通知不用等到第二天翻日志才发现又有人来试探。我自己的体验是安全配置永远没有“配完就完事”的时候互联网上的扫描基本是全自动的你会持续看到新的恶意域名往你IP上解析。只要默认拦截的习惯保持住后面就是升级打怪的手段问题了。如果哪天你发现某个陌生域名居然还能打开你业务页面那一定是白名单哪里漏了回头检查检查server_name有没有少写一个子域。