一个埋藏9年的底层bug发现历程

发布日期:2024-08-23 23:30    点击次数:87


导读

一个问题往往是由多个小的不规范或错误累积而成的。本文记录了作者发现问题、现象分析、排查过程、最后解决问题的全历程。

项目背景

我所在的项目组主要负责对店铺招牌拍摄,我负责App客户端的开发工作。此项目从立项之初到现在已经有很长的历史了。

现在出现了一个问题:用户在拍摄照片时,会出现照片损坏的情况,这个问题在线上环境出现了有一段时间了,再加上自己接手时,此问题已经出现了,就没有深入排查过产生原因。暂时的解决策略是让用户手动删除损坏的照片,上传图片时,服务端也会进行一次文件损坏检测。

我们会下发各种拍摄任务类型,有的任务只需要拍摄几张照片即可,有的任务需要拍摄上千张图片,此问题就会更容易暴露。在同事的建议下,决定要找到问题的根源。

现象

之前只是知道有此问题,没有仔细研究过。经过自测+了解,初步明确了以下现象:

现象1:不同任务类型都有此问题

目前项目内的不同任务类型都共用同一个拍照存储模块。此现象可以明确,出错范围是在底层拍照存储模块,而不是在上层的业务逻辑。

现象2:1/200的概率稳定出现图片损坏

通过与同事的共同复现,发现连续拍摄200多张的时候,就会出现一张损坏的图片。这中间我们复现好多次,出现频率都很符合预期,甚至有一丝诡异,因为这个bug出现频率太稳定了,反而有些不正常了。面对此现象,当时想到了2种可能的情况:

概率和1/256(16进制的FF转为十进制的值,2的8次方,一字节[Byte]的大小)很接近,是不是由于在解析到某一字节时,出现了异常。每拍摄200多张,此时就出现重GC+手机温度过高导致降频,导致了卡顿,造成某一步执行超时或者失败。

以上只是猜测,完全没有任何证据,只是当时的思考方向。

现象3:仅webp格式会出现此问题

目前拍摄的图片有两种存储格式,分别是jpeg和webp格式。项目之前都是使用JPEG作为存储格式,后来为了减小图片的大小,开始改用webp格式进行存储。当我们把存储格式改为jpeg时,此问题不会出现;换为webp格式时,就是出现此问题。

统计了这两者的整体耗时(从图片字节流到存储到文件中),webp的用时大概是jpeg耗时的5倍;jpeg的存储大小是webp大小的1.5倍左右。面对此现象,当时的想法是处理图片耗时久,因而导致锁(线程锁、IO锁)竞争激烈,某一瞬间发生了数据冲突。

排查过程

首先熟悉了一下项目代码,下面是整个存储过程的流程图:

整个流程还是比较简单易懂的,按照我当时的怀疑方向,制定了以下排查顺序:

摄像头生成webp图片时出错了。

代码调用逻辑出错。

加密算法本身就有问题。

摄像头生成webp图片时出错了。

代码调用逻辑出错。

摄像头输出的图片在压制为webp照片的时候,就出现损坏了,而jpeg压制时不会损坏。该问题排查比较简单,只需要把未加密的原始webp图片也存储下来,与加密后无法解密的图片进行对照即可。实践之后,发现损坏的加密图片,对应的原始webp照片都是可以正常展示的。

因此可以明确排除手机摄像头和压制webp图片的问题。

排查方向2:加密流程产生问题

调用AES加密算法的时候,调用可能会出错。比如:由于偶然情况,同一个图片被连续调用了两次加密算法。要排查此问题,需要深入阅读此部分的代码,并进行梳理。

先查阅了AES加密算法的相关资料。

AES是高级加密标准,在密码学中又称Rijndael加密法,是美国联邦政府采用的一种区块加密标准。这个标准用来替代原先的DES,目前已经被全世界广泛使用,同时AES已经成为对称密钥加密中最流行的算法之一。AES支持三种长度的密钥:128位,192位,256位。

自己总结了一下:

AES算法属于对称加密,加密和解密只需要一个相同的密钥;AES算法在对明文加密的时候,并不是把整个明文一股脑加密成一整段密文,而是把明文拆分成一个个独立的明文块,每一个明文块长度128bit;在没有填充的情况下,密文和原文长度相等。

先重点看了一下线程安全问题,排查一圈,认真看了在此过程中所有涉及的共享变量,没有发现任何问题。

下面梳理了加密解密流程,发现了一个很严重的问题。此问题发生在预览图片部分,代码如下:

Bitmap bitmap = super.decode(decodingInfo);if(isLoaclFile) {ImageEncryptTool.encrypt(imagePath);}returnbitmap;}// 解密代码publicstaticvoidencrypt(String filePath)throwsIOException {try{RandomAccessFile raf = newRandomAccessFile(file, "rw");byte[] buffer = newbyte[ENCRYPTED_SIZE];raf.read(buffer);buffer = JniArithmetic.aesEncryptNoPadding(buffer);raf.seek(0);raf.write(buffer);raf.close;} catch(IOException e) {e.printStackTrace;throwe;}}

