浅析 Java 命令执行

对 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()

先看一下源码

image-20210205161800399

首先 java.lang.Runtime 是一个单例模式,它不能被实例化,只能通过 getRuntime 获得对象,具体可参考: 单例模式

看一个最简单的命令执行的例子

package exec;

import java.io.IOException;

public class ExecDemo00 {
public static void main(String[] args) throws IOException {
Process procdemo = Runtime.getRuntime().exec("open -a calculator");
}
}

成功弹出了计算器,那么其实在命令执行的更多利用方式上,光弹一个计算器是不够的

因为我们在很多场景下需要其回显功能

由此一般的命令执行代码如下;

package exec;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;

public class ExecDemo01 {
public static void main(String[] args) throws IOException {
/**
* 获取 Process 类
*/
Process procexec = Runtime.getRuntime().exec("whoami");
/**
* 获取输入流、子进程标准输出
*/
InputStream ins = procexec.getInputStream();
/**
* 创建 ByteArrayOutputStream 缓冲区
*/
ByteArrayOutputStream bos = new ByteArrayOutputStream();
byte[] bytes = new byte[1024];
int size;
/**
* ins.read(bytes) 从输入流中读取一定数量的字节,并将其存储到缓冲区数组中 bytes
* 返回值:读入缓冲区的总字节数,或者 -1 由于到达流的末尾而没有更多数据。
*/
while ((size = ins.read(bytes)) > 0 ){
/**
* 将 size 指定字节数组中从偏移量开始的字节写入此数组输出流
*/
bos.write(bytes, 0, size);
}
/**
* 通过解码字节将缓冲区内容转换为字符串。
*/
System.out.println(bos.toString());
}
}

那么为什么上述代码能够获取回显呢?

先回顾一下 Runtime.exec() 的作用。其实命令执行只是我们对结果的一个统称。那么它其实真正的作用是:

创建一个本地进程,并返回Process子类的一个实例,该实例可用来控制进程并获取相关信息。

对比一下之前的例子,我们发现其实关键出现在 getInputStream 这里

那么我们发现其实 getInputStream是 Process 类的API

image-20210104230049823

之后创建 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;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;

public class ExecDemo02 {
public static void main(String[] args) throws IOException {
String[] cmds = {"/bin/bash", "-c", "whoami"};
Process procexec = new ProcessBuilder(cmds).start();
InputStream ins = procexec.getInputStream();
ByteArrayOutputStream bos = new ByteArrayOutputStream();
byte[] bytes = new byte[1024];
int size;
while ((size = ins.read(bytes)) > 0 ){
bos.write(bytes, 0, size);
}
System.out.println(bos.toString());
}
}

java.lang.ProcessImpl

通过之前的图我们也可以知道 java.lang.ProcessImpl 是更为底层的实现。

特点是不能直接调用,需要通过反射去间接调用

package exec;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Map;

public class ExecDemo03 {
public static void main(String[] args) throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, IllegalAccessException, IOException {
String[] cmds = {"whoami"};
Class clz = Class.forName("java.lang.ProcessImpl");
Method method = clz.getDeclaredMethod("start", String[].class, Map.class, String.class, ProcessBuilder.Redirect[].class, boolean.class);
method.setAccessible(true);
Process procexec = (Process) method.invoke(null,cmds, null, ".", null, true);
InputStream ins = procexec.getInputStream();
ByteArrayOutputStream bos = new ByteArrayOutputStream();
byte[] bytes = new byte[1024];
int size;
while ((size = ins.read(bytes)) > 0 ){
bos.write(bytes, 0, size);
}
System.out.println(bos.toString());
}
}

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
Runtime.getRuntime().exec(new String[]{"/bin/sh", "-c", "echo \"flag\" > flag.txt"});

// windows
Runtime.getRuntime().exec(new String[]{"cmd", "/c", "echo \"flag\" > flag.txt"});

究其原因 java.lang.Runtime 中其实有 6 个 exec 的重载方法

image-20210104234608650

查了下官方文档,大概如下:


/**
* 在单独的进程中执行指定的字符串命令
*/
public Process exec(String command)
throws IOException


/**
* 在具有指定环境的单独进程中执行指定的字符串命令
*/
public Process exec(String command,
String[] envp)
throws IOException


/**
* 在具有指定环境和工作目录的单独进程中执行指定的字符串命令
*/
public Process exec(String command,
String[] envp,
File dir)
throws IOException


/**
* 在单独的进程中执行指定的命令和参数。
*/
public Process exec(String[] cmdarray)
throws IOException


/**
* 在具有指定环境的单独进程中执行指定的命令和参数
*/
public Process exec(String[] cmdarray,
String[] envp)
throws IOException


/**
* 在具有指定环境和工作目录的单独进程中执行指定的命令和参数
*/
public Process exec(String[] cmdarray,
String[] envp,
File dir)
throws IOException

下面进行命令执行解析流程分析:

首先分析

Runtime.getRuntime().exec("echo \"flag\" > flag.txt");

首先将命令完全传给了 command 参数,返回值形如 exec(command, null, null);

image-20210105150858270

这一步传入的参数为 command,跟进分析

image-20210105151115015

经过如下处理:

java.util.StringTokenizer#StringTokenizer(java.lang.String)
->
java.util.StringTokenizer#StringTokenizer(java.lang.String, java.lang.String, boolean)
->
java.util.StringTokenizer#setMaxDelimCodePoint
->
java.util.StringTokenizer#countTokens

