简介一套基于SSMSpringSpringMVCMybatisMySQLJSP实现的水果蔬菜商城系统属于已通过导师指导的高分毕业设计项目可直接用于课程设计、期末大作业或JavaWeb开发练手。系统按角色分为用户端和管理端用户可注册登录、浏览商品、管理购物车与订单管理员可对商品、类目、订单、客户、公告及留言进行全方位维护整体功能完善、界面美观、操作方式清晰具有较高的实际应用价值。资源采用rar压缩打包共含2000个文件体积约227.54MB文件类型以图片素材jpg/png/gif、前端样式与脚本css/js、JSP页面、Java源码及配置文件xml/properties、依赖jar包为主并附带SQL数据库脚本、项目说明文档基于Tomcat8.0JDK1.8MySql5.7IntelliJ IDEA环境即可快速导入运行。目前已有129人学习下载适合正在完成SSM框架课程设计、需要完整可运行商城项目或参考前后端交互与订单流程的Java学习者。1. 跳板请求一个商品图片URL是怎么变成内网入口的水果商城后台有个「商品图片URL导入」功能运营同学把第三方图床的图片链接粘进去服务器就去拉取图片存到本地。这个功能我是在SSM项目的商品管理模块里见到的当时检查了一段图片拉取代码发现URL参数从表单一路传到后端几乎没做任何校验就直接交给HttpURLConnection去请求。换句话说任何人只要能调用这个接口就能让服务器替他访问任意地址——包括127.0.0.1、内网网段、云数据库的内网地址。这就是SSRF服务端请求伪造的典型入口。它不是一种需要复杂利用链的漏洞而是「开发时少写几行校验」就能埋进去的雷在电商后台、CMS、爬虫抓取类项目里尤其常见。这篇笔记把这个漏洞从触发点、绕过手法到修复和自测完整过一遍。2. SSRF 的触发点与最小复现从一段直传参数的 HttpClient 代码说起2.1 为什么图片URL导入这类功能天生高危任何由用户提供URL、由服务器去请求的功能理论上都存在SSRF风险。图片抓取、网页快照、PDF生成、Webhook回调、快递查询接口、甚至「检测网站是否存活」的小工具都是重灾区。高危险性的原因有两个层面第一这类功能拿到的URL来自外部输入攻击者可以直接控制请求的目标地址第二服务器所在的内网环境通常比公网更可信数据库、Redis、管理后台、云元数据服务都监听在内网地址上这些地址在公网访问不到但服务器自己能访问。我在排查上面的商城项目时商品图片的URL就存在product.picUrl字段里商品新增页面上传时直接把这个字段交给后端。这个链路的问题不在于用的是不是SSM框架而在于「外部输入→服务器发请求」这个模式没有边界控制。SpringMVC的Controller层只做了表单绑定HttpClient或JDK自带的URLConnection就把请求发出去了中间没有任何网段校验。要判断一个功能是否高危就看两点URL是不是用户可控的请求是不是从服务器内网发出的——两问都答「是」就要重点检查。2.2 一段最小复现参数直传与 HttpURLConnection复现不需要整个商城跑起来一个Controller加一个工具方法就够。下面是用JDK原生HttpURLConnection写的简化版项目里不少人这样写PostMapping(/product/image/import) ResponseBody public String importImage(RequestParam String imageUrl) throws IOException { // 反例URL直接来自前端未做任何协议与地址校验 URL url new URL(imageUrl); HttpURLConnection conn (HttpURLConnection) url.openConnection(); conn.setConnectTimeout(3000); conn.setReadTimeout(3000); conn.setRequestMethod(GET); int code conn.getResponseCode(); if (code 200) { try (InputStream in conn.getInputStream()) { // 简化处理实际会写入OSS或本地目录 return download ok, length in.available(); } } conn.disconnect(); return http status: code; }这段代码的问题不说大家也看得出imageUrl参数完全由前端控制后端没有检查协议是否是http/https没有检查域名解析出来的IP是不是内网地址也没有关掉重定向跟随。HttpURLConnection默认开启了followRedirects也就是说即使你后续加了个「判断URL以http开头」的校验攻击者用一个合法的公网URL做跳板302跳到http://192.168.1.1/admin请求照样被发出去。我用curl直接复现了一下效果curl -X POST http://localhost:8080/product/image/import \ -d imageUrlhttp://127.0.0.1:8080/admin \ -H Content-Type: application/x-www-form-urlencoded响应不是报错而是返回了内网管理页面的HTML片段。这就说明两点一是目标商城的管理后台没有做IP白名单二是我在商品导入的URL校验上确实偷了懒等于把内网管理后台敞开了一个口子。本机测试时127.0.0.1都能通换到生产环境把127.0.0.1换成云数据库内网地址、Redis的6379端口结果是一样的。2.3 三个层级的校验字符串、域名、IP很多同学在自测时以为「校验过了」后来发现测试方法本身就有问题。URL校验有三个层级从弱到强分别是第一层是字符串校验比如startsWith(http)、用正则判断URL格式、过滤localhost关键字。这是最容易被绕过的http://example.com127.0.0.1/照样以http开头127.0.0.1的十进制写法2130706433也不在关键字黑名单里。第二层是域名校验把URL解析出host之后判断域名是否在白名单里。这个层级能挡住一些乱填的URL但挡不住两个问题一是解析差异不同语言、不同版本的URL解析器对同一个字符串可能给出不同的host二是DNS重绑定域名第一次解析是公网IP放行之后第二次解析变成了内网IP。第三层是IP校验把解析出的host再通过DNS解析成IP然后判断这个IP是否属于内网/保留地址段并在建立连接之后再做一次确认。这才是相对可靠的方案。注意这里的关键点是「解析成IP之后判断」而不是拿字符串去比对127.0.0.1。原因是很多绕过手法就是利用字符串与IP之间的转换差异。2.4 验证时的判断方法手工验证SSRF最快的方式是找一个你能控制的服务看它能不能收到来自目标服务器的请求。我一般在自己电脑上起一个nc -l 8088监听端口然后把imageUrl填成http://本机公网IP:8088/probe如果你的服务端日志里出现了来自目标机器的连接记录说明SSRF确认存在。顺手把http://127.0.0.1:8080/、http://[::1]:8080/这几个地址也试一遍能区分出代码里到底做了哪层校验。3. 把校验写对DNS解析、IP段判断与重定向处理的完整实现3.1 协议白名单与URL解析第一道闸修复这段代码第一步是把协议和URL解析控制住。我在实际项目中用的方案是先做协议白名单然后用URI而非URL来解析——URL.equals()会触发DNS查询而URI只是纯语法解析不会产生网络请求。解析之后检查host是否存在以及URL里是否带了userinfo也就是符号前面的内容。符号的杀伤力在于很多人只知道http://userhost/是带认证信息的但http://example.com127.0.0.1/的host其实是127.0.0.1前面那段只是userinfo而已。public static URI parseAndCheckScheme(String rawUrl) throws Exception { if (!rawUrl.startsWith(http://) !rawUrl.startsWith(https://)) { throw new SecurityException(scheme not allowed: rawUrl); } URI uri URI.create(rawUrl); if (uri.getHost() null) { throw new SecurityException(missing host: rawUrl); } if (uri.getScheme() null || (!uri.getScheme().equalsIgnoreCase(http) !uri.getScheme().equalsIgnoreCase(https))) { throw new SecurityException(scheme not allowed: rawUrl); } if (uri.getUserInfo() ! null) { throw new SecurityException(userinfo not allowed in url); } return uri; }说明URI.create只做语法解析不会发起网络请求这一点比new URL()安全。getUserInfo()不为空直接拒绝防止利用http://合法域名内网IP这种结构让域名校验形同虚设。协议白名单是最低要求宁可后续加功能时麻烦点也不能放行file、gopher、dict这类协议——file:///etc/passwd在某些实现里是可以读本地文件的。3.2 IP判断的精髓不只127.0.0.1URL解析通过之后要把host解析成IP再判断IP是否落在需要封禁的网段里。Java的InetAddress.isSiteLocalAddress()和isLoopbackAddress()能覆盖大部分情况但它们内部判断的网段并不完整100.64.0.0/10这段运营商级NAT保留地址不归isSiteLocalAddress()管192.0.2.0/24这类文档保留地址也不在判断范围内。生产环境里更稳的方式是写成一段自定义的网段判断方法把需要用到的范围显式列出来public static boolean isUnsafeIp(InetAddress address) { if (address.isAnyLocalAddress() || address.isLoopbackAddress()) { return true; } if (address instanceof Inet6Address) { byte[] b address.getAddress(); // fc00::/7 -- IPv6私有地址 if ((b[0] 0xFE) 0xFC) { return true; } // fe80::/10 -- IPv6链路本地地址 if ((b[0] 0xFF) 0xFE (b[1] 0xC0) 0x80) { return true; } return address.isSiteLocalAddress(); } byte[] b address.getAddress(); int first b[0] 0xFF; int second b[1] 0xFF; // 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8 if (first 10) return true; if (first 172 second 16 second 31) return true; if (first 192 second 168) return true; if (first 127) return true; // 169.254.0.0/16 -- 链路本地地址 if (first 169 second 254) return true; // 100.64.0.0/10 -- CGNAT保留段 if (first 100 second 64 second 127) return true; // 192.0.2.0/24 -- 文档示例地址 if (first 192 second 0 (b[2] 0xFF) 2) return true; return false; }写这段的主要目的是把「判断范围」和「判断方式」都钉死方便后面查代码的人一眼看到封了哪些网段。实际使用中把InetAddress.getByName(host)拿到的结果传进这个方法就行。这里特别提一下十进制IP绕过http://2130706433/这个URL里host字符串是2130706433如果你在代码里对这个字符串做equals(127.0.0.1)的判断肯定拦不住但如果你把字符串交给InetAddress.getByName()去解析Java底层会把它识别成127.0.0.1返回的InetAddress对象就能被上面这段正确拦截。这就是为什么我一直强调「用IP解析结果判断不要用字符串判断」。3.3 禁止跟随重定向把跳转变成二次校验默认情况下HttpURLConnection遇到301/302会自动跟着跳转。这里有两个问题一是自动跳转会绕过你之前做过的IP校验因为followRedirects发生在校验收尾之后二是跳转后的新请求不受你的代码控制。处理方式是把自动跟随关掉自己读取Location头然后对跳转目标重新走一遍完整校验。HttpURLConnection conn (HttpURLConnection) uri.toURL().openConnection(); conn.setInstanceFollowRedirects(false); conn.setConnectTimeout(3000); conn.setReadTimeout(5000); conn.setRequestProperty(User-Agent, Mozilla/5.0 (compatible; SSMFetch/1.0)); int code conn.getResponseCode(); if (code 300 code 400) { conn.disconnect(); String location conn.getHeaderField(Location); if (location null) { throw new SecurityException(redirect without location header); } // Location可能是相对路径 URL next new URL(uri.toURL(), location); // 重新走协议、host、IP三层校验 URI nextUri parseAndCheckScheme(next.toString()); checkTargetIp(nextUri); return importImage(nextUri.toString()); }参数说明setInstanceFollowRedirects(false)只对当前连接生效不会影响全局配置Location头里的地址可能是相对路径比如/admin所以要用new URL(uri.toURL(), location)来拼出完整地址。递归调用的深度要有限制我在项目里限制最多3次跳转超过就视为异常防止有人用一串跳转消耗服务器资源。3.4 网络层兜底出口ACL与独立代理代码层的校验再完备也顶不住后续需求变更——今天图片导入限制了明天报表导出又接了个URL参数新来的同事不一定记得这套规则。所以更稳的做法是网络层兜底在服务器的防火墙或安全组上把出方向流量收紧只允许访问必需的端口比如80/443内网网段直接禁止从应用服务器发起访问。我见过一些云厂商的安全组默认放行所有出站流量这个习惯要改。另一种做法是给URL抓取单独配一个正向代理所有外呼请求都走这台代理内网地址在代理层直接丢弃。这样即使应用代码出现疏忽代理能把最后一关守住。运维上多一个组件但换来的收益是显著的——你不需要指望每个开发人员都懂SSRF只要代理层统一把关就行。4. 绕过与修复的对抗解析差异、IP编码和DNS重绑定4.1 解析差异同样的URL两个库两个结论URL解析是个容易被低估的「黑匣子」。不同语言、不同版本的标准库对同一个URL字符串解析出的host可能完全不同。最经典的是反斜杠问题http://example.com\127.0.0.1/有的解析器把example.com当host有的把127.0.0.1当host。再比如http://127.0.0.1#example.com/有的解析器把example.com当fragment有的却把它当成host的一部分。这个问题的可怕之处在于你用一种解析器做校验HTTP客户端用的是另一种解析器两者一旦不一致校验就等于白做了。我曾经踩过一个很不起眼的坑校验代码用的是JDK 8的URI业务代码用的是Apache HttpClient 4.xhttp://example.com127.0.0.1/这个URL在URI解析下host是127.0.0.1而在HttpClient的HttpHost解析下host也是127.0.0.1两者倒是统一了。但换一个URL就会暴露差异。稳妥的做法是校验和发请求用同一套解析组件不要把解析逻辑分散在两处如果做不到就在校验层做「双解析对比」不一致就拒绝。4.2 进制IP与IPv6黑名单的盲区进制IP是SSRF绕过里流传最广的一招。127.0.0.1写成十进制是2130706433写成十六进制是0x7f000001写成八进制是0177.0.0.1。问题在于很多项目做黑名单校验时拿到的host字符串跟127.0.0.1做字符串比较进制写法直接绕过。其实解决思路前面说过——不要判断字符串直接把host丢给InetAddress.getByName()解析底层会把各种进制的IP归一化成标准形式再判断。但注意部分语言的URL解析器会把全数字的host识别成「非法域名」直接报错这时候如果你在异常处理里把URL放行了就会形成另一种绕过。异常处理的正确姿势是「解析失败一律拒绝」而不是「解析失败就跳过校验」。IPv6绕过的逻辑类似只是目标变成http://[::1]/、http://[::ffff:127.0.0.1]/这是IPv4映射地址解析出来就是127.0.0.1。验证时把::1加进去很多人自测只测了IPv4的127.0.0.1忽略了IPv6的本地回环。我自己在测试用例里固定的组合是127.0.0.1、[::1]、0.0.0.0三个都跑一遍少了任何一个都不放心。Java的InetAddress.getByName([::1])在部分版本对带方括号的输入会解析失败所以IPv6字面量记得去掉方括号再解析。4.3 DNS重绑定存活时间内的两次欺骗DNS重绑定DNS Rebinding是相对高级的绕过手法。攻击者注册一个域名第一次解析返回公网IP通过校验等到应用服务器真正发起连接时第二次解析返回内网IP。由于InetAddress.getByName()在校验时解析一次HttpURLConnection建连时可能又解析一次两次结果不一致校验就被架空了。防御手段有两个方向。第一个方向是「解析一次、用到底」把host解析成IP之后直接拿这个IP去连接而不是再拿域名去连接。这样校验和建连用的是同一个解析结果DNS重绑定就失效了。代价是HttpURLConnection不直接支持这种方式改用自定义Socket连接把Host头手动设置成原始域名代码会多一些。第二个方向是「解析两次、结果不一致就拒绝」建连前解析一次建连后再解析一次对比两次IP是否一致。我在项目里用的是第二种理由是改动小缺点是如果攻击者能把两次解析精确控制在TTL窗口内还是有概率绕过——但实际利用的难度已经很大了。4.4 修复思路汇总把上面几条串起来一个完整的URL校验链路是协议白名单 → 解析host → 拒绝userinfo → DNS解析成IP → 判断内网/保留网段 → 建连时不自动跟随重定向 → 对跳转目标递归校验 → 连接建立后再确认一次实际IP。每一步看着都不难但组合起来才能覆盖主流的绕过手法。我见过不少项目只做了其中一两步被绕过的点往往是没做的那一步。排查的时候不要只盯着代码里已有的校验要反过来想哪些绕过方式我目前没有覆盖到。5. SSRF排错避坑五个防住了又翻车的真实场景5.1 现象请求发不出去日志里报FileNotFound或ConnectException原因URL里带了符号比如http://example.com127.0.0.1:8080/你用的是new URL(imageUrl).getHost()取到的可能不是你期望的域名。很多人在这一步直接踩坑用错误的方式取了host后续判断全部错位请求自然发不出去。解决统一用URI.getHost()拿host并且先检查getUserInfo()是否为空。只要前面有内容直接拒绝。这个规则简单粗暴但能挡住大量的URL混淆攻击。实际测试时我发现URI.getUserInfo()在某些JDK版本里对特殊字符会解析失败抛异常所以调用URI.create()本身要放在try-catch里任何解析异常都按拒绝处理。5.2 现象localhost被拦了换成127.0.0.1也拦了但换成2130706433就通了原因黑名单判断用的是字符串匹配比对的是127.0.0.1这个字符串而2130706433是十进制格式的IP字面量不匹配任何黑名单条目。这个坑特别隐蔽因为你在代码里加了if (host.equals(127.0.0.1)) return false;这样的判断测试时试了127.0.0.1确实拦住了很容易以为已经安全了。解决把判断逻辑改成解析成InetAddress再比对。InetAddress.getByName(2130706433)会返回127.0.0.1对应的对象再走网段判断就能拦住。如果用的是其他语言找到等价的原生函数核心思路是「先归一化再判断」。5.3 现象外网URL能访问内网URL也能访问但代码里明明写了IP校验原因校验代码写在了Controller里但真正发请求的是另一个工具方法工具方法里直接new URL(imageUrl).openConnection()完全没有走校验入口。这是典型的「校验与执行分离」导致的漏网之鱼。代码review时看到Controller层有校验就放行了没有顺着调用链看到底层的发请求逻辑。解决把校验逻辑封装到发请求的工具方法内部也就是说无论谁调用这个方法都必须先过校验。不要让校验依赖调用方的自觉。我重构商城项目的时候把fetchRemoteImage改成内部先调用parseAndCheckScheme和checkTargetIp外面再想绕过也绕不过去。5.4 现象应用服务器在云上代码校验没问题但内网网段还是被打穿了原因云数据库的内网地址未必在常见的私有网段里。阿里云的RDS内网地址、腾讯云的数据库内网地址很多都不在10.x、172.16.x、192.168.x这三个经典私有段里而是落在厂商自己的保留段。你的IP判断只覆盖了常见的RFC1918地址漏了厂商自定义的内网段。解决核对云厂商文档中内网网段的范围把这些段加进isUnsafeIp里。另外如果数据库或Redis所在的安全组支持绑定实例到特定安全组而非IP白名单优先用安全组控制这样即使代码被绕过安全组层面的隔离仍然生效。5.5 现象线上出问题了但日志里只有URL查不到实际请求到哪个IP原因日志只记录了原始传入的imageUrl没有记录解析后的host和实际连接的IP。一旦遇到DNS重绑定或者多IP解析排查时完全不知道服务器到底连了哪个地址。我排查过的一个问题是同一个域名轮流解析到两个IP其中一个IP是内网地址日志里看到的都是域名问题定位花了很长时间。解决日志里至少记录三个字段原始URL、解析出的host及IP列表、最终连接的IP和状态码。这样不管后续出了什么问题都能快速判断是校验遗漏还是解析偏差。排查线上问题时这三个字段配合起来能省掉大量的猜测时间。6. 上线前自测把这段URL校验跑完六种情况写完了校验代码不代表就能放心上线。我给自己定了一套固定的自测清单每次改动校验逻辑都强制跑一遍。这里把清单分享出来六种情况覆盖了常见的绕过手法用例测试URL预期结果实际结果1. 正常公网URLhttps://example.com/logo.png通过通过2. 内网IPv4http://192.168.1.100/拦截拦截3. 本地回环IPv4http://127.0.0.1:8080/拦截拦截4. 本地回环IPv6http://[::1]:8080/拦截拦截5. 十进制IPhttp://2130706433/拦截拦截6. 重定向到内网https://example.com/redirect?targethttp://127.0.0.1/拦截拦截跑一遍这些用例用不了几分钟但能挡住绝大多数低级失误。借这个机会也提一下测试环境里我常用curl和一个本地监听端口来判断请求是否真的发出来了——把nc -l 8088起在另外一台机器上如果测试后它的屏幕上出现了连接记录说明某个用例的拦截逻辑失效了排查起来很快。项目上线后建议在监控面板上加一条URL抓取的错误日志看板专门展示SecurityException和超时的记录。正常业务中这类异常应该很少一旦某个时间窗口集中出现大概率是灰度环境有人在不合规地调用接口需要立刻回溯日志。我把这套流程固化下来以后每次改完代码都强制走一遍再没出现过URL校验相关的低级事故希望这篇笔记的完整校验链和避坑记录能帮到你少走几趟弯路。本文还有配套的精品资源点击获取
