对 Java 命令执行过程中的流程以及利用坑点进行学习分析
Preface
Java 的大多数攻击的最后调用都在命令执行这个地方,而命令执行涉及的方方面面还是大有可深究
本文从以下几个维度分析 Java 命令执行
- How to Command execution
- How to getshell
How to Command execution
首先谈一下 Java 当中究竟有哪些方式可以进行命令执行
- java.lang.Runtime#exec()
- java.lang.ProcessBuilder#start()
- java.lang.ProcessImpl#start()
- 更多(如 JNI 调用动态链接库、Unsafe 类、defineClass() 等,不在本文探讨范围)
java.lang.Runtime#exec()
先看一下源码
首先 java.lang.Runtime 是一个单例模式,它不能被实例化,只能通过 getRuntime
获得对象,具体可参考: 单例模式
看一个最简单的命令执行的例子
package exec; |
成功弹出了计算器,那么其实在命令执行的更多利用方式上,光弹一个计算器是不够的
因为我们在很多场景下需要其回显功能
由此一般的命令执行代码如下;
package exec; |
那么为什么上述代码能够获取回显呢?
先回顾一下 Runtime.exec()
的作用。其实命令执行只是我们对结果的一个统称。那么它其实真正的作用是:
创建一个本地进程,并返回Process子类的一个实例,该实例可用来控制进程并获取相关信息。
对比一下之前的例子,我们发现其实关键出现在 getInputStream
这里
那么我们发现其实 getInputStream
是 Process 类的API
之后创建 ByteArrayOutputStream
然后转换出来即可
网上很多文章说这里的getInputStream
的作用就只说了一个获取输入流,之前很困惑为什么获取输入流可以获取命令执行的结果,不太理解输入流跟命令执行输出的关系,后来看了下官方文档,发现官方文档的返回值写的很清楚大概这里的输入流不是字面意思上的理解的那种,反正会获取子流程正常输出内容就对了
看一下官方的定义
getInputStream
public abstract InputStream getInputStream()Returns the input stream connected to the normal output of the subprocess. The stream obtains data piped from the standard output of the process represented by this
Process
object.If the standard output of the subprocess has been redirected using
ProcessBuilder.redirectOutput
then this method will return a null input stream.Otherwise, if the standard error of the subprocess has been redirected using
ProcessBuilder.redirectErrorStream
then the input stream returned by this method will receive the merged standard output and the standard error of the subprocess.Implementation note: It is a good idea for the returned input stream to be buffered.
Returns:
the input stream connected to the normal output of the subprocess
那么联想到 Weblogic 2725 的获取回显的思路也是在 Weblogic中获取到当前线程类,然后定位到输出类,最后获取的回显信息。
java.lang.ProcessBuilder#start()
ProcessBuilder是一个final类,Process是一个抽象类。
ProcessBuilder.start()
和Runtime.exec()
方法都被用来创建一个操作系统进程(执行命令行操作),并返回Process
子类的一个实例,该实例可用来控制进程状态并获得相关信息。
ProcessBuilder.start()
只支持字符串数组参数,且 第一个参数必须是可执行程序,可以添加参数使用{"cmd", "/c"}
或 {"/bin/bash", "-c"}
package exec; |
java.lang.ProcessImpl
通过之前的图我们也可以知道 java.lang.ProcessImpl
是更为底层的实现。
特点是不能直接调用,需要通过反射去间接调用
package exec; |
How to getshell by java.lang.Runtime#exec()
getshell
java.lang.Runtime#exec()
作为 Java 里最常见进行命令执行的方法。我们下文探究如何通过其进行 getshell
- wget 或者 curl 下载文件
- echo 写入webshell
- String[] cmdarray
- String command + base64encode
wget and curl
- 优势
- 操作便捷
- 可以下载可执行文件
- 缺陷
- 受限于网络环境
wget
Runtime.getRuntime().exec("wget https://p2hm1n.com/images/logo.png"); |
curl
Runtime.getRuntime().exec("curl -O https://p2hm1n.com/images/logo.png"); |
String[] cmdarray
- 优势
- 相比于 wget / curl 不受限于网络环境
- 缺陷
- 兼容性较差,比如有的系统固定命令执行方式
前提:getshell的过程中需要写入 webshell,而写入 Webshell 的命令形如
echo "flag" > flag.txt |
但是利用 java.lang.Runtime#exec(String command)
直接执行无法写入文件
Runtime.getRuntime().exec("echo \"flag\" > flag.txt"); |
如果想写入文件的话,需要使用如下命令
// linux |
究其原因 java.lang.Runtime
中其实有 6 个 exec
的重载方法
查了下官方文档,大概如下:
|
下面进行命令执行解析流程分析:
首先分析
Runtime.getRuntime().exec("echo \"flag\" > flag.txt"); |
首先将命令完全传给了 command 参数,返回值形如 exec(command, null, null);
这一步传入的参数为 command
,跟进分析
经过如下处理:
java.util.StringTokenizer#StringTokenizer(java.lang.String) |
最后转换成了 cmdarray。将原本传入的参数值进行了切割,转换成了数组,最后再次调用 exec
重载方法
调用到 java.lang.Runtime#exec(java.lang.String[], java.lang.String[], java.io.File)
。后续调用
java.lang.ProcessBuilder#start
来进行命令执行
知识点一:
java.lang.Runtime 中 6个 exec 的重载方法根据参数不同进行区分,主要是传入字符串跟数组两种形式,但最终调用都在
java.lang.Runtime#exec(java.lang.String[], java.lang.String[], java.io.File)
这里,该函数内部首先调用ProcessBuilder类的构造函数创建ProcessBuilder对象,然后调用start(),最终返回一个Process对象。知识点二:
Runtime#exec()底层还是调用的ProcessBuilder#start(),且传入构造函数的参数要求是数组类型。所以传给Runtime#exec()的命令字符串需要先使用StringTokenizer类分割为数组再传入ProcessBuilder类
这里同时解释了命令执行的底层关系,以及解释了 ProcessBuilder#start()
传入参数值的问题。但似乎还没有解决我们之前遗留的写入 webshell 的问题。
到这里先暂停一下。因为我们知道 Runtime#exec()底层还是调用的 ProcessBuilder#start()。所以我们的变量控制的范围应该在这里先停下。
因为我们得知道 ProcessBuilder#start()
需要什么样的命令格式,我们才能去控制 Runtime#exec
的参数去调整,又因为 ProcessBuilder#start()
最后会调用到 ProcessImpl.start()
我们看一下以下三种情况传入到 ProcessImpl.start()
的状态
Runtime.getRuntime().exec("echo echo flag > flag.txt");
Runtime.getRuntime().exec("/bin/sh -c echo flag > flag.txt");
Runtime.getRuntime().exec(new String[]{"/bin/sh", "-c", "echo flag > flag.txt"});
着重看第三种方法
这里可以发现是直接 return 到 java.lang.Runtime#exec(java.lang.String[], java.lang.String[], java.io.File)
的
然后直接进行后面的调用
划重点:不会经过 StringTokenizer
进行字符串处理
经过实验我们知道只有 第三种 是可以输出到一个文件的,究其核心原因在于 StringTokenizer
对字符串的分割破坏了原有的语义,而直接传入数组类型字符串则由自己分割字符串
而第一种不能直接执行的原因是因为 Linux 下执行命令前面需要加上 /bin/sh -c
,详见后面分析。
回到我们的分析,之前的分析已经调用到 ProcessImpl.start()
了
java.lang.Process
里这里有一个操作:
取出 cmdarray[0] 赋值给prog,如果安全管理器 SecurityManager 开启,会调用SecurityManager#checkExec()对执行程序prog进行检查
跟据注释可以看到这段代码的用途 : Throws IndexOutOfBoundsException if command is empty
随后返回一个 new UNIXProcess()
针对第一个参数的处理在 java.lang.ProcessImpl#toCString
针对除第一个参数之外的处理在之前就已经完成
之后调用到 java.lang.UNIXProcess#UNIXProcess
这里还会调用 forkAndExec
方法,这个方法是一个 native 方法(会调用 C 语言之类的)
看一下此时的参数分割
在开发者的眼中prog是要执行的命令, argBlock都是传给 prog 的参数。
可见经过 StringTokenizer 对字符串中空格类的处理其实是一种java对命令执行的保护机制,他可以防御以下这种命令注入
>String cmd = "echo " + 可控点;
>Runtime.getRuntime().exec(cmd)
题外话:站在开发的角度来看这也确实是 Java 相比 PHP 在防止命令注入的场景天然的优势了
启动了一个 sh 进程
那第二种为什么不行呢,看一下第二种的参数分割。
依照我的理解:既然 argBlock 是传递给 prog 的参数,所以当 echo 后面的东西分快传播就会破坏原来的语义,因此不能正常解析 (我也不知道这样理解对不对 )
这样虽然也能启一个sh进程,但由于不能正常解析,所以不会写入文件
而第一个方法显然不能启动一个 sh 进程,因此在 Linux 下第一个传入的参数需要为 /bin/sh
,所以第一种不行。
关于 Windows 的分析可以参考 360BugCloud 的《浅析Java命令执行》:
需要添加cmd /c的原因:
在传入 echo_test > echo.txt 命令字符串时,出现错误(“java.io.IOException: Cannot run program “echo”: CreateProcess error=2, 系统找不到指定的文件。”)。原因是echo为命令行解释器cmd.exe的内置命令,并不是一个单独可执行的程序(如下图),所以如果想执行echo命令写文件需要先启动cmd.exe,然后将echo命令做为cmd.exe的参数进行执行。
另外关于cmd下的 /c 参数,当未指定时,运行如下示例程序,系统会启动一个pid为8984的cmd后台进程,由于cmd进程未终止导致java程序卡死。当指定/c时,cmd进程会在命令执行完毕后成功终止。
所以在Windows环境下,使用Runtime.getRuntime()执行的命令前缀需要加上cmd /c,使得底层Windows的processthreadsapi.h#CreateProcessW()方法在创建新进程时,可以正确识别cmd且成功返回命令执行结果。
String command + base64encode
优势
- 适用于字符串命令传参
缺点
- 无
字符串命令传参的情况下可以通过 @jackson 这个国外研究员的方式解决:http://www.jackson-t.ca/runtime-exec-payloads.html
Process procdemo = Runtime.getRuntime().exec("bash -c {echo,ZWNobyBmbGFnID4gZmxhZy50eHQ=}|{base64,-d}|{bash,-i}"); |
下面进行简单的分析
这里可以看到因为没有空格的原因,即使经过 StringTokenizer
处理,依然只分割成了三个参数
最后传入 java.lang.UNIXProcess#forkAndExec
时也是如我们所期望的那样
那么这个payload的构造有什么巧妙?
bash -c {echo,ZWNobyBmbGFnID4gZmxhZy50eHQ=}|{base64,-d}|{bash,-i} |
我们对这个 payload 进行从左至右的分析
首先 bash -c
同我们之前分析所用的 /bin/sh -c
是一样的道理,只不过调用的环境不一样
接着我们分析 {echo,ZWNobyBmbGFnID4gZmxhZy50eHQ=}|{base64,-d}
其实就是 大括号 + 管道符 的利用,核心目的是为了绕过空格的限制从而避免被 StringTokenizer
处理
接着分析
echo flag > flag.txt|{bash,-i} |
bash -i
常见用于反弹shell中,其核心是 -i
这个参数表示的是产生交互式的shell
再延伸一下思路:
经过上面的分析其实我们知道核心思路就是一个 bypass 符号的问题
我们的目的也就是用 shell 能识别但是不会被 StringTokenizer
切割的
想想 CTF 中最常见的 bypass 空格的方式 ${IFS}
Process procdemo = Runtime.getRuntime().exec("bash echo${IFS}flag>flag.txt"); |
根据最后传参格式来看是可以执行的
但是本地为 MacOS 环境,无法测试最终结果
End
在后面调试的 ProcessBuilder#start()
过程中看到了 @李三 师傅的文章:https://xz.aliyun.com/t/7046 以及 @threedr3am 师傅的评论。
写的太细了,本来我已经没有任何补充空间了,但是还是重新写了一遍,我觉得写点东西才能沉淀下来
REF: