这篇文章主要目的在于学习前人文章,并从深入一点的角度探讨为什么Runtime.getRuntime().exec某些时候会失效这个问题。
测试代码如下
import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; public class linux_cmd1 { public static void main(String[] args) throws IOException { String cmd = "cmd which you want to exec"; InputStream in = Runtime.getRuntime().exec(cmd).getInputStream(); ByteArrayOutputStream baos = new ByteArrayOutputStream(); byte[] b = new byte[1024]; int a = -1; while ((a = in.read(b)) != -1) { baos.write(b, 0, a); } System.out.println(new String(baos.toByteArray())); } }
先看看可以成功的情况
再来看看不能成功的情况
这里 &&
并没有达到bash中的效果
如果以前有人问我为什么会出现这种,我会毫不犹豫的回答:因为 Runtime.getRuntime().exec
执行命令的时候并没有shell上下文环境所以无法把类似于 &
|
这样的符号进行特殊处理。
解决这种问题的方法有两种
第一种就是对执行命令进行编码,编码地址在这
第二种是使用数组的形式命令执行
String[] command = { "/bin/sh", "-c", "echo 2333 2333 2333 && echo 2333 2333 2333" }; InputStream in = Runtime.getRuntime().exec(command).getInputStream();
至此从实战应用的角度这个问题已经解决了。
不过我们可以看到其实这第二种方法用到了 &
上面 Runtime.getRuntime().exec执行命令的时候并没有shell上下文环境所以无法把类似于 &
|
_
_` _这样的符号特殊处理。_这一结论似乎看起来并站不住脚?
下面来跟踪一下源码,看看到底发生了什么。
import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; public class linux_cmd1 { public static void main(String[] args) throws IOException { String cmd = "echo 2333 && echo 2333"; InputStream in = Runtime.getRuntime().exec(cmd).getInputStream(); ByteArrayOutputStream baos = new ByteArrayOutputStream(); byte[] b = new byte[1024]; int a = -1; while ((a = in.read(b)) != -1) { baos.write(b, 0, a); } System.out.println(new String(baos.toByteArray())); } }
因为传入的命令是String类型,所以进入 java.lang.Runtime#exec(java.lang.String, java.lang.String[], java.io.File)
。这里是第一个非常关键的点, StringTokenizer
会把传入的conmmand字符串按 \t \n \r \f
中的任意一个分割成数组cmdarray。
代码来到exec的多态实现 java.lang.Runtime#exec(java.lang.String[], java.lang.String[], java.io.File)
,exec内部调用了ProcessBuilder的start。
ProcessBuilder.start内部又调用了ProcessImpl.start。
在ProcessImpl.start中有第二个非常关键的点我们可以看到程序把cmdarray第一个参数(cmdarray[0])当成要执行的命令,把其后的部分(cmdarray[1:])作为命令的参数转换成byte 数组 argBlock(具体规则是以\x00进行implode)。
ProcessImpl.start最后又会把处理好的参数传入UNIXProcess
UNIXProcess内部又调用了forkAndExec方法
这里的是forkAndExec是一个native方法。
从变量的命名来看,在开发者的眼中prog是要执行的命令即 echo
,argBlock都是传给 echo
的参数即2333\x00&&\x002333
且传给 echo
的参数个数argc是4。
可见经过StringTokenizer对字符串中空格类的处理其实是一种java对命令执行的保护机制,他可以防御以下这种命令注入,其效果相当于php中的escapeshellcmd。
String cmd = "echo " + 可控点; Runtime.getRuntime().exec(cmd)
补一个完整的调用栈。
我们再来看看给Runtime传入数组的时候是什么情况。
import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; public class linux_cmd1 { public static void main(String[] args) throws IOException { String[] command = { "/bin/sh", "-c", "echo 2333 && echo 2333" }; InputStream in = Runtime.getRuntime().exec(command).getInputStream(); ByteArrayOutputStream baos = new ByteArrayOutputStream(); byte[] b = new byte[1024]; int a = -1; while ((a = in.read(b)) != -1) { baos.write(b, 0, a); } System.out.println(new String(baos.toByteArray())); } }
因为这里传入的数组,所以并没有经StringTokenizer对字符串的分割处理这一步而是直接进入了。java.lang.Runtime#exec(java.lang.String[])
。
后面的流程和字符串的情形是一致的,最后来到forkAndExec
按照上面的说法这里 /bin/bash
是要执行的命令, -c\x00"echo 2333 && echo 23333"
是传给的 /bin/bash
的参数。
补一个调用栈
看到这里不知道你是不是有点晕,心底生出了疑问,在执行字符串的时候加上 /bin/bash
不就好了。像下面这样。
import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; public class linux_cmd1 { public static void main(String[] args) throws IOException { String cmd = "/bin/bash -c 'echo 2333 && echo 2333'"; InputStream in = Runtime.getRuntime().exec(cmd).getInputStream(); ByteArrayOutputStream baos = new ByteArrayOutputStream(); byte[] b = new byte[1024]; int a = -1; while ((a = in.read(b)) != -1) { baos.write(b, 0, a); } System.out.println(new String(baos.toByteArray())); } }
运行试试看,发现什么结果都没有,推测应该是shell执行命令失败了。
为什么会失败呢?我们来diff一下和数组执行最后进native的层的区别。
可以看到prog都是 /bin/bash
但是字符串模式下执行的参数变成了 -c\x00'echo\x002333\x00&&\x00echo\x002333'
,对比数组模式 -c\x00"echo 2333 && echo 23333"
。可以发现字符串模式下因为StringTokenizer
对字符串空格类字符的处理破坏了命令执行的语义。
如果再仔细看看会发现字符串模式argc为6而数组模式只有2。写到这里其实我还想钻以下牛角尖,凭什么6个参数最后就不能执行?
带着这样的疑问,我自不量力的编译了java源码并现学了一下怎么调试jvm(调试的环境是ubuntu14.04+jdk8)下面是学习成果。
import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; public class Test { public static void main(String[] args) throws IOException { String[] command = { "/bin/bash", "-c", "echo 2333 && echo 2333" }; InputStream in = Runtime.getRuntime().exec(command).getInputStream(); ByteArrayOutputStream baos = new ByteArrayOutputStream(); byte[] b = new byte[1024]; int a = -1; while ((a = in.read(b)) != -1) { baos.write(b, 0, a); } System.out.println(new String(baos.toByteArray())); } }
根据java native函数命名规则可以知道forkAndExec对应的c函数是 Java_java_lang_UNIXProcess_forkAndExec
。
这个函数初始化执行命令所需要一些变量(如输入输出错误流)以及提取并处理java传入进来的参数,最后调用startChild函数开启子进程。
startChild会根据是mode的数值不同进入不同的分支,mode由操作系统、libc版本决定。
我这里进入了vforkChild,vforkChild会使用vfork开启一个子进程,并且在子进程内部调用了childProcess,在clion中为了调试进入子进程需要在进入之前在gdb调试框输入 set follow-fork-mode child
和 set detach-on-fork off
childProcess中调用JDK_execvpe。
JDK_execvpe最后调用系统execvp函数,我们来细一看传参情况。
故数组情况下等价于
那么我们再来考察一下,字符串的情况的情况。
故字符串模式等价于
所以整个调用链如下
java.lang.Runtime.exec(cmd);
->java.lang.ProcessBuilder.start();
-->java.lang.ProcessImpl.start();
--->Java_java_lang_UNIXProcess_forkAndExec() in j2se/src/solaris/native/java/lang/UNIXProcess_md.c
---->fork或VFORK或POSIX_SPAWN
----->execvp();
字符串形式下Runtime.getRuntime().exec执行命令的时候无法解释&
等特殊字符的本质是execvp特殊符号。而之所以数组情况能成是因为execvp调用了 /bin/bash
,/bin/bash
解释了 &
, |
和execvp没关系。
Java下奇怪的命令执行
在 Runtime.getRuntime().exec(String cmd) 中执行任意shell命令的几种方法
Java JVM、JNI、Native Function Interface、Create New Process Native Function API Analysis
How to debug a forked child process using CLion