最近在做一个授权渗透测试项目,目标是一个基于Java技术栈的电商后台系统。最初的发现非常常规:某个查询接口存在SQL注入,通过报错回显能直接拿到数据库版本信息。这类问题放在平时,可能拖出几张表就写报告结束了。但那天我多问了自己一句:这条注入链路还能不能再往前走一步?结果一路向下探,最后触达的竟然是一个SpEL表达式注入点,实现了从数据库到应用层再到命令执行层的完整跳转。整条链路走通之后,我才真正意识到"从SQL注入到SpEL表达式注入"这条组合路线,远比单点漏洞的报告价值高得多,它本质上是在用一条攻击链,测试一个系统从数据层到执行层的信任边界。
这篇文章不打算讲那种"一个漏洞搞定一切"的童话故事。真实场景下,单点漏洞的价值往往是有限的,组合拳的价值才真正反映出一个系统在面对有耐心、有思路的攻击者时,到底能扛到哪一层。适合谁看?做渗透测试、漏洞挖掘、安全开发的人,以及那些想理解"为什么修了一个SQL注入,系统还是被打穿"的甲方同学。
1. 为什么把SQL注入和SpEL组合在一起
1.1 单一漏洞的局限性
先说一个很现实的问题:SQL注入在今天的渗透测试里,已经不算什么"稀罕物"了。很多初学安全的人觉得,发现一个SQL注入就等于拿下了数据库,但实际情况往往没那么乐观。现在的数据库账号分权做得普遍比以前好,很多应用连接数据库用的账号只有增删改查权限,连LOAD FILE、INTO OUTFILE这些操作都没有。你折腾了半天,最多把某张业务表拖出来,拿到一些用户手机号、订单数据,然后呢?写报告,结束。
但从攻击者的视角看,SQL注入其实不是终点,而是一个"入口"。它的真正价值取决于你能从数据库这层拿到什么,以及这些信息能不能帮你在应用层找到下一个突破口。如果只把SQL注入看作"拖数据"的手段,那它确实局限;但如果你把它看作"读取系统配置、探测应用链路、触发更深层漏洞"的通道,它的效力会被放大很多倍。这个思路上的转变,就是组合拳打法的起点。
1.2 组合拳的核心思想
组合拳的本质,是把彼此独立、看似不相关的漏洞通过业务逻辑串联起来,形成一条从外部到内部、从低权限到高权限的攻击路径。拿这次的项目举例,SQL注入负责提供数据库层的访问通道,而数据库里往往藏着大量应用运行期的秘密——配置表、管理员令牌、第三方密钥、甚至整个application.yml的内容;SpEL表达式注入则负责在应用层打开命令执行的口子。两个漏洞单独看,可能分别都是"高危",但串起来以后,就有机会拿到服务器控制权。
这就像闯一栋楼:SQL注入是帮你混进大厅的手段,SpEL注入才是那个能直接打开保险柜的钥匙。大厅里什么都有——门禁卡、楼层平面图、保安交班表,关键在于你有没有意识到这些东西能用来找到那把钥匙。组合拳打的不是某个漏洞本身,而是系统内部"信息孤岛"之间的信任缺口。
2. SQL注入:先拿下第一个突破点
2.1 注入点存在性的判断
这次的SQL注入出现在一个订单查询接口上。参数是订单编号,后端大概拼接了类似这样的语句:
SELECT * FROM orders WHERE order_no = '{user_input}'接口返回很直接,单引号会直接触发语法错误,并且在错误信息里完整回显了SQL语句片段。这种报错回显型注入在当前环境里算是"友好型"了,连盲注都不用打。判断注入点的时候,我习惯先用三类输入交叉验证:单引号、布尔条件、时间函数。单引号看有没有语法错误,布尔条件看返回内容是否随条件变化,时间函数看响应延迟。三者都命中,才下结论说这里能注入。
要注意的是,有些系统接入WAF之后,报错信息会被吞掉,但响应时间不会撒谎。所以即使当时没有报错回显,我也会把时间盲注的payload先在本地准备好,确保随时能切换到"静默模式"继续探测。实际测试中,很多新手一看到报错就兴奋,忽略了对注入类型的完整判断,这会导致后面利用时选错payload方向。
2.2 数据库权限探测与利用面
确认注入点之后,第一步不是急着脱库,而是测权限。很多人忽略这一步,直接union select拖数据,但权限决定了你后续能走多远。我用了一条非常简单的payload来探测当前数据库用户和权限:
-- 查询当前数据库用户及权限(仅在授权环境测试) SELECT current_user(); SELECT * FROM information_schema.user_privileges WHERE GRANTEE LIKE '%testuser%';结果非常有意思:目标数据库账号是testuser,但居然拥有FILE权限,这意味着可以通过LOAD_FILE()读取数据库所在服务器的本地文件。大多数数据库账号不会开这个权限,一旦开了,利用面立刻就宽了。我马上尝试读取应用配置文件,这一步是整个组合拳的转折点。
这里补充一个细节:LOAD_FILE()能读文件的前提是,数据库进程对目标文件有读权限,且文件在当前服务器上。如果数据库和应用不在同一台机器,读到的就是数据库服务器的文件,不是应用服务器的。这次的架构里,数据库和应用虽然分库部署,但配置文件中明文保存了应用服务器的连接信息和管理员接口地址,已经足够往下走了。
2.3 读取配置文件的关键步骤
因为我需要的不是用户数据,而是应用层的"地图",所以读取目标直接锁定在常见的配置文件路径上。Java应用通常会把配置放在application.yml或application.properties里,路径一般在应用安装目录下。我用文件读取的方式拿到了这样一段核心配置(出于安全考虑,这里只展示结构,真实内容已脱敏):
spring: datasource: url: jdbc:mysql://10.0.0.x:3306/appdb username: appuser password: xxxx server: address: 192.168.x.x management: endpoints: web: exposure: include: "*"很多人看到配置文件的反应是"哦,拿到数据库密码了",然后转头去连数据库。但我的关注点落在了management.endpoints.web.exposure.include: "*"这一行上。这是Spring Boot Actuator的配置,*意味着所有执行器端点全部暴露。结合之前读取到的管理员接口地址,一个更大胆的想法冒了出来:如果应用端有表达式解析的点,配合暴露出来的执行器端点,是不是可以直接往命令执行的方向打?
这就是组合拳的关键所在:SQL注入只是让你拿到了地图,而地图上标注的"密道入口",可能藏在完全不同的技术栈里。
3. SpEL表达式注入:Java世界的"命令执行"入口
3.1 SpEL的运行机制与危险特性
SpEL(Spring Expression Language)是Spring框架提供的表达式语言,在Java生态里极其常见,常被用于配置注入、权限校验、动态路由等场景。它能访问对象属性、调用方法、执行静态方法,甚至能通过T()运算符引用任意类。而StandardEvaluationContext模式下,表达式解析是"全功能"的——这意味着,如果能控制传递给解析器的表达式内容,就相当于在目标JVM进程里获得了一个"代码执行点"。
举一个最典型的危险写法。假设某接口接收一个keyword参数,后端代码做了这样的处理:
// 反例:直接对用户输入做表达式求值(仅用于测试演示) ExpressionParser parser = new SpelExpressionParser(); StandardEvaluationContext context = new StandardEvaluationContext(); String result = parser.parseExpression(userInput).getValue(context, String.class);这时传入T(java.lang.Runtime).getRuntime().exec("id"),就足够在服务器上执行命令了。由于SpEL表达式可以用T()引用任意类,理论上能达到的效果和反射调用差不多。这不是某个框架的"隐藏漏洞",而是"允许不可信输入进入全功能表达式解析器"这个错误写法的必然结果。
3.2 什么场景会出现SpEL注入
在实际代码里,SpEL注入的入口往往藏得比SQL注入深。最常见的几种场景:
- 配置中心动态更新配置,配置项直接被解析为SpEL表达式
- 规则引擎或策略模式,用SpEL做复杂条件判断
- 接口层直接接收表达式参数,用于自定义排序、过滤
- AOP切面里对注解参数做表达式解析
- 缓存key的生成逻辑,包含可变量拼接
在这次项目中,SpEL注入点正好落在一个"规则配置接口"上。业务方说这个接口是为了让运营人员自定义促销活动的计算规则,比如"当订单金额大于X时,折扣为Y",后端的实现是把规则字符串直接丢进ExpressionParser做求值。从业务上讲,这个功能设计得很聪明;从安全上讲,这是一颗定时炸弹。
3.3 深入:从表达式解析到命令执行
SpEL注入和SQL注入有个很大的不同:SQL注入通常需要绕一堆语法限制,而SpEL注入一旦触发,基本就是直接的代码执行。原因在于StandardEvaluationContext本身没有做能力裁剪,它把整个Java类型系统都暴露给了表达式。
我的实测过程是这样的:先确认接口对异常信息有回显,输入一个简单的非法表达式,看错误信息里是否暴露了解析器的类型;然后尝试一个无副作用的表达式,比如T(java.lang.Math).random(),看响应里有没有返回随机数;最后才替换成命令执行表达式。这一套渐进式验证,可以最大限度避免在确认漏洞存在之前就触发危险操作。
// 用于验证命令执行的回显表达式 T(java.lang.Runtime).getRuntime().exec(new String[]{"/bin/sh", "-c", "id"})从表达式到系统命令执行,中间隔着的只是解析器配置。如果业务代码里用的是SimpleEvaluationContext,那很多危险操作会被拦下来;但只要用了StandardEvaluationContext,攻击者就拥有了一把万能钥匙。这也是我后来在报告中反复强调的一点:SpEL本身不可怕,可怕的是拿全功能上下文去解析不可信输入。
4. 组合拳完整链路与实操
4.1 链路设计:从数据库到应用层
现在把前面的发现串起来,整条组合拳链路是这样的:
- SQL注入 → 获取数据库
FILE权限 → 读取应用配置文件 - 配置文件 → 暴露Spring Boot Actuator端点、应用地址、管理员接口
- Actuator端点 → 查看环境详情,确认SpEL相关组件的使用情况
- 规则配置接口 → 确认SpEL注入点 → 构造表达式执行命令
这条链路的每一步都是上一步的"凭证":没有SQL注入,读不到配置文件;没有配置文件,不一定能快速定位到Actuator暴露和规则接口;没有Actuator的信息,SpEL注入的利用方向就不够清晰。从攻击者视角看,这四步是环环相扣的。
4.2 具体步骤拆解
第一步,通过SQL注入确认数据库权限。这一步要记录好current_user()和version()的结果,后面排查时要用。
第二步,读取配置文件。服务端支持LOAD_FILE()的情况下,优先尝试绝对路径读取。常见的路径包括/opt/app/config/application.yml、/etc/app/application.properties。读不到就换目录,利用报错信息中的路径提示来缩小范围。
第三步,根据配置文件中的server.address访问应用管理端口。这里要特别提醒:在授权测试中,任何跨系统的横向动作都必须严格遵循测试范围和授权书。我在这一步只做了本系统的验证性访问,没有做任何横向移动。
# 查看Actuator端点是否可访问(仅限授权环境) curl -s http://192.168.x.x:8080/actuator第四步,寻找SpEL注入入口。规则配置接口通常是POST类型,参数会包含"规则表达式"或"condition"关键字。构造一个探测表达式,确认解析器存在,再渐进式升级为命令执行验证。
4.3 关于组合拳的适用边界
并不是所有系统都能走通"SQL注入→SpEL注入"这条路线。它有几个前置条件:
- 目标技术栈必须是Java系,且用了Spring生态
- SQL注入点必须能读取到有效配置文件,或者能间接暴露应用层信息
- 应用层必须存在一个可被外部调用的SpEL表达式解析点
- 各环节之间必须有信息关联,否则就只是两个孤立漏洞
如果前置条件不满足,组合拳就要换成别的组合,比如SQL注入加文件上传、SQL注入加反序列化等等。思路永远是通用的:先拿到信息,再放大信息,最后用信息去触发一个更致命的漏洞。
5. 实操中的常见问题与避坑经验
5.1 问题排查速查表
这条路走下来,我踩了不少坑,也总结了几个高频问题的排查方向,列在下面供参考。
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| SQL注入报错但无法读文件 | 数据库进程缺少文件读权限 | 改用LOAD DATA INFILE、尝试备份文件路径 |
| 配置文件读到但内容为空 | LOAD_FILE()对特殊编码文件可能返回NULL | 确认文件路径是否正确、文件是否为文本格式 |
| Actuator端点无法访问 | 管理端口与应用端口分离,或网络策略限制 | 查看配置文件中management.server.port项 |
| SpEL表达式无回显 | 接口存在统一异常处理,吞掉了错误信息 | 改用时间延迟表达式,如T(java.lang.Thread).sleep(5000) |
| 命令执行不成功 | 执行环境可能没有/bin/sh | 换用cmd.exe /c或Java反射方式再试 |
5.2 几个容易被忽略的细节
第一个细节:SQL注入读文件不只局限于配置文件,/etc/passwd、历史命令文件、临时目录下的上传文件、日志文件,都可能包含有价值的信息。不过在实际测试中,我还是建议把精力集中在跟应用运行相关的配置上,信息利用率最高。
第二个细节:SpEL表达式注入探测阶段,避免用过重的payload。先用无副作用的表达式确认解析行为,再考虑是否执行命令。这样既能确认漏洞,又能把测试风险控制在合理范围内。
第三个细节:组合拳中的每个环节都要保留证据。包括注入payload、配置文件截图、Actuator端点返回内容、SpEL验证表达式和响应结果。写报告时这些证据能把整条链路讲得非常扎实,也有助于甲方理解为什么单修一个SQL注入远远不够。
第四个细节:很多SpEL注入点藏在"看似人畜无害"的业务功能里。促销规则、数据筛选、动态表单校验,这些地方一旦实现偷懒用了parseExpression,就是现成的入口。挖洞时看到这种"灵活配置"类功能,值得多花时间盯一下。
6. 从攻击链反推防御策略
6.1 SQL注入侧的防御要点
SQL注入的修复,其实没有太多新东西:预编译语句、参数化查询、最小权限原则,这些都做到了,绝大部分注入点能被挡掉。但这次案例中有一个特殊教训:即使应用本身对大部分SQL都做了预编译,那个订单查询接口只要有一处手写拼接,前面所有防御都归零。所以代码审计里,比起"到处查注入",更重要的是建立"所有SQL语句只能通过统一数据访问层出库"的纪律。
数据库权限的最小化,这次也体现得很明显。如果当时数据库账号没有FILE权限,哪怕SQL注入成功,也没办法直接读取配置文件,组合拳在第一环就会断掉。建议所有业务环境自查一遍:生产数据库账号是否具备FILE、SUPER这类高危权限?如果没有合理业务诉求,全部回收。
6.2 SpEL注入侧的防御要点
SpEL注入修复的核心,是把解析能力降到最低限度。能用SimpleEvaluationContext就不用StandardEvaluationContext,后者连类引用都开放了,跟直接交给攻击者一个Java反射接口没什么区别。
还有一个容易被忽视的点:对用户输入做黑名单过滤,不是有效的SpEL注入修复手段。表达式语言的语法灵活到离谱,黑名单永远追不上语法变体。正确做法是把"表达式"明确当成"代码"来管理:不允许业务用户直接提交表达式字符串,改为提交结构化数据,由后端用预定义好的逻辑去解释。
6.3 纵深防御的工程化落地
从这次组合拳案例可以看出,单点防御再强,也扛不住"信息关联"带来的放大效应。真正有效的防御体系,应该是分层的:网络层限制管理端口的访问来源,应用层对执行器端点做认证和授权,数据层控制数据库账号的文件读写权限,代码层杜绝危险表达式解析模式。四层各守一段,攻击者就算打通了其中一环,也未必能顺着链路走到头。
我后来在做复盘报告时,给甲方提了一个很具体的建议:把"配置文件中的敏感信息"和"接口能力的暴露面"做成联动清单,每次配置变更时自动触发一轮轻量级安全扫描。这个建议的本意,就是要切断攻击链中"信息获取→信息利用"的关键环节。毕竟漏洞的组合拳再厉害,只要信息传不出去,后面的拳法就全都打空了。
写在最后的个人体会
这条组合链路走通之后,我最大的感受是:漏洞挖掘真的不是"找一个洞就交差"的活。SQL注入太常见了,常见到很多人会下意识地低估它——可只要你愿意多想一步,愿意从数据库层的角落里翻出那份配置文件,后面等着你的可能就是一片完全不同的攻击面。反过来,SpEL注入也没有很多人想象中那么神秘,它就是"不可信输入进入了强大的解析引擎"这件事的必然结果。两者的结合点,正是现代Web系统里数据层与应用层之间那条往往没人检查的信任链。愿每一次测试都能守好授权的边界,也愿每一份报告都能让系统的下一层防御变得更扎实。