天天看點

常識普及-C++常見的三種記憶體破壞場景1. 記憶體破壞之強制類型轉換2. 字元串拷貝溢出3. 随機性的記憶體被修改總結

有一定

C++

開發經驗的同學大多數踩過記憶體破壞的坑,有這麼幾種現象:

  1. 比如某個變量整形,在程式中隻可能初始化或者指派為

    1

    或者

    2

    , 但是在使用的時候卻發現其為 或者其他的情況。對于其他類型,比如字元串等,可能出現了一種

    出乎意料

    的值!
  2. 程式在堆上申請記憶體或者釋放記憶體的時候,在記憶體充足的情況下,居然出現了堆錯誤。

當出現以上場景的時候,你該思考一下,是不是出現了記憶體破壞的情況了。而本文主要通過展示和分析常見的三種記憶體破壞導緻覆寫相鄰變量的場景,讓讀者在碰到類似的場景,不至于束手無策。而對于堆上的記憶體破壞,很常見并且棘手的場景,本人将在後續的文章和大家分享。

1. 記憶體破壞之強制類型轉換

大家都知道不比對的類型強制轉換會帶來一些

bug

,比如

int

unsigned int

互相轉換,又或者

int

__int64

強行轉換。是不是每次當讀起這類文章起來如雷貫耳,但是當自己去寫代碼的時候還是容易犯錯?這也就是為什麼

C++

容易寫出

的原因,明知可能有錯,還難以避免。這往往是因為真實的項目中複雜程度,往往讓人容易忽略這些細節。

不少老的工程代碼還是采用

VC6

編譯,為了安全問題或者使用C++新特性需要将

VC6

更新到更新的

Visual Studio

。接下來要介紹的一個樣例程式,就是隐藏于代碼中的一個問題,如果從

VC6

更新到

VS2017

的時候會帶來問題嗎?可以先找找看:

#include <iostream>
#include <time.h>
class DemoClass
{
public:
  DemoClass() : m_bInit(true), m_tRecordTime(0)
  { 
    time((time_t *)(&m_tRecordTime));
  };
  void DoSomething()
{
    if (m_bInit)
      std::cout << "Do Task!" << std::endl;
  }
private:
  int     m_tRecordTime;
  bool   m_bInit;
};
int main()
{
  DemoClass testObj;
  testObj.DoSomething();
  return 0;
}      

Do Task!

這個字元串會不會列印出來呢? 可以發現這段程式在

VC6

中可以列印出來,但是在

VS2017

中卻列印不出來了。那是因為如下原因:

  1. 函數原型

    time_t time( time_t *destTime );

    ,在

    VC6

    time_t

    預設是32位,而在

    VS2017

    中預設是64位。早期程式以為32位中表達最大的時間是

    2038

    年,那時候完全夠用,但随着計算機本身的發展

    64位

    逐漸成為主流

    time_t

    在最新的編譯器中也預設采用

    64位

    ,這樣時間完全夠用以

    億年

    為機關了,那時候計算機發展超出我們想象了。
  2. 程式的問題所在

    m_tRecordTime

    采用的是

    int

    類型,預設為32位,那麼其位址作為

    time_t time( time_t *destTime );

    函數實參後,在

    VC6

    time_t

    本身為32位自然也不會出錯,但是在

    VS2017

    中因為

    time_t

    為64位,則

    time((time_t *)(&m_tRecordTime));

    後寫入了一個

    64位

    的值。結合下圖,看下這個對象的記憶體布局,

    m_bInit

    的值将會被覆寫,而這裡原先的

    m_bInit

    的值為

    1

    ,被覆寫為 ,進而導緻記憶體破壞,導緻程式執行意想不到的結果。這裡隻是不輸出,那在真實程式中,可能會導緻某個邏輯錯亂,發生嚴重的問題。
    常識普及-C++常見的三種記憶體破壞場景1. 記憶體破壞之強制類型轉換2. 字元串拷貝溢出3. 随機性的記憶體被修改總結