手机预览图片时,需要从手机磁盘中读取照片,进行解密后,转为bitmap的方式显示在屏幕上。上面这段代码的流程如下:

这里的逻辑很不合理,先把磁盘文件读取到内存解密,解密后再写回磁盘,此时磁盘上的图片是一个解密后的数据,再交由图片加载框架加载此图片,加载完成之后再进行加密,加密完再次写回文件。

此过程需要多次IO操作,执行效率很低。此过程不能保证是“原子性”操作,在流程中,发生任何问题都会导致最终的图片发生损坏,比如解密完成之后,由于崩溃导致没有进行加密。不合理的方式大大增加了出错概率。

另外,还有一个更严重的问题,当解密文件覆盖原文件后,另外一个线程可能会调用此文件,会把已经解密后的文件,再一次进行解密,解密完之后再重新覆盖写回,最终文件就是“一团乱麻”,会造成图片损坏。

修改为如下代码:

在正确且合理的流程中,解密操作只会在内存中进行,不会写入到磁盘之中,这样就不会产生各种覆盖的情况了。

最后又排查了整个项目,移除了所有【解密再加密】 的过程,整个项目就保留了一处加密的地方,就是在第一次保存图片的时候,才会进行加密,然后再写入磁盘中。

成功解决?

这么明显的错误都被解决了,这时候想着,肯定没啥问题了。怀着十足的信心,进行了一番测试,可此时依然有此问题。刚开始有点不太确定,试了多次之后,可能100%确定问题依然未解决。

排查方向3:锁竞争问题

这时候又把视角转到了线程安全方向,为了使整个加密存储的耗时可控,我决定自己实现加解密算法。当然,我自己实现的加解密算法很简单。代码如下:

publicstaticbyte[] aesEncrypt(byte[] data){for(inti = 0; i < N; i++) {data[i] = (byte) (data[i] + 1);}returndata;}

publicstaticbyte[] aesDecrypt(byte[] data){for(inti = 0; i < N; i++) {data[i] = (byte) (data[i] - 1);}returndata;}

只是简单地把每一个字节的值+1,解密的时候,再把每一个字节-1进行解密,我可以使用N的大小控制加解密耗时。又进行了一番测试,这时候不论怎么调整加解密耗时,都没有发生图片损坏的情况。

通过现有信息,加解密算法应该很有问题。但我依然相信加密算法没有问题,加密代码已经存在了9年之久,而且用的是很成熟的AES加密算法,应该不会有问题。

排查方向4:图片问题

从手机中把损坏的图片单独取出来,又分别用加密算法、解密算法处理这张图片。拿到了以下数据:

图1:未加密的原始照片,可以看到以RIFF开头,是用来识别webp文件的标志位

图2:按照代码流程加密结果

Case1:把原始图片,用加密算法单独跑一遍,发现内容和图2一致;Case2:把加密图片,用解密算法单独运行一遍,发现内容和图1不一致,尝试多次后发现,每次解密的数据居然都是随机内容。

对应这一张图片,每次都把解密后的内容打印出来,发现有时候正确,有时候又是随机的,而且修改先后执行顺序,结果也可能不一样。由于加解密算法是由C++所写,根据以往经验,猜测出现此种情况,是由于C++内存残留导致的。

服务端在使用图片时,也需要进行解密,由于服务端不怕代码泄漏,因此直接使用了Java类库实现AES解密算法。在服务端同事的配合下,单独上传该图片,尝试了多次,发现服务端是可以稳定进行解密的。

又在服务端同事的帮助下,拿到了服务端解密算法,我把端上的解密算法替换为服务端解密算法,这张损坏的图片居然又可以正确展示了。最后又经过一番测试,发现再也没有出现图片损坏的情况。

到此,已经有95%的把握,可以证明是解密算法导致的。此时也可以安心下班了,第二天再排查解密算法。

排查方向5:AES解密算法

先向同事要到了加解密仓库的git地址,由于这块代码历史比较悠久,立项之初,使用SVN进行管理,后来迁移时整体被打包放到git仓库里,已经无法看到提交记录了。

项目本身的架构也比较老,使用了Android.mk作为构建工具,现在已经废弃很久了,我也没有接触过。在ChatGPT的帮助下,自动帮我转换成了现代的CMakeLists构建工具。接下来就可以正常的debug跟踪代码了。

AES算法本身就比较简单,只是不停地按照密码表,对原文进行替换。代码中没有使用任何三方库,自己实现了AES算法。

解密算法如下:

intAES::strToUChar(constchar*ch, unsignedchar*uch, intlen) {inttmp = 0;if(ch == NULL


Powered by 门徒注册 @2013-2022 RSS地图 HTML地图