標籤:

C++返回局部變數的引用是安全的嗎?

今天看到C++ Primer Plus上說用函數返回值給某變數賦值的時候,如果函數返回的是值,則這個值先複製到一個臨時變數上,再把這個臨時變數的值複製到要賦值的變數上,但返回引用就省了中間的臨時變數。一想這樣其實也是把引用的值複製給要賦值的變數啊,那即便是返回臨時變數的引用也應該不會造成內存泄漏。於是試了一下還真行:

int test();

int test(int);

int main()

{

int testVarl;

testVarl = test(10);

cout &<&< testVarl &<&< endl;

int testRef = test(10);

cout &<&< testRef &<&< endl;

return 0;

}

int test()

{

int i = 10;

return i;

}

int test(int t)

{

int i = 100;

int ir = i;

return ir;

}

現在想問下這麼做有沒有什麼潛在的危險或問題?


Talk is cheap, show me the assembly. 漏洞利用研究者所關注的,就是把這些看似undefined behavior的warning,變成真能格式化掉C盤的利用。

懶得往下翻的同學請直接看TL;DR:

這是一個典型的返回未初始化棧值的安全漏洞,第二個test函數事實上返回了對一個已經被回收的棧內存的指針然後使用,攻擊者完全可能通過棧布局,控制這個指針的值。這個sample里僅僅是返回一個int,問題還不是很大。如果這裡是一個有vtable的object,或者是有函數指針的struct,那麼完全有可能被利用來劫持程序控制流,格掉C盤。

測試環境Apple LLVM version 8.0.0 (clang-800.0.42.1),SystemV amd64 ABI,默認優化選項

gcc version 5.4.0 20160609 (Ubuntu 5.4.0-6ubuntu1~16.04.4),SystemV amd64 AB, 默認優化選項

題主這個代碼實際上馬上會被clang告警

test.cc:24:8: warning: reference to stack memory associated with local variable "i" returned [-Wreturn-stack-address]
return ir;
^~
test.cc:23:7: note: binding reference variable "ir" here
int ir = i;
^ ~
1 warning generated.

首先,我們需要重述一個事實。在編譯後生成的機器碼中,引用和指針是完全等效的。讓我們來看看test1生成的彙編:

; __int64 __fastcall test(int)
public __Z4testi
__Z4testi proc near

var_10= qword ptr -10h
var_8= dword ptr -8 (int i;)
var_4= dword ptr -4 ()

push rbp
mov rbp, rsp ;棧準備操作
lea rax, [rbp+var_8] ; int ir = i; 取i地址
mov [rbp+var_4], edi ; 入參t,沒有使用
mov [rbp+var_8], 64h ; int i = 100;
mov [rbp+var_10], rax ;
mov rax, [rbp+var_10] ; 棧地址被放入rax中返回
pop rbp
retn ;棧恢復操作
__Z4testi endp

再來看main函數的彙編,關注對test1實施調用的地方

mov edi, 0Ah ; int
mov ecx, [rax]
mov [rbp+var_8], ecx
call __Z4testi ; test(int)

也就是說,在第二個test函數(test1)中,stack frame是這樣的

(lldb) ni
Process 7923 stopped
* thread #1: tid = 0xaf6d9, 0x0000000100000f9a test`test(int) + 26, queue = "com.apple.main-thread", stop reason = instruction step over
frame #0: 0x0000000100000f9a test`test(int) + 26
test`test:
-&> 0x100000f9a &<+26&>: pop rbp
0x100000f9b &<+27&>: ret
0x100000f9c &<+28&>: nop dword ptr [rax]

test`test:
0x100000fa0 &<+0&>: push rbp
(lldb) x/10xg $rbp
0x7fff5fbffa40: 0x00007fff5fbffa60 0x0000000100000f59
0x7fff5fbffa50: 0x00007fff5fbffa70 0x0000000000003036
0x7fff5fbffa60: 0x00007fff5fbffa70 0x00007fff97013255
0x7fff5fbffa70: 0x0000000000000000 0x0000000000000001
0x7fff5fbffa80: 0x00007fff5fbffbd0 0x0000000000000000
(lldb) x/10xg 0x7fff5fbffa30
0x7fff5fbffa30: 0x00007fff5fbffa38 0x0000000a00000064
0x7fff5fbffa40: 0x00007fff5fbffa60 0x0000000100000f59
0x7fff5fbffa50: 0x00007fff5fbffa70 0x0000000000003036
0x7fff5fbffa60: 0x00007fff5fbffa70 0x00007fff97013255
0x7fff5fbffa70: 0x0000000000000000 0x0000000000000001

在test1返回後,它的棧幀空間被回收,現在testRef指向了一片荒漠,你可以叫它un-initialized area。在下一次函數調用中,這塊內存可能沒有被覆蓋,依然保持著100這個值,也有可能被覆蓋,從而引發問題。

