62

蚂蚁技术团队:传输币种被篡改问题排查

 5 years ago
source link: http://www.10tiao.com/html/476/201806/2651820621/1.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.


内容来源:本文转载自战略合作伙伴  金融级分布式架构 (ID:Antfin_SOFA



责编 | 菱酒

 第  707  篇技术好文:7772字 | 18分钟阅读


导读  

本文将分享一个在 RPC 调用过程中,传输币种信息被篡改问题的排查过程,借此跟大家分享一些排查问题的思路和经验。


本文这个问题只能在预发环境复现,用到的排查工具为蚂蚁内部研发的工具,但大家也可以使用其他类似的工具,比如 greys 等来做替代。以下文章中用到的工具将称为 greys。如果线下遇到疑难问题,能够复现的,也可以直接远程 debug 来排查。本文主要还是想分享以下问题的排查思路,不局限于某些工具的使用。

01

问题描述

_____


首先简要介绍下,出现问题的系统链路可以简化为:

Client 直接调用 Server,可以看到 Server 返回了某个对象币种是 CNY,但是 Client 从自己的调用结果处看到的是 USD。

业务同学反馈是线上的问题,应用运行一段时间后出现,一段时间后又会恢复正常,线下未能复现。 

在 Client 机器上,通过 curl 简单测试这个接口,发现返回结果确实是 USD,不符合预期。debug 下游系统 Server 时返回的是 CNY,但是在 Client 这个调用端里面拿到的却是 USD。 当时通过远程 debug 和 greys 都看到了这个情况,以下是直接观察 Server 的实现类返回结果的命令:

  1. watch com.alipay.server.QueryFacadeImpl convert "{params,returnObj}" -x 4

02

复现步骤

_____


这种问题无法通过现有的中间件或者业务日志进行排查,需要有可以复现的环境。经过业务同学的尝试和观察,发现系统运行一段时间后,这个问题会复现。方便起见,可以直接通过 xfire (历史兼容的调用方式)触发以下操作复现:

  1. curl -v -d '

  2. <Envelope xmlns="http://schemas.xmlsoap.org/soap/envelope/">;

  3.    <Body>

  4.        <queryList xmlns="http://quotation.api.facade.service.common.client.alipay.com">;

  5.            <in0>

  6.                <userId xmlns="http://request.facade.service.common.client.alipay.com">用户</userId>;

  7.            </in0>

  8.        </queryList>

  9.    </Body>

  10. </Envelope>' -H  "Content-Type:  text/xml;charset=UTF-8  "  -H  "SOAPAction:"  [http://127.0.0.1:8080/client/queryFacade](http://127.0.0.1:8080/client/queryFacade) | xmllint --format - | sed 's/ xmlns=".*"//g'

03

排查

_____


排除业务自己的操作

跟相关同学确认 Server 目标系统本周未发布,但 Client 有变更发布。

对 Client 和 Server 的代码都进行了阅读,可以非常明确地确认,Client 没有任何转换结果的操作,而 Server 线上一直正常。且从现象看到 Server 返回的结果确实正确,而且 Server 的这段返回代码通过 Git 记录可以看到,已经上线运行两年,基本排除 Server 端代码的问题。

同时,jad 反编译看了一下运行中的类代码:

可以看到关键位置代码确实没有任何转换操作,直接调用了 Server 的接口,然后就返回了结果。这个原因可以排除了。

确认是否有业务的拦截器干扰

再确认一下拦截器的干扰。因为拦截器有时候写的非常泛化,比如很多开发同学会配置一个 *Facade 用来拦截所有的对外提供的服务,所以还是要确认下是否有拦截器的特殊操作。

Client 端执行下以命令:

  1. stack com.alipay.client.QueryFacadeClientImpl queryList

可以看到通过 WS 请求的调用栈没什么异常,可以排除这个影响:

  1. $ stack com.alipay.client.QueryFacadeClientImpl queryList

  2. PressCtrl+C to abort.

  3. Affect(class-cnt:1 , method-cnt:1) cost in 81 ms.

  4. ts=2017-11-2213:26:39;thread_name=xxxThreadPool.1--22;id=ac;is_daemon=false;priority=5;TCCL=com.alipay.cloudengine.extensions.equinox.KernelAceClassLoader@48e5707f

  5.    @sun.reflect.GeneratedMethodAccessor209.invoke()

  6.        at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:25)

  7.        at java.lang.reflect.Method.invoke(Method.java:597)

  8.        以下省略.
           


