作者:Longofo@知道创宇404实验室
时间:2019年9月4日
原文链接:https://paper.seebug.org/1034/
起因
一开始是听@Badcode师傅说的这个工具,在Black Hat 2018的一个议题提出来的。这是一个基于字节码静态分析的、利用已知技巧自动查找从source到sink的反序列化利用链工具。看了几遍作者在Black Hat上的演讲视频与PPT,想从作者的演讲与PPT中获取更多关于这个工具的原理性的东西,可是有些地方真的很费解。不过作者开源了这个工具,但没有给出详细的说明文档,对这个工具的分析文章也很少,看到一篇平安集团对这个工具的分析,从文中描述来看,他们对这个工具应该有一定的认识并做了一些改进,但是在文章中对某些细节没有做过多的阐释。后面尝试了调试这个工具,大致理清了这个工具的工作原理,下面是对这个工具的分析过程,以及对未来工作与改进的设想。
关于这个工具
这个工具不是用来寻找漏洞,而是利用已知的source->…->sink链或其相似特征发现分支利用链或新的利用链。
这个工具是在整个应用的classpath中寻找利用链。
这个工具进行了一些合理的预估风险判断(污点判断、污点传递等)。
这个工具会产生误报不是漏报(其实这里还是会漏报,这是作者使用的策略决定的,在后面的分析中可以看到)。
这个工具是基于字节码分析的,对于Java应用来说,很多时候我们并没有源码,而只有War包、Jar包或class文件。
这个工具不会生成能直接利用的Payload,具体的利用构造还需要人工参与。
序列化与反序列化
序列化(Serialization)是将对象的状态信息转化为可以存储或者传输形式的过程,转化后的信息可以存储在磁盘上,在网络传输过程中,可以是字节、XML、JSON等格式;而将字节、XML、JSON等格式的信息还原成对象这个相反的过程称为反序列化。
在JAVA中,对象的序列化和反序列化被广泛的应用到RMI(远程方法调用)及网络传输中。
Java中的序列化与反序列化库
JDK(ObjectInputStream)
XStream(XML,JSON)
Jackson(XML,JSON)
Genson(JSON)
JSON-IO(JSON)
FlexSON(JSON)
Fastjson(JSON)
…
不同的反序列化库在反序列化不同的类时有不同的行为、被反序列化类的不同"魔术方法"会被自动调用,这些被自动调用的方法就能够作为反序列化的入口点(source)。如果这些被自动调用的方法又调用了其他子方法,那么在调用链中某一个子方法也可以作为source,就相当于已知了调用链的前部分,从某个子方法开始寻找不同的分支。通过方法的层层调用,可能到达某些危险的方法(sink)。
ObjectInputStream
例如某个类实现了Serializable接口,ObjectInputStream.readobject在反序列化类得到其对象时会自动查找这个类的readObject、readResolve等方法并调用。
例如某个类实现了Externalizable接口,ObjectInputStream.readobject在反序列化类得到其对象时会自动查找这个类的readExternal等方法并调用。
Jackson
ObjectMapper.readValue在反序列化类得到其对象时,会自动查找反序列化类的无参构造方法、包含一个基础类型参数的构造方法、属性的setter、属性的getter等方法并调用。
…
在后面的分析中,都使用JDK自带的ObjectInputStream作为样例。
控制数据类型=>控制代码
作者说,在反序列化漏洞中,如果控制了数据类型,我们就控制了代码。这是什么意思呢?按我的理解,写了下面的一个例子:
public class TestDeserialization {
interface Animal {
public void eat();
}
public static class Cat implements Animal,Serializable {
@Override
public void eat() {
System.out.println("cat eat fish");
}
}
public static class Dog implements Animal,Serializable {
@Override
public void eat() {
try {
Runtime.getRuntime().exec("calc");
} catch (IOException e) {
e.printStackTrace();
}
System.out.println("dog eat bone");
}
}
public static class Person implements Serializable {
private Animal pet;
public Person(Animal pet){
this.pet = pet;
}
private void readObject(java.io.ObjectInputStream stream)
throws IOException, ClassNotFoundException {
pet = (Animal) stream.readObject();
pet.eat();
}
}
public static void GeneratePayload(Object instance, String file)
throws Exception {
//将构造好的payload序列化后写入文件中
File f = new File(file);
ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream(f));
out.writeObject(instance);
out.flush();
out.close();
}
public static void payloadTest(String file) throws Exception {
//读取写入的payload,并进行反序列化
ObjectInputStream in = new ObjectInputStream(new FileInputStream(file));
Object obj = in.readObject();
System.out.println(obj);
in.close();
}
public static void main(String[] args) throws Exception {
Animal animal = new Dog();
Person person = new Person(animal);
GeneratePayload(person,"test.ser");
payloadTest("test.ser");
// Animal animal = new Cat();
// Person person = new Person(animal);
// GeneratePayload(person,"test.ser");
// payloadTest("test.ser");
}
}
为了方便我把所有类写在一个类中进行测试。在Person类中,有一个Animal类的属性pet,它是Cat和Dog的接口。在序列化时,我们能够控制Person的pet具体是Cat对象或者Dog对象,因此在反序列化时,在readObject中pet.eat()具体的走向就不一样了。如果是pet是Cat类对象,就不会走到执行有害代码
Runtime.getRuntime().exec(“calc”);这一步,但是如果pet是Dog类的对象,就会走到有害代码。
即使有时候类属性在声明时已经为它赋值了某个具体的对象,但是在Java中通过反射等方式依然能修改。如下:
public class TestDeserialization {
interface Animal {
public void eat();
}
public static class Cat implements Animal, Serializable {
@Override
public void eat() {
System.out.println("cat eat fish");
}
}
public static class Dog implements Animal, Serializable {
@Override
public void eat() {
try {
Runtime.getRuntime().exec("calc");
} catch (IOException e) {
e.printStackTrace();
}
System.out.println("dog eat bone");
}
}
public static class Person implements Serializable {
private Animal pet = new Cat();
private void readObject(java.io.ObjectInputStream stream)
throws IOException, ClassNotFoundException {
pet = (Animal) stream.readObject();
pet.eat();
}
}
public static void GeneratePayload(Object instance, String file)
throws Exception {
//将构造好的payload序列化后写入文件中
File f = new File(file);
ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream(f));
out.writeObject(instance);
out.flush();
out.close();
}
public static void payloadTest(String file) throws Exception {
//读取写入的payload,并进行反序列化
ObjectInputStream in = new ObjectInputStream(new FileInputStream(file));
Object obj = in.readObject();
System.out.println(obj);
in.close();
}
public static void main(String[] args) throws Exception {
Animal animal = new Dog();
Person person = new Person();
//通过反射修改私有属性
Field field = person.getClass().getDeclaredField("pet");
field.setAccessible(true);
field.set(person, animal);
GeneratePayload(person, "test.ser");
payloadTest("test.ser");
}
}
在Person类中,不能通过构造器或setter方法或其他方式对pet赋值,属性在声明时已经被定义为Cat类的对象,但是通过反射能将pet修改为Dog类的对象,因此在反序列化时依然会走到有害代码处。
这只是我自己对作者"控制了数据类型,就控制了代码"的理解,在Java反序列化漏洞中,很多时候是利用到了Java的多态特性来控制代码走向最后达到恶意执行目的。
魔术方法
在上面的例子中,能看到在反序列化时没有调用Person的readobject方法,它是ObjectInputStream在反序列化对象时自动调用的。作者将在反序列化中会自动调用的方法称为"魔术方法"。
使用ObjectInputStream反序列化时几个常见的魔术方法:
Object.readObject()
Object.readResolve()
Object.finalize()
…
一些可序列化的JDK类实现了上面这些方法并且还自动调用了其他方法(可以作为已知的入口点):
· HashMap
Object.hashCode()
Object.equals()
· PriorityQueue
Comparator.compare()
Comparable.CompareTo()
· …
一些sink:
Runtime.exec(),这种最为简单直接,即直接在目标环境中执行命令
Method.invoke(),这种需要适当地选择方法和参数,通过反射执行Java方法
RMI/JNDI/JRMP等,通过引用远程对象,间接实现任意代码执行的效果
…
作者给出了一个从Magic Methods(source)->Gadget Chains->Runtime.exec(sink)的例子:
上面的HashMap实现了readObject这个"魔术方法",并且调用了hashCode方法。某些类为了比较对象之间是否相等会实现equals方法(一般是equals和hashCode方法同时实现)。从图中可以看到AbstractTableModelKaTeX parse error: Expected group after '_' at position 58: …是IFn对象,并且f能通过属性_̲_clojureFnMap获取…ff19274a.hashcode中的f.invoke具体选择IFn的哪个实现类,根据后面对这个工具的测试以及对决策原理的分析,广度优先会选择短的路径,也就是选择了FnEval.invoke,所以这也是为什么要人为参与,在后面的样例分析中也可以看到)。
有了这条链,只需要找到触发这个链的漏洞点就行了。Payload使用JSON格式表示如下:
{
"@class":"java.util.HashMap",
"members":[
2,
{
"@class":"AbstractTableModel$ff19274a",
"__clojureFnMap":{
"hashcode":{
"@class":"FnCompose",
"f1":{"@class","FnConstant",value:"calc"},
"f2":{"@class":"FnEval"}
}
}
}
]
}
gadgetinspector工作流程
如作者所说,正好使用了五个步骤:
// 枚举全部类以及类的所有方法
if (!Files.exists(Paths.get("classes.dat")) || !Files.exists(Paths.get("methods.dat"))
|| !Files.exists(Paths.get("inheritanceMap.dat"))) {
LOGGER.info("Running method discovery...");
MethodDiscovery methodDiscovery = new MethodDiscovery();
methodDiscovery.discover(classResourceEnumerator);
methodDiscovery.save();
}
//生成passthrough数据流
if (!Files.exists(Paths.get("passthrough.dat"))) {
LOGGER.info("Analyzing methods for passthrough dataflow...");
PassthroughDiscovery passthroughDiscovery = new PassthroughDiscovery();
passthroughDiscovery.discover(classResourceEnumerator, config);
passthroughDiscovery.save();
}
//生成passthrough调用图
if (!Files.exists(Paths.get("callgraph.dat"))) {
LOGGER.info("Analyzing methods in order to build a call graph...");
CallGraphDiscovery callGraphDiscovery = new CallGraphDiscovery();
callGraphDiscovery.discover(classResourceEnumerator, config);
callGraphDiscovery.save();
}
//搜索可用的source
if (!Files.exists(Paths.get("sources.dat"))) {
LOGGER.info("Discovering gadget chain source methods...");
SourceDiscovery sourceDiscovery = config.getSourceDiscovery();
sourceDiscovery.discover();
sourceDiscovery.save();
}
//搜索生成调用链
{
LOGGER.info("Searching call graph for gadget chains...");
GadgetChainDiscovery gadgetChainDiscovery = new GadgetChainDiscovery(config);
gadgetChainDiscovery.discover();
}
Step1 枚举全部类以及每个类的所有方法
要进行调用链的搜索,首先得有所有类及所有类方法的相关信息:
public class MethodDiscovery {
private static final Logger LOGGER = LoggerFactory.getLogger(MethodDiscovery.class);
private final List<ClassReference> discoveredClasses = new ArrayList<>();//保存所有类信息
private final List<MethodReference> discoveredMethods = new ArrayList<>();//保存所有方法信息
...
...
public void discover(final ClassResourceEnumerator classResourceEnumerator) throws Exception {
//classResourceEnumerator.getAllClasses()获取了运行时的所有类(JDK rt.jar)以及要搜索应用中的所有类
for (ClassResourceEnumerator.ClassResource classResource : classResourceEnumerator.getAllClasses()) {
try (InputStream in = classResource.getInputStream()) {
ClassReader cr = new ClassReader(in);
try {
cr.accept(new MethodDiscoveryClassVisitor(), ClassReader.EXPAND_FRAMES);//通过ASM框架操作字节码并将类信息保存到this.discoveredClasses,将方法信息保存到discoveredMethods
} catch (Exception e) {
LOGGER.error("Exception analyzing: " + classResource.getName(), e);
}
}
}
}
...
...
public void save() throws IOException {
DataLoader.saveData(Paths.get("classes.dat"), new ClassReference.Factory(), discoveredClasses);//将类信息保存到classes.dat
DataLoader.saveData(Paths.get("methods.dat"), new MethodReference.Factory(), discoveredMethods);//将方法信息保存到methods.dat
Map<ClassReference.Handle, ClassReference> classMap = new HashMap<>();
for (ClassReference clazz : discoveredClasses) {
classMap.put(clazz.getHandle(), clazz);
}
InheritanceDeriver.derive(classMap).save();//查找所有继承关系并保存
}
}
来看下classes.dat、methods.dat分别长什么样子:
classes.dat
找了两个比较有特征的