2010年10月14日

[心得]sprintf and snprintf and buffer overflow 測試與研究

[2010/10/18]Update : 修正一些錯誤,以及改變排版方式

史萊姆最近被一些問題困擾著 :
到底要怎樣才能確定 sprintf 不會 buffer overflow 以及有正確的 zero end 呢 ?
sprintf 與 sprintf_s 到底有何不同 ?
看見很多人的 char buffer 都用 [BUF_MAX+1] 來宣告 ... 為何要 +1 ?

這小小的問題一點一點地在心中擴大,為了避免連夢中都會出現
本著實驗的精神,我打開 VS2010 做了以下的測試 ...

(註:以下程式碼會採用 TCHAR 等 unicode 轉換相容語法)



首先,我先定義了一些常數

//
// 暫存區大小
const unsigned int c_BUFFER_MAX = 8;
// 字串大小
const unsigned int c_STRING_MAX = 10;
// 超過暫存區的字串
const TCHAR c_Copy_String[c_STRING_MAX+1] = _T("1234567890");
// 小於暫存區的字串
const TCHAR c_Safe_String[c_BUFFER_MAX+1] = _T("1234567");
//

字串的記憶體狀態如圖所示



然後使用結構來包裝三個 char array ,這是為了要確保記憶體會是連續的
並且在預設建構式將暫存區做 zero initialize

//
struct Buf3
{
    TCHAR szBuf1[c_BUFFER_MAX];
    TCHAR szBuf2[c_BUFFER_MAX]; 
    TCHAR szBuf3[c_BUFFER_MAX];

    Buf3()
    {
        memset(szBuf1, 0, sizeof(szBuf1));
        memset(szBuf2, 0, sizeof(szBuf2));
        memset(szBuf3, 0, sizeof(szBuf3));
    }
};
//

另外建置一個對照組,是尾端有 +1 的版本
同樣的初始化的時候,全部設定成為 0 ,包含額外的那一個

// (暫存區尾端+1)
struct Buf3_s
{
    TCHAR szBuf1[c_BUFFER_MAX+1];
    TCHAR szBuf2[c_BUFFER_MAX+1]; 
    TCHAR szBuf3[c_BUFFER_MAX+1];

    Buf3_s()
    {
        memset(szBuf1, 0, sizeof(szBuf1));
        memset(szBuf2, 0, sizeof(szBuf2));
        memset(szBuf3, 0, sizeof(szBuf3));
    }
};
//

來看看字串大小是否如預期一樣

// 取得字串大小
size_t sizeCopyString = 0;
size_t sizeSafeString = 0;
sizeCopyString = _tcslen(c_CopyString); // 10
sizeSafeString = _tcslen(c_SafeString); // 7
// 這只是字串大小,實際儲存大小會多出一個 \0 結尾

果然,sizeCopyString 大小為 10
接著,產生一個暫存區結構,並且看看第二個暫存區大小

// 宣告暫存區
Buf3 stBuf;
size_t sizeBuffer = 0;
size_t sizeCount = 0;
sizeBuffer = sizeof(stBuf.szBuf2); // 16
sizeCount = sizeof(stBuf.szBuf2) / sizeof(TCHAR); // 8
// 注意:記憶體大小與元素數量不一定相同

果然,sizeBuffer 大小為 16 bytes (因為是 unicode)
而內含元素數量為 8 個

========== 喘口氣分隔線,休息一下 ==========

實驗開始 :

首先,使用最常用的 sprintf 系列來複製字串
// sprintf
_stprintf(stBuf.szBuf2, _T("%s"), c_CopyString);
// wrong usage -> buffer overflow.
結果:錯誤的用法, 暫存區溢位

如圖所示


你看,硬生生的把後面的暫存區蓋掉了三個,真是慘劇啊!!
(除了數字 9 與 0 之外,還外加一個 \0 結尾在 [2] 的位置,因為原本就是 \0 所以看不出來)
後方的受害者永遠找不出到底是誰蓋掉了自己的記憶體區塊
(因為有可能是從很遠的地方一路蓋過來)
我認為這是最難追蹤的 bug ! 千萬要避免