排查是否是故障注入


蚂蚁中间件在框架中做了埋点,允许在授权的情况下进行一些故障的注入模拟,因此也进行了排查。

通过检查服务端和客户端的 /logs/appName/sla*.log 相关的日志,可以确认并没有故障故障相关的操作,相关同学从故障注入管控台也同步进行了确认,这一项也可以排除。

排查是否PRC框架序列化本身的问题

前面看到服务端业务返回正常,那么正常处理的下一步就是服务端将业务的 response 序列化成二进制,然后传输给客户端。客户端的 RPC 再将这个二进制数据进行反序列化成 response,所以两端 debug :

通过 greys 在客户端查看反序列化的结果信息:

  1. watch com.alipay.remoting.rpc.protocol.RpcResponseCommand deserializeContent 'target.responseObject.appResponse' -x 4

同时在服务端看一下序列化 reponse 的信息:

  1. watch com.alipay.remoting.rpc.protocol.RpcResponseCommand serializeContent 'target.responseObject.appResponse' -x 4

查看服务端序列化结果的时候 appResponse 是对的,注意看currency=@Currency[CNY] 这个字段:

  1. serialVersionUID=@Long[4967706468767245519],

  2. stockId=@String[2026],

  3. date=@Date[2017-11-2214:00:00,000],

  4. price=@XXXMoney[

  5.   serialVersionUID=@Long[-4428879431422757138],

  6.   amount=@BigDecimal[656.80],

  7.   DEFAULT_CURRENCY_CODE=@String[CNY],

  8.   DEFAULT_SCALE=@Integer[4],

  9.   DEFAULT_ROUNDING_MODE=@Integer[6],

  10.   currency=@Currency[CNY],

  11.   scale=@Integer[2],

  12. ],

对比了一下客户端和服务端返回的结果:

可以很清楚地看到反序列化完成时,客户端收到的结果就已经变了。注意看图中的 currency 字段。

同时说明服务端序列化是没有问题的,问题可能出在客户端反序列化,或者网络传输的过程中。

TCP抓包判断网络

由于序列化之后就是 remoting 的传输,所以需要进行抓包,来确认两端的数据传输是否有问题。

在客户端抓包命令中的 IP 是服务端 IP

  1. sudo tcpdump -i eth0 -nnvvX 'dst host serverIp'

服务端抓包命令中的 IP 是客户端 IP

  1. sudo tcpdump -i eth0 -nnvvX 'dst host clientIp'

抓完包直接拷贝到本地对比 16 进制,进行 diff :

排除数据的位置问题,关键位置 TCP 包完全一致,问题再次回到序列化。

为了验证 TCP 的二进制包确实没有问题,将客户端收到的 TCP 包本地保存,然后以 byte 数组的方式读入内存:

然后通过 RPC 中 Hessian 的反序列化进行操作:

  1. /**

  2. * Alipay.com Inc. Copyright (c) 2004-2017 All Rights Reserved.

  3. */

  4. package com.alipay.demo.test;

  5. import com.caucho.hessian.io.Hessian2Input;

  6. import org.apache.commons.io.FileUtils;

  7. import org.apache.log4j.Logger;

  8. import java.io.ByteArrayInputStream;

  9. import java.io.File;

  10. import java.io.IOException;

  11. import java.util.Currency;

  12. /**

  13. * @author bystander

  14. * @version $Id: DecTest.java, v 0.1 2017年11月22日 下午3:16 bystander Exp $

  15. */

  16. publicclassDecTest {

  17.    privatestatic final Logger LOGGER = Logger

  18.            .getLogger(DecTest.class.getName());

  19.    publicstaticvoid main(String[] t) {

  20.        String result = "";

  21.        String lines = null;

  22.        try {

  23.            lines = FileUtils.readFileToString(newFile("~/Downloads/byte.txt"));

  24.        } catch (IOException e) {

  25.            e.printStackTrace();

  26.        }

  27.        String[] arr = lines.split(",");

  28.        byte[] bytes = new byte[arr.length];

  29.        for (int i = 0; i < arr.length; i++) {

  30.            bytes[i] = Byte.parseByte(arr[i]);

  31.        }

  32.        XXXSerializerFactory serializerFactory = newXXXSerializerFactory();

  33.        ByteArrayInputStream input = newByteArrayInputStream(

  34.                bytes);

  35.        Hessian2Input hessianInput = newHessian2Input(input);

  36.        hessianInput.setSerializerFactory(serializerFactory);

  37.        try {

  38.            Object object = hessianInput.readObject();

  39.            System.out.println(object);

  40.        } catch (IOException e) {

  41.            e.printStackTrace();

  42.        }

  43.    }

  44. }