這個問題修改自然比較簡單,将

m_tRecordTime

定義為

time_t

類型就可以了。如果有類似的問題發生的時候,比如這個變量的可疑的發生了不該有的變化的時候,你可以檢視下這個變量定義的附近是否有記憶體的操作可能産生溢出,找到問題所在。因為記憶體上溢的比較多,一般可以檢視下定義在目前出現問題的變量的低位址出的變量操作,是否存在可疑的地方。最後,針對這種場景,我們是不是也可以得到一些收獲呢,個人總結如下兩點:

  1. 在定義類型的時候,盡量和原始類型一緻,比如這裡的

    time_t

    有些程式員可能慣性的認為就是

    32位

    ,那就定義一個時間戳的時候就定義為

    int

    了,而我們要做的應該是和原始類型比對(也就是函數的輸入類型),将其定義為

    time_t

    ,于此類似的還有

    size_t

    等,這樣可以避免未來在資料集變化或者做平台遷移的時候造成不必要的麻煩。
  2. 在有一些複雜的場景的下,也許你不得不做類型轉換,而這個時候就格外的需要注意或者了解清楚,轉換帶來的情況和後果,保持警惕,否則就可能是一個潛在的

    bug

    。這和開車一樣,當你開車的時候如果看到前方車輛忽然産生一個不合常理的變道行為,首先要做的不是噴那輛車,而是集中注意力,看看是否更前方有障礙物或者事故放生,做出相應的反應。

2. 字元串拷貝溢出

這種情況應該是最常見了,我們來看一看樣例程式:

#include <iostream>
#define BUFER_SIZE_STR_1 5
#define BUFER_SIZE_STR_2 8
class DemoClass
{
public:
  void DoSomething()
{
    strcpy(m_str1, "Hi Coder!");
    std::cout << m_str1 << std::endl;
    std::cout << m_str2 << std::endl;
  }
private:
  char m_str1[BUFER_SIZE_STR_1] = { 0 };
  char m_str2[BUFER_SIZE_STR_2] = { 0 };
};
int main()
{
  DemoClass testObj;
  testObj.DoSomething();
  return 0;
}      

這種情況下肉眼可以分析的,輸出結果為:

常識普及-C++常見的三種記憶體破壞場景1. 記憶體破壞之強制類型轉換2. 字元串拷貝溢出3. 随機性的記憶體被修改總結

m_str1

的空間為

5

,但是

Hi Coder!

包含

\0

10

個字元,在調用

strcpy(m_str1, "Hi Coder!");

的時候超過了

m_str1

的空間,于是覆寫了

m_str2

的記憶體,進而導緻記憶體破壞。記憶體溢出這種尤其字元串溢出,程式崩潰可能是小事兒,如果是一個廣為流傳的軟體,那麼就很有可能會被黑客所利用。

這種字元串場景如何分析呢,如果程式崩潰了,可以收集

Dump

先看看被覆寫的地方是什麼樣的字元串,然後聯想看看自己的程式哪裡有可能對這個字元串的操作,進而找到原因。别小看這種方法,簡單粗暴很有用,曾經就用這種方式分析過Linux驅動子產品的記憶體洩露問題。

那如果還找不到問題呢?如果問題還能重制,那還是有調試手法的,下一節将會進行講解。

當然最差最差的還是不要放棄代碼審查。尤其在這個記憶體被破壞的附近的邏輯。對于這種場景的建議,比較簡單就是使用微軟安全函數

strcpy_s

,注意這裡雖然列出了傳回值

errno_t

不過對于微軟的實作來說,如果是目标記憶體空間不夠的情況下,在

Relase

版本下會調用

TerminateProcess

, 并且要注意的是這個時候抓Dump有時候并不是完整的Dump。

至于微軟為什麼要這樣做,有可能是安全的考慮比崩潰優先級更高,于是在記憶體溢出不夠的時候,直接讓程式結束。

errno_t strcpy_s(   char *dest,   rsize_t dest_size,   const char *src);      

