【问题排查】【⭐⭐】kryo4的反序列化BUG

起因

书接上回:【问题排查】【⭐⭐】简约而不简单的StringIndexOutOfBoundsException

其实当时排查已经接近正确答案了,但仍然没有查清楚是具体哪个序列化环节出错,所以本文继续深入探索到底问题出在哪。

排查

kryo序列化流程

当我们知道是序列化框架问题的时候就好办了。

先来梳理一下kryo的调用顺序是什么样的(其实一开始是不知道的,是调试的时候一点一点看出来的:cry:):

模拟环境

由于上回排查问题时已经知道问题会出现在版本差异比较大的jar包之间,所以这次也是选用了差异比较大的两个版本。

为了展示例子足够简单和尽量不涉及敏感信息,本文对结构进行了简化。

旧版本Class结构

class Outer {
	String name;
	List<Inner> innerList;
}

class Inner {
	Integer count;
	String userType;
	String idNo;
    // String agreementType; 对此字段进行注释,相当于旧版本没有这个字段
}
复制代码

新版本Class结构

class Outer {
	String name;
	List<Inner> innerList;
}

class Inner {
	Integer count;
	String userType;
	String idNo;
    String agreementType;
}
复制代码

模拟新版本Class序列化,旧版本Class反序列化

序列化操作代码

private static String encode() {
    Kryo kryo = new Kryo();
    kryo.setDefaultSerializer(CompatibleFieldSerializer.class);

    Output output = new Output(256, -1);
    kryo.writeClassAndObject(output, newInstance());

    return Base64.getEncoder().encodeToString(output.toBytes());
}

private static Outer newInstance() {
    List<Inner> innerList = new LinkedList<>();
    for (int i = 0; i < 10; i++) {
        Inner inner = new Inner();
        inner.count = i;
        inner.agreementType = "1";
        inner.idNo = "350103";
        inner.userType = "01";
        innerList.add(inner);
    }

    Outer outer = new Outer();
    outer.name = "out";
    outer.innerList = innerList;
    return outer;
}
复制代码

调用上面encode得到一串base64字符串

AQBjb20uZXNvdGVyaWNzb2Z0d2FyZS5rcnlvLnNlcmlhbGl6ZXJzLk91dGXyAQJpbm5lckxpc/RuYW3laQEBamF2YS51dGlsLkxpbmtlZExpc/QBCgECY29tLmVzb3Rlcmljc29mdHdhcmUua3J5by5zZXJpYWxpemVycy5Jbm5l8gEEYWdyZWVtZW50VHlw5WNvdW70aWRO73VzZXJUeXDlAwGCMQQAAgEACQAHATM1MDEwswUAAwEwsQYAAQIBAQUEAAIBAgMAAQYDAAEHBgABAgEBBQQAAgEEAwABBgMAAQcGAAECAQEFBAACAQYDAAEGAwABBwYAAQIBAQUEAAIBCAMAAQYDAAEHBgABAgEBBQQAAgEKAwABBgMAAQcGAAECAQEFBAACAQwDAAEGAwABBwYAAQIBAQUEAAIBDgMAAQYDAAEHBgABAgEBBQQAAgEQAwABBgMAAQcGAAECAQEFBAACARIDAAEGAwABBwEAAAQBb3X0AA==
复制代码

因为这个框架都是直接写入或读出bytes,所以乍一看根本看不出这字符串有问题。接着我们拿着这串字符串反序列化。

反序列化操作代码

private static Object decode(String base64) {
    byte[] decode = Base64.getDecoder().decode(base64);

    Kryo kryo = new Kryo();
    kryo.setDefaultSerializer(CompatibleFieldSerializer.class);

    Input input = new Input(decode);
    kryo.setClassLoader(Thread.currentThread().getContextClassLoader());
    Object o = kryo.readClassAndObject(input);

    return o;
}
复制代码

得到的对象toString后

Outer{name='out', innerList=[Inner{count=0, userType='01', idNo='350103'}, Inner{count=1, userType='', idNo='01'}, Inner{count=2, userType='', idNo='01'}, Inner{count=3, userType='', idNo='01'}, Inner{count=4, userType='', idNo='01'}, Inner{count=5, userType='', idNo='01'}, Inner{count=6, userType='', idNo='01'}, Inner{count=7, userType='', idNo='01'}, Inner{count=8, userType='', idNo='01'}, Inner{count=9, userType='', idNo='01'}]}
复制代码

发现问题了吗?

list中从第1位开始往后的对象里字段userTypeidno的值错了。我们刚刚让所有对象的idNo=350103的。这怎么回事?

调试

我们是知道我们自己使用的是什么序列化器的,那么直接将断点打在read方法里

github地址:github.com/EsotericSof…

上面链接是read方法,方法内部完整代码太长就不贴了,我将反序列化过程用文字描述:

  1. 实例化我们要反序列化的对象
  2. 找到byte数据中所有此类的字段名
  3. 根据字段名找到字段包装类(如果字段太多,会启用二分搜索)
  4. 使用字段包装类还原字段值
    1. 如果字段值是原始类型类的数据(不可继续深度遍历的),反射设置到对象的字段中
    2. 如果字段值是包装类(里面还有其他字段的),递归1~4步骤

