Go語言安全研究-內存安全

 

0x00 重釋放

1、指針重釋放

沒有釋放指針內存的語法 ,不涉及。Go使用垃圾收集器自動管理內存,無需顯示釋放內存,同時懸掛指針以及多次釋放同一個指針指向內存的問題就不會發生。

下面通過一個例子證明go中內存是自動回收的,代碼如下:

函數前後打印內存使用狀況,並啓動協程每10s打印一次內存使用情況。

通過指針申請了100M空間,申請結束後打印內存使用狀況

內存狀態函數爲:

程序運行後,打印如下所示:

         在mutiFreeTest函數中申請堆內存後,第一處紅框顯示了程序使用的內存增加了200M,和事實相符。在返回到主函數後,雖然函數返回前沒有通過類似free或delete形式的釋放內存,由於go的GC機制在第二處紅色框處Alloc減少了200M,內存得到釋放。所以go中沒有內存釋放的語法,而是有GC收集這些堆內存資源,也就沒有重釋放指針的問題。

         這裏引申出一個問題:既然Go中沒有提供內存釋放語法並通過GC進行內存管理,是不是就沒有內存泄漏的風險了?請看下文內存泄漏小節。

         注:理解上述內存字段的含義,建議讀者去理解Go語言中的內存管理機制-內存池。

 

2、資源重釋放

重複釋放go對象資源會觸發運行時錯誤,從而造成Dos攻擊,例如:文件、數據庫對象、網絡連接、channel等對象資源。這種情況經常發生在使用了defer進行close資源後,仍然在異常分支結構中也加入了資源關閉close語句,如果惡意攻擊者構造出錯誤條件使程序進入異常分支結構,則運行錯誤。例如下面的代碼:

當用戶輸入除數爲0的情況時,程序異常退出:

程序報錯並退出:關閉已經關閉的channel。

0x01 解引用空指針

解引用一個空指針能導致Go程序崩潰,程序如下:

         代碼中僅申明瞭指針,而沒有爲其new分配空間,指針變量類型爲nil,當解引用時產生解引用空指針。上圖所示報錯信息爲:空指針解引用並退出程序。這是和C++指針的使用是相同的。修改的建議增加代碼中的註釋部分,在解引用指針前進行判空。

0x02 內存分配大小控制

計算機內存資源是有限的,尤其對未經校驗的外部數據作爲make申請內存的大小時,容易將服務器資源耗盡,使得其他服務沒有內存資源運行;或者內存超過服務器可提供的大小,使得程序無法從系統獲取資源而無法運行。這些都最終會導致Dos攻擊。下面給出一個例子:

程序將用戶輸入的數據作爲make申請分片的內存大小,若用戶惡意輸入非常大的數據,會造成如下錯誤:

0x03 unsafe包引發的內存風險

指針在Go中是“安全”的,指針在正常的代碼編寫中無法指向任意的內存區域,緩衝區溢出的問題和訪問越界問題是不會發生的。除非爲了算法優化等其他原因而使用unsafe包的Unsafe.Pointer和Uintptr方法繞過指針使用的限制,使得和C++中的指針用法和效果相似,會造成用戶的使用不當造成上述問題。

代碼參見整數安全中的案例試驗2.2小節的分享。

http://3ms.huawei.com/hi/group/2347/thread_7812256.html?mapId=9604496

如果不使用unsafe包對內存操作,密碼驗證的操作如下:

