程式碼靜態掃描規則——型別轉換檢查

hchlqlz發表於2022-01-16
  • 簡介

      在專案中,存在許多不規範的程式碼,其一就是將無符號變數賦值給有符號變數。在大多數情況下是不會出現問題的,因為那些變數值往往小於 2147483648
      但是一些特定的介面,如時間獲取介面,可能返回一個較大的無符號值,如果使用 int 變數接收,便可能出現異常。當這些介面在專案中大量使用時,排查起來較為困難,容易發生遺漏,因此引入程式碼掃描工具進行特定介面的使用檢查。
      後續將針對 TimeGet 函式進行問題的詳細說明。
  • TimeGet 介面宣告

    // 獲取時間
    // IN: 時間格式(如 TIME_YYMMDDHHMM)
    // OUT: 時間值(如 2105301530 -> 21年5月30日15點30分)
    unsigned TimeGet(TIME_TYPE type);
    
  • 問題表現

      在進入22年後,TIME_YYMMDDHHMM 格式的時間值便超出了 int 的表示範圍,如果誤用 int 變數進行接收,便可能出現如開始時間未到等各種問題。
  • 錯誤用法

    1. 使用 int 變數接收 TimeGet 返回值。如下程式碼,在進入22年後,活動仍然處於未開始狀態。
      int nTime = TimeGet(TIME_YYMMDDHHMM);
      if (nTime < 2112125959)
      {
          ShowMessage("活動未開始。");
          return ;
      }
      
    2. 格式串中使用 %d 接收 TimeGet 返回值。如下程式碼,在進入22年後,會將整張表讀取到記憶體,實際上需要的可能只有三五條。
      char szSQL[1024];
      sprintf(szSQL
          , "select * from tbl where out_time > %d"
          , TimeGet(TIME_YYMMDDHHMM));
      auto result = database()->executeQuery(szSQL);
      
    3. 通用的資料庫記錄物件可能只提供了 GetIntSetInt 介面,在表欄位型別為 unsigned 時,呼叫 SetInt( TimeGet(TIME_YYMMDDHHMM) ) 是不會出現問題的,但是需要小心使用 GetInt 介面。如下程式碼就將問題藏得很隱蔽,實際上,GetInt 返回值已經是個負數了,直接賦值給 long long 變數,i64Value 仍然是個負數。
      long long i64Value = data.GetInt(start_time);
      
      這種情況下,加個型別轉換就能解決問題。
      long long i64Value = (unsigned)data.GetInt(start_time);
      
  • 排查難點

    1. TimeGet 在程式碼中使用得很頻繁,直接搜尋 TimeGet(TIME_YYMMDDHHMM) 會出現大幾百行。
    2. 有可能存在 TimeGet 接收時無誤,但後續傳遞過程中出現錯誤的情況,如下程式碼,Func 函式正確處理了 TimeGet 的返回值,但外部使用 Func 卻出現了錯誤。
      unsigned FuncX()
      {
          return TimeGet(TIME_YYMMDDHHMM);
      }
      
      int main()
      {
          int nTime = FuncX();
          //...
          return 0;
      }
      
  • 程式碼靜態掃描工具

      考慮到人工排查的困難,這裡引入cppcheck 並自定義規則進行程式碼的掃描,通過工具輔助,來度過22年時間溢位帶來的危機。
  • 掃描規則

    1. 確定合法的接收型別:
      unsigned
      unsigned &
      unsigned long
      unsigned long &
      unsigned long long
      unsigned long long &
      signed long long
      signed long long &  
      
    2. 定位到所有 TimeGet(TIME_YYMMDDHHMM) 呼叫位置。
    3. 查詢 TimeGet 返回值的接收者,為了方便理解,這裡直接描述為向前尋找接收物件(實際實現上使用cppcheck 的語法分析樹查詢)。接收者存在如下幾種情況:
      1. 變數賦值
        int nTime = TimeGet(TIME_YYMMDDHHMM);
        
        這裡向前查詢會遇到賦值符號(可能是=、+=、-=、|=等等),這說明 TimeGet 將會賦值給某個變數,這時可以檢查變數的型別是不是合法的。
      2. 函式返回
        int func()
        {
            return TimeGet(TIME_YYMMDDHHMM);
        }
        
        這裡向前查詢會遇到 return,這說明 TimeGet 返回值將通過函式進一步返回,這時可以檢查函式的返回值型別是不是合法的。
      3. 函式傳參
        func( TimeGet(TIME_YYMMDDHHMM) );
        
        這裡向前查詢會遇到 func(,這說明 TimeGet 返回值將傳遞給func的引數,這時檢查對應函式的引數型別。
      4. 構造
        1. 匿名構造
          struct User
          {
              User(int nTime)
              {
                  //...
              }
          };
          
          int main()
          {
              func ( User( TimeGet(TIME_YYMMDDHHMM) ) );
              // ...
          }
          
          這裡向前查詢會遇到 User(,此處的 User 是一個型別名,這說明 TimeGet 返回值將傳遞給 User 的建構函式,這時檢查對應函式的引數型別。cppcheck 這裡並未直接將User程式碼連結到 User 類的建構函式,而僅僅認為此處的User 是一個型別,因此這裡需要自行根據傳入引數索引建構函式。
        2. 普通構造
          struct User
          {
              User(int nTime)
              {
                  //...
              }
          };
          
          int main()
          {
              User user(TimeGet(TIME_YYMMDDHHMM));
          }
          
          這裡向前查詢會遇到 user(,此處的 user 是一個變數,這說明 TimeGet 返回值將傳遞給 user 變數所屬型別的建構函式,同樣的,這時需要檢查對應函式的引數型別。
        3. 標準資料型別構造
          int(TimeGet(TIME_YYMMDDHHMM))
          
          這裡向前查詢會遇到 int(,此處的 int 是一個型別,但其屬於基本型別,無法找到其建構函式,此時應直接判斷型別是否合法。
      5. 取餘、比較
        int nHHMM = TimeGet(TIME_YYMMDDHHMM) % 10000;
        bool bZero = TimeGet(TIME_YYMMDDHHMM) == 0;
        
        這裡直接向前查詢會發生誤判,認為將時間賦值給 intbool 變數,但是使用語法分析樹判斷時,會先找到 %== 符號,這裡認為返回值的性質已經發生了變化,則不應算是錯誤。
      6. 控制流
        if (TimeGet(TIME_YYMMDDHHMM))
        {
            // ...
        }
        
        這裡最終會查詢到 if(,事實上這裡隱含了時間值與 0的判斷,可以認為返回值性質發生了變化,不應算是錯誤。掃描工具中允許了 ifswitch 的控制流關鍵字,其他關鍵字(如 while)則輸出錯誤資訊。
      7. 不定參函式
        char szSQL[1024];
        sprintf(szSQL
            , "select * from tbl where out_time > %d"
            , TimeGet(TIME_YYMMDDHHMM));
        
        這裡會查詢到 sprintf(,但與普通的函式傳參不同,這裡的 sprintf 是不定參的,即無法正常檢查傳參型別是否合法。不定參函式過於複雜,目前版本只處理系統庫中格式串函式。
      8. 其他複雜情況
        func( { TimeGet(TIME_YYMMDDHHMM) } );
        array[0] = TimeGet(TIME_YYMMDDHHMM); 
        *(p + 1) = TimeGet(TIME_YYMMDDHHMM);
        \\ ...
        
        過於複雜的程式碼,這裡暫不考慮,目前版本只適用於一般情況。
    4. 如果接收者的型別不合法,則可以簡單地輸出錯誤log 並結束該處 TimeGet 的檢查。如果接收者的型別合法,則需要進行遞迴檢查。遞迴檢查存在如下情況:
      1. 接收者為變數
        unsigned uTime = TimeGet(TIME_YYMMDDHHMM);
        int nTime = uTime;
        
        此時,需要重新掃描變數的生效區域,針對 uTime 進行類似 TimeGet 返回值的檢查。值得注意的是,如果接收者是區域性變數,則只要搜尋當前塊即可,如果接收者是全域性變數,則需要搜尋全部程式碼。
        目前版本未針對處理引用變數,如下問題工具無法掃描出來。
        unsigned uTime = 0;
        unsigned& rTime = uTime;
        rTime = TimeGet(TIME_YYMMDDHHMM);
        int nTime = uTime;
        
      2. 接收者為成員變數
        struct User
        {
            unsigned nLoginTime;
        };
        
        void func(User& user)
        {
            user.nLoginTime = TimeGet(TIME_YYMMDDHHMM);
        }
        
        此時,為了簡化邏輯,將遞迴檢查物件設定為 User::nLoginTime,即所有 User 物件的 nLoginTime 都視為檢查目標,不關心是否真的傳遞過 TimeGet 返回值。
      3. 接收者為函式返回值
        unsigned func()
        {
            return TimeGet(TIME_YYMMDDHHMM);
        }
        
        此時,需要檢查 func 所有呼叫位置。
      4. 接收者為函式引數
        void func(unsigned uTime)
        {
            int nTime = uTime;
        }
        func(TimeGet(TIME_YYMMDDHHMM));
        
        此時,需要檢查 func 引數列表中的 uTime 變數。
  • 標籤功能

      基於cppcheck 的框架,掃描時並沒有一份全部程式碼的符號庫,而是遍歷掃描每一個cpp 檔案,同時間僅有當前掃描cpp 檔案的完整內容,及其關聯標頭檔案的函式宣告等。這意味著,發現向某函參傳遞 TimeGet 時,如果該函式體未被cppcheck 載入分析,此時只能判斷引數型別是否合法,無法跟蹤函式引數後續的使用是否合法。
      因為上述問題,引入標籤功能,對於當次執行無法掃描的功能,記錄標籤寫入配置檔案,下次執行cppcheck 時讀取標籤檔案進行掃描。
      標籤型別如下:
    1. 初始函式 [INIT_FUNCTION],即本例中的 TimeGet
      標籤型別;函式標識;追查堆疊;忽略引數
      [INIT-FUNCTION];TimeGet(signed int type) at /project8/base.h;;
      
      忽略引數可用於控制只檢查個別時間格式,如-1 |0 TIME_SECOND|0 TIME_DAY 表示不檢查 TimeGet()TimeGet(TIME_SECOND)TimeGet(TIME_DAY) 的使用。
    2. 普通函式 [FUNCTION],即出現 return TimeGet 並且返回值型別合法的函式。與 [INIT_FUNCTION] 的區別在於後續掃描未增加該標籤,則會被清除(如函式丟失或函式的 return 語句不再返回 TimeGet 等)。
      標籤型別;函式標識;追查堆疊
      [FUNCTION];Test() at /project8/main.cpp;$G_TimeGet at (/project8/main.cpp, 76)
      
    3. 函式引數 [FUNCTION-ARGUMENT],即出現 func(TimeGet) 並且 func 引數型別合法的情況。
      標籤型別;函式標識;引數編號;引數型別|引數名;追查堆疊
      [FUNCTION-ARGUMENT];func(unsigned int uTime) at /project8/base.h;0;unsigned int|uTime;$G_TimeGet at (/project8/main.cpp, 83)
      
    4. 變數 [VARIABLE],即出現 user.time = TimeGet 並且變數型別合法的情況。
      標籤型別;變數宣告的檔案路徑|變數歸屬|變數型別|變數名;追查堆疊
      [VARIABLE];/project8/base.h|User|unsigned int|time;$G_TimeGet at (/project8/main.cpp, 83)
      
      結合上述的標籤,便可以通過cppcheck 實現跨cpp 檔案的檢查。
  • 原始碼地址

相關文章