近几年里,java安全威胁性较高的就是反序列化漏洞了,原因在于反序列化漏洞通常会利用Runtime类来实现RCE命令执行从而控制目标服务器,但有时候我们会发现在某些情况下,Runtime类并不能执行一些较为复杂的命令,或者说无法获得我们想要的预期结果。
例如我们在linux系统下执行该命令是没问题,可以执行成功
但是通过java本地命令执行的Runtime类的exec方法来执行该命令无法获得我们预期的结果
我们换一种方式,不执行这么复杂的命令,Runtime类可以成功执行命令并回显
通过这两种情况的对比发现,java本地命令执行不允许命令中出现一些特殊符号,例如上面的示例中出现的“&&”符号,为什么会出现这种情况?实际上无论是windows还是linux下,java本地命令执行是不支持“|”,“<”,“>”,“&”等特殊符号的,接下来,我们来分析Runtime命令执行的调用链是如何执行命令的。
在Runtime类执行命令的调用链中可以看到exec方法并不是命令执行的终点
我们跟进Runtime类的exec方法,发现exec是一个重载的方法
根据传入的参数大致可以分为两类,exec方法支持传入的命令可以是一个字符串类型,也可以是一个字符串数组类型
//字符串
public Process exec(String command);
public Process exec(String command, String[] envp);
public Process exec(String command, String[] envp, File dir);
//数组
public Process exec(String cmdarray[]);
public Process exec(String[] cmdarray, String[] envp);
public Process exec(String[] cmdarray, String[] envp, File dir);
根据前面我们传入的参数,exec方法调用了一个重载的exec方法,继续跟进
exec方法首先会校验命令是否为空,然后使用了一个StringTokenizer类处理传入的command命令,StringTokenizer的作用大致就是给command字符串打上标记之类的,调用nextToken方法扫描command字符串并按“ \t \n \r \f ”中的任意一个进行分割,因此这里会以空格为标记将command字符串分割放到cmdarray数组,然后调用exec方法。
思考一下:为什么要将命令分割成数组?
可以看到Runtime类的exec方法底层是把cmdarray数组封装到了一个ProcessBuilder对象中并调用了start方法,也就是说Runtime类的exec方法底层实际上是调用了ProcessBuilder的start方法执行命令
在start方法中,prog会获取cmdarray数组中第一个元素,也就是“echo”,然后调用了checkExec方法进行安全校验
继续跟踪,接着会调用ProcessImpl.start方法,在start方法中有一个new UNIXProcess操作,也就是调用UNIXProcess类来执行系统命令
UNIXProcess的构造方法会调用forkAndExec方法创建命令执行环境(forkAndExec是一个native方法),native方法会去调用底层封装的系统调用去执行命令
forkAndExec方法会返回命令执行进程的pid,我们在命令行中确实会看到一个echo命令的进程,说明forkAndExec方法确实会根据prog的内容来创建命令执行进程
再回到前面提出的一个思考问题,Runtime类的exec方法之所以把command中的命令通过StringTokenizer类分割成数组的目的就是为了让UNIXProcess类的forkAndExec方法根据prog来构建命令执行进程环境。
分析完Runtime类的命令执行流程,我们来看一个linux系统下奇怪的命令执行,以下命令在命令行窗口可以执行成功,但是在使用java本地命令环境下Runtime类的exec方法并没有执行成功。
/bin/sh -c "echo 111 > 3.txt"
根据前面的分析我们知道,Runtime类的exec方法中command会经过一个StringTokenizer类的分割和处理,从cmdarray数组的结果来看,在command原来的语义中"echo 111 > 3.txt"是一个整体,但是StringTokenizer类把"echo 111 > 3.txt"进行了拆分,破坏了command中命令原来的语义,导致在执行命令时返回的并不是我们想要的预期结果。
有同学可能会说,直接将command中的命令传给ProcessBuilder类的构造方法,然后调用start方法,绕过StringTokenizer类的处理,不就可以实现命令执行了。
我们来试一下,发现程序抛异常了,思考一下为什么会抛异常?
理论上思路是可行的,实际上ProcessBuilder类的start方法中prog会获取cmdarray[0]中的内容,然后根据cmdarray[0]来构建命令执行的进程环境,如果直接在ProcessBuilder类的构造中传入command的话,那么forkAndExec方法就无法根据prog的内容来构建命令执行环境,至于具体的原因,我们接下来分析一下ProcessBuilder类的构造犯法。
ProcessBuilder类有两个构造方法,我们先来看String类型参数的构造,可以看到ProcessBuilder的构造中期望传入一个String[]数组
public ProcessBuilder(String... command) {
this.command = new ArrayList<>(command.length);
for (String arg : command)
this.command.add(arg);
}
如果直接传入一个字符串command的话,forkAndExec方法无法根据/bin/sh -c \"echo 111 > 3.txt\"来判断应该创建什么类型的命令执行进程环境,然后就会抛出异常(IOException)
String command = "/bin/sh -c \"echo 111 > 3.txt\"";
因此解决方案就是传入一个数组,再执行程序发现Project的目录下生成了一个3.txt文件。
对于ProcessBuilder类的另一个构造方法也是如此,按照要求传入一个ArrayList就可以实现了
public ProcessBuilder(List<String> command) {
if (command == null)
throw new NullPointerException();
this.command = command;
}
命令同样也是可以执行成功
另外,通过Runtime类的exec方法来执行命令,有时候传入的命令经过StringTokenizer类的分割后,破坏了原有的语义,那么也可以传入一个数组的方式
当传入一个数组后会调用重载的exec方法,该方法没有调用StringTokenizer类,而是直接调用了ProcessBuilder类的start方法来执行命令,保留了命令语义完整性,例如ysoserial利用工具中的所有命令都是通过数组传参的。
关于如何通过反射UNIXProcess/ProcessImpl执行命令来绕过RASP机制,还有如何通过JNI编程实现命令执行,放在下一篇来讲吧。
参考资料:
http://www.lmxspace.com/2019/10/08/Java%E4%B8%8B%E5%A5%87%E6%80%AA%E7%9A%84%E5%91%BD%E4%BB%A4%E6%89%A7%E8%A1%8C/