安全使用CString [轉]

西呱發表於2019-01-18
1. 安全使用CString
 
今天我花了差不多一下午的功夫,解決了一個很隱蔽的bug,包括修改和排除相關的可能存在隱患程式碼。
就是一個關於CString的使用問題,重點體現在Format上。
目前我們的程式碼裡,對於Format的應用可以分為下面的幾種方式:
 
① 格式字串(format)和可變引數(args)都為非目標字串物件(str)
CString str;
str.Format( format, args );
 
② 將目標字串物件(str)初始化為格式字串(format),並作為格式化引數使用
CString str = format;
str.Format( str, args );
 
③ 將目標字串物件(str)初始化為某文字引數(args),並作為某一可變引數使用
CString str = text;
str.Format( format, str, args );
 
各位仔細分析一下,這幾種方式會不會出現什麼問題或安全隱患?
 
回答這個問題,需要了解WTL::CString::Format()的實現原理。檢視一下WTL::CString的原始碼可以看出,
CString::Format並沒有真正實現格式化字串的操作,而只是對相關API的封裝而已,即_vstprintf_s/_vstprintf
 
Format的實現虛擬碼如下(完整流程):
 
BOOL CString::Format( LPCTSTR lpszFormat, va_list argList )
{
// 預估算格式化後的結果字串長度nMaxLen
int nMaxLen = PreCalcLength( lpszFormat, argList );
 
// 為目標串分配足夠的記憶體
if( GetData()->nRefs > 1 ||                 // 有其他CString物件引用了本字串,這時必須重新分配記憶體
    nMaxLen > GetData()->nAllocLength )    // 原分配的記憶體不足,重新分配
{
// 快取原有字串。當重新分配記憶體之後,需要將原有字串拷貝到新記憶體中
CStringData* pOldData = GetData();
int nOldLen = GetData()->nDataLength;
 
// 由於存在引用計數,所有可能存在原始記憶體足夠但卻必須重新分配的情況(不能修改被其他CString物件引用的字串內容)
if( nMaxLen < nOldLen )
nMaxLen = nOldLen;
if( !AllocBuffer( nMaxLen ) )
return FALSE;
 
// 將原字串內容拷貝到新記憶體中
memcpy(m_pchData, (nMaxLen + 1) * sizeof(TCHAR), pOldData->data(), (nOldLen + 1) * sizeof(TCHAR));
GetData()->nDataLength = nOldLen;
 
// 釋放對原有字串物件的引用,這一步執行之後,原有字串物件將被銷燬,或其他應用者接管
CString::Release(pOldData);
}
 
// 呼叫庫函式/API進行真正的格式化字串操作
int nRet = _vstprintf( lpszFormat, argList );
ASSERT( nRet <= GetAllocLength() );
 
// 掃描結果字串,設定正確的字串結尾(新增'\0')
ReleaseBuffer();
 
return TRUE;
}
 
從虛擬碼看出,Format的基本功能分為三步:1. 估算結果字串長度,2. 分配足夠的記憶體,3. 呼叫API格式化字串。
 
那麼,現在我們再回過頭來分析文章開頭給出的Format的三種使用方式,就不難發現問題了。
問題就在於,在執行vsprintf時可能存在字串拷貝動作的源字串和目標字串記憶體區域相重疊的情況!想想下面這段程式碼會有什麼結果?
示例1:
char str[] = {"abcdefg1234567890"};
char* pDest = str, *pSrc = &str[1];
strcpy( pDest, pSrc );
 
天知道strcpy內部實現的是逐個字元拷貝的還是幾個幾個一起拷貝的?
那如果將引數pDest和pSrc交換位置呢?
示例2:
char str[] = {"abcdefg1234567890"};
char* pDest = &str[1], *pSrc = str;
strcpy( pDest, pSrc );
 
哈哈,這回如果使用strncpy,可以防止記憶體越界,卻無法得到我們想要的結果了!
 
vsprintf的實現也是一樣,因為在其可變引數列表的規則裡,無法指定"%s"型別源字串引數的長度,這就只能靠'\0'結束符來判斷字串拷貝的邊界,但這個邊界卻可能隨時被我們沖掉!
 
在CString::Format裡,如果經過估算,該物件本身已分配的記憶體足以儲存格式化結果串而沒有重新分配記憶體,這時候Format的使用方法②和方法③就出現了類似的問題。
方法②可能出現死迴圈和記憶體越界的危險,而方法③中,則可能將一個已經被修改了的源字串(也是目標串)作為拷貝物件,同樣可能出現死迴圈和記憶體訪問越界的危險!
 
另外,我們看到在分配記憶體這一步,原有字串就已經被釋放了,而_vstprintf卻是在這之後被呼叫。就是說_vstprintf可能會將一個已經被銷燬的字串作為拷貝源,這顯然是極其危險的,應當極力避免!
 
有興趣的可以對簡單測試一下下面的程式碼段,它們都是根據Format的原理精心構建出來的,非常可愛!
 
if(true)
{
// 執行結果:崩潰!
CString str = _T("%s test %s");
str.Format( str, _T("123"), _T("0") );
}
if(true)
{
// 執行結果:str == "8888"!錯誤!
CString str = _T("text2");
str = _T("abc");
str.Format( _T("%c%s"), _T('8'), str );
}
if(true)
{
// 執行結果:崩潰!
// 這是從現有工程中摘出來的一段程式碼,就是她為我這篇文章提供了最有力的證據,感謝她!
CString str = TEXT("<?xml version=\"1.0\" encoding=\"utf-8\" ?>")
  TEXT("<args>")
  TEXT("<personal nickname =\"%s\" gender =\"%s\" />")
  TEXT("</args>");
str.Format( str, _T("123"), _T("0") );
}
 
修改建議:現有程式碼中這樣的用法有很多,在一般情況下問題不容易暴露出來,但一旦出現,後果非常嚴重!
所以,建議儘量避免這種使用方式。如果已經用了,可以巧妙利用CString的引用計數功能來解決,即通過構造一個新CString物件來防止源字串物件被修改或被釋放。
如下面的形式:
 
1.   CString str = format;
str.Format( str, args );
str.Format( CString(str), args );    // 安全
 
2.   CString str = text;
str.Format( format, str, args );
str.Format( format, CString(str), args );    // 安全
 
2. 提升CString的執行效率
CString封裝的非常強大,但是從Format虛擬碼可以看出,其內部操作可能涉及到頻繁的記憶體分配和字串拷貝,而記憶體分配過程確也是非常耗時的,在Mobile上面速度就更慢。
所以建議做大量操作時根據實際情況預先估算大小並一次性分配足夠的記憶體,程式效率定會大幅提升!像下面的程式碼,至少應該做這樣的優化:
 
CString str = _T("http://");
str += strHost;
str += _T("/");
str += strPath;
優化為:
CString str;
str.Format( _T("http://%s/%s"), strHost, strPath );
 
Format的由於存在預處理與格式分析,執行效率比單純的字串拷貝要慢很多,現在程式碼中存在很多類似下面這樣的程式碼,個人認為也是應該避免的。
a).
CString strFormat = _T("%d");
CString strResult;
strResult.Format( strFormat, nID );
// 改為
strResult.Append( nID );    // CString& CString::Append(int n);
b).
CString strTemp( _T("text") );
CString strResult;
strResult.Format( _T("%s"), strTemp );
// 改為
strResult = _T("text");
c).
CString strHost;
strHost.Format( _T("http://%s"), szHost );
CString strPath;
strPath.Format( _T("%s/%s"), szDir, szFile );
CString strURL;
strURL.Foramt( _T("%s/%s"), strHost, strPath );
// 改為:
strURL.Format( _T("http://%s/%s/%s"), szHost, szDir, szFile );
 
 
3. 關於CString的模板膨脹
 
WTL::CString採用模板編寫,所有函式都是行內函數。在程式程式碼中用到一次CString將會生成大量相同的程式碼段!當 CString被作為引數以物件的形式傳遞或返回時,程式碼膨脹非常明顯(我們在以前的專案中做過測試,採用物件方式傳遞CString型別引數比使用引用 方式傳遞編譯後的目標程式大約10~30%)。所以建議:
a. 在作為引數時將CString弱化為LPCTSTR或採用引用方式傳遞,前者更方便於與不適用CString的部分程式碼相相容,這應該也是WTL的大多數類都不依賴於其他類的原因。
b. 儘量避免函式返回CString型別的物件(其他物件也一樣),避免頻繁構造和析構物件,也避免程式碼冗餘膨脹。而是將CString物件以引用引數的形式傳入函式,處理之後可以以引用返回,或返回函式執行結果(一般為BOOL型別),如下形式:
 
CString foo( CString strSrc );
改為:
CString& foo( IN const CString& strSrc, INOUT CString& strDest );    (不推薦,不便檢查函式執行成功與否,可以通過設定strDest為空返回,但需要多一步操作)
或:
BOOL foo( IN const CString& strSrc, INOUT CString& strDest );        (推薦)
或:
BOOL foo( IN LPCTSTR lpszSrc, INOUT CString& strDest );               (推薦,lpszSrc引數可以直接使用任一字串而不是限定在CString物件上)
或:
BOOL foo( IN LPCTSTR lpszSrc, INOUT LPTSTR lpszDest,  IN int nLength );    (API形式)
 
除此之外,ATL或MFC的CString還存在另外一個問題,已經有開發者發現了這個bug,WTL中將其修服了(其實似乎就是刪掉了這一功能)。內容摘錄如下:
 
有關CString的一個陷阱,小心了
文章出處:http://www.diybl.com/course/3_program/c++/cppjs/2008828/138377.html
 
 
發現有關CString的一個容易掉入的陷阱,重現問題的原始碼如下:
(注:編譯器為VC9 SP1)
void Test(LPCTSTR str)
{
    if (str == NULL)
        AfxMessageBox(_T("Null Pointer"));
    else
        AfxMessageBox(_T("A Valid String"));
}
CString str;
Test(str.IsEmpty() ? NULL : str);
 
    相信一定有人跟我一樣,會直覺上認為上面程式的執行結果為"Null Pointer"。但事實上,其結果為"A Valid String"。
    為什麼我(們)會這樣想呢?因為在我(們)的思維中,會習慣性認為NULL為空指標(事實上是int型別),而CString有到指標型別 (LPCTSTR)的自動轉換。因此,我們可能會認為,如果該布林表示式的條件為真,則傳遞NULL引數;如果條件為假,則先把str自動型別轉換為 LPCTSTR型別,然後傳遞轉換後的引數。一切想法那麼自然,流暢。然後,它卻是錯誤的。
    讓我們來仔細的審視下這段程式碼,其實,關鍵的也就這一布林表示式:
    str.IsEmpty() ? NULL : str
該 布林表示式的兩個結果子表示式型別分別為int(NULL的型別)和CString(str的型別),而C++語言規定布林表示式的型別為兩個結果子表達 的共同型別。然而,int和CString沒有共同型別,也無法相互轉換。因此,該表示式所在語句應當無法通過編譯器編譯。
    但這裡,該語句卻通過了編譯器(VC9 SP1)的編譯,並且執行結果為“A Valid String”。剛開始,我認為是編譯器錯誤地執行了布林表示式,把str作為了布林表示式的結果(為此,我還跑到Microsoft Forum上去發帖詢問,但無果)。後來,我到Microsoft Connect上釋出了一條關於此問題的資訊,很快有網友給出了意見,認為是使用NULL呼叫了CString的建構函式。我跟蹤測試了一下程式碼,確實如 此。編譯器先用NULL構造一個CString物件,然後把該CString物件轉換為LPCTSTR指標。後來,Microsoft也給出了官方對此問 題的跟蹤資訊,並最後給出了Visual C++ Compiler Team的官方解釋。引用其文如下:
Hi: with a recent build of the compiler then I am getting the following error message:
t498875.cpp(20) : error C2446: ':' : no conversion from 'CString' to 'int'
     No user-defined-conversion operator available that can perform this conversion, or the operator cannot be called
which is what I would expect. The C++ Standard requires that when faced with some code like:
    f(str.IsEmpty() ? NULL : str);
The compiler must convert "NULL" and "str" to a common type. Which in this case is either int or CString -- but there is no way to perform this conversion (in either direction).
Jonathan Caves
Visual C++ Compiler Team
 
    由此可知,這確實是當前版本VC編譯器的一個問題(雖然與我當初所想有所不同)。據Jonathan Caves所言,我們應該能夠在下一個VC釋出版中看到此問題的解決。

相關文章