出发点是Java Agent
内存马的自动分析与查杀,实际上其他内存马都可以通过这种方式查杀
本文主要的难点主要是以下三个,我会在文中逐个解答
- 如何
dump
出JVM
中真正的当前的字节码 - 如何解决由于
LAMBDA
表达式导致非法字节码无法分析的问题 - 如何对字节码进行分析以确定某个类是内存马
基于静态分析动态,打破规则之道 -- Java King 对本文的评价
背景
对于Java内存马的攻防一直没有停止,是Java安全领域的重点
回顾Tomcat
或Spring
内存马:Filter
和Controller
等都需要注册新的组件
针对于需要注册新组件的内存马查杀起来比较容易:
例如c0ny1
师傅的java-memshell-scanner
项目,利用了Tomcat API
删除添加的组件。优点在于一个简单的JSP
文件即可查看所有的组件信息,结合人工审查(类名和ClassLoader
等信息)对内存马进行查杀,也可以对有风险的Class进行dump
后反编译分析
或者LandGrey
师傅基于Alibaba Arthas编写的copagent
项目,分析JVM
中所有的Class,根据危险注解和类名等信息dump
可疑的组件,结合人工反编译后进行分析
但实战中,可能并不是以上这种注册新组件的内存马
例如师傅们常用的冰蝎内存马,是Java Agent
内存马。以下这段是冰蝎内存马一段代码,简单分析后可以发现冰蝎内存马是利用Java Agent
注入到javax.servlet.http.HttpServlet
的service
方法中,这是JavaEE
的规范,理论上部署在Tomcat
的都要符合这个规范,简单来理解这是Tomcat
处理请求最先且总是经过的地方,在该类加入内存马的逻辑,可以保证稳定触发
类似的逻辑,可以使用Java Agent
将内存马注入org.apache.catalina.core.ApplicationFilterChain
类中,该类位于Filter
链头部,也就是说经过Tomcat
的请求都会交经过该类的doFilter
方法处理,所以在该方法中加入内存马逻辑,也是一种稳定触发的方式(据说这是老版本冰蝎内存马的方式)
还可以对类似的类进行注入,例如org.springframework.web.servlet.DispatcherServlet
类,针对于Spring
框架的底层进行注入。或者一些巧妙的思路,比如注入Tomcat
自带的Filter
之一org.apache.tomcat.websocket.server.WsFilter
类,这也是Java Agent
内存马可以做到的
上文简单地介绍了各种内存马的利用方式与普通内存马的查杀,之所以最后介绍Java Agent
内存马的查杀,是因为比较困难。宽字节安全的师傅提出查杀思路:基于javaAgent内存马检测查杀指南
引用文章讲到Java Agent
内存马检测的难点:
调用retransformClass
方法的时候参数中的字节码并不是调用redefineClass
后被修改的类的字节码。对于冰蝎来讲,根本无法获取被冰蝎修改后的字节码。我们自己写Java Agent
清除内存马的时候,同样也是无法获取到被redefineClass
修改后的字节码,只能获取到被retransformClass
修改后的字节码。通过Javaassist
等ASM
工具获取到类的字节码,也只是读取磁盘上响应类的字节码,而不是JVM
中的字节码
宽字节安全的师傅找到了一种检测手段:sa-jdi.jar
借用公众号师傅的图片,这是一个GUI
工具,可以查看JVM
中所有已加载的类。区别在于这里获取到的是真正的当前的字节码,而不是获取到原始的,本地的字节码,所以是可以查看被Java Agent
调用redefineClass
后被修改的类的字节码。进一步可以dump
下来认为存在风险的类然后反编译人工审核
介绍
以上是背景,接下来介绍我做了些什么,能够实现怎样的效果
不难看出,以上内存马查杀手段都是半自动结合人工审核的方式,当检测出内存马后
是否可以找到一种方式,做到一条龙式服务:
- 检测(同时支持普通内存马和
Java Agent
内存马的检测) - 分析(如何确定该类是内存马,仅根据恶意类名和注解等信息不完善)
- 查杀(当确定内存马存在,如何自动地删除内存马并恢复正常业务逻辑)
大致看来,实现起来似乎不难,然而实际中遇到了很多坑,接下来我会逐个介绍
SA-JDI分析
我尝试通过Java Agent
技术来获取当前的字节码,发现如师傅所说拿不到被修改的字节码
所以为了可以检测Agent
马需要从sa-jdi.jar
本身入手,想办法dump
得到当前字节码(这样不止可以分析被修改了字节码的Agent
马也可以分析普通类型的内存马)
注意到其中一个类:sun.jvm.hotspot.tools.jcore.ClassDump
并通过查资料发现该类功能正是dump
当前的Class(根据类名也可猜测出)其中的main
方法提供一个dump class
的命令行工具
于是我想了一些办法,用代码实现了命令行工具的功能,并可以设置一个Filter
ClassDump classDump = new ClassDump();
// my filter
classDump.setClassFilter(filter);
classDump.setOutputDirectory("out");
// protected start method
Class<?> toolClass = Class.forName("sun.jvm.hotspot.tools.Tool");
Method method = toolClass.getDeclaredMethod("start", String[].class);
method.setAccessible(true);
// jvm pid
String[] params = new String[]{String.valueOf(pid)};
try {
method.invoke(classDump, (Object) params);
} catch (Exception ignored) {
logger.error("unknown error");
return;
}
logger.info("dump class finish");
// detach
Field field = toolClass.getDeclaredField("agent");
field.setAccessible(true);
HotSpotAgent agent = (HotSpotAgent) field.get(classDump);
agent.detach();
上文提到设置一个Filter
是用于确定需要对哪些类进行dump
操作(dump过多会导致性能等问题)
public class NameFilter implements ClassFilter {
@Override
public boolean canInclude(InstanceKlass instanceKlass) {
String klassName = instanceKlass.getName().asString();
// 在黑名单中的类需要dump
if (blackList.contains(klassName)) {
return true;
}
// 包含了关键字的类也需要dump
for (String k : Constant.keyword) {
if (klassName.contains(k)) {
return true;
}
}
return false;
}
}
以上包含了类的黑名单和关键字:
- 黑名单:Java Agent内存马通常会Hook的地方,需要
dump
下来进行分析 - 关键字:类名如果出现
memshell
和shell
等关键字认为可能是普通内存马,需要分析
public class Constant {
// BLACKLIST (Analysis Target)
// CLASS_NAME#METHOD_NAME
public static List<String> blackList = new ArrayList<>();
// SHELL KEYWORD
public static List<String> keyword = new ArrayList<>();
static {
blackList.add("javax/servlet/http/HttpServlet#service");
blackList.add("org/apache/catalina/core/ApplicationFilterChain#doFilter");
blackList.add("org/springframework/web/servlet/DispatcherServlet#doService");
blackList.add("org/apache/tomcat/websocket/server/WsFilter#doFilter");
keyword.add("shell");
keyword.add("memshell");
keyword.add("agentshell");
keyword.add("exploit");
keyword.add("payload");
keyword.add("rebeyond");
keyword.add("metasploit");
}
}
另外如果想在Maven
项目中加入JDK/lib
下的依赖,需要特殊配置
<dependency>
<groupId>sun.jvm.hotspot</groupId>
<artifactId>sa-jdi</artifactId>
<version>jdk-8</version>
<scope>system</scope>
<systemPath>${env.JAVA_HOME}/lib/sa-jdi.jar</systemPath>
</dependency>
在打包成工具Jar
包时默认情况下不会加入system scope
的依赖,所以需要特殊处理
<artifactId>maven-assembly-plugin</artifactId>
<configuration>
<appendAssemblyId>false</appendAssemblyId>
<descriptors>
<descriptor>assembly.xml</descriptor>
</descriptors>
<archive>
<manifest>
<mainClass>org.sec.Main</mainClass>
</manifest>
</archive>
</configuration>
编写assembly.xml
文件
<!-- 省略部分 -->
<dependencySets>
<dependencySet>
<outputDirectory>/</outputDirectory>
<unpack>true</unpack>
<scope>system</scope>
</dependencySet>
</dependencySets>
接着就可以通过代码的方式,根据黑名单和关键字来确定需要dump
哪些类然后进行dump
操作了
我在测试中遇到一个小问题,值得分享:HttpServlet
是正常可以dump
的但是ApplicationFilterChain
类没有找到。这是因为SpringBoot
的懒加载问题,需要手动请求下某个接口就可以了
解决非法字节码
接下来我遇到了一个比较大的坑,通过sa-jdi
库dump
下来的字节码是非法的
在对ApplicationFilterChain
类分析的时候,会报如下的错
起初我怀疑是自己用了最新版ASM
框架:9.2
于是逐渐降级,发现降级到7.0后不再报错,但ClassReader
不报错,在分析时候会报错
经过对比,发现是以下的情况
不报错版本
稍微分析了下,发现是ApplicationFilterChain
类包含了LAMBDA
不止这个类,不少的类都有可能会包含LAMBDA
发现通过sa-jdi
获取的字节码在存在LAMBDA
的情况下是非法字节码,无法进行分析
这时候如果还想进行分析,只有两个选择:
- 自己解析CLASS文件做分析(本末倒置)
- 改写ASM源码使跳过
LAMBDA
根据Java基础知识可以得知:LAMBDA
和INVOKEDYNAMIC
指令相关,于是我改了ASM
的代码
(这里不解释为什么这么改了,是经过多次调试确定的)
org/objectweb/asm/ClassReader#274
代码语言:javascript复制bootstrapMethodOffsets = null;
org/objectweb/asm/ClassReader#2456
代码语言:javascript复制case Opcodes.INVOKEDYNAMIC:
{
return;
}
改了源码后,就可以正常对非法字节码进行分析了。目前来看没有什么大问题,可以正常分析,但不确定这样的修改是否会存在一些隐患和BUG。总之目前能继续了
分析字节码
分析字节码并不需要太深入做,因为大部分可能出现的内存马都是Runtime.exec
或冰蝎反射调ClassLoader.defineClass
实现的,针对于这两种情况做分析,足以应对绝大多数情况
以下代码是读取dump
的字节码并针对两种情况对所有方法分析
List<Result> results = new ArrayList<>();
int api = Opcodes.ASM9;
int parsingOptions = ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES;
for (String fileName : files) {
byte[] bytes = Files.readAllBytes(Paths.get(fileName));
if (bytes.length == 0) {
continue;
}
ClassReader cr;
ClassVisitor cv;
try {
// runtime exec analysis
cr = new ClassReader(bytes);
cv = new ShellClassVisitor(api, results);
cr.accept(cv, parsingOptions);
// classloader defineClass analysis
cr = new ClassReader(bytes);
cv = new DefineClassVisitor(api, results);
cr.accept(cv, parsingOptions);
} catch (Exception ignored) {
}
}
for (Result r : results) {
logger.info(r.getKey() " -> " r.getTypeWord());
}
对于Runtime.exec
型的分析最为简单,仅判断已dump
的字节码中所有方法中是否存在该方法的调用即可(理论上会存在误报,但黑名单类不可能存在该方法,关键字类本身就是可疑的,所以这样做并无不妥)
@Override
public void visitMethodInsn(int opcode, String owner, String name, String descriptor, boolean isInterface) {
boolean runtimeCondition = owner.equals("java/lang/Runtime") &&
name.equals("exec") &&
descriptor.equals("(Ljava/lang/String;)Ljava/lang/Process;");
if (runtimeCondition) {
Result result = new Result();
result.setKey(this.owner);
result.setType(Result.RUNTIME_EXEC_TIME);
results.add(result);
}
super.visitMethodInsn(opcode, owner, name, descriptor, isInterface);
}
但这种情况不适用于冰蝎反射调ClassLoader.defineClass
代码不长,但对应的字节码较复杂
代码语言:javascript复制Method m = ClassLoader.class.getDeclaredMethod("defineClass",
String.class, ByteBuffer.class, ProtectionDomain.class);
m.invoke(null);
对应字节码
代码语言:javascript复制LDC Ljava/lang/ClassLoader;.class // 重点关注
LDC "defineClass" // 重点关注
ICONST_3
ANEWARRAY java/lang/Class
DUP
ICONST_0
LDC Ljava/lang/String;.class
AASTORE
DUP
ICONST_1
LDC Ljava/nio/ByteBuffer;.class
AASTORE
DUP
ICONST_2
LDC Ljava/security/ProtectionDomain;.class
AASTORE
INVOKEVIRTUAL java/lang/Class.getDeclaredMethod (Ljava/lang/String;[Ljava/lang/Class;)Ljava/lang/reflect/Method; // 重点关注
ASTORE 1
L1
LINENUMBER 11 L1
ALOAD 1
ACONST_NULL
ICONST_0
ANEWARRAY java/lang/Object
INVOKEVIRTUAL java/lang/reflect/Method.invoke (Ljava/lang/Object;[Ljava/lang/Object;)Ljava/lang/Object; // 重点关注
POP
这种操作需要多个步骤,并不是简单的一个INVOKE
那么简单,不特殊处理的话,由于反射和ClassLoader
相关操作都算是比较常见的,有一定的误报可能
于是继续掏出栈帧分析大法,具体不再介绍,之前文章 已有详细解释
根据字节码,在defineClass
和Ljava/lang/ClassLoader;
通过LDC
指令入栈之前,应该认为这是恶意操作,模拟JVM指令执行后应该在栈顶设置污点
@Override
public void visitLdcInsn(Object value) {
if (value instanceof String) {
if (value.equals("defineClass")) {
super.visitLdcInsn(value);
this.operandStack.set(0, "LDC_STRING");
return;
}
} else {
if (value.equals(Type.getType("Ljava/lang/ClassLoader;"))) {
super.visitLdcInsn(value);
this.operandStack.set(0, "LDC_CL");
return;
}
}
super.visitLdcInsn(value);
}
后续主要是对于两个INVOKE
进行分析
- 如果
getDeclaredMethod
传入的是上文LDC
处设置的污点,认为方法返回值也是污点,给栈顶的返回值设置REFLECTION_METHOD
标志 - 如果
Method.invoke
方法中的Method
被标记了REFLECTION_METHOD
则可以确定这是内存马 - 开头一部分代码主要是根据方法参数的实际情况对参数在操作数栈中的索引位置进行确定,是一种动态和自动的确认方式,而不是直接根据经验或者调试写死索引,算是优雅写法
public void visitMethodInsn(int opcode, String owner, String name, String descriptor, boolean isInterface) {
Type[] argTypes = Type.getArgumentTypes(descriptor);
if (opcode != Opcodes.INVOKESTATIC) {
Type[] extendedArgTypes = new Type[argTypes.length 1];
System.arraycopy(argTypes, 0, extendedArgTypes, 1, argTypes.length);
extendedArgTypes[0] = Type.getObjectType(owner);
argTypes = extendedArgTypes;
}
boolean reflectionMethod = owner.equals("java/lang/Class") &&
opcode == Opcodes.INVOKEVIRTUAL && name.equals("getDeclaredMethod");
boolean methodInvoke = owner.equals("java/lang/reflect/Method") &&
opcode == Opcodes.INVOKEVIRTUAL && name.equals("invoke");
if (reflectionMethod) {
int targetIndex = 0;
for (int i = 0; i < argTypes.length; i ) {
if (argTypes[i].getClassName().equals("java.lang.String")) {
targetIndex = i;
break;
}
}
if (operandStack.get(argTypes.length - targetIndex - 1).contains("LDC_STRING")) {
super.visitMethodInsn(opcode, owner, name, descriptor, isInterface);
operandStack.set(TOP, "REFLECTION_METHOD");
return;
}
}
if (methodInvoke) {
int targetIndex = 0;
for (int i = 0; i < argTypes.length; i ) {
if (argTypes[i].getClassName().equals("java.lang.reflect.Method")) {
targetIndex = i;
break;
}
}
if (operandStack.get(argTypes.length - targetIndex - 1).contains("REFLECTION_METHOD")) {
super.visitMethodInsn(opcode, owner, name, descriptor, isInterface);
Result result = new Result();
result.setKey(owner);
result.setType(Result.CLASSLOADER_DEFINE);
results.add(result);
return;
}
}
super.visitMethodInsn(opcode, owner, name, descriptor, isInterface);
}
检测效果如下:
先写个内存马注入的Agent
注入到HttpServlet
中(关于这个不是文章重点)
然后跑起来我写的工具
- 其中红色框内是注入的
Agent
内存马,可以分析出 - 发现上面还有两个内存马结果,这是我模拟的普通内存马,直接写入到代码中做测试的
自动修复
接下来是内存马的修复,自行写一个Java Agent
即可
暂时只处理ApplicationFilterChain
和HttpServlet
的情况(也是最常见的情况)
public class RepairAgent {
public static void agentmain(String agentArgs, Instrumentation ins) {
ClassFileTransformer transformer = new RepairTransformer();
ins.addTransformer(transformer, true);
Class<?>[] classes = ins.getAllLoadedClasses();
for (Class<?> clas : classes) {
if (clas.getName().equals("org.apache.catalina.core.ApplicationFilterChain")
|| clas.getName().equals("javax.servlet.http.HttpServlet")) {
try {
ins.retransformClasses(clas);
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
}
处理的逻辑并不复杂
- 由于ApplicationFilterChain中包含了LAMBDA所以我直接简化了代码,变成简单的一句internalDoFilter(1,2)做修复(慎重选择,为什么这样做我将在总结里解释)
- 修改方法的参数需要用1 2这样表示,不能写req和resp
- 这里
HttpServlet
的情况稍复杂,其中有两个service
方法,实际上对任何一个进行修改都可以导致内存马的效果,所以我要做的事情是恢复这两个方法,而不是只针对某一个 - 注意任何非
java.lang
下的类都需要完整类名
public class RepairTransformer implements ClassFileTransformer {
@Override
public byte[] transform(ClassLoader loader,
String className,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer) {
className = className.replace("/", ".");
ClassPool pool = ClassPool.getDefault();
if (className.equals("org.apache.catalina.core.ApplicationFilterChain")) {
try {
CtClass c = pool.getCtClass(className);
CtMethod m = c.getDeclaredMethod("doFilter");
m.setBody("{internalDoFilter($1,$2);}");
byte[] bytes = c.toBytecode();
c.detach();
return bytes;
} catch (Exception e) {
e.printStackTrace();
}
}
if (className.equals("javax.servlet.http.HttpServlet")) {
try {
CtClass c = pool.getCtClass(className);
CtClass[] params = new CtClass[]{
pool.getCtClass("javax.servlet.ServletRequest"),
pool.getCtClass("javax.servlet.ServletResponse"),
};
CtMethod m = c.getDeclaredMethod("service", params);
m.setBody("{"
" javax.servlet.http.HttpServletRequest request;n"
" javax.servlet.http.HttpServletResponse response;n"
"n"
" try {n"
" request = (javax.servlet.http.HttpServletRequest) $1;n"
" response = (javax.servlet.http.HttpServletResponse) $2;n"
" } catch (ClassCastException e) {n"
" throw new javax.servlet.ServletException(lStrings.getString("http.non_http"));n"
" }n"
" service(request, response);"
"}");
CtClass[] paramsProtected = new CtClass[]{
pool.getCtClass("javax.servlet.http.HttpServletRequest"),
pool.getCtClass("javax.servlet.http.HttpServletResponse"),
};
CtMethod mProtected = c.getDeclaredMethod("service", paramsProtected);
mProtected.setBody("{"
"String method = $1.getMethod();n"
"n"
" if (method.equals(METHOD_GET)) {n"
" long lastModified = getLastModified($1);n"
" if (lastModified == -1) {n"
" doGet($1, $2);n"
" } else {n"
" long ifModifiedSince;n"
" try {n"
" ifModifiedSince = $1.getDateHeader(HEADER_IFMODSINCE);n"
" } catch (IllegalArgumentException iae) {n"
" ifModifiedSince = -1;n"
" }n"
" if (ifModifiedSince < (lastModified / 1000 * 1000)) {n"
" maybeSetLastModified($2, lastModified);n"
" doGet($1, $2);n"
" } else {n"
" $2.setStatus(javax.servlet.http.HttpServletResponse.SC_NOT_MODIFIED);n"
" }n"
" }n"
"n"
" } else if (method.equals(METHOD_HEAD)) {n"
" long lastModified = getLastModified($1);n"
" maybeSetLastModified($2, lastModified);n"
" doHead($1, $2);n"
"n"
" } else if (method.equals(METHOD_POST)) {n"
" doPost($1, $2);n"
"n"
" } else if (method.equals(METHOD_PUT)) {n"
" doPut($1, $2);n"
"n"
" } else if (method.equals(METHOD_DELETE)) {n"
" doDelete($1, $2);n"
"n"
" } else if (method.equals(METHOD_OPTIONS)) {n"
" doOptions($1, $2);n"
"n"
" } else if (method.equals(METHOD_TRACE)) {n"
" doTrace($1, $2);n"
"n"
" } else {n"
" String errMsg = lStrings.getString("http.method_not_implemented");n"
" Object[] errArgs = new Object[1];n"
" errArgs[0] = method;n"
" errMsg = java.text.MessageFormat.format(errMsg, errArgs);n"
"n"
" $2.sendError(javax.servlet.http.HttpServletResponse.SC_NOT_IMPLEMENTED, errMsg);n"
" }"
"}");
byte[] bytes = c.toBytecode();
c.detach();
return bytes;
} catch (Exception e) {
e.printStackTrace();
}
}
return new byte[0];
}
}
当我们写好了Agent
后,需要加入自动修复的逻辑
List<Result> results = Analysis.doAnalysis(files);
if (command.repair) {
RepairService.start(results, pid);
}
如果分析出了结果,且用户选择了修复功能,才会进入修复逻辑(暂只修复这两个最常见的类)
代码语言:javascript复制public static void start(List<Result> resultList, int pid) {
logger.info("try repair agent memshell");
for (Result result : resultList) {
String className = result.getKey().replace("/", ".");
if (className.equals("org.apache.catalina.core.ApplicationFilterChain") ||
className.equals("javax/servlet/http/HttpServlet")) {
try {
start(pid);
return;
} catch (Exception ignored) {
}
}
}
}
修复的核心代码:把打包好的Agent
拿过来,做一下Atach
和Load
将字节码替换为正常情况即可
public static void start(int pid) {
try {
String agent = Paths.get("RepairAgent.jar").toAbsolutePath().toString();
VirtualMachine vm = VirtualMachine.attach(String.valueOf(pid));
logger.info("load agent...");
vm.loadAgent(agent);
logger.info("repair...");
vm.detach();
logger.info("detach agent...");
} catch (Exception e) {
e.printStackTrace();
}
}
注意使用VirtualMachine
等API
需要加入tools.jar
,由于上文已经配置了打包插件,所以可以直接打入Jar
包,使用时候java -jar xxx.jar --pid 000
这样会比较方便
<dependency>
<groupId>com.sun.tools</groupId>
<artifactId>tools</artifactId>
<version>jdk-8</version>
<scope>system</scope>
<systemPath>${env.JAVA_HOME}/lib/tools.jar</systemPath>
</dependency>
通过以上这些修复手段可以做到的效果:
- 启动某SpringBoot应用
- 通过
Agent
注入内存马,访问后内存马可用 - 通过工具检测到内存马,尝试修改,使字节码被还原
- 再次访问后内存马失效,不需要重启
总结
关于Dump字节码
经过我的一些测试,使用sa-jdi
库不能保证dump
所有的字节码,会出现莫名其妙的异常,猜测是某些字节码不允许被dump
下来。但测试了常见Tomcat
和SpringBoot
等程序,发现基本没有问题
关于非法字节码
只要是包含LAMBDA
的字节码都是非法字节码,无法正常处理,需要用修改源码后的ASM
来做。这种方式终究不是完美的办法,是否存在能够dump
下来合法字节码的方式呢(经过一些尝试没有找到办法)
关于检测
可以看到,字节码分析的过程比较简单,尤其是Runtime.exec
的普通执行命令内存马,很容易绕过,但个人认为这已足够,因为之前的一些条件已经限制了分析的类是不可能包含Runtime.exec
的黑名单类,且大多数用户都是脚本小子,使用免杀型内存马的可能性不大。大多数用户可能直接用了现成的工具,例如冰蝎型内存马的检测方式已完成,暂时来看这样做是足够的,没有必要加入各种免杀检测手段
关于查杀
使用Agent恢复字节码的修复方式理论上没有问题。但其中的ApplicationFilterChain
类的doFilter
方法中包含了LAMBDA
和匿名内部类,这两者都是Javassist
框架不支持的内容,可以用ASM
来做,但可能难度较高
另外对于普通型内存马的修复,通过Agent技术只能覆盖方法体,不可以增加或删除方法。所以理论上可以根据方法的返回值类型,做返回NULL
的处理进行修复
关于拓展
例如代码中我定义的黑名单和关键字,可以根据实战经验自行添加新的类,以实现更完善的效果。在查杀方面我做了最常见的两种,可以根据实际情况自行添加更多的逻辑
最后
代码地址:https://github.com/4ra1n/FindShell