Author:360天眼實驗室

0x00 前言


由于殺軟對商業殼比較敏感,并且商業殼檢測,脫殼技術比較成熟,病毒作者一般不會去選擇用商業的殼來保護自己的惡意代碼,所以混淆殼成為了一個不錯的選擇.混淆殼可以有效對抗殺軟,因為這種殼一般不存在通用的檢測方法,并且很難去靜態的脫殼,所以其惡意代碼就不會被發現,從而使自己長時間的存在.對于惡意代碼分析者來說,分析這種帶混淆殼的樣本往往會花費很大精力,甚至有時候會使分析變得不可能。本文主要幾種常見的混淆手段,不涉及樣本本身功能分析。

0x01 從一個樣本說起


Citadel(md5: 767a861623802da72fd6c96ce3a33bff)是一個zeus的衍生版本,其較zeus更加的健壯,也更穩定。前一段時間發現了一個citadel 樣本,較之普通的citadel稍微有一點特別,其整體的結構如下圖:

圖1 Citadel樣本結構

即外層加了upx的殼,里面是一個混淆殼,再往里面才是citadel原始代碼。脫掉UPX后的,混淆殼代碼分支總覽圖:

圖2混淆殼代碼分支總覽圖

對于citadel樣本本身的功能與特點,本文不會提及,剛興趣的可以自己去查資料,這里主要講講citadel殼的一些特點,與常見的幾種混淆的手段。

下面的圖為該混淆殼的混淆代碼片段,其中這么一大段代碼中只有紅框中的指令是有用的。其他都是無效指令。很顯然比一般的垃圾指令填充不知道高到哪里去了。

圖 3,混淆片段實例

 1.1 Citadel混淆殼的一個trick

當手動脫掉upx后,運行樣本后就崩潰了,然而不脫upx殼,樣本是可以正常運行的。運行前后api trace 對比圖:

圖4,api trace

其中上圖是脫掉upx殼的api log,下圖為沒脫upx殼的api log,從圖中我們可以看到在地址0x4176768地址中的調用的API名不同。很顯然從這里出錯了。從這個地址往上回溯,這個調用api的過程被劃分為十幾個代碼塊,然后利用JMP連接起來,其中每個代碼塊就只有一兩條代碼是有效指令:

圖5,獲取導入表導入函數地址表第四項過程

通過上圖我們發現call esi指令中esi的值由mov esi,[esi+0xch]獲得,esi+0xch的值是導入函數地址表中第四項的api的地址。所以問題很可能出現在這里,即脫殼后與脫殼前導入地址表第四項api不同。

圖6,沒脫upx殼的導入函數地址表:

圖 7,脫掉upx殼的第四項:

所以我們可以看出問題就出現在這里。即脫殼后與脫殼前導入函數地址表的不同導致了脫殼后citadel運行奔潰.從這一點可以看出這個樣本在加殼的時候就是upx殼與內層混淆殼是天然一體的。

1.2 混淆殼整個的執行流程

圖 8,,代碼執行流程

  1. upx殼代碼執行。
  2. 混淆代碼Routine 1。
  3. 解密Routine2代碼(堆內存)。
  4. Routine2 執行(堆代碼),解密原始citadel代碼,修復api調用函數地址。
  5. 執行原始citadel代碼流程。

1.3 混淆代碼的細節

在這個樣本中,各種混淆函數中,大部分的代碼是操作都是在操作0x439000-0x4390a4區塊的數據,其中混淆函數里面插入一兩條真正有用的代碼,如圖1紅框中的指令,然后這些混淆函數串聯起來,完成對0x4390a8開始大小為0x3b70的數據的解密。如果對這種類型混淆殼不熟悉的的話很容易被這些無用的混淆指令所干擾,分析人員可能會花費大量的時間在理解這些無用的計算上面了。

1.4混淆代碼snippet 類型