為了直觀展示它的危害,我們假設有這樣一個toy 程序,一個所謂的console登陸應用。你需要提供一個正確的密碼,才能獲得vczh的愛,列印出Vczh loves you。為了簡潔期間,這裡我們不使用cin/cout,直接使用stdio

#include &
#include &
void judge(int);
int checkPass(const char*);
int main()
{
puts("#Simple Login system, auth required, input your pass");
char passbuf[16];
fgets(passbuf, sizeof(passbuf), stdin);
int ret = checkPass(passbuf);
judge(ret);

return 0;
}

void judge(int ir)
{
char name[40];
puts("#what"s your name, Sir?");
fgets(name, sizeof(name), stdin);
if(ir)
{
printf("#congratulations! Vzch loves %s
", name);
}
else
{
printf("#oops, try again, %s
", name);
}
}

int checkPass(const char* pass)
{
int i = strcmp(pass, "YourSecret") == 0;
int ir = i;
return ir;
}

看看這個程序,假設你不知道YourSecret,是不是除了爆破沒有辦法輸出Vczh loves you?

? /tmp ./test2
#Simple Login system, auth required, input your pass
dontknow
#what"s your name, Sir?
zhihuuser
#oops, try again, zhihuuser

但是事實上,在judge調用的時候,name這個char array在棧上會向下延展,事實上佔據了原來ret應用所指向的內存空間,我們只需要構造特定的輸入,覆蓋掉原來ret的地址,就可以修改ret的值,實現調用繞過

觀察checkPass的彙編:

var_20= qword ptr -20h
var_18= qword ptr -18h
var_C= dword ptr -0Ch
var_8= qword ptr -8

push rbp
mov rbp, rsp
sub rsp, 20h
lea rax, [rbp+var_C] ;返回的是 rbp-Ch這個地址
mov [rbp+var_8], rdi
mov rdi, [rbp+var_8] ; char *
lea rsi, aYoursecret ; "YourSecret"
mov [rbp+var_20], rax
call _strcmp
cmp eax, 0
setz cl
and cl, 1
movzx eax, cl
mov [rbp+var_C], eax
mov rsi, [rbp+var_20]
mov [rbp+var_18], rsi
mov rax, [rbp+var_18]
add rsp, 20h
pop rbp

再觀察judge函數的彙編。需要注意的一點是,因為兩次調用中間父函數rsp沒有發生變化,在這裡兩個子函數的rbp值也是一樣的。

var_50= dword ptr -50h
var_4C= dword ptr -4Ch
var_48= qword ptr -48h
var_3C= dword ptr -3Ch
var_38= qword ptr -38h
var_30= byte ptr -30h
var_8= qword ptr -8

push rbp
mov rbp, rsp
sub rsp, 50h
lea rax, aWhatSYourNameS ; "#what"s your name, Sir?"
mov rcx, cs:___stack_chk_guard_ptr
mov rcx, [rcx]
mov [rbp+var_8], rcx
mov [rbp+var_38], rdi
mov rdi, rax ; char *
call _puts
mov esi, 28h ; int
mov rcx, cs:___stdinp_ptr
lea rdi, [rbp+var_30] ; char *
mov rdx, [rcx] ; FILE *
mov [rbp+var_3C], eax ; fgets寫入地址
call _fgets
mov rcx, [rbp+var_38]
cmp dword ptr [rcx], 0
mov [rbp+var_48], rax
jz loc_100000E8F

fgets從rbp-30h的地址開始接受用戶輸入,共寫入28h位元組,也就是覆蓋了rbp-30h到rbp-8h的地址空間,剛好包含了rbp-Ch這個checkPass函數返回值所指向的地址!

所以只要構造出足夠長的輸入,覆蓋到rbp-8h,就能成功列印出Vczh loves you:

? /tmp ./test2
#Simple Login system, auth required, input your pass
dontknow
#what"s your name, Sir?
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
#congratulations! Vczh loves aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa

(具體的棧偏移可能受不同版本編譯器和不同編譯選項影響,請對應調整)

這還只是最簡單的情況,如果這是一個c++ object的引用,有虛表,那麼我們完全可以通過劫持虛函數,直接接管程序。


這就是緩存溢出攻擊的信號啊


好機智啊。

這樣做確實不會直接導致內存泄露(memory leak)。只會造成內存不安全(memory unsafety)。rust 社群常說的,Memory Leaks are Memory Safe。內存不安全的後果比內存泄露嚴重得多


如果不考慮右值引用——比如你的函數返回值是一個基本類型變數——的話,那這種行為是作大死


題主你犯了兩個認識上的錯誤。

原話是:

一想這樣其實也是把引用的值複製給要賦值的變數啊,那即便是返回臨時變數的引用也應該不會造成內存泄漏。

第一個錯是: 把引用的值複製給要賦值的變數。

錯了,你看看你的寫法:

int testRef = test(10);

這是將引用的地址複製給要賦值的變數。

地址只是內存的一個編號,就像街道地址,地址一直在,只是地址上的樓已經隨test函數調用的結束而人去樓空,不能再訪問。

第二個月錯是,你說到內存泄漏。可這裡沒有內存泄漏的事。從頭到尾又沒有分配堆內存的操作。討論泄漏幹嘛? 例中的內存都是棧內存,全是自動釋放的。所以這裡的錯的叫法也正是: 訪問了已釋放的內存。


兄弟你把人家primer的書的意思理解錯了。

不是這個意思 「再把這個臨時變數的值複製到要賦值的變數上,但返回引用就省了中間的臨時變數」

人家說的意思是

struct x{};
x ret_x(){return x();}

int main(){
const x xx = ret_x();
//balbalablall
return 0;
}

人家絕對沒有讓你去返回引用的。。


說一個跟題主的問題關係不太大的意見。

C++ Primer Plus是本很糟糕的書,比C++ Primer差很多。

C++11引入了move和copy elision的概念。所以把函數返回值賦給變數的話,這個過程沒有copy也沒有賦值,c++會直接創建那個變數。


不安全,應該絕對禁止。


C++的引用通常是用類似指針的方式實現的。函數中的局部變數存放在棧中,只在函數執行期間有效,執行完畢後就可能被挪作他用。如果函數返回了一個局部變數的引用,那麼這個引用只是指向棧中局部變數曾經所在的位置,類似於「野指針」。


你說的這個「如果函數返回的是值,則這個值先複製到一個臨時變數上,再把這個臨時變數的值複製到要賦值的變數上」在有了右值引用以及copy elision以後已經不是問題了。內存泄露的意思不是這樣,而是指分配的內存無法被回收的情況。函數不應該返回對局部變數的引用,因為在離開函數體的時候局部變數的生存期已經結束,因此試圖訪問它其結果是未定義的——你可能會得到期望的值,也可能會格式化C盤。


這顯然不行呀,你返回來的棧上(棧外)的地址,讀寫當然是沒問題,但是值沒變,只能說明後續的寫操作沒有 touch 到那裡。當然,那裡的值隨時都可能被「覆寫」。


如果是64位x64編程,就題主這個來說是安全的,引用了子函數的堆棧的內容,父函數子函數都比較簡單,子函數的臨時變數在父函數的redzone區域內 ,redzone是rsp以下128位元組,這個區域的數據是保留的,不會被修改。相當於在主函數裡面申明了臨時變數一樣。如果是32位,則沒有這個要求,那麼在子函數返回之後發生了中斷,中斷有可能破壞子函數臨時變數區域的棧,是不安全的。而64位中斷不會修改這個區域。總得來說這種做法是不推薦的。


Effective c++裡邊有一個條款詳細的講解了這個問題

條款名稱好像是 該返回值的時候,不要返回它的引用,書不在身邊,不記得了具體叫啥了


安全問題已經有人說了,談一下性能。

首先,針對題目的例子,返回引用等價於返回指針等價於一次指針賦值,比起一次 int 賦值並不節省中間變數。

如果要返回的是複雜的對象,請相信編譯器會把它 move 掉的,比起題主返回引用的做法,除了內存安全依然不會有什麼性能區別。


對於指針和引用類型的函數,返回值必須從型參傳入,或者返回值是全局變數。因為函數完成後,他所佔用的存儲空間也隨之釋放掉了,因此,這也意味著局部變數的引用將指向不再有效的內存區域。

c++primer對此的解釋:


你這個結果如果是對的是因為函數棧沒有被覆蓋,如果覆蓋了,結果就很難說了。


C++ Primer Plus 是蹭熱點的假書(個人觀點)。

請看 Effective C++ 第21條


當然不安全,在函數返回之前局部變數就被銷毀了,因此返回的局部變數的引用就像「野指針」一樣虛無縹緲。

進一步想想:如果這裡操作的不是內置類型,而是一個類的對象呢?這是有可能造成內存泄露的。

《effective c++》條款21「必須返回對象時,別妄想返回其引用」這一節中對該問題有充分的討論。

總的來說就是:

函數返回一個指向局部對象的指針或引用,是「未定義行為」,你想對該函數返回的對象做任何操作都是不行的。

所以,不要在程序中以身犯險。。。


如果函數不複雜,直接返回臨時對象,編譯器會做RVO 優化


返回的是esp的值,如果esp中的值還沒改變的話是能得到正常結果的,奇怪的是vs不報錯嗎?


推薦閱讀:

iOS開發包含哪些內容?
windows下的服務和進程有什麼區別?
Swift 現在的語法穩定了嗎,之後會不會出現像從 Swift 1 到 Swift 2 這樣的大改動?
哪些 C++ 項目的源代碼最值得閱讀?
編譯器中都有哪些演算法?

TAG:編程 | C | CC | C編程 |