最后转换成了 cmdarray。将原本传入的参数值进行了切割,转换成了数组,最后再次调用 exec 重载方法

image-20210105151526661

调用到 java.lang.Runtime#exec(java.lang.String[], java.lang.String[], java.io.File)。后续调用

java.lang.ProcessBuilder#start 来进行命令执行

image-20210105152752920

知识点一:

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() 的状态

  1. Runtime.getRuntime().exec("echo echo flag > flag.txt");

image-20210105195813557

  1. Runtime.getRuntime().exec("/bin/sh -c echo flag > flag.txt");

image-20210105195931773

  1. Runtime.getRuntime().exec(new String[]{"/bin/sh", "-c", "echo flag > flag.txt"});

image-20210105191140624

着重看第三种方法

这里可以发现是直接 return 到 java.lang.Runtime#exec(java.lang.String[], java.lang.String[], java.io.File)

image-20210105201427968

然后直接进行后面的调用

image-20210105201541913

划重点:不会经过 StringTokenizer 进行字符串处理

经过实验我们知道只有 第三种 是可以输出到一个文件的,究其核心原因在于 StringTokenizer 对字符串的分割破坏了原有的语义,而直接传入数组类型字符串则由自己分割字符串

而第一种不能直接执行的原因是因为 Linux 下执行命令前面需要加上 /bin/sh -c,详见后面分析。

回到我们的分析,之前的分析已经调用到 ProcessImpl.start()

java.lang.Process 里这里有一个操作:

取出 cmdarray[0] 赋值给prog,如果安全管理器 SecurityManager 开启,会调用SecurityManager#checkExec()对执行程序prog进行检查

跟据注释可以看到这段代码的用途 : Throws IndexOutOfBoundsException if command is empty

image-20210105214313973

随后返回一个 new UNIXProcess()

image-20210105214108311

针对第一个参数的处理在 java.lang.ProcessImpl#toCString

image-20210105213850286

针对除第一个参数之外的处理在之前就已经完成

image-20210105214745759

之后调用到 java.lang.UNIXProcess#UNIXProcess

image-20210105205855774

这里还会调用 forkAndExec 方法,这个方法是一个 native 方法(会调用 C 语言之类的)

看一下此时的参数分割

image-20210105211753318

在开发者的眼中prog是要执行的命令, argBlock都是传给 prog 的参数。

可见经过 StringTokenizer 对字符串中空格类的处理其实是一种java对命令执行的保护机制,他可以防御以下这种命令注入

>String cmd = "echo " + 可控点;
>Runtime.getRuntime().exec(cmd)

题外话:站在开发的角度来看这也确实是 Java 相比 PHP 在防止命令注入的场景天然的优势了

启动了一个 sh 进程

image-20210105212003953

那第二种为什么不行呢,看一下第二种的参数分割。

依照我的理解:既然 argBlock 是传递给 prog 的参数,所以当 echo 后面的东西分快传播就会破坏原来的语义,因此不能正常解析 (我也不知道这样理解对不对

image-20210105220301789

这样虽然也能启一个sh进程,但由于不能正常解析,所以不会写入文件

image-20210105220659575

而第一个方法显然不能启动一个 sh 进程,因此在 Linux 下第一个传入的参数需要为 /bin/sh,所以第一种不行。

image-20210105220920800

关于 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 处理,依然只分割成了三个参数

image-20210105225722972

最后传入 java.lang.UNIXProcess#forkAndExec 时也是如我们所期望的那样

image-20210105225923407

image-20210105225908619

那么这个payload的构造有什么巧妙?

bash -c {echo,ZWNobyBmbGFnID4gZmxhZy50eHQ=}|{base64,-d}|{bash,-i}

我们对这个 payload 进行从左至右的分析

首先 bash -c 同我们之前分析所用的 /bin/sh -c 是一样的道理,只不过调用的环境不一样

image-20210105230506285

接着我们分析 {echo,ZWNobyBmbGFnID4gZmxhZy50eHQ=}|{base64,-d}

其实就是 大括号 + 管道符 的利用,核心目的是为了绕过空格的限制从而避免被 StringTokenizer 处理

image-20210105231756910

接着分析

echo flag > flag.txt|{bash,-i}

bash -i 常见用于反弹shell中,其核心是 -i 这个参数表示的是产生交互式的shell

1

再延伸一下思路:

经过上面的分析其实我们知道核心思路就是一个 bypass 符号的问题

image-20210106145037368

我们的目的也就是用 shell 能识别但是不会被 StringTokenizer 切割的

想想 CTF 中最常见的 bypass 空格的方式 ${IFS}

Process  procdemo = Runtime.getRuntime().exec("bash echo${IFS}flag>flag.txt");

image-20210106145538593

根据最后传参格式来看是可以执行的

但是本地为 MacOS 环境,无法测试最终结果

End

在后面调试的 ProcessBuilder#start() 过程中看到了 @李三 师傅的文章:https://xz.aliyun.com/t/7046 以及 @threedr3am 师傅的评论。

写的太细了,本来我已经没有任何补充空间了,但是还是重新写了一遍,我觉得写点东西才能沉淀下来

image-20210105183122187

REF:

https://xz.aliyun.com/t/7046

https://www.anquanke.com/post/id/221159

https://mp.weixin.qq.com/s/pzpc44-xH932M4eCJ8LxYg

Laravel <= v8.4.2 debug mode RCE 漏洞分析 ysoserial URLDNS 调试分析
Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×