var rightPWD string = "12121212";
func validate_pwd(passwd string) bool {
	var isRight bool = false;
	var tempPwd [8]rune;
	//var tempPwdPtr *rune = &tempPwd[0] //即使獲取到首地址指針也無法移動進行操作
	//fmt.Println("----------------");
	//fmt.Println(tempPwd)    //go中數組名不代表地址,並且指針不支持位移操作
	//var add = &tempPwd    //只能&tempPwd[0]獲取地址
	//fmt.Println(add)   //不滿足通過指針操作覆蓋棧內容,而且數組會檢查索引越界
	//fmt.Println(&tempPwd[0]);//無法通過緩衝區溢出造成緩衝區溢出和惡意代碼執行
	//fmt.Println("----------------");
	var passwd_len uint8 = uint8(len(passwd)); //數據截斷使得以下判斷失效 len返回int類型大小
	if (4 <= passwd_len && passwd_len <= 33) {
		fmt.Println("密碼長度正常")
		for index,char:=range passwd{
			tempPwd[index] = char;    //數組索引會出界,程序會退出
			//tempPwdPtr = tempPwdPtr + 1; //Go中無法對指針進行運算,編譯報錯
		}
		isRight = true
		for index,char:=range rightPWD{
			if char!=tempPwd[index] {
				isRight = false
			    break;
			}
		}	
	} else{
		fmt.Println("密碼長度異常")
		isRight = false
	}
	return isRight
}

上述代碼正常使用了Go中的指針,雖然指針能夠獲取數組的首元素的地址,但是在循環中通過運算指針編譯是不通過的。Go是不允許對指針進行運算。所以此時只能通過數組名對每個元素進行索引,並且當索引越界時程序會因索引越界退出。Go中對指針這種限制,在上述代碼中,由於整數的截斷漏洞,最多隻會因爲數組索引出界引發程序退出造成Dos攻擊,而不會造成更嚴重的緩衝區溢出風險,從而帶來惡意代碼執行。

若開發人員使用了unsafe包中的方法突破指針的限制來實現上述相同功能的代碼,將會產生嚴重問題,代碼如下所示:

var rightPWD string = "12121212";
func validate_pwd(passwd string) bool {  
	var isOverWrite = [8]byte{0xFE,0xFE,0xFE,0xFE,0xFE,0xFE,0xFE,0xFE};
	var isRight bool = false;
	var tempPwd [8]byte;
	var tempPwdPTR *byte = &tempPwd[0];
	var passwd_len uint8 = uint8(len(passwd)); //數據截斷使得以下判斷失效
	if (4 <= passwd_len && passwd_len <= 8) {
		fmt.Println("密碼長度正常")
		for index,char:=range passwd{  //range會把字節轉換成rune類型
			tempPwdPTR = (*byte)(unsafe.Pointer(uintptr(unsafe.Pointer(&tempPwd[0])) + uintptr(index)))
			*tempPwdPTR = byte(char)
			//fmt.Println(index)
		}
		isRight = true
		for index,char:=range rightPWD{
			if char!=rune(tempPwd[index]) {
				isRight = false
			    break;
			}
		}	
	} else{
		fmt.Println("密碼長度異常")
		isRight = false
	}
	fmt.Printf("理論上isOverWrite數組元素全部爲[254,254,254,254,254,254,254,254],\n實際上爲:");
	fmt.Println(isOverWrite) //棧數據被重寫,如果該數據返回用戶其他操作,則會被非法用戶利用
	return isRight 
}

         紅色框中使用的方法使得指針能夠偏移,從而造成對任意地址的訪問。上述代碼配合整數截斷漏洞後,會造成棧的緩衝區溢出,輕則Dos攻擊,重則對輸入的數據進行精心構造使得棧中保存的RIP值改寫,以及保存RIP以上的內存區塊放入對應的參數,則造成惡意代碼的執行,另一方面也可覆蓋棧中變量,改變程序執行流程或者運算結果。

0x04 內存泄漏

1、切片使用產生內存泄漏

當使用make在字符串或者數組上創建切片時,創建的切片實質上共用了原數據的內存,只要切片數據一直在使用,GC就會認爲原數據所有數據有再被使用的可能,不會去清理無用數據。所以如果程序後續僅關注獲取的切片內容,則原數據其他部分的數據一直會佔用內存,導致在程序運行時的內存泄漏,除非使得Go程序退出或者切片數據在後續其他代碼獲取大量內存前不再使用,內存纔會釋放。下面看兩個例子進行說明。

         爲了快速進行垃圾收集,代碼中強制調用了GC方法。運行結果如下:

         可以看到,由於sliceLeak001中申請的空間在函數返回後不再使用,所以在函數退出後內存得到回收,無內存泄漏。若在該函數中返回上述數組的很小一部分切片,代碼如下所示:

         sliceLeak002中增加了返回切片的語句,並在主函數中使用,會使得sliceLeak002中其他部分的數據既難以再次訪問,也無法使GC回收那些不在使用的內存,導致運行時內存泄漏。運行結果如下:

         在強制GC後程序使用的內存依然保持而沒有歸還內存池,產生內存泄漏。

 