如前面所說citadel大部分的混淆代碼主要是操作數據讀寫,主要的混淆代碼是由一下幾種模式組合起來,形成這種長的混淆代碼片段的,作者使用這幾種模式:

如下圖所示

圖9,混淆模式

基本上在Routine1的混淆代碼中就是這種代碼的隨機組合形成的,然后用控制流程指令,如jmp/jz/jnz/jne/je連接起來。

0x02 混淆殼常見的幾種技術手段


  1. 控制流程混淆
  2. 數據混淆
  3. 代碼混淆

需要說明的是這幾種混淆方式是完全可以同時存在的。

2.1 控制流程混淆

2.1.1 碼塊亂序

對于編譯器來說在生成代碼的時候,一般情況下邏輯相關的代碼塊都處在相距離比較近的位置。但是對于混淆來說是故意打破這種規則的,毫無疑問這將會使分析人員花費更長的時間來分析此類樣本。

2.1.2 代碼塊分割

即將一個函數過程分割成更多的流程。擾亂分析者對樣本分析,很顯然這樣的過程會使分析者感到沮喪,嚴重拖慢了分析效率。這個在citadel中是有體現的。即在執行Routine2堆內存代碼的時候:

圖10 代碼塊分割

每執行一跳指令就會jmp到另一個代碼塊。當然這只是一個很小的例子,其中這里面可以更加的復雜,比如添加更多的dead code blocks。

2.2 數據混淆

2.2.1 常量拆分(constant unfolding)

常量拆分是一個比較常用的混淆手段,主要目的是隱藏真實的代碼邏輯,讓分析者內心崩潰

比如:value=9*8,實際上value就是72,惡意代碼編寫者故意讓這些常數72,在運行時由乘法指令產生。常量拆分就是一種逆向的操作,把本來可以直接獲取的值,通過計算來產生的一種混淆方式。如下圖:

圖11,常量拆分

本來直接可以mov esi,0x400000,但是卻拆分成兩部分而且其中添加不少無效指令

其中經過紅框中的計算可以得到esi的值為0x400000.這一步的目的是獲取pe文件基址。很顯然惡意代碼作者沒有考慮地址隨機化問題。

2.2.2 數據編碼

數據編碼的原理就是將常量數據動態編碼,然后在動態的解解碼,數據編碼集中體現在加密解密上。同樣在citadel這個樣本里我們發現有這樣的過程,如下圖:

圖12,數據編碼

Result由 4390a0與43905c異或獲取,而這兩個值也是動態計算出來的。所以這樣的編碼如想靜態的獲取result會比較困難。

2.3 代碼混淆

2.3.1 指令替換

對于指令的替換,這個大家見得比較多。就是指令的拆分,或者合并,目的是使分析人員更加難以理解,或者拖慢分析速度。

MOV Mem,Imm
CMP/TEST Reg,Mem     --> CMP/TEST Reg,Imm
Jcc @xxx                 Jcc @xxx    

 MOV Reg,Imm      -> LEA Reg,[Imm]           
ADD Reg,Imm       -> LEA Reg,[Reg+Imm]       
MOV Reg,Reg2      -> LEA Reg,[Reg2]          
ADD Reg,Reg2       -> LEA Reg,[Reg+Reg2]          

OP Reg,Imm    ->   MOV Mem,Imm             TEST Reg,Imm  -> MOV Mem,Reg
OP Mem,Reg                Jcc @xxx         AND/TEST Mem,Imm
MOV Reg,Mem                             Jcc @xxx   

這些指令的含義很簡單,這里就不介紹了。類似的這種指令替換方式變化無窮。

2.3.2 MOV指令混淆

在去年的recon大會中《The M/o/Vfuscator-Turning ‘mov’ into a soul-crushing RE nightmare》議題,讓我們見識到了,代碼混淆的另一種方式,作者演示了所有的機器指令,除過控制流指令外,都用mov指令來實現,很顯然,如果人為去理解這樣的代碼,是很困難的,這個可以看出作者對x86指令深入的理解,讓我們大開眼界,下面我就從我的角度來說明下這個背后的原理和一些細節。先來直觀的感受下,這些代碼指令吧:

