Java Runtime.getRuntime().exec由表及里
2020-01-06 10:40:58 Author: xz.aliyun.com(查看原文) 阅读量:1550 收藏

这篇文章主要目的在于学习前人文章,并从深入一点的角度探讨为什么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上下文环境所以无法把类似于 & | __` _这样的符号特殊处理。_这一结论似乎看起来并站不住脚?

下面来跟踪一下源码,看看到底发生了什么。

源码分析

当传入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 = "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.getRuntime().exec的是字符串数组

我们再来看看给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个参数最后就不能执行?

进入jvm看看

带着这样的疑问,我自不量力的编译了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 childset 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


文章来源: http://xz.aliyun.com/t/7046
如有侵权请联系:admin#unsafe.sh