2、Goroutine產生內存泄漏

協程(Goroutine)是Go語言並行設計的核心,每個協程都有私有的棧空間。若在系統不退出的情況下,協程也沒有設置退出條件,則相當於協程失去了控制,它佔用的資源無法回收,導致內存泄露。

如果啓動了1個goroutine,但並沒有符合預期的退出,直到程序結束,此goroutine才退出,每個goroutine佔用2KB內存,泄露一百萬goroutine至少泄露2KB * 1000000 = 2GB內存。由於goroutine執行過程中還存在一些變量,如果這些變量指向堆內存中的內存,GC會認爲這些內存仍在使用,不會對其進行回收,這些內存誰都無法使用,將造成更多的內存泄露。所以goroutine泄露有2種方式造成內存泄露:

  1. goroutine本身的棧所佔用的空間造成內存泄露。
  2. goroutine中的變量所佔用的堆內存導致堆內存泄露.

綜上所述,如果不知道何時停止一個goroutine,這個goroutine就是潛在的內存泄露。請看下面的例子:

這裏首先簡單介紹一個非常重要的概念:channel的機制是先進先出,如果你給channel賦值了,那麼必須要讀取它的值,不然就會造成阻塞,當然這個只對無緩衝的channel有效。對於有緩衝的channel,發送方會一直阻塞直到數據被拷貝到緩衝區;如果緩衝區已滿,則發送方只能在接收方取走數據後才能從阻塞狀態恢復。

代碼中創建了一個無緩存的channel變量,並開啓了一個讀取線程。然而在該線程中永遠無法走到讀取channel中內容的分支,這也是模擬系統在某些情況下或者在某段時間內產生了無法讀取數據的異常情況。

然後,又創建了一個向channel寫數據的協程,該協程模擬大量用戶再向服務器傳輸數據的狀況。用戶向服務器發送數據是不受控的,並且每個用戶都有自己的線程。代碼如下所示:

協程內部申請了10M的堆內存空間,然後向channel發送數據。但是由於前文所述的讀代碼因爲某些原因無法讀取,此處將被阻塞。請看運行結果:

在強制GC後,goroutine的內存是沒有歸還的。若在讀協程發生異常的時間段內,大量用戶發起了寫數據動作而創建了大量的掛起協程,對於這些協程GC是無法回收的,因爲GC是無法瞭解協程是否以後將被喚起。當大量的阻塞協程被創建後,系統內存資源將被擠壓,最終導致其他進程無法執行甚至服務器崩潰。

綜上所述,阻塞的原因是outCh這個寫操作無法完成,outCh是無緩衝的通道,並且由於讀線程代碼是死代碼,所以goroutine始終沒有從outCh讀數據,造成outCh阻塞,進而可能造成無數個alloc1的goroutine阻塞,形成內存泄露。上述goroutine泄漏的本質是channel阻塞,無法繼續向下執行,導致此goroutine關聯的內存都無法釋放,進一步造成內存泄露。所以必須確保每個協程都能退出。

 

3、 不正確使用SetFinalize造成內存泄露

在實際的編程中,我們都希望每個對象釋放時執行一個方法,在該方法內執行一些計數、釋放或特定的要求,以往都是在對象指針置nil前調用一個特定的方法,golang提供了runtime.SetFinalizer函數,當GC準備釋放對象時,會回調該函數指定的方法,非常方便和有效。不過值得注意的是,指針構成的 "循環引用" 加上 runtime.SetFinalizer 會導致內存泄露。請看下述代碼:

當循環引用的變量不使用SetFinalizer

