Java Runtime.getRuntime().exec由表及里

2020-01-06 约 515 字 预计阅读 3 分钟

声明:本文 【Java Runtime.getRuntime().exec由表及里】 由作者 李三 于 2020-01-06 09:40:58 首发 先知社区 曾经 浏览数 205 次

感谢 李三 的辛苦付出!

这篇文章主要目的在于学习前人文章,并从深入一点的角度探讨为什么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

关键词:[‘安全技术’, ‘WEB安全’]


author

旭达网络

旭达网络技术博客,曾记录各种技术问题,一贴搞定.
本文采用知识共享署名 4.0 国际许可协议进行许可。

We notice you're using an adblocker. If you like our webite please keep us running by whitelisting this site in your ad blocker. We’re serving quality, related ads only. Thank you!

I've whitelisted your website.

Not now