3. 随機性的記憶體被修改

這一個一聽都快崩潰了,

C++

的坑能不能少一點呢。但是确實是會有各種各樣的場景讓你落入坑内。上一節的程式我稍作修改:

#include <iostream>
#define BUFER_SIZE_STR_1 5
#define BUFER_SIZE_STR_2 8
class DemoClass
{
public:
  void DoSomething()
{
    strcpy_s(m_str2, BUFER_SIZE_STR_2, "Coder");
    strcpy_s(m_str1, BUFER_SIZE_STR_1, "Test");
    
    //Notice this line:
    m_str1[BUFER_SIZE_STR_2 - 1] = '\0';
    std::cout << m_str1 << std::endl;
    std::cout << m_str2 << std::endl;
  }
private:
  char m_str1[BUFER_SIZE_STR_1] = { 0 };
  char m_str2[BUFER_SIZE_STR_2] = { 0 };
};
int main()
{
  DemoClass testObj;
  testObj.DoSomething();
  return 0;
}      

程式本意是

m_str2

指派為

Coder

,

m_str1

Test

, 在程式設計中很多字元串拷貝或者操作中有些是在字元串末尾補

\0

有的可能不補

\0

, 而在本例中實際上

strcpy_s

會自動補0,但是有的程式員防止萬一,字元串靠背後,在數組的最後一位設定為’\0’。這種有時候就變成了好心辦壞事。

比如這裡的

m_str1[BUFER_SIZE_STR_2 - 1] = '\0';

,大家注意到沒,這裡應該改寫為

m_str1[BUFER_SIZE_STR_1 - 1] = '\0';

,也就是說程式員可能拷貝代碼或者不小心寫錯了

BUFER_SIZE_STR_2

BUFER_SIZE_STR_1

因為兩者宏差不多。隻要是人寫代碼,就有可能會犯這種錯誤。這個程式的輸出變為:

常識普及-C++常見的三種記憶體破壞場景1. 記憶體破壞之強制類型轉換2. 字元串拷貝溢出3. 随機性的記憶體被修改總結

這個程式是比較簡單,一目了然,但是在大型程式中呢,這個數組的位置跳躍的通路到了其他變量的位置,你首先得判斷這個被跳躍式修改的變量,是不是程式本意造成的,因為混合了這麼多的猜想,可能會導緻分析變的異常複雜。那麼有什麼好的方法嗎?隻要程式能偶爾重制這個問題,那就是有方法的。

通過Windbg調試指令

ba

可以在指定的記憶體位址做操作的時候進入斷點。假設目前已經知道

m_str2

的第四個字元,總是被某個地方誤寫,那麼我們可以在這個位址處設定一個

ba

指令: 當寫的這個記憶體位址的時候進入斷點。不過這樣還是有個問題,那就是程式中有可能有很多次對這塊記憶體的寫操作,有時候是正常的寫操作,如果一直進入斷點,人工分析将會非常累,不現實。

這個時候有個方法,同時也是一個

workaround

,就是當你還沒找到程式出錯的根本原因的時候在被誤踩的記憶體前面加上一個足夠大的不使用的空間。比如下面的代碼,

m_str2

總是被誤寫,于是在

m_str2

的前面加上一個100個位元組的不使用的記憶體

m_strUnused

(因為一般程式記憶體溢出是上溢,當然也可以在

m_str2

的後面同樣加上)。

這樣我們被踩的記憶體就很容易落在

m_strUnused

空間裡面了,這個時候我們在其空間裡設定寫記憶體操作的斷點,就容易捕獲到問題所在了。