根据上面模拟测试结果发现idNo=’01’,而01是我们设置给userType的。再加上我们是减少了个字段,初步判断是值读错位了!

反序列化

首先跟断点至innerList[0].userType,也就是第一次反序列化Inner.userType。

经历了以下步骤:

  1. 初始化准备工作(校验、初始化等等)
  2. 判断是否是引用,是
  3. 尝试读取引用中的数据,如读到则直接返回对象
  4. 读不到引用中的数据时,从原始数据中反序列化后放入引用中
  5. 返回对象

引用在这里的作用相当于一个“缓存”,默认有两种实现ListReferenceResolverMapReferenceResolver。如果相同对象相同值,kryo偷懒地将引用中的值直接返回,间接地节省了重复反序列化的耗时。

第二次跟踪端点至innerList[1].userType

经历了以下步骤:

  1. 初始化准备工作(校验、初始化等等)
  2. 判断是否是引用,是
  3. 尝试读取引用中的数据,读到后返回对象

这流程看起来没问题啊?但请看下此时从引用中读到的对象是什么

readObject=Inner对象,而userType实际类型是String。把Inner对象塞给String,这简直在乱来!

读取引用

id是引用的坐标,可以看成是key

referenceResolver是维护引用的类,可以看成是map(实际上里面的实现是list)

方法的大致意思:

  1. 获取key
  2. value = map.get(key)
  3. return value

错位就发生在第一步

看代码831行,id = input.readVarInt(true)此操作从原始数据中读取id,意味着id是序列化时生成的。 但我们的原始数据是用新Class生成的,新Class是有agreementType字段的,而旧Class没有该字段。如果以list作为引用维护对象,相当于字段增加或缺少N个,后续的索引会整体向左或向右移动N位。

以下图为例(实际代码和这个很像,但不完全一样,比如下标的生成,这里只是举个例子):

我们将inner.c字段删除。inner.d和inner.e顺位向左移动1格。那么在反序列化inner.d时,按照序列化时的id应当是4。接着按照id=4在旧Class生成的引用数组寻找id=4的值,值为”ee”。将”ee”赋值inner.d,从而发生了错位。

结论

触发条件

当开启引用时,引用对象在新旧版本发生增减则会导致兼容性问题。

kryo4在单独使用CompatibleFieldSerializer时没有问题,比如这样的结构:

{
    "user": {
        "name": "zhangsan",
        "son": {
            "name": "zhangsi",
            "son": {
                "name": "zhangwu"
            }
        }
    }
}
复制代码

但是CompatibleFieldSerializer结合CollectionSerializer使用时,新旧版本Class有新增或减少字段,就会出现字段读取错位的情况,比如这样的结构:

{
    "list": [
        {
            "name": "zhangsan",
            "age": 20,
        },
        {
            "name": "lisi",
            "age": 25,
        }
    ]
}
复制代码

解决方法

关闭引用

Kryo kryo = new Kryo();
kryo.setReferences(false);
复制代码

结果正常

Outer{name='out', innerList=[Inner{count=0, userType='01', idNo='350103'}, Inner{count=1, userType='01', idNo='350103'}, Inner{count=2, userType='01', idNo='350103'}, Inner{count=3, userType='01', idNo='350103'}, Inner{count=4, userType='01', idNo='350103'}, Inner{count=5, userType='01', idNo='350103'}, Inner{count=6, userType='01', idNo='350103'}, Inner{count=7, userType='01', idNo='350103'}, Inner{count=8, userType='01', idNo='350103'}, Inner{count=9, userType='01', idNo='350103'}]}
复制代码

升级至kryo5

由于kryo5在kryo4的基础上做了破坏性的改动,比如:

com.esotericsoftware.kryo.io.Output#writeInt

  • 调换写入顺序(完全不想兼容旧版本了属于是)
  • 减少posistion赋值动作
// kryo4: com.esotericsoftware.kryo.io.Output#writeInt
public void writeInt (int value) throws KryoException {
    require(4);
    byte[] buffer = this.buffer;
    buffer[position++] = (byte)(value >> 24);
    buffer[position++] = (byte)(value >> 16);
    buffer[position++] = (byte)(value >> 8);
    buffer[position++] = (byte)value;
}

// kryo5: com.esotericsoftware.kryo.io.Output#writeInt
public void writeInt (int value) throws KryoException {
    require(4);
    byte[] buffer = this.buffer;
    int p = position;
    position = p + 4;
    buffer[p] = (byte)value;
    buffer[p + 1] = (byte)(value >> 8);
    buffer[p + 2] = (byte)(value >> 16);
    buffer[p + 3] = (byte)(value >> 24);
}
复制代码

两个版本间生成的数据大概率是不能兼容的,原话:

Moving from v2 to v5 or from v4 to v5 is about the same: the newer Kryo version is unlikely to be able to read bytes written with the older version.

对于不能向下兼容的升级是很痛苦的。而且dubbo3已经把kryo从序列化支持里移除了。

参考

  1. 迁移文档 github.com/EsotericSof…
  2. Buffer underflow while deserializing with Collection type field removed (with reproducible test case) github.com/EsotericSof…
© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享