01 介绍
内存马如何检测一直是比较麻烦的问题,前有 c0ny1 师傅编写的 java-memshell-scanner (https://github.com/c0ny1/java-memshell-scanner)工具,以及其他师傅写的基于 Arthas 框架的查杀工具。曾经我也写过一款名为 FindShell 的工具,在网上可以看到一些师傅研究使用我的工具并写文章(https://juejin.cn/post/7153546553074909198)
回到主题,曾经的内存马查杀项目都有各自的问题,例如 jsp 版查杀工具需要目标能够解析 jsp 文件,且需要用户上传 jsp 文件,显得有一些复杂。基于 Arthas 框架查杀似乎杀鸡用牛刀,过于重型不够轻便。我自己的 FindShell 也有不足之处,只能检测 agent 类型,以及一些潜在的 bug 问题
于是我花了一天一夜的时间,写了一个简单的工具
工具目前有很多问题,需要较长的时间逐渐完善。等到真正完善的那一套,我会开源到 Github 中,敬请期待
02 整体架构
Java Agent 绝大多数情况用于修改字节码(retransformClasses)
例如 Agent 内存马以及其他师傅文章提到的修复内存马方案,其实都是用于一次性地通过 Java Agent 动态 Attach 到目标然后注入内存马,或者通过修改 doFilter 等关键方法的返回值来删除内存马
如图所示,我这里用到了一种持久的思路,通过 Java Agent 启动一个 Socket Server 监听请求并返回。通过不同的指令/命令执行不同的操作,并返回对应的信息,在 shell-analyzer 端处理信息并渲染
暂时我定义了六种命令,前五种是根据关键字返回对应类,最后一种是根据完整类名获得对应的字节码,并将字节码序列化数据返回到 shell-analyzer 中
03 代码实现
Talk is cheap. Show me the code.
我们可以通过 tools.jar 的方法拿到所有的 Java 进程与 PID
List<VirtualMachineDescriptor> list = VirtualMachine.list();
for (VirtualMachineDescriptor v : list) {
ProcessObj p = new ProcessObj();
p.setId(v.id());
String t = v.displayName();
}
将这里的 PID 和 类名显示给用户选择,指定 Java Agent 包通过 loadAgent 动态 Attach 到目标 Java 进程
VirtualMachine vm = VirtualMachine.attach(pid);
String path = "your_path_to_agent";
vm.loadAgent(path);
vm.detach();
重点应该关注 Java Agent 程序如何编写
public static Instrumentation staticIns;
public static Class<?>[] staticClasses;
@SuppressWarnings("all")
public static void agentmain(String agentArgs, Instrumentation ins) {
staticIns = ins;
staticClasses = (Class<?>[]) ins.getAllLoadedClasses();
new Thread(() -> {
try {
// 10032号
int port = 10032;
ServerSocket ss = new ServerSocket(port);
while (true) {
Socket socket = ss.accept();
new Thread(new Task(socket)).start();
}
} catch (Exception ex) {
ex.printStackTrace();
}
}).start();
}
动态 Java Agent 的 agentmain 入口中我们可以拿到 Instrumentation 对象,保存了当前 JVM 中加载的所有类。声明 ins 静态字段保存,供后续使用,如果不保存当第一次加载执行完 agentmain 后将无法获得需要的信息
04 普通命令
部分代码借鉴 c0ny1 师傅的博客,为了方便我采用了直接反序列化 Socket 中得到的输入流,得到请求命令。以 <FILTERS> 命令为例,通过 ClassLoader 拿到 Filter 类对线,再通过 isAssignableFrom 方法判断某一个类是否是 Filter 子类。Servlet等方式的内存马类似,遍历所有的 Class 拿到符合要求的部分,将类名添加到 List 构造序列化数据发送到客户端
ObjectInputStream ois = new FilterObjectInputStream(socket.getInputStream());
String targetClass = (String) ois.readObject();
if (targetClass.equals("<FILTERS>")) {
List<String> classList = new ArrayList<>();
for (Class<?> c : Agent.staticClasses) {
try {
ClassLoader classLoader;
if (c.getClassLoader() != null) {
classLoader = c.getClassLoader();
} else {
classLoader = Thread.currentThread().getContextClassLoader();
}
Class<?> clsFilter = null;
try {
clsFilter = classLoader.loadClass("javax.servlet.Filter");
} catch (Exception ignored) {
}
if (clsFilter != null && clsFilter.isAssignableFrom(c)) {
classList.add(c.getName());
}
} catch (Exception ignored) {
}
}
ByteArrayOutputStream bao = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(bao);
oos.writeObject(classList);
System.out.println("write data to socket: " + classList.size());
socket.getOutputStream().write(bao.toByteArray());
return;
}
为了安全考虑,可以对监听的10032端口进行保护,加入反序列化黑名单,只允许反序列化字符串和字符串数组类型
public class FilterObjectInputStream extends ObjectInputStream {
public FilterObjectInputStream(InputStream in) throws IOException {
super(in);
}
@Override
protected Class<?> resolveClass(final ObjectStreamClass classDesc) throws IOException, ClassNotFoundException {
if (classDesc.getName().equals("[Ljava.lang.String;") ||
classDesc.getName().equals("java.lang.String")) {
return super.resolveClass(classDesc);
}
throw new RuntimeException(String.format("not support class: %s", classDesc.getName()));
}
}
05 获得字节码
不同于普通命令,想要拿到具体某个类的字节码稍麻烦一些,需要借助 Transformer 类的方法
for (Class<?> c : Agent.staticClasses) {
if (c.getName().equals(targetClass)) {
CoreTransformer coreTransformer = new CoreTransformer(targetClass);
Agent.staticIns.addTransformer(coreTransformer, true);
Agent.staticIns.retransformClasses(c);
if (coreTransformer.data != null && coreTransformer.data.length != 0) {
ByteArrayOutputStream bao = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(bao);
oos.writeObject(coreTransformer.data);
System.out.println("write data to socket: " + coreTransformer.data.length);
socket.getOutputStream().write(bao.toByteArray());
}
}
}
其中 CoreTransformer 类的实现如下,当找到我们希望的类时,写入字节码到 data 变量中,当主动触发上文 retransformClasses 方法后,会调用每一个Transformer的 transform 方法
public class CoreTransformer implements ClassFileTransformer {
private final String targetClass;
public byte[] data;
public CoreTransformer(String targetClass) {
this.targetClass = targetClass;
}
@Override
public byte[] transform(ClassLoader loader,
String className,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer) {
className = className.replace("/", ".");
if (className.equals(targetClass)) {
System.out.println("get bytecode form: " + className);
data = new byte[classfileBuffer.length+1];
System.arraycopy(classfileBuffer, 0, data, 0, classfileBuffer.length);
System.out.println("bytecode length: "+data.length);
}
return classfileBuffer;
}
}
06 客户端代码
客户端的代码就很简单了,用户点击 UI 界面的某个按钮后,调用这里的 getAllFilters 方法,返回 arrayList 并渲染到 UI 界面上
public static List<String> getAllFilters() throws Exception {
String host = "127.0.0.1";
int port = 10032;
Socket client = new Socket(host, port);
ByteArrayOutputStream bao = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(bao);
oos.writeObject("<FILTERS>");
client.getOutputStream().write(bao.toByteArray());
ObjectInputStream ois = new ObjectInputStream(client.getInputStream());
ArrayList<String> arrayList = (ArrayList<String>) ois.readObject();
return arrayList;
}
请求字节码时,因为 FernFlower 的 Console 反编译方式需要使用本地文件,所以读取序列化数据中的字节后,保存到临时文件
public static void getBytecode(String className) throws Exception {
String host = "127.0.0.1";
int port = 10032;
Socket client = new Socket(host, port);
ByteArrayOutputStream bao = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(bao);
oos.writeObject(className);
client.getOutputStream().write(bao.toByteArray());
ObjectInputStream ois = new ObjectInputStream(client.getInputStream());
byte[] data = (byte[]) ois.readObject();
Files.write(Paths.get("test.class"), data);
}
通过 FernFlower 的反编译接口得到 Java 代码并显示给用户
String[] args = new String[]{classPath,javaDir};
ConsoleDecompiler.main(args);
07 Agent 内存马
对于 Agent 内存马的检测,参考我编写的 FindShell 工具,需要接触 JDK 的 sa-jdi 高级调试工具,以下载被 Java Agent retransformClasses 修改过的字节码。但 sa-jdi 这个工具存在一些小问题,在接下来的文章中分享
目前对于 Java Agent 内存马的检测,办法是尝试下载四种常见位置的类,也是网上绝大多数 Agent 内存马的位置,调用 sa-jdi 工具的代码,自动 Dump 常见 Agent 内存马类的字节码并分析
08 一些想法
可以看到,我目前的进展主要是如何动态地获得目标 JVM 中的类和字节码信息,并且结合人工审计的方式来处理。对于进一步的开发,首先要做的是完善已有的功能,例如右上角的配置功能其实是废的,可以在客户端收到服务端相应之后处理数据,这可能比 Java Agent 端进行过滤更稳定且简洁
当基础功能完善后,可以做以下几点:
(1)在客户端加入一些简单的判断条件,例如不寻常的类名。在客户端拿到字节码之后,使用 ASM 直接分析,如果有 Runtime/ProcessBuilder/JNDI lookup 等危险 INVOKE 指令,应当重点标注
(2)在 Agent 服务端加入新的判断,可以结合 ASM 直接分析某一个类的方法中是否包含了恶意的指令。但实际上我不太喜欢在 Agent 端做太多的事情,因为 Agent 端潜在地不稳定且无法调试
(3)当反编译 Java 代码后确认这是一个内存马,应该有一个按钮可以自动修复,通过 Javassist 等方式 Hook 掉 doFilter 等方法。对于 Agent 内存马,直接修改原有的危险类为 Tomcat/Spring 的源码即可
最后,我个人的精力终究有限,且我对 Java 安全的了解还只是皮毛,缺少实战经验。如果有开发或者实战大佬愿意帮助完善项目,我会很感谢!