结果反序列化正确,可以看到确实还是 CNY , TCP 包传输网络无问题,客户端收到的包也没有问题

再看序列化可能的错误

由于只有 Currency 有问题,所以和组内同学经过一系列讨论,反序列化时可以看下客户端 Currency 被调用的情况,直接在服务器上 debug :

  1. watch java.util.Currency getInstance "{params,returnObj.currencyCode}" -x 2


debug 的结果:


图中每一块,上半部分是方法调用入参,下半部分是返回结果的币种信息。

这时候,意想不到的情况出现了。Currency 传进去的入参是 CNY,返回结果却是 USD(业务反馈有时候也会变成其他币种,比如这里复现的时候变成了 HKD),问题基本定位。

然后需要阅读下 Currency 的实现,核心的一个代码段是如下的部分:

  1. // class data: instance map

  2. privatestaticHashMap instances = newHashMap(7);

这个信息是存储在内部的 Map 中,怀疑被其他操作改掉了,同时重新触发了 USD 等币种的查询:

发现是没有问题的,准备 dump 一下内存,看下内存中这个 Currency 内部的  Map 中都有什么值,对 dump 内存进行确认:

可以看到,这个 Map 确实改变了,里面有四个值,CNY 对应的值已经是错误的了。

PS:XXX 是 Currency 中用来表示无币种的情况,是由hadoop的类初始化的,跟本问题无关

  1. https://en.wikipedia.org/wiki/ISO_4217

  2. The code XXX is used to denote a "transaction" involving no currency.


排查其他序列化的影响

由于 Hessian 线上稳定运行,而且 Hessian 的反序列化方案是先 new 一个 Currency,然后反序列化完成后,通过 Currency 的 code 获取到原始的静态类,并不会被内存中原始的单例进行修改。

这里 Hessian 有一个 resolve 的过程,如果发现自己本次构造的对象类型有这个 readResolve,就会调用一次,然后反序列化器继续使用原有的实例。

这里 readResolve() 的作用是将返回的对象替换反序列化创建的实例,主要使用在单例的场景,在序列化和反序列化中主要用于用返回的对象代替反序列化的对象,否则的话,反序列化会导致内存中有很多单例对象。

这个单例类的改动,业务方肯定不会直接调用,因此怀疑是其他序列化方案导致的。跟系统 owner 沟通,业务确实使用了 ProtoStuff 的一个方案做缓存对象的序列化和反序列化,通过注解来生效,所以看代码时没有观察到。

跟组内同学一起看了一下 ProtoStuff 序列化的代码,构造一个单测。先描述一下这几个关键类的关系,简化之后:

  1. XXXMoney:

  2.   currency:java.util.Currency

  3.     currencyCode:java.lang.String

