Java Runtime.getRuntime().exec()碰到的问题

介绍

在CVE-2020-5902 F5-BigIP-TMUI远程代码执行漏洞分析中WorkspaceUtils.saveFile()碰到这么一段代码。

f5-runtime

众所周知,Runtime.getRuntime.exec()在Java中用来执行系统命令。

本以为Java和其他语言一样可能会导致命令注入的问题,但经过测试发现该处并不能造成任意命令执行,于是对Runtime.getRuntime.exec()进行分析。

测试

测试时Java版本为1.8.0_221

先在/tmp目录下新建两个测试文件:test1.txttest2.txt,此时权限是644(rw-r–r–)

before-chmod

为了方便测试,我们将F5中的代码简化一下,代码将/tmp/test1.txt/tmp/test2.txt文件权限变为777

1
2
3
4
5
6
7
public class WorkspaceUtils {
public static void main(String args[]) throws Exception{
String fileName= "/tmp/test1.txt && /bin/chmod 777 /tmp/test2.txt";
String[] chmodCmd = new String[]{"/bin/chmod", "777", fileName};
Runtime.getRuntime().exec(chmodCmd);
}
}

执行代码,发现文件权限并没有变化。

array-chmod

再使用字符串形式传递command参数,变成如下形式,

1
2
3
4
5
6
7
public class WorkspaceUtils {
public static void main(String args[]) throws Exception{
String fileName= "/tmp/test1.txt && /bin/chmod 777 /tmp/test2.txt";
String chmodCmd = "/bin/chmod 777 " + fileName;
Runtime.getRuntime().exec(chmodCmd);
}
}

执行代码,此时发现两个文件的权限都变成777(rwxrwxrwx)

string-chmod

那么是不是可以说明,使用字符串传递command参数就能产生命令注入的漏洞?

并不是这样,我们将执行的命令改成/tmp/test1.txt变为644,/tmp/test2.txt变成700

1
2
3
4
5
6
7
public class WorkspaceUtils {
public static void main(String args[]) throws Exception{
String fileName= "/tmp/test1.txt && /bin/chmod 700 /tmp/test2.txt";
String chmodCmd = "/bin/chmod 644 " + fileName;
Runtime.getRuntime().exec(chmodCmd);
}
}

执行代码,发现都变成了644(rw-r–r–),而不是一个644,一个700。具体原因在分析章节中会讲到。

diff-string-chmod.png

我们再来看下面一个例子,将/tmp/test1.txt文件权限变为777,另外在/tmp/中新建一个名为seikei的文件夹。

1
2
3
4
5
6
7
public class WorkspaceUtils {
public static void main(String args[]) throws Exception{
String fileName= "/tmp/test1.txt && /bin/mkdir /tmp/seikei";
String chmodCmd = "/bin/chmod 777 " + fileName;
Runtime.getRuntime().exec(chmodCmd);
}
}

执行代码,此时仅是/tmp/test1.txt权限改成777,并没有生成/tmp/seikei文件夹。

chmod-mkdir

那么为什么会导致以上现象发生,下面我们通过代码来分析一下。

分析

首先来看Runtime.getRuntime.exec()的代码,Runtime.getRuntime.exec()有以下六种重载方法,虽然应用程序可能会调用不同的重载方法,最终都会执行到public Process exec(String[] cmdarray, String[] envp, File dir)

runtime-getRuntime-exec

先来看第三个public Process exec(String command, String[] envp, File dir),也就是传入将命令以字符串的形式传入。

1
2
3
4
5
6
7
8
9
10
11
public Process exec(String command, String[] envp, File dir)
throws IOException {
if (command.length() == 0)
throw new IllegalArgumentException("Empty command");

StringTokenizer st = new StringTokenizer(command);
String[] cmdarray = new String[st.countTokens()];
for (int i = 0; st.hasMoreTokens(); i++)
cmdarray[i] = st.nextToken();
return exec(cmdarray, envp, dir);
}

其中将字符串形式的command参数使用StringTokenizer类进行实例化,StringTokenizer用于构造一个字符串分词器,它根据指定字符将字符串分成字符串数组,指定字符为:空格等字符(ascii十进制数值在32以下)、制表符(\t)、换行符(\n)、回车符(\r)、换页符(\f)

StringTokenizer

我们先使用字符串形式传递command参数,进行调试

1
2
3
4
5
6
7
public class WorkspaceUtils {
public static void main(String args[]) throws Exception{
String fileName= "/tmp/test1.txt && /bin/mkdir /tmp/seikei";
String chmodCmd = "/bin/chmod 777 " + fileName;
Runtime.getRuntime().exec(chmodCmd);
}
}

