一个埋藏9年的底层bug发现历程
The following article is from 阿里云开发者 Author 进之
导读
项目背景
现象
现象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;
}
}
修改为如下代码:
@Override
protected 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;
}
总结
对于入参校验,应该提早进行校验,出现“非法入参”时,应当有合理的措施。比如:以返回值或者标志位的方式告诉调用者,实在不行可以造成崩溃,这样就能及早暴露问题。
底层模块要有通用性,不能只考虑当时的情况,此模块日后可能会在多种情况下使用;
要有风险意识,不要把风险问题扩大化,对同一份文件多次解密再加密,会出现错上加错的情况。
解决一个错误时,要看一下有没有相似的错误,可以一并修改。