DNS重绑定攻击防御实战:Host校验与解析隔离

DNS 重绑定(DNS Rebinding)是一种让外部网页借受害者浏览器之手访问内网服务的攻击手法,防御它不靠杀毒软件,而靠解析层与 HTTP 层的三道校验。只要内网还跑着管理面板、路由器后台、NAS 页面、Jenkins、Redis 可视化工具这类没做 Host 校验的服务,这条路就一直敞着。下面给出一套在 unbound、Nginx 与应用代码三处同时落地的配置,并附可复现的验证命令。

适用场景

满足以下任一条件,都建议按本文做一遍收敛:

  • 内网有 Web 管理后台(路由器、防火墙、NAS、iLO/IPMI、Jenkins、Grafana、RedisInsight),只能内网访问但习惯用域名打开;
  • 员工终端可以正常上外网,攻击者只需要诱导点击一个链接,不需要任何木马落地;
  • 反向代理配置里 server_name 写成通配符或用默认 server 兜底,任何 Host 都能进来;
  • 研发和测试环境把 0.0.0.0 当监听地址,服务实际对整个办公网开放。

不适用的场景也要说清楚:服务本身就要对公网提供访问,并且已经做了强认证与 CSRF 防护,风险等级会下降。但即便如此,补齐 Host 校验依然是成本极低的纵深防御,没有理由跳过。

前置条件

  • 内网 DNS 由自己控制,unbound、dnsmasq、BIND 任选其一,能改配置并重启;
  • 目标服务前有 Nginx 1.18 以上版本,具备 reload 权限;
  • 能修改应用代码或加一层中间件,Python、PHP、Java 都可以,思路一致;
  • 验证工具:dig、curl、浏览器开发者工具的 Network 面板。

原理说明

浏览器的同源策略只认「协议 + 域名 + 端口」,不认 IP。攻击者注册一个域名,把 TTL 设成 0 或极短,第一次解析返回自己的公网地址,浏览器加载页面并执行其中的 JS;随后攻击者把该域名的 A 记录改成 127.0.0.1 或 192.168.1.1,浏览器再发起请求时就会连到内网,而页面里的 JS 依然认为自己在和同一个域通信,于是能读到响应内容。

关键细节在这里:浏览器发出的 Host 头始终是攻击者的域名,绝不会变成内网面板的真实域名。这就是整条防线的支点——服务端只要校验 Host 必须命中白名单,重绑定流量在第一步就被拒。

三层防线各管一段。解析层限制私有地址应答,切断「本地 DNS 帮攻击者返回内网 IP」这条辅助路径;代理层用严格 server_name 加默认 server 拒绝,把非白名单 Host 直接断开;应用层校验 Origin 与 Sec-Fetch-Site,挡住那些绕过了前两层的写操作。单做一层能挡住公开 PoC,真被针对性打时,三层同时存在才够看。

操作步骤

第一步:解析层开启重绑定保护

unbound 的 private-address 会让解析器丢弃应答中指向私有网段的记录,这正是重绑定最依赖的一环。注意它同时会误伤内网自有域名,所以必须用 private-domain 把内部域豁免出来,否则所有 corp.local 的解析都会失败。

# /etc/unbound/unbound.conf.d/rebind.conf
server:
    # 应答中出现以下私有网段即视为可疑,直接丢弃
    private-address: 10.0.0.0/8
    private-address: 172.16.0.0/12
    private-address: 192.168.0.0/16
    private-address: 169.254.0.0/16
    private-address: 127.0.0.0/8
    # 内网自有域名豁免,否则内部系统全部解析失败
    private-domain: "corp.local"
    # 拒绝向上游转发本地域,避免信息外泄
    local-zone: "corp.local." nodefault

# 校验配置并热加载
unbound-checkconf
unbound-control reload

如果用的是 dnsmasq,等价配置是两个开关:stop-dns-rebind 开启保护,rebind-domain-ok=/corp.local/ 做豁免。改完执行 systemctl reload dnsmasq。

第二步:Nginx 严格 Host 校验

核心是「不匹配就断连」,而不是返回 404 或跳转首页。444 是 Nginx 的非标准状态码,作用是直接关闭连接、不回任何内容,扫描器拿不到指纹,攻击者的 JS 也读不到任何可判断的信息。

server {
    listen 80 default_server;
    server_name _;
    return 444;                 # 无匹配域名的请求一律断连
}