可以看到/bin/chmod 777 /tmp/test1.txt && /bin/mkdir /tmp/seikei被转换成cmdarray[6],也就是["/bin/chmod","777","/tmp/test1.txt","&&","/bin/mkdir","/tmp/seikei"]

command-string

我们再使用字符串数组的格式,对比一下两者的差别

1
2
3
4
5
6
7
public class WorkspaceUtils {
public static void main(String args[]) throws Exception{
String fileName= "/tmp/test1.txt && /bin/mkdir /tmp/seikei";
String[] chmodCmd = new String[]{"/bin/chmod", "777", fileName};
Runtime.getRuntime().exec(chmodCmd);
}
}

此时为cmdarray[3],也就是["/bin/chmod","777","/tmp/test1.txt && /bin/mkdir /tmp/seikei"],这是Java命令执行中第一个需要注意的点。

command-array

不管传入的是字符串命令还是字符串数组命令,都会将传入的命令统一转换成cmdarray[]也就是字符串数组,之后再将转换成字符串数组的命令使用ProcessBuilder处理。

1
2
3
4
5
6
7
public Process exec(String[] cmdarray, String[] envp, File dir)
throws IOException {
return new ProcessBuilder(cmdarray)
.environment(envp)
.directory(dir)
.start();
}

ProcessBuilder.start()中又会调用ProcessImpl.start(),在ProcessImpl.start()中创建一个UNIXProcess(调试环境为Mac OS,所以是起一个Unix进程)执行命令。其中过程非常简单不过多赘述,需要注意的是UNIXProcess的参数,其中第一个参数toCString(cmdarray[0])就是我们传入的cmdarray数组中第一个元素,也就是需执行的命令。而cmdarray数组中剩余的元素则放在argBlock中。

UNIXProcess

也就是说只要我们无法控制被执行命令的第一个元素,就无法控制Runtime.getRuntime().exec()中执行的命令,因为后续的元素都将会是第一个元素所代表的命令的参数。

就拿我们之前的命令举例:/bin/chmod 777 /tmp/test1.txt && /bin/mkdir /tmp/seikei

若以字符串形式传入Runtime.getRuntime().exec(),会被解析成["/bin/chmod","777","/tmp/test1.txt","&&","/bin/mkdir","/tmp/seikei"]

此时,执行的命令是/bin/chmod,数组后面的所有元素都会被当作该命令的参数去执行,而不会因为&&而被解析成两条语句。这里特别需要理解命令的参数是什么意思?使用man chmod查看chmod命令的详细解释,其中SYNOPSISchmod后面的内容都是参数。

man-chmod

此时["/bin/chmod","777","/tmp/test1.txt","&&","/bin/mkdir","/tmp/seikei"]仅仅执行到了/bin/chmod 777 /tmp/test1.txt,后面的&& /bin/mkdir /tmp/seikei会被认为是错误的语法而不执行。

但是在第二个测试案例["/bin/chmod","777","/tmp/test1.txt","&&","/bin/chmod","777","/tmp/test2.txt"]/tmp/test1.txt/tmp/test2.txt都是存在的,因为命令存在一定的容错性,所以等于执行的是["/bin/chmod","777","/tmp/test1.txt","/tmp/test2.txt"],也就是将/tmp/test1.txt/tmp/test2.txt的权限同时改为777。

第三个测试案例["/bin/chmod","644","/tmp/test1.txt","&&","/bin/chmod","700","/tmp/test2.txt"]也就将/tmp/test1.txt/tmp/test2.txt的权限同时改为644,而不是一个644,一个700。因为["&&","/bin/chmod","700"]在这里是没有起到任何作用的。

我们再来看若命令以数组形式传递进来,["/bin/chmod","777","/tmp/test1.txt && /bin/mkdir /tmp/seikei"]

此时/tmp/test1.txt && /bin/mkdir /tmp/seikei会被当作file参数,也就是更改/tmp/test1.txt && /bin/mkdir /tmp/seikei这个文件的权限,但是/tmp/test1.txt && /bin/mkdir /tmp/seikei该文件是不存在的,因为有空格和&符号,所以执行不会有结果。

那么,只要Java中的Runtime.getRuntime().exec()第一个字段不可控,就一定不会造成危害吗?

不是的,还可以考虑该命令是否存在参数注入的问题。若第一个字段是执行bash命令,也可以尝试base64等编码形式同时执行多条语句。

结论

由于Java的安全设计,已经尽可能规避了命令执行的风险。执行命令时仅仅将命令数组中第一个元素作为操作系统的进程去执行,其后全部元素都是该进程的参数。

若是使用数组形式传递命令,数组中单个元素只作为单一参数去执行,不会因为空格等字符就分开执行。

若是/bin/bash形式的命令执行,仍可以用bash -c {echo,base64的payload}|{base64,-d}|{bash,-i}执行多条命令。