一个埋藏9年的底层bug发现历程
阿里妹导读
项目背景
现象
现象1:不同任务类型都有此问题
现象2:1/200的概率稳定出现图片损坏
概率和1/256(16进制的FF转为十进制的值,2的8次方,一字节[Byte]的大小)很接近,是不是由于在解析到某一字节时,出现了异常。
每拍摄200多张,此时就出现重GC+手机温度过高导致降频,导致了卡顿,造成某一步执行超时或者失败。
现象3:仅webp格式会出现此问题
排查过程
摄像头生成webp图片时出错了。 代码调用逻辑出错。 加密算法本身就有问题。
排查方向1:压制照片时出错
排查方向2:加密流程产生问题
AES算法属于对称加密,加密和解密只需要一个相同的密钥;
AES算法在对明文加密的时候,并不是把整个明文一股脑加密成一整段密文,而是把明文拆分成一个个独立的明文块,每一个明文块长度128bit;
在没有填充的情况下,密文和原文长度相等。
public Bitmap decode(ImageDecodingInfo decodingInfo) throws IOException {// 如果是本地图片,OriginalImageUri会是'file:///xxx'以此来判断是否正在加载本地图片boolean isLoaclFile = decodingInfo.getOriginalImageUri().startsWith("file://");String imagePath = null;if (isLoaclFile) {imagePath = decodingInfo.getImageUri().replaceFirst("file://", "");if (!TextUtils.isEmpty(imagePath) && new File(imagePath).exists()) {ImageEncryptTool.decrypt(imagePath);} else {return null;}}Bitmap bitmap = super.decode(decodingInfo);if (isLoaclFile) {ImageEncryptTool.encrypt(imagePath);}return bitmap;}// 解密代码public static void encrypt(String filePath) throws IOException {try {RandomAccessFile raf = new RandomAccessFile(file, "rw");byte[] buffer = new byte[ENCRYPTED_SIZE];raf.read(buffer);buffer = JniArithmetic.aesEncryptNoPadding(buffer);raf.seek(0);raf.write(buffer);raf.close();} catch (IOException e) {e.printStackTrace();throw e;}}
修改为如下代码:
@Overrideprotected InputStream getImageStream(ImageDecodingInfo decodingInfo) throws IOException {// 如果是本地图片,OriginalImageUri会是'file:///xxx'以此来判断是否正在加载本地图片boolean isLoaclFile = decodingInfo.getOriginalImageUri().startsWith("file://");if (!isLoaclFile) {return super.getImageStream(decodingInfo);}// 解密本地图片InputStream imageStream = super.getImageStream(decodingInfo);byte[] encodeByteArray = inputStreamToByteArray(imageStream);final int ENCRYPTED_SIZE = 1024;byte[] decodeBuffer = new byte[ENCRYPTED_SIZE];System.arraycopy(encodeByteArray, 0, decodeBuffer, 0, ENCRYPTED_SIZE);decodeBuffer = JniArithmetic.aesDecryptNoPadding(decodeBuffer);System.arraycopy(decodeBuffer, 0, encodeByteArray, 0, ENCRYPTED_SIZE);return new ByteArrayInputStream(encodeByteArray);}
在正确且合理的流程中,解密操作只会在内存中进行,不会写入到磁盘之中,这样就不会产生各种覆盖的情况了。
成功解决?
排查方向3:锁竞争问题
private static final int N=100;public static byte[] aesEncrypt(byte[] data) {for (int i = 0; i < N; i++) {data[i] = (byte) (data[i] + 1);}return data;}public static byte[] aesDecrypt(byte[] data) {for (int i = 0; i < N; i++) {data[i] = (byte) (data[i] - 1);}return data;}
排查方向4:图片问题
图1:未加密的原始照片,可以看到以RIFF开头,是用来识别webp文件的标志位
Case1:把原始图片,用加密算法单独跑一遍,发现内容和图2一致;
Case2:把加密图片,用解密算法单独运行一遍,发现内容和图1不一致,尝试多次后发现,每次解密的数据居然都是随机内容。
排查方向5:AES解密算法
void AES::InvCipher( BYTE *input, BYTE *output, int len){int arrLen = len;unsigned char *uch_input = new unsigned char[arrLen + 1];strToUChar((const char*)input, uch_input, len);for (int i = 0; i < arrLen / 16; i++) {InvCipher(uch_input + i*16);}ucharToStr((const unsigned char*)uch_input, (char *)output, len);delete[] uch_input;}int AES::strToUChar(const char *ch, unsigned char *uch, int len) {int tmp = 0;if (ch == NULL || uch == NULL)return -1;if (strlen(ch) == 0)return -2;while (len) {tmp = (int) *ch;*uch++ = tmp;ch++;len--;}*uch = (int) '\0';return 0;}
错误原因
char str[] = "hello";// 字符串实际存储为 {'h', 'e', 'l', 'l', 'o', '\0'}
在JAVA中,字符串本身就存储了字符串的长度。这个长度字段在String对象创建时就被计算并存储起来,因此可以非常快速地调用length()方法来得到字符串的长度,而不需要遍历整个字符数组。
问题解决
int AES::strToUChar(const char *ch, unsigned char *uch, int len) {int tmp = 0;if (ch == NULL || uch == NULL)return -1;// if (strlen(ch) == 0)// return -2;while (len) {tmp = (int) *ch;*uch++ = tmp;ch++;len--;}*uch = (int) '\0';return 0;}
总结
对于入参校验,应该提早进行校验,出现“非法入参”时,应当有合理的措施。比如:以返回值或者标志位的方式告诉调用者,实在不行可以造成崩溃,这样就能及早暴露问题。
底层模块要有通用性,不能只考虑当时的情况,此模块日后可能会在多种情况下使用;
要有风险意识,不要把风险问题扩大化,对同一份文件多次解密再加密,会出现错上加错的情况。
解决一个错误时,要看一下有没有相似的错误,可以一并修改。