於是,微軟的編譯器在編譯時,會很熱心地警告使用者,要用尾端有加 _s 的呼叫
===
註:編譯期警告為
warning C4996: 'XXX': This function or variable may be unsafe. Consider using XXX_s instead. To disable deprecation, use _CRT_SECURE_NO_WARNINGS.
===

那麼,讓我們來試試看
// sprintf_s
_stprintf_s(stBuf.szBuf2, _T("%s"), c_CopyString);
// assert -> buffer too small.
結果:跳出暫存區太小的警告, 程式中斷


應用程式直接死掉了 ... 而且是 ASSERT 等級, 連 try ... catch 都抓不到
在資料面來說,確實是"安全"了,但是對於應用程式來說卻問題大了
尤其是在背景運行的伺服器,發生了這種狀況,可能連 dump 都來不及就消失了 T_T
於是,你可能會常常被叫去老闆的辦公室喝茶XD

那 ... 有沒有其他的解法呢 ?
啊,我知道了,給他目標緩衝區的大小,就可以預防了吧?
// sprintf_s
_stprintf_s(stBuf.szBuf2, sizeBuffer, _T("%s"), c_CopyString);
// wrong usage -> buffer overflow.
結果:錯誤的用法, 暫存區溢位

咦?雖然程式沒有中斷但是卻還是溢位了,你不是說這是安全的呼叫嗎?!
其實,第二的參數是指元素數量,如果給了過大的值,還是一樣會有溢位的情況
會通過安全檢查是因為 sizeBuffer > _tcslen(c_CopyString) 的關係
簡單說,就是用錯方法(給錯參數)啦XD

那換其他幾種呼叫呢?
// sprintf_s
_stprintf_s(stBuf.szBuf2, sizeCopyString, _T("%s"), c_CopyString);
// assert -> buffer too small.
_stprintf_s(stBuf.szBuf2, sizeCount, _T("%s"), c_CopyString);
// assert -> buffer too small.
_stprintf_s(stBuf.szBuf2, sizeCount-1, _T("%s"), c_CopyString);
// assert -> buffer too small.
結果:跳出暫存區太小的警告, 程式中斷

使用 _s 的安全呼叫,大部分的情況只要
目標緩衝區 < 來源大小 都會用 ASSERT 報錯,以確保資料是正確的 ...
但是程式就直接掛點了 orz

不然這樣,我們用 snprintf 系列的,規定他複製的大小總可以吧 ...?
// snprintf
_sntprintf(stBuf.szBuf2, sizeBuffer, _T("%s"), c_CopyString);
// wrong usage -> buffer overflow
_sntprintf(stBuf.szBuf2, sizeCopyString, _T("%s"), c_CopyString);
// wrong usage -> buffer overflow.
結果:錯誤的用法, 暫存區溢位, 編譯時期警告

同上,第二個參數是指複製的元素數量,如果多給了,一樣會造成緩衝區溢位
那麼,如果給他元素數量總可以吧?
// snprintf
_sntprintf(stBuf.szBuf2, sizeCount, _T("%s"), c_CopyString);
// danger -> no zero end.
結果:危險的用法, 沒有 0 結尾


至少沒有發生緩衝區溢位的情形了
不過原本應該有的 \0 結尾卻沒地方可以放而憑空消失了!!
為了預防這種狀況,我們必須要把元素減去 1 來給結尾額外的空間
// snprintf
_sntprintf(stBuf.szBuf2, sizeCount-1, _T("%s"), c_CopyString);
// ok -> has zero end.
結果:正確的 0 結尾


總算找到能夠安全複製字串的方法了!!
雖然這有個小缺點,就是多出來的字串會被 截斷
不過總比 緩衝區溢位 或是 ASSERT 中斷程式要來得好,我如此認為

那試試看有安全的 _s 看看?
// snprintf_s
_sntprintf_s(stBuf.szBuf2, sizeBuffer, _T("%s"), c_CopyString);
// assert -> buffer too small.
_sntprintf_s(stBuf.szBuf2, sizeCopyString, _T("%s"), c_CopyString);
// assert -> buffer too small.
_sntprintf_s(stBuf.szBuf2, sizeCount, _T("%s"), c_CopyString);
// assert -> buffer too small.
_sntprintf_s(stBuf.szBuf2, sizeCount-1, _T("%s"), c_CopyString);
// ok -> has zero end.
結果還是只有最後的方法能平安複製,其他的方法都會讓程式中斷