#include <iostream>
#define BUFER_SIZE_STR_1 5
#define BUFER_SIZE_STR_2 8
#define BUFFER_SIZE_UNUSED 100
class DemoClass
{
public:
  void DoSomething()
{
    strcpy_s(m_str2, BUFER_SIZE_STR_2, "Coder");
    strcpy_s(m_str1, BUFER_SIZE_STR_1, "Test");
    
    //Notice this line:
    m_str1[BUFER_SIZE_STR_2 - 1] = '\0';
    std::cout << m_str1 << std::endl;
    std::cout << m_str2 << std::endl;
  }
private:
  char m_str1[BUFER_SIZE_STR_1] = { 0 };
  char m_strUnused[BUFFER_SIZE_UNUSED] = { 0 };
  char m_str2[BUFER_SIZE_STR_2] = { 0 };
};
int main()
{
  DemoClass testObj;
  testObj.DoSomething();
  return 0;
}      

下面完整的展示一下分析過程:

第一步

Windbg

啟動(有的情況下可能是Attach,根據情況而定)到調試程序,設定

main

的斷點

0:000> bp ObjectMemberBufferOverFllow!main
*** WARNING: Unable to verify checksum for ObjectMemberBufferOverFllow.exe
0:000> g
Breakpoint 0 hit
eax=010964c0 ebx=00e66000 ecx=00000000 edx=00000000 esi=75aae0b0 edi=0109b390
eip=003a1700 esp=00defa00 ebp=00defa44 iopl=0         nv up ei pl nz na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000206
ObjectMemberBufferOverFllow!main:
003a1700 55              push    ebp      

第二步

使用

p

指令單步執行代碼到

testObj.DoSomething();

第三步

找到

testObj

的位址為

00def984

0:000> dv /t /v
00def984          class DemoClass testObj = class DemoClass      

第四步

設定斷點到

testObj

相對偏移的位置,這個位置即

&m_str1+BUFER_SIZE_STR_2 - 1

=

&m_str1+7

。并且繼續執行代碼:

0:000> ba w1 00def984+7
0:000> g      

第五步

你會發現程式運作進入斷點,這個時候檢視對應的函數調用棧即可。這個斷點不一定在一個非常精确的位置,但是當你按照函數調用棧去閱讀附近的代碼,便比較容易找出問題所在了。

0:000> k
 # ChildEBP RetAddr  
00 00def97c 003a1720 ObjectMemberBufferOverFllow!DemoClass::DoSomething+0x41 [......\strcpybufferoverflow.cpp @ 16]
01 00def9fc 003a1906 ObjectMemberBufferOverFllow!main+0x20 [......\strcpybufferoverflow.cpp @ 30]
02 (Inline) -------- ObjectMemberBufferOverFllow!invoke_main+0x1c [d:\agent\_work\3\s\src\vctools\crt\vcstartup\src\startup\exe_common.inl @ 78]
03 00defa44 75818494 ObjectMemberBufferOverFllow!__scrt_common_main_seh+0xfa [d:\agent\_work\3\s\src\vctools\crt\vcstartup\src\startup\exe_common.inl @ 288]
04 00defa58 770a40e8 KERNEL32!BaseThreadInitThunk+0x24
05 00defaa0 770a40b8 ntdll!__RtlUserThreadStart+0x2f
06 00defab0 00000000 ntdll!_RtlUserThreadStart+0x1b      

總結

以上對三種記憶體破壞場景做了分析,在實際應用中将會變的更加複雜。在寫代碼的時候要注意避開其中的坑,有個叫做墨菲定律,你感覺可能會出問題的地方,那它一定會在某個時刻出現,當你對某個地方有所疑慮的時候一定要多加考慮,否則這個坑可能查找的時間,比寫代碼的時間要長的許多,更可怕的是可能會帶來意想不到的後果。同樣的分析問題要保持足夠的耐心,相信真相總會出現,這樣的底氣也是來自于自己平時不斷的學習和實踐。

記憶體破壞問題不區分棧上還是堆上,我們在産品中離不開使用堆開間,而且由多個子產品核心功能子產品組成,而這些子產品通常是公用一個程序預設堆的。是以也有人推薦在這些關鍵子產品中,各自建立一個獨立的堆,進而降低一個堆記憶體的使用對另一個堆中記憶體的影響。雖然不是完全隔離,但是也是一個聊勝于無的操作了。

繼續閱讀