从原理到防御:深入剖析SSRF如何打穿内网 你有没有想过明明只在公网跑着一个网站攻击者怎么就能摸到你公司内网去了绝大多数人第一时间会想到防火墙、漏洞、弱口令但实际上很多内网失守的起点就是一个不起眼的URL参数。这种问题在安全圈有个专门的名字叫SSRF服务端请求伪造。这篇长文我会从零开始把SSRF的原理、触发点、验证方法、防御策略全部过一遍配合两三个可以自己动手跑的本地实验让没接触过安全的新手也能建立起完整的认知。如果你是后端开发、运维、或者刚入门安全测试这篇文章应该能帮你省下不少自己翻文档的时间。先说清楚整篇文章里的所有实验都在本地环回环境上完成不涉及任何公网目标也不推荐拿别人的系统测试。安全研究的价值在于理解原理、提前堵住漏洞而不是拿它去做不合规的事。1. 先搞懂SSRF到底是怎么回事1.1 一个电话转接的类比想象一下公司前台有一部电话。外线打进来前台先接听确认身份后再帮你转接到公司内部的某个分机。正常流程没问题但如果前台不核实来电人是谁、要转的分机是哪部会发生什么任何外人都可以打进来让前台帮他接通财务部、服务器机房、甚至老板办公室的分机。SSRF就是这个逻辑的数字化版本。服务器本身具备从内网向其他服务发起请求的能力它接收用户传来的URL然后以“服务器自己的身份”去请求这个地址。如果没有任何限制用户就可以把URL改成内网地址、云主机内部的管理地址、或者是本机回环地址利用服务器的身份去访问这些本不该暴露给外部的东西。这个类比你记住就好后面所有案例都能套进去。你只需要记住三个角色发起请求的人用户、代为转发的人服务器、真正被访问的目标内网服务。1.2 代码层面到底发生了什么用代码来看更直观。我见过很多SSRF漏洞本质就是一段“拿着用户输入直接发请求”的逻辑。写一段非常简化的示意代码你感受一下这个数据流向from flask import Flask, request import requests app Flask(__name__) app.route(/proxy) def proxy(): url request.args.get(url) # 没有任何校验直接把用户给的URL交给requests库 resp requests.get(url) return resp.content这段代码的问题看得清清楚楚用户传一个url参数后端直接请求并返回内容。如果用户传的是http://127.0.0.1:8080/服务器就会以自身身份去访问本机的8080端口。如果这台服务器的内网里还有其他服务比如Redis、数据库、管理后台它们也都可能被这样访问到。理解了这层原理你会发现SSRF真正的可怕之处不在于“服务器帮忙发了个请求”而在于它放大了攻击者的权限。攻击者本身在公网目标服务器在内网边界内网服务通常认为“能访问到的都是自己人”因此不会设防。服务器被利用之后攻击者相当于拿到了一个内网跳板这就是标题里说的“打穿内网”的基础过程。2. 日常开发中哪些功能容易踩这个坑2.1 高频触发场景图片上传与URL导入如果你平时写业务代码可能已经经手过有SSRF风险的接口只是没人告诉你它叫这个名字。最经典的是“图片代理”功能。很多网站的编辑器支持“从URL插入图片”用户填一个图片地址后端下载这个图片存到自己服务器上。这个功能的本意是方便但这里的URL就是用户的直接输入后端一旦用这个URL发起请求就是潜在的SSRF。类似的还有“头像更新-导入网络图片”、“商品主图一键采集”、“爬虫抓取网页生成摘要”、“RSS订阅导入”、“Webhook回调地址配置”。尤其Webhook这个很多第三方平台允许用户填写自己的回调地址如果平台服务端去请求这个地址就等于给了用户一个任意URL请求的能力。2.2 云环境和内网架构为什么让SSRF更危险独立机房时代SSRF最多打到内网几台机器。但云环境不一样每台云主机上基本都有一个内部的元数据metadata服务大多数云厂商会把它挂在169.254.169.254这个保留地址上。这个地址只有云主机内部能访问上面放着这台机器的临时访问凭证、配置信息、密钥数据等。攻击者如果能利用SSRF请求到这个地址轻则拿到一些环境信息重则直接拿到云账号的操作凭证进而对云上的资源进行大面积接管。这也是为什么现在很多安全应急响应里一旦发现SSRF都会第一时间去检查云元数据接口是否被命中。你不用记住那个具体地址的细节但一定要知道这个风险链路的存在url参数 - 任意HTTP请求 - 云内部接口 - 凭证泄露。2.3 攻击面速查表从代码审查到手工探测我把常见的易触发点整理成一个表格你可以直接拿去做自查参考功能模块用户输入形式风险点说明图片/附件下载代理完整的图片URL后端代理下载可能访问内网任意地址文件导入/导出URL指向远程文件可能请求内网文件服务Webhook回调配置回调URL服务端请求用户指定地址典型的SSRF入口PDF生成/打印页面URL渲染引擎可能带请求访问内网资源网页快照/爬虫目标网页URL服务端抓取指定页面健康检查/状态同步对端服务地址管理接口地址由用户填充如果你是开发者看到这些功能点第一反应就应该是“这个URL参数最终会被服务端请求吗”。如果答案是会那它就必须进SSRF防护清单。3. 本地搭一套实验靶场亲手走一遍完整流程3.1 一个最简单的模拟环境理论聊再多不如动手跑一遍。我们不需要外网服务器不需要域名本地就能搭建整个验证环境。流程是这样的在本机跑两个小服务一个服务扮演“前端应用”它接收URL参数并代为请求另一个服务扮演“内网遗留系统”正常来说外部不应该能直接访问到它。然后我们用那个“前端应用”去请求“内网遗留系统”看看会发生什么。这是我实验时使用的环境Python 3.10以上版本Flask库用来快速起两个Web服务两个端口8000端口跑模拟的“对外应用”9000端口跑模拟的“内网服务”先写“内网服务”。注意我特意在返回内容里加了一个只有内网服务才会输出的标记方便后面对比判断。# inner_service.py from flask import Flask app Flask(__name__) app.route(/) def index(): return inner-secret-token: THIS_IS_INNER_SERVICE app.route(/health) def health(): return ok if __name__ __main__: app.run(host127.0.0.1, port9000)这个服务模拟的是内网里一个没有任何防护的遗留系统它的特点就是“只要有人能访问到我就返回数据”。再写模拟的“对外应用”# public_app.py from flask import Flask, request import requests app Flask(__name__) app.route(/get_content) def get_content(): target request.args.get(url) resp requests.get(target) return resp.text if __name__ __main__: app.run(host127.0.0.1, port8000)启动两个服务后正常用户体验是输入一个公网网页地址比如http://127.0.0.1:8000/get_content?urlhttp://example.com/应用会返回那个页面的内容。但如果我们输入的是http://127.0.0.1:8000/get_content?urlhttp://127.0.0.1:9000/呢实验结果就是返回内容是inner-secret-token: THIS_IS_INNER_SERVICE。你看这个对外应用本身不包含任何内网信息但它作为一个跳板把内网服务的响应带了回来。3.2 用响应差异判断内网端口状态上一步只是访问了一个已知存在的服务。更常见的场景是我们要探测某个内网IP的哪些端口是开放的。思路很简单利用返回内容的差异来判断。你可以这样理解如果请求返回了正常页面内容说明这个端口开放且是一个Web服务如果连接被拒绝或超时说明端口不存在或者不是Web服务如果收到一段错误信息说明端口开放但不是HTTP服务。三种情况的表现完全不同攻击者就是靠这种差异把内网情况摸清楚的。我同样在本地验证一下。比如请求http://127.0.0.1:9000/health看到返回ok推断那是个存活服务请求一个不存在的端口http://127.0.0.1:9999/程序会抛出连接异常。这种探测过程在安全领域就是“内网端口探测”。下表总结了常见的几种响应表现和对应的判断请求目标响应表现推断结果http://127.0.0.1:9000/返回带标记的内容服务存活且可被访问http://127.0.0.1:9999/连接异常/超时该端口未开放http://127.0.0.1:22/返回非HTTP内容或报错端口开放但不是HTTP服务整个过程只需要几个URL参数的变化就能从前端应用拿到不少内网信息。到这里你应该明白为什么SSRF被列进主流安全风险了它不需要多高深的攻击技巧唯一要做的就是让服务器帮你多请求几个不同的地址。我个人建议你把这两个Python文件保存下来后面学习任何关于SSRF的检测和防御知识都可以在这套本地环境上反复验证。4. 验证和排查中最容易踩的几个坑4.1 为什么页面一直超时但服务确实存在实际验证时最常遇到的就是“请求超时”。排错顺序很重要。先确认目标端口本身通不通直接在浏览器或命令行工具里请求一次那个地址如果直连也有问题再检查是不是防火墙或者网络配置挡了如果直连没问题但通过模拟的“对外应用”访问超时大概率是代码里的requests库默认超时时间太短或者目标服务响应太慢。我自己在实验中还遇到过一个经典情况内网服务明明在监听但请求它时超时了。后来发现是那个监听服务只监听了IPV6的地址而模拟应用请求时用的IPV4。这种问题用一句话概括就是“不是目标挂了而是你俩根本不在一个协议栈上”。4.2 URL解析差异带来的判断错觉另一个很容易出错的地方是URL解析差异。同一个字符串你的模拟应用解析出来的目标地址和你自己手工理解的目标地址可能不是同一个。最简单的例子http://127.0.0.1:9000127.0.0.1:8000/这种写法有的解析器把127.0.0.1:9000当成用户名密码真正请求到的是127.0.0.1:8000有的解析器则直接当作URL报错。这就导致“你以为探测的是某个端口实际打的是另一个地址”。在做任何结论之前先确认解析器到底把URL解析成了什么。4.3 利用重定向增加排查难度还有一种容易让人误判的情况服务端在请求过程中跟随了重定向。你把URL指向内网A服务A服务返回一个302跳转到内网B服务最后拿到的是B服务的内容。整个过程你是感知不到重定向的除非你在代码里特意打印了最终请求的URL。所以排查的时候不要只盯着最初输入的URL还要关注请求链路中的每一次跳转。很多简单的防护策略只校验了第一层URL结果被一个重定向就绕过去了。这不是说重定向本身是漏洞而是提醒你验证环节如果没有考虑到重定向判断结果可能完全不准确。5. 防御与修复从代码层面封死这条路5.1 最简单的白名单策略但别天真SSRF的防御核心在于“该访问的才允许访问”。最直接的方法就是做白名单校验。如果业务明确只需要请求某些固定的域名那就只允许这些域名其他的一律拒绝。代码示例ALLOWED_HOSTS [images.example.com, cdn.example.com] def validate_url(url): from urllib.parse import urlparse host urlparse(url).hostname if host not in ALLOWED_HOSTS: raise ValueError(host not allowed)但是如果你以为这就万无一失那我得泼盆冷水。很多绕过手法都建立在解析差异上。前端的校验代码用的解析逻辑是urlparse但最终发实际请求的库可能用另一种解析逻辑两者解析同一个URL可能得到不同的host白名单就被绕过了。所以我的建议是校验使用什么解析库实际请求就使用同一个解析库最好校验和请求在同一个代码模块里完成。5.2 基于内网网段过滤排除保留地址另一种常见的防御手段是网段过滤。如果你的业务根本不需要访问内网地址那就把所有的内网地址和保留地址全部拉黑。这里说的保留地址包括本机回环地址、私网网段、链路本地地址等。注意这类过滤不能只看字符串因为URL可以写成各种等价形式。你以为拦截了127.0.0.1人家写成2130706433这个整数形式解析器可能照样当成本机地址请求。真正有用的做法是先解析出最终要连接的IP地址再拿这个IP地址去和黑名单比对而不是拿用户输入的字符串去匹配。5.3 云环境的额外防线禁用IMDS相关功能前面提到云元数据信息存在的风险。如果你用的是主流云厂商很多云主机的元数据服务默认是可以被任意本机进程访问的。在可以关闭的云环境中比如某些服务支持IMDSv2强制模式建议关闭不需要的旧版元数据访问方式或者为元数据服务启用会话限制。这属于SSRF防护链路里重要的一环。就算代码层做得好多一层限制总归更稳妥。5.4 响应层面的兜底限制最后一道兜底是控制“你能把什么带回来”。严格限制服务器返回内容的类型比如强制按图片类型解析如果目标返回的不是合法图片格式就直接失败不要透传内容。这个思路尤其适合图片代理这类场景。因为很多SSRF利用方式依赖“把内网服务返回的文本原样展示给攻击者”如果你只允许图片二进制内容回传文本型的内网敏感数据就不会被直接带出来。有的团队还会对出网流量做审计把服务器主动发起的非常规访问记录到日志中心。一旦出现异常SSRF请求至少可以追溯。这不算漏洞修复但能帮你快速发现和止血。5.5 一个相对完整的防护流程我建议你在项目里按顺序实现这个防护流程对用户输入的URL做协议限制只允许http和https其他协议直接拒绝。解析URL获取host将host解析成真实IP。检查IP是否为内网地址、本机地址、保留地址命中则拒绝。如果业务有白名单再校验host是否在白名单内。请求时禁用重定向跟随改为手动处理每跳都做一次上述检查。限制返回内容类型非预期内容直接丢弃。记录完整的访问日志包括原始URL、目标IP、请求时间、返回状态。这个流程不复杂但每一层都在降低风险。不要觉得步骤多就省略SSRF的绕过思路就是“找到你遗漏的那个环节”。你少做一层校验就等于多给攻击者留了一条路。6. 一些真心建议和最终提醒实验做到这里我相信你已经对SSRF有了很具体的认知。从原理到代码从验证到防御这条路其实不算长关键在于你是否愿意动手把环境搭起来跑一遍。我的经验是很多人看了概念觉得“不过如此”结果真正在代码里遇到一个外部传入URL时还是不知道该怎么判断。解决这个问题的最好方式就是把你手头业务里所有可能出现“服务端请求用户指定地址”的地方都翻出来用本文第2节那个表去对照一遍逐一确认有没有防护。如果你发现历史代码里有类似逻辑先不要慌。按我第5节的流程补上校验加上日志然后做一次针对性测试。改完之后再去模拟一个恶意请求确认确实被拦截了这才算闭环。最后再分享一个我长期坚持的习惯不要只满足于“修好当前这个点”。你发现一个SSRF说明你的项目里可能存在一类问题——凡是接收外部输入并发起请求的地方都可能有问题。借着这个机会做一次全面自查收益往往比单纯修一个漏洞要大得多。安全这种事情至少一半的功力来自前期细致的梳理剩下的一半才是具体的攻防技巧。