圖13,mov 混淆代碼

源碼是這樣子的:

圖14,源代碼

可以看出本來一個很簡單的c程序代碼,現在混淆的面目全非。

技術原理:

Christopher Domas 的這個議題源自Stephen Dolan的一篇paper《mov is Turing-complete

所謂圖靈完備指的就是如果一個系統的數據操作規則(比如計算機指令集,編程語言)能夠模擬任意的單磁帶圖靈機就成稱之為圖靈完備。我們主要看看Christopher Domas是如何來完成mov obfuscation的。

首先解釋一下為什么Chirstopher Domas為什么會選擇 mov指令。

Mov指令可以用于內存讀寫,同時可以將立即數載入到寄存器,并且有不少尋址模式,它沒有條件分支和比較的功能,因此貌似不是很顯性圖靈完備。在有限的時間里執行有限的數量的mov指令序列,為了圖靈完備性,除過mov指令外還得再加入跳轉指令,這樣一來就完全符合圖靈完備了。

對于mov指令來說,不能實現跳轉,代碼的執行流就只有一種,所以需要跳轉指令來幫助實現跳轉來完成真正意義上的圖靈完備。

所以整體上說代碼流如下:

**Start: mov … mov … mov … mov … mov … mov … jmp Start **

mov模擬其他指令偽代碼:

case 1: // mov檢查值是否相等:

x==y?
mov [x], 0
mov [y], 1
mov R, [x]

很顯然當x==y的時候R的值就是1,否則為0。

Case2://條件分支指令

IF X == Y THEN
   X=100

對于這種分支代碼,設置一個Selector(相當于一個指針),一個data內存區域存放的數據是100,一個scratch 內存區,是存放的原始x的原始值,如果x的值與y的值相等的話就將selector的指向data區,如果不等就將selector指向scratch區域。

從上圖我們可以總結出具體的實現原理是這樣的:

#!c
int* select_x[]={&DUMMY_X , &X}
*select_x[x==y]=100

即selector就是一個包含有假的X地址(DUMMY_X)與X的真實地址的數組,如果X等于Y則select_x[x==y]指向第二個元素,并給*X賦值等于100,否則selector_x[x==y]是DUMMY_X。

模擬代碼:

mov eax,[X]
mov [eax],0
mov eax,[Y]
mov [eax],4
mov eax,[X]
mov eax,[Select_x+eax]
mov [eax],100  ;X=100

在這里可以看出作者很巧妙的利用x86指令內存排布特性分別在X ,Y,所代表的內存地址放置值0,4,這剛好是DUMMY_X與X地址的偏移,這樣[Select_x+eax]就指向了DUMMY_X或者X,最后賦值X或者DUMM_X,實現了上面的整個的過程。這里可以看出代碼比正常的匯編指令膨脹了不少。正常匯編指令最多4條就夠了,這里用到了7條,很顯然現在代碼不是那么容易理解了。

一些模擬指令序列:

兩個值相等

%macro eq 3
mov eax, 0
mov al, [%2]
mov byte [e+eax], 0
mov byte [e+%3], 4
mov al, [e+eax]
mov [%1], al
%end macro

其中%2 %3 為要比較的兩個值,%1為比較的結果。

兩個值不等

%macro neq 3
mov eax, 0
mov al, [%2]
mov byte [e+eax], 4
mov byte [e+%3], 0
mov al, [e+eax]
mov [%1], al
%endmacro

neq與eq的差別就在第三條與第四條指令中,賦值的區別,

創建一個選擇器

; create selector
%macro c_s 1
%1: dd 0
d_%1: dd 0
s_%1: dd d_%1, %1
%endmacro

即一個4字節的內存塊,包含兩個元素 %1 ,d_%1

關于循環與分支

Extend the if/else idea
  On each branch
    If the branch is taken
      Store the target address
      Turn execution “off”
    If the branch is not taken
      Leave execution “on”

解釋下上面的意思,分支代碼被觸發的時候,保存目標地址代碼,置位該執行為關閉狀態,

如果分支代碼沒有被執行,置位離開執行塊狀態。

On each operation
  If execution is on
    Run the operation on real data
  If execution is off
    Is current address the stored branch target?
      Yes?
        Turn execution “on”
        Run operation on real data
      No?
        Leave execution “off”
        Run on dummy data

如果執行塊開啟,執行真實代碼,如果執行塊關閉,先判斷當前地址是否存儲了目標指令代碼,如果是,將執行體置位為on,執行代碼。如果不是,置離開執行體為off,執行dummy data中的代碼。關于mov 混淆的更多的細節,可以查看去年recon的議題與相關的視頻。

2.3.3 編譯器混淆

利用編譯器進行混淆的樣本不是很常見,但是這類的樣本將會成為一個新的發展方向。 編譯器代碼混淆就是在編譯器生成二進制代碼的時候插入混淆代碼。下面簡單的介紹下一個實例。

2.3.3.1 tcc編譯器的混淆

原理就是在tcc生成機器碼的時候加入混淆函數。作者patch了tcc編譯器加入了一些混淆的指令:

插入混淆代碼序列的過程

#!c
for (i=0; i<t; i++)
{  int q;
    q=rand_reg (0, 4);
    switch (q)
    {
    case 0: // add
        rrr=genrand(); curval=curval+rrr;
             o(0x81); oad(0xc0 | (0 << 3) | r, rrr); // add
        break;
    case 1: // sub
        rrr=genrand(); curval=curval-rrr;
                o(0x81); oad(0xc0 | (5 << 3) | r, rrr); // add
        break;
    case 2: // shl
        rrr=genrand()&0x7; curval=curval<<rrr;
        o(0xc1); /* shl/shr/sar $xxx, r */
        o(0xe0 | r);
        g(rrr);
        break;
    case 3: // shr
        rrr=genrand()&0x7; curval=curval>>rrr;
        o(0xc1); /* shl/shr/sar $xxx, r */
        o(0xe8 | r);
        g(rrr);
        break;
    case 4: // xor
        rrr=genrand(); curval=curval^rrr;
                o(0x81); oad(0xc0 | (6 << 3) | r, rrr); // xor
        break;
    };

首先隨機選取了寄存器,Genrand()是產生隨機數的函數,o()是產生opcode的函數,oad()是產生指令其余部分的函數。每次隨機選取一個寄存器,然后對選取的寄存器產生對應的混淆指令。

對于call指令會產生這樣的代碼:

對應的代碼如下:

就是在原始的call 之前加入代碼,最后jmp到原來的流程,然后返回繼續執行下面的流程。還有很多的細節這里就不一一介紹了,如果感興趣可以自己研究下源碼。

0x03 如何反混淆


從上面可以看出混淆技術的種類繁多,但是也是有層次的,對于海量樣本的處理,反混淆流程是必須的,也是一個很重要的流程,怎么做,如何做,這將直接影響到對惡意樣本的分類,數據提煉效果上,所以這是一個很有意義的話題,關于如何反混淆,將會在后續的文章談到。

參考文獻:

  1. https://en.wikipedia.org/wiki/Turing_completeness
  2. https://www.cl.cam.ac.uk/projects/raspberrypi/tutorials/turing-machine/one.html
  3. https://en.wikipedia.org/wiki/Turing_machine
  4. https://recon.cx/2015/slides/recon2015-14-christopher-domas-The-movfuscator.pdf
  5. http://conus.info/stuff/tcc-obfuscate/

您的支持將鼓勵我們繼續創作!

[微信] 掃描二維碼打賞

[支付寶] 掃描二維碼打賞