类结构比较简单,XXXMoney 中有一个属性是 Currency 类型, Currency里面有一个关键属性是 currencyCode。以下是测试代码:

  1. /**

  2. * Alipay.com Inc. Copyright (c) 2004-2017 All Rights Reserved.

  3. */

  4. package com.alipay.demo.test;

  5. import com.alipay.xxx.util.money.XXXMoney;

  6. import com.alipay.client.cache.provider.CacheValueSizeOverflowException;

  7. import com.alipay.client.cache.provider.ProtostuffSerializeHelper;

  8. import org.apache.log4j.Logger;

  9. import java.util.Currency;

  10. /**

  11. * @author bystander

  12. * @version $Id: DecTest.java, v 0.1 2017年11月22日 下午3:16 bystander Exp $

  13. */

  14. publicclassSecTest {

  15.    privatestatic final Logger LOGGER = Logger

  16.            .getLogger(SecTest.class.getName());

  17.    publicstaticvoid main(String[] t) {

  18.        XXXMoney xxxMoney =null;

  19.        ProtostuffSerializeHelper helper = newProtostuffSerializeHelper();

  20.        byte[] bytes;

  21.        try {

  22.            xxxMoney =newXXXMoney(11.0,Currency.getInstance("USD"));

  23.            bytes = helper.serialize(xxxMoney, XXXMoney.class);

  24.             helper.deserialize(bytes, XXXMoney.class);

  25.            System.out.println( Currency.getInstance("CNY"));

  26.        } catch (CacheValueSizeOverflowException e) {

  27.            e.printStackTrace();

  28.        }

  29.    }

  30. }

以下是 debug 执行的结果,可以看到此时内存中已经被修改。


04

原因分析

_____

deserialize 的时候会调用以下代码:

  1. /**

  2. * 反序列化

  3. *

  4. * @param <T> the type parameter

  5. * @param raw the raw

  6. * @param cls the cls

  7. * @return the t

  8. */

  9. public <T> T deserialize(byte[] raw, Class<T> cls) {

  10.  Schema<T> schema = RuntimeSchema.getSchema(cls);

  11.  T val = schema.newMessage();

  12.  ProtobufIOUtil.mergeFrom(raw, val, schema);

  13.  return val;

  14. }

这段代码的 schema.newMessage() 会调用类的构造函数,此时会调用到 XXXMoney 的无参构造函数,默认是 CNY,内存中此时会生成了一个 CNY 的 Currency 单例,并且此时 XXXMoney 的 currency 字段已经有这个值的引用了。 然后把这 val(XXXMoney) 传到反序化处理里面,进行反序列化各个字段的赋值赋值是一个嵌套循环的过程,会不断的从当前类中获取已有的属性,进行这个属性其他字段的赋值。

嵌套赋值的时候,关键在于这个序列化工具是将 XXXMoney 这个对象的 currency 字段对应的值直接拿出来(持有单例的引用),然后赋值 currency 的其他属性,也就是这里 getObject 的方法。

嵌套执行到 currency 时,此时反序列到 Currency 这个属性的时候,二进制里面是 USD,所以直接把这个 Currency 引用里面的币种 (CurrencyCode) 给改了(通过 unsafe 操作修改了单例),并且变成了 USD,导致出现这个诡异的场景。

05

结论

_____

序列化方案使用有 bug,需要对单例类做处理。比较困难是这个由于是非常底层的类,所以在问题排查初期很难直接定位到该问题,需要不断精确范围排查,逐个排除验证。

另外,序列化是一个比较难的操作,最好使用蚂蚁已经久经考验,修改过的 Hessian。

后来,针对这个问题,跟 ProtoStuff 的作者进行了沟通,作者认为用法中对于这种单例是提供了一个 delegate 的功能的,但是需要使用的人自己识别出来这个问题。这个我们保留意见,毕竟,单例的类很多,如果依赖与人为识别来使用,那么成本和风险都会很高。



目前60000+人已关注加入我们

       

       

推荐阅读 盒子科技刘恒:聚合支付系统演讲
手把手教你搭建一个基于Java的分布式爬虫系统
知识付费时代,程序员,你的知识在哪里?
深入浅出分布式缓存的通用方法
蚂蚁金服开源 | 在 Spring Boot 中集成 SOFABoot 类隔离能力

中生代技术

每天早上7点,推送有营养的干货文章;

总覆盖会员60000+人;资深架构、总监等职位以上3000+人。

定期在线分享超过100期,线下技术沙龙超过70次、覆盖20多个等城市!

关注技术架构、研发管理、互联网金融、电商、大数据、区块链、人工智能等方向!


加入中生代技术群聊,请添加白明微信:zsdwyq,注明姓名、职称和技术方向,通过后加入中生代技术群,和群友们共同学习成长!

↓↓↓ 点击"阅读原文" 【查看跟 ProtoStuff 的作者沟通的 issue】  


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK