天天看點

一個埋藏9年的底層bug發現曆程

作者:閃念基因
一個埋藏9年的底層bug發現曆程

導讀

一個問題往往是由多個小的不規範或錯誤累積而成的。本文記錄了作者發現問題、現象分析、排查過程、最後解決問題的全曆程。

項目背景

我所在的項目組主要負責對店鋪招牌拍攝,我負責App用戶端的開發工作。此項目從立項之初到現在已經有很長的曆史了。

現在出現了一個問題:使用者在拍攝照片時,會出現照片損壞的情況,這個問題線上上環境出現了有一段時間了,再加上自己接手時,此問題已經出現了,就沒有深入排查過産生原因。暫時的解決政策是讓使用者手動删除損壞的照片,上傳圖檔時,服務端也會進行一次檔案損壞檢測。

我們會下發各種拍攝任務類型,有的任務隻需要拍攝幾張照片即可,有的任務需要拍攝上千張圖檔,此問題就會更容易暴露。在同僚的建議下,決定要找到問題的根源。

現象

之前隻是知道有此問題,沒有仔細研究過。經過自測+了解,初步明确了以下現象:

現象1:不同任務類型都有此問題

目前項目内的不同任務類型都共用同一個拍照存儲子產品。此現象可以明确,出錯範圍是在底層拍照存儲子產品,而不是在上層的業務邏輯。

現象2:1/200的機率穩定出現圖檔損壞

通過與同僚的共同複現,發現連續拍攝200多張的時候,就會出現一張損壞的圖檔。這中間我們複現好多次,出現頻率都很符合預期,甚至有一絲詭異,因為這個bug出現頻率太穩定了,反而有些不正常了。面對此現象,當時想到了2種可能的情況:

  1. 機率和1/256(16進制的FF轉為十進制的值,2的8次方,一位元組[Byte]的大小)很接近,是不是由于在解析到某一位元組時,出現了異常。
  2. 每拍攝200多張,此時就出現重GC+手機溫度過高導緻降頻,導緻了卡頓,造成某一步執行逾時或者失敗。

以上隻是猜測,完全沒有任何證據,隻是當時的思考方向。

現象3:僅webp格式會出現此問題

目前拍攝的圖檔有兩種存儲格式,分别是jpeg和webp格式。項目之前都是使用JPEG作為存儲格式,後來為了減小圖檔的大小,開始改用webp格式進行存儲。當我們把存儲格式改為jpeg時,此問題不會出現;換為webp格式時,就是出現此問題。

統計了這兩者的整體耗時(從圖檔位元組流到存儲到檔案中),webp的用時大概是jpeg耗時的5倍;jpeg的存儲大小是webp大小的1.5倍左右。面對此現象,當時的想法是處理圖檔耗時久,因而導緻鎖(線程鎖、IO鎖)競争激烈,某一瞬間發生了資料沖突。

排查過程

首先熟悉了一下項目代碼,下面是整個存儲過程的流程圖:

一個埋藏9年的底層bug發現曆程

整個流程還是比較簡單易懂的,按照我當時的懷疑方向,制定了以下排查順序:

  1. 攝像頭生成webp圖檔時出錯了。
  2. 代碼調用邏輯出錯。
  3. 加密算法本身就有問題。

排查方向1:壓制照片時出錯

攝像頭輸出的圖檔在壓制為webp照片的時候,就出現損壞了,而jpeg壓制時不會損壞。該問題排查比較簡單,隻需要把未加密的原始webp圖檔也存儲下來,與加密後無法解密的圖檔進行對照即可。實踐之後,發現損壞的加密圖檔,對應的原始webp照片都是可以正常展示的。

是以可以明确排除手機攝像頭和壓制webp圖檔的問題。

排查方向2:加密流程産生問題

調用AES加密算法的時候,調用可能會出錯。比如:由于偶然情況,同一個圖檔被連續調用了兩次加密算法。要排查此問題,需要深入閱讀此部分的代碼,并進行梳理。

先查閱了AES加密算法的相關資料。

AES是進階加密标準,在密碼學中又稱Rijndael加密法,是美國聯邦政府采用的一種區塊加密标準。這個标準用來替代原先的DES,目前已經被全世界廣泛使用,同時AES已經成為對稱密鑰加密中最流行的算法之一。AES支援三種長度的密鑰:128位,192位,256位。

一個埋藏9年的底層bug發現曆程

自己總結了一下:

  1. AES算法屬于對稱加密,加密和解密隻需要一個相同的密鑰;
  2. AES算法在對明文加密的時候,并不是把整個明文一股腦加密成一整段密文,而是把明文拆分成一個個獨立的明文塊,每一個明文塊長度128bit;
  3. 在沒有填充的情況下,密文和原文長度相等。

先重點看了一下線程安全問題,排查一圈,認真看了在此過程中所有涉及的共享變量,沒有發現任何問題。

下面梳理了加密解密流程,發現了一個很嚴重的問題。此問題發生在預覽圖檔部分,代碼如下:

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;

    }

}
           

手機預覽圖檔時,需要從手機磁盤中讀取照片,進行解密後,轉為bitmap的方式顯示在螢幕上。上面這段代碼的流程如下:

一個埋藏9年的底層bug發現曆程

這裡的邏輯很不合理,先把磁盤檔案讀取到記憶體解密,解密後再寫回磁盤,此時磁盤上的圖檔是一個解密後的資料,再交由圖檔加載架構加載此圖檔,加載完成之後再進行加密,加密完再次寫回檔案。

此過程需要多次IO操作,執行效率很低。此過程不能保證是“原子性”操作,在流程中,發生任何問題都會導緻最終的圖檔發生損壞,比如解密完成之後,由于崩潰導緻沒有進行加密。不合理的方式大大增加了出錯機率。

另外,還有一個更嚴重的問題,當解密檔案覆寫原檔案後,另外一個線程可能會調用此檔案,會把已經解密後的檔案,再一次進行解密,解密完之後再重新覆寫寫回,最終檔案就是“一團亂麻”,會造成圖檔損壞。

修改為如下代碼:

@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);
}           

在正确且合理的流程中,解密操作隻會在記憶體中進行,不會寫入到磁盤之中,這樣就不會産生各種覆寫的情況了。

最後又排查了整個項目,移除了所有【解密再加密】 的過程,整個項目就保留了一處加密的地方,就是在第一次儲存圖檔的時候,才會進行加密,然後再寫入磁盤中。

成功解決?

這麼明顯的錯誤都被解決了,這時候想着,肯定沒啥問題了。懷着十足的信心,進行了一番測試,可此時依然有此問題。剛開始有點不太确定,試了多次之後,可能100%确定問題依然未解決。

排查方向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;

}
           

隻是簡單地把每一個位元組的值+1,解密的時候,再把每一個位元組-1進行解密,我可以使用N的大小控制加解密耗時。又進行了一番測試,這時候不論怎麼調整加解密耗時,都沒有發生圖檔損壞的情況。

通過現有資訊,加解密算法應該很有問題。但我依然相信加密算法沒有問題,加密代碼已經存在了9年之久,而且用的是很成熟的AES加密算法,應該不會有問題。

排查方向4:圖檔問題

從手機中把損壞的圖檔單獨取出來,又分别用加密算法、解密算法處理這張圖檔。拿到了以下資料:

圖1:未加密的原始照片,可以看到以RIFF開頭,是用來識别webp檔案的标志位

一個埋藏9年的底層bug發現曆程

圖2:按照代碼流程加密結果

一個埋藏9年的底層bug發現曆程
  • Case1:把原始圖檔,用加密算法單獨跑一遍,發現内容和圖2一緻;
  • Case2:把加密圖檔,用解密算法單獨運作一遍,發現内容和圖1不一緻,嘗試多次後發現,每次解密的資料居然都是随機内容。

對應這一張圖檔,每次都把解密後的内容列印出來,發現有時候正确,有時候又是随機的,而且修改先後執行順序,結果也可能不一樣。由于加解密算法是由C++所寫,根據以往經驗,猜測出現此種情況,是由于C++記憶體殘留導緻的。

服務端在使用圖檔時,也需要進行解密,由于服務端不怕代碼洩漏,是以直接使用了Java類庫實作AES解密算法。在服務端同僚的配合下,單獨上傳該圖檔,嘗試了多次,發現服務端是可以穩定進行解密的。

又在服務端同僚的幫助下,拿到了服務端解密算法,我把端上的解密算法替換為服務端解密算法,這張損壞的圖檔居然又可以正确展示了。最後又經過一番測試,發現再也沒有出現圖檔損壞的情況。

到此,已經有95%的把握,可以證明是解密算法導緻的。此時也可以安心下班了,第二天再排查解密算法。

排查方向5:AES解密算法

先向同僚要到了加解密倉庫的git位址,由于這塊代碼曆史比較悠久,立項之初,使用SVN進行管理,後來遷移時整體被打包放到git倉庫裡,已經無法看到送出記錄了。

項目本身的架構也比較老,使用了Android.mk作為建構工具,現在已經廢棄很久了,我也沒有接觸過。在ChatGPT的幫助下,自動幫我轉換成了現代的CMakeLists建構工具。接下來就可以正常的debug跟蹤代碼了。

AES算法本身就比較簡單,隻是不停地按照密碼表,對原文進行替換。代碼中沒有使用任何三方庫,自己實作了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;

}
           

在跟蹤到 if (strlen(ch) == 0) 這一行代碼時,發現會直接傳回-2作為錯誤碼,上面的處理過程會直接忽視錯誤碼,繼續進行解密。當然,這時候肯定是無法解密成功的。

錯誤原因

我對這一做法進行了合理猜測,剛開始此處的加密隻用于字元串,是以在入參時,會判斷一下是否為空字元串。

在C++中,字元串通常以字元數組的形式表示,遵循C語言的傳統。C/C++中的字元串是以空字元\0(ASCII值為0)結尾的字元數組。這種字元串被稱為C-風格字元串或null-terminated字元串。判斷字元串結束的方式就是檢查每個字元,直到遇到\0。

char str[] = "hello";
// 字元串實際存儲為 {'h', 'e', 'l', 'l', 'o', '\0'}           

在JAVA中,字元串本身就存儲了字元串的長度。這個長度字段在String對象建立時就被計算并存儲起來,是以可以非常快速地調用length()方法來得到字元串的長度,而不需要周遊整個字元數組。

但是後來需要對二進制圖檔進行加密,二進制圖檔在存儲過程中,也會用到'0x00'位元組,但和字元串中的'0x00'含義完全不一樣。如果二進制圖檔第一個位元組為0x00,當成字元串處理就會得出加密内容為空,因而終止後續流程。

又找了幾張解密失敗的圖檔,發現它們無一例外,開頭第一個位元組都是0x00,怪不得失敗的機率是1/256。又把jpeg圖檔找來,所有jpeg圖檔的前16個位元組(目測前500位元組都是)是固定的,是以加密之後第一個位元組就是固定。而webp格式加密後第一個位元組是随機的,當然不會出現問題了。

問題解決

由于這部分代碼已經存在了9年了,再加上自己對此部分代碼不是很熟悉,秉承着最小改動的原則,隻是去掉了對空字元串校驗的情況,空字元可以提前校驗。

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;

}
           

後來在整理時,發現另外一個charToHex方法,居然有同樣的代碼,相同的兩行已經被注釋掉了,看來之前的人也發現了此問題,但沒有把兩個地方的相同問題一并解決了。

代碼改動之後,又打包進行測試,發現再也沒有出現此問題了。最後能成功解決此問題,也是非常開心。

總結

從這件事情中,完美驗證了“一個問題往往是由多個小的不規範或錯誤累積而成的”。每次的代碼修改者,站在自身角度來看,沒有造成大問題,單獨來看,也不是完全不合理。

如果代碼寫得不規範,留有安全隐患,當時雖然不會暴露,所有風險問題彙總到一起的時候,就造成最後的“災難”。

我也收獲到了以下經驗:

  1. 對于入參校驗,應該提早進行校驗,出現“非法入參”時,應當有合理的措施。比如:以傳回值或者标志位的方式告訴調用者,實在不行可以造成崩潰,這樣就能及早暴露問題。
  2. 底層子產品要有通用性,不能隻考慮當時的情況,此子產品日後可能會在多種情況下使用;
  3. 要有風險意識,不要把風險問題擴大化,對同一份檔案多次解密再加密,會出現錯上加錯的情況。
  4. 解決一個錯誤時,要看一下有沒有相似的錯誤,可以一并修改。

作者:進之

來源-微信公衆号:阿裡雲開發者

出處:https://mp.weixin.qq.com/s/lwJASuZ3BZldK6a-UHXK_g