再來看看多載的另一個版本
// snprintf_s
_sntprintf_s(stBuf.szBuf2, sizeBuffer, sizeCopyString, _T("%s"), c_CopyString);
// wrong usage -> buffer overflow.
_sntprintf_s(stBuf.szBuf2, sizeCount, sizeCount, _T("%s"), c_CopyString);
// assert -> buffer too small.
_sntprintf_s(stBuf.szBuf2, sizeCount, sizeCount-1, _T("%s"), c_CopyString);
// ok -> has zero end.
也是只有最後的方法才能平安複製,給大家當個參考

既然要給元素數量減一的數量,那我們一開始就已經知道元素數量了
因為緩衝區的陣列,我們是使用常數 c_BUFFER_MAX 去定義的
所以,可以將語法換成如下所示:

// 參考範例
_stprintf_s(stBuf.szBuf2, c_BUFFER_MAX, _T("%s"), c_CopyString);
// assert -> buffer too small
_stprintf_s(stBuf.szBuf2, c_BUFFER_MAX-1, _T("%s"), c_CopyString);
// assert -> buffer too small

_sntprintf(stBuf.szBuf2, c_BUFFER_MAX, _T("%s"), c_CopyString);
// danger -> no zero end.
_sntprintf(stBuf.szBuf2, c_BUFFER_MAX-1, _T("%s"), c_CopyString);
// ok -> has zero end.

_sntprintf_s(stBuf.szBuf2, c_BUFFER_MAX, _T("%s"), c_CopyString);
// assert -> buffer too small
_sntprintf_s(stBuf.szBuf2, c_BUFFER_MAX-1, _T("%s"), c_CopyString);
// ok -> has zero end.

_sntprintf_s(stBuf.szBuf2, c_BUFFER_MAX, c_BUFFER_MAX-1, _T("%s"), c_CopyString);
// ok -> has zero end.

大家可以比較看看差異在哪 ^_^

========== 喘口氣分隔線,休息一下 ==========

接下來試試看有多 +1 的暫存區會有怎樣不同的結果
Buf3_s stBuf_s;
sizeBuffer = sizeof(stBuf_s.szBuf2); // 18
sizeCount = sizeof(stBuf_s.szBuf2) / sizeof(TCHAR); // 9
確認,sizeBuffer 大小為 18 bytes (因為是 unicode)
而內含元素數量為 9 個

實驗趴特兔開始 :

以下就不贅述了,基本上都跟上面一樣,但是要注意有些地方的結果有微妙的差異
//
_stprintf(stBuf_s.szBuf2, _T("%s"), c_CopyString);
// wrong usage -> buffer overflow.
_stprintf_s(stBuf_s.szBuf2, _T("%s"), c_CopyString);
// assert -> buffer too small.

_stprintf_s(stBuf_s.szBuf2, sizeBuffer, _T("%s"), c_CopyString);
// wrong usage -> buffer overflow.
_stprintf_s(stBuf_s.szBuf2, sizeCopyString, _T("%s"), c_CopyString);
// assert -> buffer too small.
_stprintf_s(stBuf_s.szBuf2, sizeCount, _T("%s"), c_CopyString);
// assert -> buffer too small.
_stprintf_s(stBuf_s.szBuf2, sizeCount-1, _T("%s"), c_CopyString);
// assert -> buffer too small.

_sntprintf(stBuf_s.szBuf2, sizeBuffer, _T("%s"), c_CopyString);
// wrong usage -> buffer overflow.
_sntprintf(stBuf_s.szBuf2, sizeCopyString, _T("%s"), c_CopyString);
// wrong usage -> buffer overflow.
_sntprintf(stBuf_s.szBuf2, sizeCount, _T("%s"), c_CopyString);
// danger -> no zero end.
_sntprintf(stBuf_s.szBuf2, sizeCount-1, _T("%s"), c_CopyString);
// ok -> has zero end.

