上次花了很多筆墨來說明一個變形的shellcode到底是什麼原理,這一次的實戰篇我們就來做一個簡單的變形shellcode。由于篇幅的限制,這個變形的東西還是比較幼稚,但是它已經能夠逃脫幾乎所有按照shellcode特征來殺 “毒”的各類軟體,在shellcode不限長度的時候,你甚至可以反複的使用這個加密的頭部來進行變形。具體的内容,我們在下面慢慢說。
切入正題之前還是按照老傳統先科普一下。shellcode後面一部分是實作真正功能的部分,這一塊我們可以用某種加密的方式來進行編碼,而前面的 decode部分則是解碼的。實作功能部分的編碼本身就帶有變形的意義,比如我用0x99(最常見的)來異或,與我們用0x98或者0xee來異或,結果是不同的,這一點外在的表現就是變形。我們需要的更多是一種能夠變形的解碼部分,而且要求這部分可以相對獨立,這樣子就像一個套子一樣,套在一塊代碼上面就變了一次形,多套幾次也沒關系,最多變成其它的更加讓人不知為何物的東西。
需要明白的是,我們寫的這個變形shellcode是一次性的(也叫做抛棄型的),因為shellcode在一次exploit的時候隻能用一次,不像病毒一樣還要辛辛苦苦的傳播。是以我們作的東西,隻要能夠生成每次不一樣的shellcode就可以,shellcode不用包含自己讓自己變形的部分—— 現在這句話還有點拗口,看到後面自然就會明白的。
那麼,我們的變形之旅還是從一個最基本的decode部分開始:
jmp l
de: pop ebx
xor ecx, ecx
mov cl, 222h
lp: xor byte ptr [ebx], 0x99
inc ebx
loop lp
jmp stt
l: call de
stt:
看過兩期連載的朋友可能都要笑了,又是這個,都看煩了。沒關系,總是從最簡單的開始嘛。我們假設起作用的部分是xor過0x99的,就可以專注于解碼部分了。如果異或的數字不是0x99,那隻需要改變第五行的那個操作數即可——這也是一個變形。這一塊解碼部分首先滿足了相對的獨立性,也就是說它不依賴于任何的環境,比如各種寄存器的值或者是棧上的資料,它隻是負責将後面的資料按照異或0x99的方式解碼,這樣子,我們把它看作一個“帽子”,并用D來表示,将X()作為異或0x99的編碼方法,對于任何一個可執行的shellcode(表示為字元串S),下面就是我們最常看到的形式:
D + X(S)
将帽子再套一層,變成了:
D + X( D + X(S) )
這也是可以執行的,就是作了兩次解碼工作。不過我們平時是見不到這種形式的shellcode,因為解多少次碼都可以變成解一次碼的方式,而黑客們總是喜歡最簡便的東西。話說回來,雖然不常見到,對于變形來說,這也是很不錯的方法,前提是你的shellcode沒有限制長度。
仔細看這個decode部分,除了跳轉以外,用到了兩個寄存器,ebx和ecx。其中ecx是作為計數器使用,一旦确定了要用loop,那就不能改變,是以真正可以選擇的還是ebx。考慮到棧的完整性和指令的長度,一般不建議使用esp和ebp,是以好像隻有eax/ebx/edx/esi/edi五個寄存器可以選擇使用,而随機的選擇出一個寄存器後,就要把原始中所有的ebx全部替換成選出來的那個寄存器才行。
這裡的替換不是VB中replace這麼簡單。我們操作的是最後生成的機器碼,寄存器的變換導緻位元組的變換,不是簡單的replace。在原理篇裡面說過,總可以有某個公式來對應不同寄存器相同指令下的機器碼,在說明這個問題前,先得了解寄存器的順序問題。
寄存器本身沒有高低的級别,然而對應指令的時候,它們有一個潛在的順序。簡單的舉個例子,對
inc 而言,inc eax對應指令為0x40,inc ecx對應指令為0x41,inc edx對應指令為0x42……一直到inc edi對應的指令為0x47,排列的寄存器順序就是eax/ecx/edx/ebx/esp/ebp/esi/edi。倘若給出一個順序定義如下:
enum Register
{
EAX = 0, ECX = 1, EDX = 2, EBX = 3,
ESP = 4, EBP = 5, ESI = 6, EDI = 7
};
很容易得到inc exx的對應指令是0x40 + Register,同樣的dec、push、pop等都滿足這樣簡單的規律。
回過頭來看那個decode,要做的工作是選擇寄存器然後改變代碼,自然而然的就要去尋找其中的規律,inc的那個已經說了,剩下pop和xor byte ptr[exx], 0xXX就要去動手找一下。在VC中嵌入彙編然後檢視代碼後可以清楚地看到,前面說到的五個寄存器,基本上滿足的是如下兩個公式:
pop exx:
0x58 + Register
xor bytr ptr[exx], 0xXX:
[80] [0x30 + Register] [XX]
XOR是一個三位元組的指令,前面0x80固定,最後一個是操作數。在實驗的時候你也看到了,對于esp和ebp,這是一個四位元組的指令,長度不一樣,也是我們要抛棄的一個原因。
準備工作已經就緒,就從一個程式開始,按照原理篇的幾個部分來做。第一個程式是test0.cpp,我已經寫好了,這個沒有什麼特别的地方,隻是告訴你這是最基礎的部分,看看可以熟悉一下解碼部分的最基本寫法,而且在後面我們也可以把每一步生成的decode部分拿過來測試測試。作為一個基礎,我把它命名成了0,也是C程式員的習慣吧~
程式test1.cpp就是變形的開始。我們先人為的把decode部分分成了八份,基本上每個指令就是一份——本來這裡有九條指令的,但是xor ecx,ecx和mov cl, 222h其實就是mov ecx, 222h一個指令,不過是我們為了避免0x00出現而耍的花招而已,大體上還是把他們看作一個指令為好。這八個部分,真正與選擇寄存器有關的還是2、4和 5,第一步“寄存器的選擇”,焦點就集中在這三個部分上。
選擇寄存器不用說了,初始化一個随機種子,然後就可以按照擷取的随機數來選擇一個。根據這個寄存器,按照上面的公式,第二句的pop exx的機器碼就應該是0x58 + Register,第四句的XOR中,第二個位元組應該是0x30 + Register。同樣,第五句的inc exx應該成了0x40 + Register。具體的實作在test1.cpp裡面對應了step1函數,函數雖然很短,但程式微長,建議大家還是打開看看。
除了選擇寄存器以外,還有一個函數是combine,這是将分散的頭部寫成一個統一的頭部。這個函數還有一個另外的功用,就是對代碼進行一些可能的調整,交換指令次序一部分也可以在這裡來實作。
不管你相不相信,就這麼簡單的一段程式(test1.cpp),已經是一個變形的頭部了。遺憾的是他的變形能力還非常有限,因為歸根結底這段代碼裡面隻用到了一個寄存器,我們選擇寄存器的組合方式隻有區區的五個。如果有更多的寄存器在decode部分出現,同時我們要選出很多個寄存器備用的話,這樣子組合下來的結果就更多,變形的效果更好(相應的會更複雜,是以還是簡單的來做例子比較好)。
原理篇裡面說到的第二種方法是交換指令的順序,在這個地方也可以辦到,看着兩段:
pop ebx
xor ecx, ecx
mov cl, 222h
還是将後面兩句看成一個整體。彈出棧頂的值給寄存器或是指派給ecx,這兩步沒有絕對的先後次序,也就是說誰在前面并不影響到最終的結果。因而我們可以随意的調換兩者的位置(雖然是“随意”,說到底也就兩種方式而已,如果很多條指令可以互換次序的話,情況就麻煩了),對應的實作在函數Step2()中,交換一下字元串的内容,這樣的話不影響到後面的一系列函數。
最麻煩的還是所謂的插入NOP-like指令。
最容易想到變形方法就是這個,然而卻是最難實作的。decode部分不可避免的要有一些相對跳轉和相對調用的指令,一旦其中的某一個指令長度發生了變化,幾乎要影響到所有相對跳轉的地方,是以,要加入NOP-like指令的時候,需要對每一個指令進行考慮。
對于我們上面寫的這個decode而言,我們已經人為的将其分成了八個部分,之是以這樣做,有一個好處是我們可以在加入NOP-like指令的時候,僅僅是加入每一個部分中去,當作這個部分的一個整體,而不是安插在某兩個部分之間,難于了解不說,同時也難于處理。
以插入NOP 0x90為例(其他的NOP-like指令我們已經在原理篇裡面讨論過了,不是麼~)。如果我們插入到第一句jmp l後面,毫無疑問的,直接影響到的隻有自身而已,最後面的一個call de也可以算一個,不過call的地方是一條有意義的指令或者是一個NOP關系不是很大,簡便起見,索性就不修改call de指令。這個修改反映在test3的Step3()函數中,在對第一句加入了NOP-like以後,jmp的操作數應該相應的加上增長的位元組數,是以 head1[1]就視情況有所修改。
對head2的插入就更為複雜。pop exx後面加入NOP-like以後,除了第一個jmp以外,後面的call也受到了影響,同樣的xor ecx,ecx和mov cl加入NOP-like以後也有同樣的影響,這兩個在前面說過是可以交換的,也容易證明交換後對前後指令影響一緻,是以可以一同處理,即:加入Nop- like後前面的jmp要多跳一跳指令,後面call的目标也要向挪一個。
後面的修正,可以挨着挨着的做,都是同樣的方法。test3.cpp中還舉了一個inc exx後的插入,這裡就不具體的解釋了,道理和前面差不多,不過插入的不再是NOP,而是《原理篇》裡面提到過的指令對——inc eax和dec eax(0x4048)。Nop-like的指令多種多樣,在網上也有相關的讨論,有興趣的話可以去看看cnhonker.com的相關文章。
還有一個misc()函數。這個函數是通過decode部分的本身性質來變形的,例如上面的循環次數,也就是要解碼多少個位元組,這個數目可大可小,隻要能夠保證所有編碼過的字元都能夠被解到即可。像此類的變形不太能說清楚屬于什麼方面,隻能視情況而定,是以放到雜類中了。
到這個時候,差不多一個變形的decode部分已經完成。剩下一件小事情,就是将其作用的shellcode用一個數字來異或,然後将對應的數字填入 decode部分即可,代碼我已經有一個簡單的實作(見CD光牒中給出的test4.cpp),具體的細節不再做解釋,大家看代碼一下子就可以明白。
CD光牒裡的東西到此為止,然而變形的路子并沒有就此結束,還有一些值得讨論的,順帶在文章中簡單的提提。對這個過于簡單例子有所不滿的朋友,下面的話是可以進一步做到的,希望您能和我交流一下。
第一是關于起作用的部分,也就是所謂real shellcode部分的編碼方式。例子中給出的是很普通的單位元組異或,據我的實驗來看,似乎四個位元組一組或者是四個位元組一組的異或效果比較好,不過限于篇幅的關系,沒有給出這樣的代碼。四個位元組一組的主要思想就是平衡解碼部分的生成難度和變形能力,對于32位機而言,簡單處理情況下四位元組(DWORD)剛好是一次性處理的極限;七位元組主要考慮的是變形的能力,這種情況下顯然不能一次性異或七個位元組,而可能要4-2-1或者2-2-2-1或者其他分次異或的方法,對應的指令集比較分散,隻是解碼部分稍微麻煩了一點。當然,其他的編碼方式也可以,隻不過寫起來可能還要複雜一些。
第二是解碼部分的編寫。這裡給出的編寫方法顯然太過于複雜,好的辦法是在你編寫的上面套一層像編譯器一樣的東西,這樣需要做的不過就是不斷地加指令,相對位置的調整還有機器碼的生成都可以讓程式自己完成。我寫了個簡單的,有興趣的話可以交換一下,省是省力些,不過不太好用就是。
第三是有關解碼頭部本身的。這個頭部,在前面說過了,可以反複的加,反複的用,沒有關系的,代碼實作起來也很友善。變形病毒的話,這個頭部是內建在了 real shellcode裡面,負責在傳播的時候生成新的解碼部分,這裡我們編寫變形的shellcode不需要這麼麻煩(抛棄型的),就單獨把頭部的生成提取出來做成了程式,這也就是前面說的“不用包含自己讓自己變形的部分”。
生成變形的shellcode不是一件很難的事情,隻要你能寫出一種編碼的方法,然後寫出解碼的頭部就可以了,然而麻煩的是如何在長度(複雜度)與變形的能力之間尋求到一個平衡點。寫病毒的話,考慮的可能不是這麼多,因為隻要能找到足夠的空間可以隐藏,變形能力越強,對防毒軟體的考驗越大。寫 shellcode則不然,通常exploit需要的shellcode不能太長,而且ids/ips借以判定的字串往往還不是shellcode,感覺上隻要能寫出一個讓防毒軟體不認識的shellcode就可以了,從這一點上看,變形shellcode的唯一好處是每次能夠給你一個基本上全新的 shellcode,隻要你不公開你的算法,防毒軟體廠商沒有哪個精力(也許是能力)來分析你的東西