上述代碼test函數中,a,b爲局部變量,並且結構內部互相引用。程序運行後查看內存使用情況:

    可以看到程序開始後和函數返回時使用的內存幾乎沒有變化,表明test函數中的a,b變量佔用的內存已經被回收。如果打開註釋部分使用SetFinalizer函數,造成循環引用和SetFinalizer函數同時使用,程序內存使用情況如下所示:

    發現每輪循環內存使用量固定增加了一定的數量,並且Finalizer函數中指定的回調函數也沒有被執行。函數中的變量自函數退出後無法進行控制,內存發生泄漏。

    綜上所述,垃圾回收器能正確處理 "指針循環引",但法確定 Finalizer 依賴次序,也就法調Finalizer 函數,這會導致目標對象法變成不可達狀態,其所佔內存法被回收

0x05 代碼

package main

import(
	"fmt"
	"time"
	"runtime"
)

func main(){
	fmt.Println("內存安全")
	
	
//1、重釋放
//重釋放指針資源  無相關語法 不涉及 有GC自動回收機制
//不涉及 沒有釋放指針內存的語法  Go使用垃圾收集器自動
//管理內存,減少很多使用指針上的操心,首先我們再也不需
//要顯示釋放內存,懸掛指針(dangling pointer,指向已釋
//放的內存)以及多次釋放同一個指針指向內存的問題就不會
//發生,甚至不用擔心內存是在棧上分配的還是堆上分配的,取
//一個變量的地址是安全的,不會產生懸掛指針。如果在程序中
//取了一個變量的地址,編譯器會自動在棧上分配這個變量

//試驗中調用的函數內部創建了很多指針對象,結束後並沒有通過
//類似C++的free進行釋放,退出函數後內存被回收。所以無需也沒有釋放語句
//從而導致指針重釋放的問題
 
 /*
	traceMemStats()
	mutiFreeTest()
	//runtime.GC()//強制啓動GC垃圾回收
	traceMemStats()
	go func() {
		for {
            traceMemStats()
            time.Sleep(10 * time.Second)
        }
    }()
	
*/

//重釋放資源 可能defer調用close重釋放、條件判斷沒有理清多處close釋放
//重複釋放一般存在於錯誤處理流程判斷中,如果惡意攻擊者構造出錯誤條件使
//程序重複釋放channel,則會觸發運行時錯誤,從而造成DoS攻擊。 重複close
//文件對象,在特殊的os的file實現中也可能會導致問題。對sql.DB對象和網絡
//連接的close同樣如此。 因此禁止重複釋放資源	 
//panic: close of closed channel  exit status 2 
/*
	var Dividend rune 
	var Divisor rune
	var channel chan int = make(chan int)
	fmt.Scanln(&Dividend,&Divisor)
	result:=mutiClose(Dividend,Divisor,channel)
	fmt.Printf("result is :%d",result)
*/
	
//2、解引用空指針-----程序崩潰:runtime error: invalid memory address or nil pointer dereference
/*
	var varPtr *int;
	//使用前進行判空 否則解引用空指針 程序崩潰
	//if varPtr==nil {
	//	varPtr = new(int) 
	//}
	*varPtr = 10;
	fmt.Println(*varPtr)
*/



//3、內存申請大小異常
//確保對輸入和計算後make slice、map、chan對象的大小進行合法性校驗
//申請過量內存  若外部數據無校驗 panic: runtime error: makeslice: len out of range
/*
	var size uint64;
	fmt.Scanln(&size)
	arr:=make([]int,size)
	fmt.Println("分配成功")
	fmt.Println(arr[10])
*/

//4、unsafa包操作內存 不嚴格的預防措施容易造成例如越界訪問、緩衝區溢出等致命漏洞
//整數安全中的例子二可以反映



//5、內存泄漏
//切片使用引起的原數據的內存泄漏
//對於字符串來說,引用切片需要複製一份出來,解除對原字符串的引用
//  例如s0 = string([]byte(s1[:50]))   
//s0 = append([]int(nil), s1[len(s1)-30:]...)

/*
	traceMemStats()
	//sliceLeak001()
	sliceArr:=sliceLeak002()
	//強制啓動GC垃圾回收 查看內存是否泄露
	runtime.GC()
	traceMemStats();
	fmt.Println(*sliceArr[0])
*/


//goroutine引起的內存泄漏
//確保每個協程都能退出
//協程(Goroutine)是Go語言並行設計的核心,啓動一個協程就會做一個入棧操作,
//在系統不退出的情況下,協程也沒有設置退出條件,則相當於協程失去了控制,
//它佔用的資源無法回收,可能會導致內存泄露
//下面代碼啓動了兩個協程,每個協程都是循環向屏幕上打印信息,在main()不退出的情況,且協程
//也沒有設置退出條件,則導致協程所佔用的資源以及啓動協程的棧信息無法得到釋放
/*
    outCh := make(chan int)
    // 死代碼 永不讀取 對於無緩衝的chan變量 在向chan變量寫數據後會阻塞
    go func() {
        if false {
            <-outCh
        }
        select {}
    }()

	traceMemStats()

   // 每s起100個goroutine,goroutine會阻塞,不釋放內存
    tick := time.Tick(time.Second / 100)
    i := 0
    for range tick {
        i++
        //fmt.Println(i)
        alloc1(outCh)
		if i==100{
			fmt.Println("done")
			break
		}
    }
	
	runtime.GC()
    traceMemStats()
*/

//6、禁止SetFinalize和指針循環引用同時使用以防止內存泄露
	traceMemStats()
    for {
        test()     
		runtime.GC()//強制啓動GC垃圾回收
		traceMemStats()
		time.Sleep(time.Millisecond)
    }
	

	
time.Sleep(1 * time.Hour) // 保持程序不退出	
}