server {
    listen 80;
    server_name panel.corp.local;

    # server_name 含通配时用 $host 再兜一层
    if ($host != "panel.corp.local") {
        return 444;
    }

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

这里必须区分 $host 与 $http_host:$host 会做小写化和端口剥离,取值优先级是请求行里的主机名、Host 头、server_name;$http_host 原样返回 Host 头。做白名单比对用 $host,做日志留证用 $http_host,两者都记下来最好。

第三步:应用层补 Origin 与请求来源校验

有些场景绕不开通配 server_name,比如多租户面板,这时应用层的校验就是最后一道闸。以 Flask 为例,挂在 before_request 上即可覆盖全部路由:

from flask import Flask, request, abort

ALLOWED_HOSTS = {"panel.corp.local"}
ALLOWED_ORIGINS = {"https://panel.corp.local"}
SAFE_METHODS = ("GET", "HEAD", "OPTIONS")

app = Flask(__name__)

@app.before_request
def guard():
    host = request.host.split(":")[0].lower()
    if host not in ALLOWED_HOSTS:
        abort(444)
    if request.method in SAFE_METHODS:
        return
    origin = request.headers.get("Origin", "")
    site = request.headers.get("Sec-Fetch-Site", "")
    if origin and origin not in ALLOWED_ORIGINS:
        abort(403)
    if site == "cross-site":
        abort(403)
    # 强制自定义头,跨站请求必须先过 CORS 预检
    if request.headers.get("X-Requested-By") != "panel":
        abort(403)

自定义头这一条为什么有效:浏览器发起带自定义头的跨站请求时,必须先发 OPTIONS 预检,而服务端从不返回 Access-Control-Allow-Origin,预检必然失败,真正的写请求根本发不出去。这比单纯判断 Referer 可靠,因为 Referer 可以被 Referrer-Policy 抹掉。

第四步:收敛监听地址

最彻底的一招是让业务只听回环,前面用 Nginx 反代。重绑定流量只能打到 Nginx 暴露的端口,而它已经被 Host 校验拦死,连业务进程都碰不到。

# 错误写法:对整个办公网开放
# gunicorn -b 0.0.0.0:8080 app:app

# 正确写法:只听回环,由 Nginx 反代
gunicorn -b 127.0.0.1:8080 -w 4 app:app

# 排查本机还有哪些服务监听在通配地址上
ss -lntp | awk '$4 ~ /0\.0\.0\.0|\[::\]/ {print $1, $4, $6}'

第五步:日志侧发现重绑定尝试

Host 校验上线后不要就此收工,需要知道有没有人在试探。先在日志格式里把 Host 和 Sec-Fetch-Site 打出来:

log_format sec '$remote_addr "$request" $status host=$http_host '
                'site=$http_sec_fetch_site ua="$http_user_agent"';
access_log /var/log/nginx/sec.access.log sec;

# 统计非白名单 Host 的来源 IP
grep -v 'host=panel.corp.local' /var/log/nginx/sec.access.log \
  | awk '{print $1}' | sort | uniq -c | sort -rn | head -20

配置验证

四步验证覆盖三层防线,命令可以直接粘贴执行。注意 444 在 curl 里表现为空响应,%{http_code} 输出 000,这是正常现象,不是命令写错了。

# 1. 解析层:指向私有地址的应答应被丢弃
dig @127.0.0.1 rebind-test.example A +short
# 期望:无输出或 SERVFAIL

# 2. 伪造 Host 应被断连
curl -s -o /dev/null -w "%{http_code}\n" -H "Host: evil.example" http://127.0.0.1/
# 期望:000

# 3. 正常 Host 应正常返回
curl -s -o /dev/null -w "%{http_code}\n" -H "Host: panel.corp.local" http://127.0.0.1/
# 期望:200

# 4. 跨站写操作应被拒
curl -s -o /dev/null -w "%{http_code}\n" -X POST \
  -H "Host: panel.corp.local" -H "Origin: https://evil.example" \
  http://127.0.0.1/api/config
# 期望:403

常见问题

Q1:只校验 Host 够不够,为什么还要 Origin 和自定义头?

Host 校验挡的是「域名不是我的」这一类请求。但如果攻击者有办法让 Host 变成白名单域名,比如存在子域接管、CDN 回源未校验、或者反向代理把上游 Host 原样透传而业务不做二次判断,Host 这一层就会失效。Origin 由浏览器强制写入,脚本无法伪造;Sec-Fetch-Site 同理。这两项配合自定义头,才能覆盖「同域但跨站」的边界。

Q2:加了校验后手机 App 和监控探活全部 444 了,怎么救?

常见原因有三种:客户端用 IP 直连,Host 变成裸 IP;探活脚本没带 Host 头;反向代理用 $host 而客户端发的是 IP:端口,取值退化。处理办法是为内网 IP 与探活标识单独开一个 server 块,只放行 /healthz 这类只读路径,其余一律 444。不要图省事把 default_server 改回正常站点,那等于把防线拆了。

Q3:Chrome 的 Private Network Access 能替代服务端校验吗?

PNA 会在公网页面请求私有地址前先发预检,要求服务端显式返回允许头,理论上能挡住大部分重绑定。但 Firefox 与 Safari 的实现并不完整,企业内部 same-site 场景也不在其覆盖范围内,用户还可能停留在旧版本浏览器上。因此 PNA 只能算额外一层,不能替代 Host 与 Origin 校验。

总结

DNS 重绑定的本质是「同源策略被骗了」,所以修复点必须在服务端而不是客户端。落地时抓三件事:解析器开 private-address 并做好 private-domain 豁免;Nginx 用 default_server 加 444 把非白名单 Host 断在门口;应用层补 Origin、Sec-Fetch-Site 与自定义头校验。再把业务监听从 0.0.0.0 收回 127.0.0.1,攻击面就基本关上了。最后提醒一句,这套配置对内网 DNS 的改动影响面较大,上线前先在测试解析器上跑一遍内部域名的解析验证,避免全公司断网。