未初始化內存的洩漏是跨信任邊界複製數據時面臨的常見問題之一。這可能發生在hypervisor和guest OS、內核和用戶空間之間,也可能發生在跨網絡之間。在這些情況中,最常見的錯誤模式是在內存中分配結構或聯合,並且在跨信任邊界複製它之前沒有初始化某些字段或填充字節。問題是,是否可以對此類漏洞進行有針對性地分析?
本文的想法是執行支配流不敏感分析(insensitive analysis),以靜態跟踪所有內存存儲操作。當跨信任邊界複製來自該內存區域的數據時,任何從未寫入的內存區域都被標識為未初始化。
泛化用於分析的代碼模式以CVE-2018-17155為例,由於缺乏結構初始化,FreeBSD內核內存在getcontext()和swapcontext()系統調用中洩漏。下面顯示的是sys_getcontext()的補丁。左邊的清單顯示了打了補丁的代碼。 Sys_swapcontext()也以類似的方式打了補丁。
sys_getcontext()信息洩漏補丁,右側顯示易受攻擊的代碼
脆弱的代碼在堆棧上聲明了一個ucontext_t結構,寫入一些但不是所有的字段,最後使用copyout()將UC_COPY_SIZE字節的數據從結構複製到用戶區。這裡的問題是,並非所有字段都已初始化,因此,佔用結構內存區域未初始化部分的任何數據都會被洩漏。為了解決這個問題,打過補丁的代碼使用bzero()函數將整個結構歸零。
上述代碼模式的泛化過程如下:
1.在堆棧上聲明或在堆上分配內存區域(結構、聯合等),這可能是未初始化內存的來源。
2.內存區域可能被完全或部分寫入。
3.有一個跨信任邊界傳輸數據的API,這可能是未初始化內存的sink。
4.API通常至少需要3個參數:源緩衝區、目標緩衝區和大小。在這種情況下,內存的源是堆棧偏移量,傳輸的大小是一個常量值。傳輸的大小不變意味著該值要么是內存區域的整個大小(使用sizeof運算符),要么是成為偏移量的一部分。
5.在使用memset()或bzero()函數之前,內存區域可能會被清空。
sink函數是特定於應用程序的,比如對於Linux內核,是copy_to_user();對於BSD內核,則是copyout();對於網絡傳輸則是send()或sendto()。如果目標是封閉源代碼,那麼這些函數的定義要么被記錄下來,要么被逆向破解。
搜索代碼模式進行分析一旦知道了sink函數及其定義,就可以使用常量大小參數和指向堆棧偏移量或堆內存的源緩衝區查詢對sink函數的調用。查詢指向堆棧內存的指針很簡單,而檢測堆指針則需要訪問源變量的定義位置。 BSD中copyout()函數的定義如下:
在查找堆棧內存洩漏時,搜索對copyout()函數的交叉引用,其中kaddr指向堆棧偏移量,len參數是常量。
Binary Ninja具有靜態數據流功能,可以在函數內傳播已知值,包括堆棧幀偏移量和類型信息。使用此功能,可以縮小對滿足搜索條件的copyout()的調用範圍。為了更好地理解這一點,讓我們檢查一下從sys_getcontext()傳遞給copyout()的參數。
sys_getcontext()調用copyout(kaddr, uaddr, len)
kaddr參數或params[0]包含一個內核堆棧指針,顯示為堆棧幀偏移量-0x398。 len參數或params[1]的值顯示為常數0x330。由於Binary Ninja沒有關於uaddr的信息,因此顯示為
靜態跟踪內存存儲分析的核心思想是使用Binary Ninja的靜態數據流功能跟踪所有內存存儲操作,並在必要時使用Single static Assignment(SSA)形式手動傳播指針。為了跟踪本地函數範圍內的堆棧內存存儲,我們依賴於低級別IL(LLIL),因為中級IL(MLIL)抽象了堆棧訪問,可能會消除一些內存存儲。為了跟踪將地址傳遞給另一個函數的跨函數(inter-procedure)存儲操作,我們依靠MLIL SSA形式傳播指針。用於處理IL指令的訪問者類是基於Josh Watson的emator實現的。
使用LLIL跟踪堆棧內存存儲在LLIL中,任何寫入內存的指令都表示為lil_store操作。它有一個源和目標參數。其思想是線性訪問函數中的每個LLIL指令,並檢查它是否是一個以堆棧幀偏移量為目標的lil_store操作。當一個寫入堆棧的內存存儲被識別出來時,我們將記錄寫入的源偏移量及其大小。一個簡單的8字節內存移動操作和Binary Ninja提供的相應LLIL信息如下:
freebsd32_sigtimedwait()中的LLIL_STORE操作
StackFrameOffset值是堆棧基數的偏移量,size屬性給出了存儲操作的大小。使用這些信息,就可以知道正在寫入的內存地址是哪個。本示例中正在初始化從堆棧基偏移量是116到109(8字節)的地址。
靜態函數掛鉤和內存寫入API雖然內存存儲指令是初始化內存的一種方法,但經常使用memset()和bzero()這樣的函數來初始化帶有null的內存區域。類似地,諸如memcpy()、memmove()、bcopy()、strncpy()和strlcpy()等函數也用於寫入內存區域。所有這些函數都有一個共同點:都有一個目標內存指針和一個要寫入的大小。如果目標值和大小值已知,則可以知道要寫入的內存區域。考慮bzero()的情況,它用於清除修補後的sys_getcontext()中的堆棧內存:
使用bzero()清除堆棧內存
通過查詢目標指針和大小參數,可以知道它們各自的值,從而知道目標內存區域。
現在讓我們考慮一下分析器如何處理CALL操作。靜態掛鉤是函數的處理程序,與其他函數相比,我們打算以不同的方式處理這些函數。對於任何具有已知目標(MLIL_CONST_PTR)的CALL指令,將獲取該符號以檢查靜態掛鉤。
一個帶有函數名及其位置參數(目標緩衝區和大小)的JSON配置被提供給分析器用於靜態掛鉤:
copyin()函數特定於BSD內核。它用於使用來自用戶空間的數據初始化內核緩衝區。任何要掛鉤的特定於目標的函數都可以添加到JSON配置中,並根據需要在visit_function_hooks()中處理。
處理x86 REP優化很多時候,編譯器會將內存寫入函數優化為REP指令或一系列存儲操作。雖然由於優化而引入的存儲操作可以像處理任何其他存儲操作一樣,但REP指令需要特殊處理。由於REP的原因,靜態函數掛鉤在檢測內存寫入時並沒有用。那麼,我們如何處理此類優化並避免錯過這些內存寫入?首先,讓我們看看Binary Ninja如何在LLIL或mll中轉換REP指令。
memcpy()優化為REP指令
MLIL中的REP指令轉換
REP指令重複字符串操作,直到RCX為0。複製操作的方向取決於方向標誌(DF),因此,一個分支增加源指針(RSI)和目標指針(RDI),另一個分支則減少。一般來說,假設DF為0,並且指針是遞增的,這是相當安全的。
當線性遍歷IL時,轉換後的REP指令看起來與其他指令沒有什麼不同。其思想是檢查GOTO指令,並且對於IL中的每個GOTO指令,在相同的地址獲取反彙編。如果反彙編是REP指令,則獲取目標指針和大小參數,並將內存區域標記為已初始化。
LLIL有一個get_possible_reg_values()API,用於靜態讀取寄存器的值。 MLIL提供了兩個API,get_var_for_reg()和get_ssa_var_version(),用於將體系結構寄存器映射到SSA變量。在缺少RegisterValueType信息(即RegisterValueType.UndeterminedValue)的情況下,使用SSA變量手動傳播值時非常有用。類似的API目前在LLIL中缺失,並作為功能請求進行跟踪,API用於獲取給定LLIL指令中寄存器的SSARegister。
使用MLIL跟踪跨函數(inter-procedure)內存存儲此時,我們可以跟踪內存存儲操作、調用操作(如bzero()、memset()),還可以處理REP優化。下一個任務是跟踪函數調用之間的內存寫入操作,就像調用者將內存地址傳遞給被調用者一樣。有趣的是,一旦堆棧指針被傳遞到另一個函數中,就不能再使用寄存器值類型信息(StackFrameOffset)對其進行跟踪了,就像我們在本地函數範圍內使用LLIL所做的那樣。
為了解決這個問題,我們使用MLIL SSA變量在被調用函數中傳播指針,就像傳播污染信息一樣。每當遇到MLIL_STORE_SSA指令時,只要根據SSA變量的值手動解析內存寫入操作的目標,我們就會記錄寫入操作的偏移量和大小值。下面顯示的set_function_args()函數遍歷MLIL變量並賦值(指針)給調用者:
設置初始SSA變量後,我們就會訪問所有的指令來傳播指針並記錄內存寫入操作。執行此操作時,對指針執行的最常見操作是加法。因此,有必要模擬MLIL_ADD指令來處理指針算術操作。此外,模擬MLIL_SUB、MLIL_LSR和MLIL_AND等指令也很重要,以便在優化的情況下處理某些指針對齊操作。下面是如何解析這些MLIL SSA表達式來記錄內存存儲操作的示例:
將SSA變量rax_43#65視為手動傳播的指針值,可以解析存儲操作的目標以及寫入的大小。但是,當SSA變量rax_43#65的值不可用時,此內存與調用者傳播的指針無關,因此不會被記錄。
處理指針對齊(pointer-aligning)優化在執行跨函數(inter-procedure)分析時,除了REP優化之外,還可以進行進一步的優化,如上面的“處理x86 REP優化”部分所講。在堆棧上分配的變量通常會對齊,以滿足後續操作的需要。假設將堆棧指針傳遞給memset(),編譯器將調用內聯爲REP指令。在這種情況下,很可能將內存分配到一個對齊的地址,以便在REP操作期間使用最快的指令。
然而,當指針被調用者作為參數接收或作為分配器函數的返回值接收時,編譯器則必須生成指針和大小對齊操作碼,這些操作碼可能在到達REP指令之前依賴於分支決策。下面是一個在用於分析的NetBSD內核中常見的優化示例:
來自NetBSD的memset()優化示例
從靜態分析的角度來看,當涉及到這種分支決策時,指針和大小可以在REP指令點獲得多個可能的值。這與我們在“處理x86 REP優化”一節中觀察到的情況不同,在該節中,指針和大小只有一個可能的值。我們的目標是在沒有指針對齊計算的情況下找到指針的實際值和大小。為了實現這一點,確定了兩個可用於解析原始值的SSA表達式:
1.搜索包含(ADDRESS BYTESIZE)的表達式,這可能是在進行任何條件分支之前首次使用ADDRESS;
2.搜索包含(SIZE 3)的表達式。這是將調整後的大小傳遞給REP指令的地方;
我想從REP指令的角度追溯上述表達式,一個完全依賴SSA,另一個基於dominator:
1.使用get_ssa_var_definition()和get_ssa_ var_uses()API獲取變量的定義位置及其用途。
2.或者,獲取包含REP指令的基本塊的dominator,並訪問dominator塊中的指令。
下面顯示的函數resolve_optimization()使用dominator獲取執行搜索操作的基本塊。由於指針是由調用者手動傳遞的,因此值是從SSA變量中獲取的。
對於可能的常量值,我們從可用值列表中獲取最大值。一旦指針和最大值都可用,我們就記錄內存區域初始化時的日誌。
Recommended Comments