func mutiFreeTest(){
	var bigArr [13107200] (*int)  //200M容量
	for i:=0;i<13107200;i++{
		var temp *int = new(int);
		bigArr[i] = temp
	}
	traceMemStats()
	
}


func mutiClose(Dividend rune,Divisor rune,channel chan int) rune {
	defer close(channel)
	
	//some operate 
	
	if(Divisor==0){
		close(channel)
		return 0
	}
	return Dividend/Divisor
}


//不返回切片 這樣使得GC對數組收集
func sliceLeak001() {
	var bigArr [13107200] (*int)  
	for i:=0;i<13107200;i++{
		var temp *int = new(int);
		*temp = i
		bigArr[i] = temp
	}
	traceMemStats()
}

//返回切片一部分,使得其他部分不能夠在此被使用且也沒辦法回收,產生內存泄露
func sliceLeak002() [](*int) {
	var bigArr [13107200] (*int)  
	for i:=0;i<13107200;i++{
		var temp *int = new(int);
		*temp = i
		bigArr[i] = temp
	} 
	ret:=bigArr[len(bigArr)-30:]
	traceMemStats()
	return ret
}

func alloc1(outCh chan<- int) {
    go func() {
        defer fmt.Println("alloc-fm exit")
        // 分配內存 協程中的堆內存也會同時泄漏
        buf := make([]byte, 1024*1024*10)
        _ = len(buf)
        //fmt.Println("alloc done")

        outCh <- 0
		fmt.Println("沒有阻塞")
    }()
}

type Data struct {
    d   [1024 * 100]byte
    o   *Data
}
func test() {
    var a, b Data
    a.o = &b
    b.o = &a
    //runtime.SetFinalizer(&a, func(d *Data) { fmt.Printf("a %p final.\n", d) })
    //runtime.SetFinalizer(&b, func(d *Data) { fmt.Printf("b %p final.\n", d) })
}

func memstate(){
	traceMemStats()
	sliceArr:= make([]int,8)
	for i:=1;i<=64*1024*1024;i++{
		sliceArr = append(sliceArr,i)
	}
	traceMemStats()
}


func traceMemStats() {
    var ms runtime.MemStats
    runtime.ReadMemStats(&ms)
	//Alloc應用層申請的堆內存 HeapSys從操作系統申請的內存 HeapReleased返還給操作系統的內存
    fmt.Printf("Alloc:%d(bytes) HeapSys:%d(bytes) HeapReleased:%d(bytes)\n", 
				ms.Alloc, ms.HeapSys, ms.HeapReleased)
}

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章