_sntprintf_s(stBuf_s.szBuf2, sizeBuffer, _T("%s"), c_CopyString);
// assert -> buffer too small.
_sntprintf_s(stBuf_s.szBuf2, sizeCopyString, _T("%s"), c_CopyString);
// assert -> buffer too small.
_sntprintf_s(stBuf_s.szBuf2, sizeCount, _T("%s"), c_CopyString);
// assert -> buffer too small.
_sntprintf_s(stBuf_s.szBuf2, sizeCount-1, _T("%s"), c_CopyString);
// ok -> has zero end.

_sntprintf_s(stBuf_s.szBuf2, sizeBuffer, sizeCopyString, _T("%s"), c_CopyString);
// wrong usage -> buffer overflow.
_sntprintf_s(stBuf_s.szBuf2, sizeCount, sizeCount, _T("%s"), c_CopyString);
// assert -> buffer too small.
_sntprintf_s(stBuf_s.szBuf2, sizeCount, sizeCount-1, _T("%s"), c_CopyString);
// ok -> has zero end.

同樣的,既然需要的是元素數量,那麼我們也可以直接給常數
不過在這邊不太一樣的是,因為我們宣告時已經事先 +1 了
所以這邊只要給常數值就可以了,不用額外計算,很方便吧XD
//
_stprintf_s(stBuf_s.szBuf2, c_BUFFER_MAX, _T("%s"), c_CopyString);
// assert -> buffer too small
_stprintf_s(stBuf_s.szBuf2, c_BUFFER_MAX-1, _T("%s"), c_CopyString);
// assert -> buffer too small

_sntprintf(stBuf_s.szBuf2, c_BUFFER_MAX, _T("%s"), c_CopyString);
// ok -> has zero end.

_sntprintf_s(stBuf_s.szBuf2, c_BUFFER_MAX, _T("%s"), c_CopyString);
// ok -> has zero end.
_sntprintf_s(stBuf_s.szBuf2, c_BUFFER_MAX+1, c_BUFFER_MAX, _T("%s"), c_CopyString);
// ok -> has zero end.

好棒,因為多了一格可以寫入結尾,所以也不會發生 ASSERT 讓程式中斷了!! ^o^

===

總結:
只有上述會發生"跳出暫存區太小的警告, 程式中斷"
或者是成功的複製並且有 0 結尾的案例,才是安全可用的

目前我主要會使用 _snprintf 來複製字串到暫存區
並且在暫存區預設大小宣告後面 +1 用來存放結尾的 \0

===

既然找到了方法,或許也會有人疑惑,這幾種呼叫到底速度差多少?

效能評比 :
每種指令共執行 10 次, 其中會跑 10,000 次迴圈之後統計經過時間

_stprintf()

Process Time : 0.00640352
Process Time : 0.00498874
Process Time : 0.00507428
Process Time : 0.0054189
Process Time : 0.00503517
Process Time : 0.00555856
Process Time : 0.00544438
Process Time : 0.00511618
Process Time : 0.00560814
Process Time : 0.00508056

_stprintf_s()

Process Time : 0.00655505
Process Time : 0.006576
Process Time : 0.0063068
Process Time : 0.00592866
Process Time : 0.00618704
Process Time : 0.00634346
Process Time : 0.00621532
Process Time : 0.00603376
Process Time : 0.00595206
Process Time : 0.00645938

_sntprintf()

Process Time : 0.00656133
Process Time : 0.0067181
Process Time : 0.00601665
Process Time : 0.00699813
Process Time : 0.00649464
Process Time : 0.00588921
Process Time : 0.00676
Process Time : 0.00663431
Process Time : 0.00601595
Process Time : 0.0064737

_sntprintf_s()

Process Time : 0.00808889
Process Time : 0.00786718
Process Time : 0.00768282
Process Time : 0.00837694
Process Time : 0.00739302
Process Time : 0.00789266
Process Time : 0.00790768
Process Time : 0.00796843
Process Time : 0.00748799
Process Time : 0.00742235

可以看見,要使用安全的作法,確實會造成效能損失
但是那也只是多了一次判斷,一般的人根本感覺不到

以上,希望可以提供給大家做為參考

沒有留言: