您好,登錄后才能下訂單哦!
今天就跟大家聊聊有關如何進行Ubuntu kernel eBPF 0day分析,可能很多人都不太了解,為了讓大家更加了解,小編給大家總結了以下內容,希望大家根據這篇文章可以有所收獲。
中國武術博大精深,其中太極作為不以拙力勝人的功夫備受推崇。同樣如果從攻擊的角度窺視漏洞領域,也不難看出攻防之間的博弈不乏“太極”的身影,輕巧穩定易利用的漏洞與工具往往更吸引黑客,今天筆者要著墨分析的就是這樣一個擅長“四兩撥千斤”的0day漏洞。
0day漏洞的攻擊威力想必大家都聽說過,內核0day更因為其影響范圍廣,修復周期長而備受攻擊者的青睞。近期,國外安全研究者Vitaly Nikolenko在twitter[1]上公布了一個Ubuntu 16.04的內核0day利用代碼[2],攻擊者可以無門檻的直接利用該代碼拿到Ubuntu的最高權限(root);雖然只影響特定版本,但鑒于Ubuntu在全球擁有大量用戶,尤其是公有云用戶,所以該漏洞對企業和個人用戶還是有不小的風險。
筆者對該漏洞進行了技術分析,不管從漏洞原因還是利用技術看,都相當有代表性,是Data-Oriented Attacks在linux內核上的一個典型應用。僅利用傳入的精心構造的數據即可控制程序流程,達到攻擊目的,完全繞過現有的一些內存防護措施,有著“四兩撥千斤”的效果 。
這個漏洞存在于Linux內核的eBPF模塊,我們先來簡單了解下eBPF。
eBPF(extended Berkeley Packet Filter)是內核源自于BPF的一套包過濾機制,嚴格來說,eBPF的功能已經不僅僅局限于網絡包過濾,利用它可以實現kernel tracing,tracfic control,應用性能監控等強大功能。為了實現如此強大的功能,eBPF提供了一套類RISC指令集,并實現了該指令集的虛擬機,使用者通過內核API向eBPF提交指令代碼來完成特定的功能。
看到這里,有經驗的安全研究者可能會想到,能向內核提交可控的指令代碼去執行,很可能會帶來安全問題。事實也確實如此,歷史上BPF存在大量漏洞 [3]。關于eBPF的更多細節,可以參考這里[4][5]。
eBPF在設計時當然也考慮了安全問題,它在內核中實現了一套verifier機制,過濾不合規的eBPF代碼。然而這次的漏洞就出在eBPF的verifier機制。
從最初Vitaly Nikolenko公布的補丁截圖,我們初步判斷該漏洞很有可能和CVE-2017-16995是同一個漏洞洞[6],但隨后有2個疑問:
1.CVE-2017-16995在去年12月份,內核4.9和4.14及后續版本已經修復,為何Ubuntu使用的4.4版本沒有修復?
2.CVE-2017-16995是Google Project Zero團隊的Jann Horn發現的eBPF漏洞,存在于內核4.9和4.14版本[7],作者在漏洞報告中對漏洞原因只有簡短的描述,跟本次的漏洞是否完全相同?
注:筆者所有的代碼分析及調試均基于Ubuntu 14.04,內核版本為4.4.0-31-generic #50~14.04.1-Ubuntu[8]。
先來回答第二個問題,中間的調試分析過程在此不表。
參考以下代碼,eBPF的verifer代碼(kernel/bpf/verifier.c)中會對ALU指令進行檢查(check_alu_op),該段代碼最后一個else分支檢查的指令是:
1.BPF_ALU64|BPF_MOV|BPF_K,把64位立即數賦值給目的寄存器;
2.BPF_ALU|BPF_MOV|BPF_K,把32位立即數賦值給目的寄存器;
但這里并沒有對2條指令進行區分,直接把用戶指令中的立即數insn->imm賦值給了目的寄存器,insn->imm和目的寄存器的類型是integer,這個操作會有什么影響呢?
我們再來看下,eBPF運行時代碼(kernel/bpf/core.c),對這2條指令的解釋是怎樣的(bpf_prog_run)。
參考以下代碼,上面2條ALU指令分別對應ALU_MOV_K和ALU64_MOV_K,可以看出verifier和eBPF運行時代碼對于2條指令的語義解釋并不一樣,DST是64bit寄存器,因此ALU_MOV_K得到的是一個32bit unsigned integer,而ALU64_MOV_K會對imm進行sign extension,得到一個signed 64bit integer。至此,我們大概知道漏洞的原因,這個邏輯與CVE-2017-16995基本一致,雖然代碼細節上有些不同(內核4.9和4.14對verifier進行了較大調整)。但這里的語義不一致又會造成什么影響?
我們再來看下vefier中以下代碼(check_cond_jmp_op),這段代碼是對BPF_JMP|BPF_JNE|BPF_IMM指令進行檢查,這條指令的語義是:如果目的寄存器立即數==指令的立即數(insn->imm),程序繼續執行,否則執行pc+off處的指令;注意判斷立即數相等的條件,因為前面ALU指令對32bit和64bit integer不加區分,不論imm是否有符號,在這里都是相等的。再看下eBPF運行時對BPF_JMP|BPF_JNE|BPF_IMM指令的解釋(bpf_prog_run),顯然當imm為有符合和無符號時,因為sign extension,DST!=IMM結果是不一樣的。注意這是條跳轉指令,這里的語義不一致后果就比較直觀了,相當于我們可以通過ALU指令的立即數,控制跳轉指令的邏輯。這個想象空間就比較大了,也是后面漏洞利用的基礎,比如可以控制eBPF程序完全繞過verifier機制的檢查,直接在運行時執行惡意代碼。
值得一提的是,雖然這個漏洞的原因和CVE-2017-16995基本一樣,但但控制跳轉指令的思路和CVE-2017-16995中Jann Horn給的POC思路并不一樣。感興趣的讀者可以分析下,CVE-2017-16995中POC,因為ALU sign extension的缺陷,導致eBPF中對指針的操作會計算不正確,從而繞過verifier的指針檢查,最終讀寫任意kernel內存。但這種利用方法,在4.4的內核中是行不通的,因為4.4內核的eBPF不允許對指針類型進行ALU運算。
到這里,我們回過頭來看下第一個問題,既然漏洞原因一致,為什么Ubuntu 4.4的內核沒有修復該漏洞呢?和Linux kernel的開發模式有關。
Linux kernel分mainline,stable,longterm 3種版本[9],一般安全問題都會在mainline中修復,但對于longterm,僅會選擇重要的安全補丁進行backport,因此可能會出現,對某個漏洞不重視或判斷有誤,導致該漏洞仍然存在于longterm版本中,比如本次的4.4 longterm,最初Jann Horn并沒有在報告中提到影響4.9以下的版本。
關于Linux kernel對longterm版本的維護,爭論由來已久[10],社區主流意見是建議用戶使用最新版本。但各個發行版(比如Ubuntu)出于穩定性及開發成本考慮,一般選擇longterm版本作為base,自行維護一套kernel。
對于嵌入式系統,這個問題更嚴重,大量廠商代碼導致內核升級的風險及成本都遠高于backport安全補丁,因此大部分嵌入式系統至今也都在使用比較老的longterm版本。比如Google Android在去年Pixel /Pixel XL 2發布時,內核版本才從3.18升級到4.4,原因也許是3.18已經進入EOL了(End of Life),也就是社區要宣布3.18進入死亡期了,后續不會在backport安全補丁到3.18,而最新的mainline版本已經到了4.16。筆者去年也在Android kernel中發現了一個未修復的歷史漏洞(已報告給google并修復),但upstream在2年前就修復了。
而Vitaly Nikolenko可能是基于CVE-2017-16995的報告,在4.4版本中發現存在類似漏洞,并找到了一個種更通用的利用方法(控制跳轉指令)。
根據上一節對漏洞原因的分析,我們利用漏洞繞過eBPF verifier機制后,就可以執行任意eBPF支持的指令,當然最直接的就是讀寫任意內存。漏洞利用步驟如下:
1.構造eBPF指令,利用ALU指令缺陷,繞過eBPF verifier機制;
2.構造eBPF指令,讀取內核棧基址;
3.根據泄漏的SP地址,繼續構造eBPF指令,讀取task_struct地址,進而得到task_struct->cred地址;
4.構造eBPF指令,覆寫cred->uid, cred->gid為0,完成提權。
漏洞利用的核心,在于精心構造的惡意eBPF指令,這段指令在Vitaly Nikolenko的exp中是16機制字符串(char *__prog),并不直觀,筆者為了方便,寫了個小工具,把這些指令還原成比較友好的形式,當然也可以利用eBPF的調試機制,在內核log中打印出eBPF指令的可讀形式。我們來看下這段eBPF程序,共41條指令(筆者寫的小工具的輸出):
parsing eBPF prog, size 328, len 41
ins 0: code(b4) alu | = | imm, dst_reg 9, src_reg 0, off 0, imm ffffffff
ins 1: code(55) jmp | != | imm, dst_reg 9, src_reg 0, off 2, imm ffffffff
ins 2: code(b7) alu64 | = | imm, dst_reg 0, src_reg 0, off 0, imm 0
ins 3: code(95) jmp | exit | imm, dst_reg 0, src_reg 0, off 0, imm 0
ins 4: code(18) ld | BPF_IMM | u64, dst_reg 9, src_reg 1, off 0, imm 3
ins 5: code(00) ld | BPF_IMM | u32, dst_reg 0, src_reg 0, off 0, imm 0
ins 6: code(bf) alu64 | = | src_reg, dst_reg 1, src_reg 9, off 0, imm 0
ins 7: code(bf) alu64 | = | src_reg, dst_reg 2, src_reg a, off 0, imm 0
ins 8: code(07) alu64 | += | imm, dst_reg 2, src_reg 0, off 0, imm fffffffc
ins 9: code(62) st | BPF_MEM | u32, dst_reg a, src_reg 0, off fffffffc, imm 0
ins 10: code(85) jmp | call | imm, dst_reg 0, src_reg 0, off 0, imm 1
ins 11: code(55) jmp | != | imm, dst_reg 0, src_reg 0, off 1, imm 0
ins 12: code(95) jmp | exit | imm, dst_reg 0, src_reg 0, off 0, imm 0
ins 13: code(79) ldx | BPF_MEM | u64, dst_reg 6, src_reg 0, off 0, imm 0
ins 14: code(bf) alu64 | = | src_reg, dst_reg 1, src_reg 9, off 0, imm 0
ins 15: code(bf) alu64 | = | src_reg, dst_reg 2, src_reg a, off 0, imm 0
ins 16: code(07) alu64 | += | imm, dst_reg 2, src_reg 0, off 0, imm fffffffc
ins 17: code(62) st | BPF_MEM | u32, dst_reg a, src_reg 0, off fffffffc, imm 1
ins 18: code(85) jmp | call | imm, dst_reg 0, src_reg 0, off 0, imm 1
ins 19: code(55) jmp | != | imm, dst_reg 0, src_reg 0, off 1, imm 0
ins 20: code(95) jmp | exit | imm, dst_reg 0, src_reg 0, off 0, imm 0
ins 21: code(79) ldx | BPF_MEM | u64, dst_reg 7, src_reg 0, off 0, imm 0
ins 22: code(bf) alu64 | = | src_reg, dst_reg 1, src_reg 9, off 0, imm 0
ins 23: code(bf) alu64 | = | src_reg, dst_reg 2, src_reg a, off 0, imm 0
ins 24: code(07) alu64 | += | imm, dst_reg 2, src_reg 0, off 0, imm fffffffc
ins 25: code(62) st | BPF_MEM | u32, dst_reg a, src_reg 0, off fffffffc, imm 2
ins 26: code(85) jmp | call | imm, dst_reg 0, src_reg 0, off 0, imm 1
ins 27: code(55) jmp | != | imm, dst_reg 0, src_reg 0, off 1, imm 0
ins 28: code(95) jmp | exit | imm, dst_reg 0, src_reg 0, off 0, imm 0
ins 29: code(79) ldx | BPF_MEM | u64, dst_reg 8, src_reg 0, off 0, imm 0
ins 30: code(bf) alu64 | = | src_reg, dst_reg 2, src_reg 0, off 0, imm 0
ins 31: code(b7) alu64 | = | imm, dst_reg 0, src_reg 0, off 0, imm 0
ins 32: code(55) jmp | != | imm, dst_reg 6, src_reg 0, off 3, imm 0
ins 33: code(79) ldx | BPF_MEM | u64, dst_reg 3, src_reg 7, off 0, imm 0
ins 34: code(7b) stx | BPF_MEM | u64, dst_reg 2, src_reg 3, off 0, imm 0
ins 35: code(95) jmp | exit | imm, dst_reg 0, src_reg 0, off 0, imm 0
ins 36: code(55) jmp | != | imm, dst_reg 6, src_reg 0, off 2, imm 1
ins 37: code(7b) stx | BPF_MEM | u64, dst_reg 2, src_reg a, off 0, imm 0
ins 38: code(95) jmp | exit | imm, dst_reg 0, src_reg 0, off 0, imm 0
ins 39: code(7b) stx | BPF_MEM | u64, dst_reg 7, src_reg 8, off 0, imm 0
ins 40: code(95) jmp | exit | imm, dst_reg 0, src_reg 0, off 0, imm 0
parsed 41 ins, total 41
稍微解釋下,ins 0 和 ins 1 一起完成了繞過eBPF verifier機制。ins 0指令后,regs[9] = 0xffffffff,但在verifier中,regs[9].imm = -1,當執行ins 1時,jmp指令判斷regs[9] == 0xffffffff,注意regs[9]是64bit integer,因為sign extension,regs[9] == 0xffffffff結果為false,eBPF跳過2(off)條指令,繼續往下執行;而在verifier中,jmp指令的regs[9].imm == insn->imm結果為true,程序走另一個分支,會執行ins 3 jmp|exit指令,導致verifier認為程序已結束,不會去檢查其余的dead code。
這樣因為eBPF的檢測邏輯和運行時邏輯不一致,我們就繞過了verifier。后續的指令就是配合用戶態exp完成對kernel內存的讀寫。
這里還需要知道下eBPF的map機制,eBPF為了用戶態更高效的與內核態交互,設計了一套map機制,用戶態程序和eBPF程序都可以對map區域的內存進行讀寫,交換數據。利用代碼中,就是利用map機制,完成用戶態程序與eBPF程序的交互。
ins4-ins5: regs[9] = struct bpf_map *map,得到用戶態程序申請的map的地址,注意這2條指令,筆者的靜態解析并不準確,獲取map指針的指令,在eBPF verifier中,會對指令內容進行修改,替換map指針的值。
ins6-ins12: 調用bpf_map_lookup_elem(map, &key),返回值為regs[0] = &map->value[0]
ins13: regs[6] = *regs[0], regs[6]得到map中key=0的value值
ins14-ins20: 繼續調用bpf_map_lookup_elem(map, &key),regs[0] = &map->value[1]
ins21: regs[7] = *regs[0],regs[7]得到map中key=1的value值
ins22-ins28: 繼續調用bpf_map_lookup_elem(map, &key),regs[0] = &map->value[2]
ins29: regs[8] = *regs[0],regs[8]得到map中key=2的value值
ins30: regs[2] = regs[0]
ins32: if(regs[6] != 0) jmp ins32 + 3,根據用戶態傳入的key值不同,做不同的操作
ins33: regs[3] = *regs[7],讀取regs[7]中地址的內容,用戶態的read原語,就在這里完成,regs[7]中的地址為用戶態傳入的任意內核地址
ins34: *regs[2] = regs[3],把上調指令讀取的值返回給用戶態
ins36: if(regs[6] != 1) jmp ins36 + 2
ins37: *regs[2] = regs[FP], 讀取eBPF的運行時棧指針,返回給用戶態,注意這個eBPF的棧指針實際上指向bpf_prog_run函數中的一個局部uint64數組,在內核棧上,從這個值可以得到內核棧的基址,這段指令對應用戶態的get_fp
ins39: *regs[7] = regs[8],向regs[7]中的地址寫入regs[8],對應用戶態的write原語,regs[7]中的地址為用戶態傳入的任意內核地址
理解了這段eBPF程序,再看用戶態exp就很容易理解了。需要注意的是,eBPF指令中的3個關鍵點:泄漏FP,讀任意kernel地址,寫任意kernel地址,在verifier中都是有檢查的,但因為開始的2條指令完全繞過了verifier,導致后續的指令長驅直入。
筆者在Ubuntu 14.04上提權成功:這種攻擊方式和傳統的內存破壞型漏洞不同,不需要做復雜的內存布局,只需要修改用戶態傳入的數據,就可以達到控制程序指令流的目的,利用的是原有程序的正常功能,會完全繞過現有的各種內存防御機制(SMEP/SMAP等),有一種四兩撥千斤的效果。這也是這兩年流行的Data-Oriented Attacks,在linux kernel中似乎并不多見。
因為linux kernel的內核版本眾多,對于安全漏洞的影響范圍往往并不容易確認,最準確的方式是搞清楚漏洞根因后,從代碼層面判斷,但這也帶來了高成本的問題,快速應急時,我們往往需要盡快確認漏洞影響范圍。從前面的漏洞原理來看,筆者大致給一個全面的linux kernel受影響版本:
3.18-4.4所有版本(包括longterm 3.18,4.1,4.4);
<3.18,因內核eBPF還未引入verifier機制,不受影響。
對于大量用戶使用的各個發行版,還需要具體確認,因為該漏洞的觸發,還需要2個條件
1.Kernel編譯選項CONFIG_BPF_SYSCALL打開,啟用了bpf syscall;
2./proc/sys/kernel/unprivileged_bpf_disabled設置為0,允許非特權用戶調用bpf syscall
而Ubuntu正好滿足以上3個條件。關于修復,upstream kernel在3月22日發布的4.4.123版已經修復該漏洞[11][12], Ubuntu官方4月5日也正式發布了安全公告和修復版本[13][14],沒有修復的同學可以盡快升級了。
但現在距漏洞Exp公開已經過去20多天了,在漏洞應急時,我們顯然等不了這么久,回過頭看看當初的臨時修復方案:
1.設置/proc/sys/kernel/unprivileged_bpf_disabled為1,也是最簡單有效的方式,雖然漏洞仍然存在,但會讓exp失效;
2.使用Ubuntu的預發布源,更新Ubuntu 4.4的內核版本,因為是非正式版,其穩定性無法確認。
Vitaly Nikolenko在twitter上公布的Ubuntu預發布源:all 4.4 ubuntu aws instances are vulnerable: echo “deb http://archive.ubuntu.com/ubuntu/xenial-proposed restricted main multiverse universe” > /etc/apt/sources.list && apt update && apt install linux-image-4.4.0-117-generic
Ubuntu的非正式內核版本,做了哪些修復,我們可以看下補丁的關鍵內容(注意這是Ubuntu的kernel版本,非upstream):
git diff Ubuntu-lts-4.4.0-116.140_14.04.1 Ubuntu-lts-4.4.0-117.141_14.04.1ALU指令區分了32bit和64bit立即數,同時regs[].imm改為了64bit integer
還增加了一項有意思的檢查,把所有的dead_code替換為nop指令,這個明顯是針對exp來的,有點類似于exp的mitigation,upstream kernel可能并不一定喜歡這樣的修復風格:)
關于這個漏洞,Ubuntu還有一些相關的修復代碼,感興趣的讀者,可以自行發掘。
我們再看下upstream kernel 4.4.123的修復,相比之下,要簡潔的多,僅有3行代碼改動[12]:
當處理32bit ALU指令時,如果imm為負數,直接忽略,認為是UNKNOWN_VALUE,這樣也就避免了前面提到的verifer和運行時語義不一致的問題。
另外Android kernel上,bpf sycall是沒有啟用的,所以不受該漏洞影響。
我們回顧以下整個漏洞分析過程,有幾點值得注意和思考:
1.eBPF作為內核提供的一種強大機制,因為其復雜的過濾機制,稍有不慎,將會引入致命的安全問題,筆者推測后續eBPF可能還會有類似安全漏洞。
2.受限于linux kernel的開發模式及眾多版本,安全漏洞的確認和修復可能存在被忽視的情況,出現N day變0 day的場景。
3.Vitaly Nikolenko公布漏洞exp后,有網友就提出了批評,在廠商發布正式補丁前,不應該公布細節。我們暫且不討論Vitaly Nikolenko的動機,作為一名安全從業者,負責任的披露漏洞是基本守則。
看完上述內容,你們對如何進行Ubuntu kernel eBPF 0day分析有進一步的了解嗎?如果還想了解更多知識或者相關內容,請關注億速云行業資訊頻道,感謝大家的支持。
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。