Hacking,約不約!

對於hacker來說,最有趣的事情莫過於破壞軟件設計者的原有規則,重新建立屬於自己的規則了。姑且不論這個行爲是否合法或違規,單就技術本身而言,矛與盾、攻與防、破壞與重建的過程中,爲了達到最終目的而衍生出來的奇妙技術,再配上天馬行空的想像和創造足以 讓人着迷不已。

開篇

儘管Linux內核開源,升級或替換內核十分方便,但仍有一些特殊場景,需要在不替換內核的前提下給內核“動手術”。考慮如下兩種場景:

  1. 24小時不能停機的服務器,因爲某些擴展性原因需要升級到新版本Linux內核;
  2. 不能二次燒錄的嵌入式設備,需要修復其內核安全漏洞。

對於前一種場景,Linux有已一套livepatch的解決方案。然而livepatch當前並不支持嵌入式常用的arm架構,因此針對後一種場景,我們只能採用一些非常規的手段達到目的。

我們要完成兩個任務:在不重編內核的前提下給嵌入式設備的Linux內核做安全修復和安全加固。修復即是規避掉有缺陷的函數;加固即是保留原有函數不變,只不過我們需要在執行函數功能之前,先檢查函數的傳參是否合法,是則放過,否則阻斷。以圖爲證:

祭出inline hook的大殺器

無論我們出發點的好壞,內核並不期待這種函數執行流的改變,那麼只能去hack它。好在內核提供了可插拔的模塊功能,我們可以將hack的邏輯放入內核模塊,然後插入到內核中。內核模塊由於是內核功能的擴展,兩者工作在同樣的權限和地址空間中(與之對比的是用戶態程序,只能通過系統調用獲得內核支持),插入內核後便可修改內核自身的數據。請見下圖:

既然需要修改函數的調用關係,那麼有兩種修改辦法:1)修改父函數;2)改造子函數。從這裏開始,所有的函數都是以二進制彙編指令的形態呈現。

修改父函數的函數調用指令,將offset替換成修複函數地址。這樣做的優點是侵入簡單,缺點是但凡調用B函數的父函數都需要修改,查找父函數的工作量難以承受。

改造子函數意味着需要替換子函數的二進制指令,在子函數中侵入一個無條件跳轉,跳轉的目標是新子函數。這樣做的優點是不用滿天下尋找父函數,缺點是對子函數的侵入需要仔細設計。

這裏有幾個比較tricky的地方:

  1. 既然是指令侵入,那麼設計的原則是侵入的指令越少越好。以ARM32 CPU爲例,儘管單指令可以做無條件跳轉,但是單指令跳轉的距離有限±32MB,而內核的長度一般都超過了這個值,單指令很有可能跳轉不到內核模塊中(見Linux虛擬地址空間圖)。因此最短的安全跳轉指令條數爲2條,以完整32位地址(4GB空間)作爲跳轉區間。
  2. 只能抹去子函數最開始的2條指令,而不可以做指令的整體後移。假如在子函數開頭做指令插入,後面所有指令整體向後平移的話,所有存在於子函數後面的函數地址都需要發生變化,函數調用的offset也都會變化,這樣修改起來幾乎是不可能的。
  3. 子函數指令被抹,意味着子函數功能被破壞。在修復缺陷的情況下,這是可以接受的,反正B函數再也不會被使用了,因爲有修複函數B’幫我實現同樣的功能。但是在加固的情況下,B‘函數負責檢查B函數的傳參是否合法,合法的話還需要跳回到B函數中。那麼怎樣回到原先那個正常的B函數呢?這裏又有兩種設計方法:i)在B’中回填B開頭被抹掉的指令;ii)再設計一個跳板函數,由跳板函數統一保管被抹掉的指令。

第一種方式實現起來比較簡單,但是存在一個致命的缺陷,它會引入競爭。內核是一個高併發的環境,假如線程1回填指令使得B函數恢復正常後,線程2執行到B函數則不會再跳轉進入B‘,成爲參數檢查的漏網之魚。同時回填指令是典型的原子操作,反覆侵入和回填會增加進程的阻塞程度,假如B函數是內存分配等頻繁使用的函數,會嚴重降低系統的性能。

第二種方式需要再額外設計一個跳板函數,如圖:

跳板函數專門預留4個指令長度的空間,用作子函數B的第1、2條指令存放,同時有兩條指令長距離跳轉到B函數的第3條指令地址處。當參數檢查函數想要恢復B函數功能時,它先執行跳板函數中保存的兩條指令,再回跳到原函數第3條指令處繼續執行。那麼通過跳板函數的“接力”,子函數B的指令被完整執行,這樣也就保留了B函數的功能不受影響。

  1. 既然是做參數檢查,那怎樣保證參數檢查函數能拿到子函數B的所有傳參?我們都知道,C語言參數的傳遞通常有兩種方式,寄存器傳參和棧傳參。也就是說,父函數在調用子函數前,要麼把參數壓棧,要麼把參數放到指定的寄存器裏面。在子函數中,要麼通過特定的棧偏移量取值,要麼通過特定的寄存器取值。在設計階段我們不能假定內核最終使用了哪種方式,因此對於跳板函數的設計原則爲:
  2. 不能改變棧;
  3. 不能修改通用寄存器。

通常情況下,編譯器會替我們打理好所有的棧操作和寄存器分配任務(事實上我們也不需要操心這些細節)。而如今我們不能信任編譯器能幫我們做好這些事。實現跳板函數時,首先我們會使用naked強制編譯器不生成棧的序言和結尾(prologue and epilogue)。其次我們使用__asm__自己編寫彙編指令操縱所需寄存器,並且使用volatile拒絕編譯器幫我們做任何優化。

結尾

至此看上去問題已經解決了,可事實上並非如此。在考慮了這麼多情況後,我們的設計達到了最優嗎?我認爲不是的。功能上的達標跟設計上的beautifully還有很長的路(比如把這種靜態的宏定義設計成爲動態可註冊的方式),設計上的美學追求將永無止境。
文/ThoughtWorks 劉濤

更多精彩洞見,請關注微信公衆號:ThoughtWorks洞見

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