SSRF进阶:DNS重绑定绕过内网IP限制的实战解析 最近整理以前的CTF题目笔记翻到一道叫 Internal System 的Web题印象很深。很多玩Web题的人看到SSRF第一反应就是试 file:// 或者 127.0.0.1但 Internal System 恰恰反着来它把外网访问卡得很死却在DNS解析环节留了一道口子。这道题非常适合当SSRF进阶教材从发现入口、确认过滤器到真正动手做DNS重绑定每一步都有不少细节。文章按我当时复现的顺序来写适合已经会写基础Web漏洞、但还没深入碰过内网与SSRF绕过的人。如果只是背过几种常见payload那看完你会明白为什么同一个域名会被两次解析以及TTL0对于一个攻击域名意味着什么。准备好Burp Suite和一台有公网IP的服务器跟着下面的流程走一遍会比想象中更有意思。1. 题目分析与考点定位1.1 拿到题目第一眼这是一个什么系统题目环境打开后是一个所谓的“内部系统管理平台”。表面上只有一个登录框但随便输入账号密码会提示“登录功能暂未开放”。页面底部有一个很小的链接指向/help页面上面写着“系统状态查询接口”和一段示例请求。示例请求是这样的curl -X POST http://target/query -H Content-Type: application/json -d {url: http://example.com}接口会返回目标URL的响应内容像是做一个简单的“URL可用性检测”。在CTF里这种“给你一个能访问外网的服务端”基本就是SSRF的直球暗示但真正难的是怎么利用它打进内部系统。题目名字叫 Internal System至少说明Flag大概率不在外网而是藏在内网某个服务里。起点是外网终点是内网中间隔着一层IP过滤器这是整道题的基本格局。1.2 考点拆解为什么说是DNS Rebinding按照常规思路SSRF一般优先试http://127.0.0.1/、http://localhost/、http://0.0.0.0/但在这道题里全部被拦截。返回的错误信息很直接forbidden ip。随后把 URL 换成公网地址比如http://203.0.113.1/又能正常返回。这说明题目在后端做了一次“解析目标域名 - 检查IP归属 - 再发起请求”的校验。这种结构很容易让人想到DNS Rebinding。因为校验过程一般会先socket.gethostbyname(domain)拿一个IP来检查然后真正发起HTTP请求时又通过另一个函数去解析同一个域名。两次解析并不是一次结果缓存下来反复用而是一次解析一次核对。如果攻击者可控DNS服务器就可以让第一次解析返回一个公网IP通过校验第二次解析返回内网IP让请求真正打到内部系统。这个思路和技术点基本就是本题的核心。1.3 用到的工具和前置知识复现这道题需要准备的东西很简单Burp Suite 或任意HTTP抓包工具用来观察请求和响应细节。一台有公网IP的服务器用来跑临时DNS服务以及接收目标系统的DNS查询。至少一个可以自己控制NS记录的域名没有的话在实验环境里也可以用本地DNS服务方式模拟。理解DNS解析的简单原理A记录返回IPv4地址TTL决定递归DNS和客户端能缓存多久。知道常见的私网IP段10.0.0.0/8、172.16.0.0/12、192.168.0.0/16、127.0.0.0/8。这道题里的内网地址大概率落在私网段。如果这些前置内容还不太熟也不用担心下面每一步都会把对应的知识点揉进讲解里。2. 信息收集与漏洞发现2.1 目录扫描与页面功能梳理开始时先对目标做简单信息收集。用常见字典扫了一下目录结果发现除了/和/help还有一个/query接口。/query是POST接口参数是JSON结构就是帮助文档里写的那样。整个站点没有其他明显的注入点也没有上传功能。这时候需要抓包看响应头发现Server头是常见的Python开发服务器风格X-Powered-By并没有出现但报错信息里的异常格式暴露了后端很可能是Flask框架。Flask作为后端有个特点代码逻辑往往集中在少数几个路由里参数处理比较直接特别适合作为SSRF题目框架。在/help页面里除了示例请求还夹杂了一句提示“Apis are only accessible from internal network.”。这句话就是明示后端会主动请求用户传入的URL而我们需要想办法让它请求到内网API。2.2 找到可以发起请求的入口/query接口需要JSON体那么首先测试它是否真的会主动请求外部URL。提交curl -X POST http://target/query -H Content-Type: application/json -d {url: http://203.0.113.1/}返回了203.0.113.1上对应页面的响应内容。说明接口确实会向任意URL发起请求并且会把响应内容放在当前响应体里返回。这种“返回完整响应”的SSRF比只能探测端口、不能读取内容的类型有用得多。然后测试本机地址curl -X POST http://target/query -H Content-Type: application/json -d {url: http://127.0.0.1/}响应变成了{error: forbidden ip}。同时提交http://localhost/也会被拦截。说明拦截规则不是简单匹配字符串而是会把域名解析成IP再判断IP是否私网。2.3 确认过滤器规则为了让后面的绕过更顺利我花了一点时间把过滤器规则测了个大概。测试结果整理如下传入URL返回结果说明http://203.0.113.1/正常响应公网IP放行http://127.0.0.1/forbidden ip私网IP拦截http://localhost/forbidden ip域名解析到127.0.0.1拦截http://0x7f000001/forbidden ip十六进制IP被解析后拦截http://2130706433/forbidden ip十进制IP被解析后拦截http://[::ffff:127.0.0.1]/forbidden ipIPv6映射地址被识别并拦截http://127.1/forbidden ip缩写回环地址被拦截从结果来看后端应该是先把URL里的主机名取出来用某种方式解析成IP再用类似ipaddress.ip_address(ip).is_private的判断去拦截。也就是说它看的不是URL字符串而是“解析后的IP归属”。这就意味着只要让两次解析结果不一致就有可能骗过它。3. 核心突破DNS重绑定绕过3.1 初探过滤器直接访问内网被拦确认过滤器后先试了最常见的域名绕过方式找一个能解析到内网IP的域名来请求。比如在本地写一个/etc/hosts映射但这是对外部目标完全没有用的。目标是远程服务器它用的DNS解析器不可能受我本地/etc/hosts控制。于是打算用一个自己控制的域名正常把A记录指向内网IP。结果请求依然是forbidden ip。这个结果符合预期因为对方在第一次解析时就能看到这个域名对应的是内网IP直接被拦。所以不能直接解析到内网必须让“校验阶段”和“请求阶段”拿到不同的解析结果。那怎么做到DNS重绑定。3.2 DNS重绑定原理对应到代码后端代码逻辑大概率类似下面这个简化版本import ipaddress import socket import urllib.request def check_url(url): host urllib.parse.urlparse(url).hostname ip socket.gethostbyname(host) if ipaddress.ip_address(ip).is_private: return {error: forbidden ip} response urllib.request.urlopen(url, timeout3) return response.read()注意这里有两个关键点socket.gethostbyname(host)解析一次域名然后urllib.request.urlopen(url)实际上也会在建立连接时再次解析域名。这两次解析之间不是同一个函数调用是否使用同一个DNS结果取决于系统实现。在常见的glibc和Python环境里getaddrinfo不会被缓存每次调用都会走一遍完整解析流程。如果域名对应的记录TTL为0那么多次解析都可能重新询问DNS服务器。这就给了我们一个机会在自己的DNS服务器上针对同一个域名第一次返回公网IP第二次返回内网IP。校验阶段看到公网IP就放行真正请求时解析到内网IP请求就打到内网服务了。3.3 搭建简易DNS服务器实际操作时我用Python启动了一个最简单的DNS服务。这个服务只处理A记录查询并且每收到一次查询就在两个IP之间轮流切换。这样目标系统如果解析两次大概率一次拿到公网IP、一次拿到内网IP。from dnslib import DNSRecord, QTYPE, RR, A from dnslib.server import DNSServer class RebindResolver: def __init__(self): self.count 0 def resolve(self, request, handler): reply request.reply() qname request.q.qname self.count 1 if self.count % 2 1: ip 203.0.113.1 # 公网IP用于通过校验 else: ip 172.16.1.10 # 内网IP用于真正请求 rr RR(qname, QTYPE.A, rdataA(ip), ttl0) reply.add_answer(rr) return reply if __name__ __main__: DNSServer(RebindResolver(), port53).start()这里有个细节TTL一定要设置为0或者设置非常短避免目标系统或中间递归DNS把第一次拿到的公网IP缓存住。如果缓存住了第二次解析还是会拿到同一个公网IP整个绕过就失效了。服务器跑起来后把攻击域名的NS记录指向公网服务器的IP。比如准备一个形如rebind.internal-lab.test的测试域名在域名服务商后台把该子域的NS记录改为ns1.attacker.example然后ns1.attacker.example的A记录指向自己的公网服务器IP。这样DNS查询最终才会交给上面的Python脚本处理。3.4 最终利用脚本与Flag获取因为DNS重绑定存在概率问题目标系统不一定每次都能刚好在“校验”时解析到公网、在“连接”时解析到内网所以需要写脚本多试几次。import requests import time target http://target/query payload_domain http://rebind.internal-lab.test:8080/flag for i in range(300): try: r requests.post(target, json{url: payload_domain}, timeout3) if forbidden ip not in r.text and error not in r.text: print(r.text) break except Exception: pass time.sleep(0.05)我实际跑的时候前几十次大多是超时或forbidden ip。超时是因为第二次解析可能拿到了公网IP然后请求到了公网服务器但公网服务器并没有开8080端口forbidden ip则是第一次解析就拿到了内网IP。多试几次后有一次请求返回了内部服务的内容里面直接写着flag{in73rn4l_5y5t3m_r3b1nd1ng}。这里还有一个非常关键的小技巧如果内网服务在接收请求时校验了Host头比如必须等于internal-system那我们可以给攻击域名带上相应的子域前缀。把payload改成http://internal-system.rebind.internal-lab.test:8080/flag这样解析出来的IP仍然由DNS服务器切换但HTTP请求里的Host头会是internal-system.rebind.internal-lab.test内网服务如果只检查前缀就能正常通过路由。这种思路在真实内网渗透里同样常见SSRF能控制目标IP但不一定能控制目标应用路由到的虚拟主机。4. 实战踩坑与问题排查4.1 为什么有时候请求会失败做DNS重绑定最直观的失败现象就是请求一直显示forbidden ip或者全部超时。原因多半是解析顺序不够理想。如果DNS服务器是“第一次公网、第二次内网”这样固定切换但目标环境里某个环节提前做了额外解析比如后端先解析一次做超时判断再解析一次做IP校验最后连接时又解析一次那整个流程就不是简单的两次解析固定交替策略很容易卡在错误状态。解决办法是不要只做固定交替。可以在DNS服务器逻辑里这样设计前1到2次查询返回公网IP之后的所有查询都返回内网IP。这样能覆盖“多解析几次”的情况。拿静态计数器控制就行或者直接用随机策略每次查询随机返回公网或内网IP然后靠大量重试去撞概率。实测下来随机策略的成功率并不低因为只要在“校验”的那一次解析返回公网并且在后续“连接”的那一次返回内网就能成功。4.2 缓存导致的绕过失败TTL0是必须的但即使TTL0某些操作系统递归DNS也可能有自己的缓存策略导致第一次解析结果被记录一段时间。如果第一次解析后后续解析都命中缓存那后面无论怎么切IP都白搭。一个通用解法是“随机子域”。每次请求都用一个从未使用过的新子域名比如rebind-1.internal-lab.test、rebind-2.internal-lab.test。每个子域都指向同一个DNS服务器DNS服务器也照样做公网/内网切换。因为每个子域名都是全新的递归DNS缓存中没有任何记录必然回源到我们的权威DNS服务器从而保证每次请求都能执行重绑定逻辑。4.3 常见问题速查表现象可能原因解决办法一直返回 forbidden ip校验阶段解析到内网IP调整DNS返回顺序增加随机策略或重试次数大量超时请求阶段解析到公网IP公网IP对应端口无服务让公网服务器在对应端口返回空响应或忽略该情况继续重试请求成功但没有flagHost头被内网服务校验将内网服务名作为攻击域名的子域前缀重试一会儿后全部失败攻击域名被目标系统或本机缓存改用随机子域每次使用全新域名DNS服务器收不到查询NS记录配置错误或UDP 53端口不通检查公网服务器防火墙、域名NS记录和DNS监听地址5. 复盘与延伸5.1 换个角度看这个题出题人想考察什么很多人把SSRF题型简单划分为“能不能读file协议”“能不能打内网端口”但 Internal System 这道题更强调“过滤器和请求器分离”带来的逻辑漏洞。出题人特意把“域名解析”这一步放到IP校验里但又没有在同一套数据上复用解析结果所以给了攻击者钻空子的空间。这种设计并不是凭空臆造很多真实的Web应用确实存在类似问题先用gethostbyname做访问控制再在HTTP库内部重新解析目标域名。两个解析结果一旦不同安全边界就形同虚设。从防御角度看修复方式也不复杂把解析得到的IP显式绑定到HTTP请求上或者用同一解析结果做校验和连接再或者直接禁止用户传入域名只允许IP白名单。但这些方案又会影响可用性所以实际工程里很难完全杜绝这类问题。5.2 内网SSRF后的其他利用思路DNS重绑定只是拿到通往内网的钥匙进入内网后还能做很多事情。常见的方向包括扫描内网端口识别开放服务处理内网接口的鉴权逻辑通过Gopher等协议打一些基于TCP的协议比如内网Redis、memcached如果内网服务存在命令执行还能进一步拿到服务器控制权。不过这里要特别提醒这些利用方式只适用于CTF靶场或已获得授权的测试环境。未授权扫描和攻击内网属于违法行为任何时候都不该在真实业务系统上复现这些操作。CTF的价值是练思路而不是提供可以乱打的武器库。我自己整理这类题目时都会额外标注“仅限实验环境”。对一个安全从业者来说知道边界在哪里和知道漏洞怎么利用同样重要。这道题给我最大的启发是不要只背SSRF的payload列表而是要理解目标代码在每个环节做了什么。过滤器拦的不是“字符串”而是“解析后的IP”请求器请求的也不是“IP”而是“解析后的域名”。当这两个环节的数据不一致时整条防线就出现了裂缝。类似的逻辑在真实漏洞里其实经常出现只是平时没有CTF里那么直观。以后再看代码遇到“先校验再请求”的流程我都会习惯性多问一句这两个解析之间真的用的是同一个结果吗