1. 程式人生 > >代碼混淆之道——控制流扁平與不透明謂詞理論篇

代碼混淆之道——控制流扁平與不透明謂詞理論篇

公式 urn 顯示 分支 等價 有一個 地址 for c/c++

控制流是指代碼執行時指令的執行順序。在各種控制邏輯的作用下,程序會沿著特定的邏輯順序執行。一般控制邏輯包括有無條件分支、循環、函數調用等。

本文原創作者:i春秋簽約作家——penguin_wwy

一、扁平化的定義

本篇講代碼混淆的一個重要手段,控制流扁平化。

所謂控制流是指代碼執行時指令的執行順序。在各種控制邏輯的作用下,程序會沿著特定的邏輯順序執行。一般控制邏輯包括有\無條件分支、循環、函數調用等。在正常情況下程序的邏輯非常好理解(代碼邏輯不好的程序員都死了。。。),開發過程中有各種人為的行為使代碼邏輯清晰,便於維護和擴展。但同時,對於逆向行為來說,清晰的代碼邏輯會導致很容易抓住程序重點,加快破解速度。而控制流扁平則是反其道而行將源代碼結構改變,使得程序的邏輯復雜不易被靜態分析,增加逆向難度。

下面通過一個例子來說明

這是《軟件加密與解密》中的示例代碼

int modexp(int y, int x[], int w, int n)
{
    int R, L;
    int k = 0;
    int s = 1;
    while(k < w) {
        if (x[k] == 1) {
            R = (s * y) % n;
        }
        else {
            R = s;
        }
        s = R * R % n;
        L = R;
        k++;
    }
    return L;
}

根據上段代碼,我們可以畫出它的控制流圖。

技術分享

這裏我們用if來代替while,這樣可以使得邏輯更加清晰。這幅圖就是扁平前的效果,可以看到程序基本是從上往下執行的,邏輯線路非常明確。

而當我們對它進行了扁平化處理之後,就變成這樣:

int modexp(int y, int x[], int w, int n)
{
    int R, L, s, k;
    int next = 0;
    for(;;) {
        switch(next) {
        case 0: k = 0; s = 1; next = 1; break;
        case 1: if(k<w) next = 2; else next = 6; break;
        case 2: if(x[k]==1) next = 3; else next = 4; break;
        case 3: R=(s * y) % n; next = 5; break;
        case 4: R = s; next = 5; break;
        case 5: s=R * R % n; L = R; k++; next = 1; break;
        case 6: return L;
        }
    }
}

控制流圖變成了這樣

技術分享

直觀的感覺就是代碼變“扁”了,所有的代碼都擠到了一層當中,這樣做的好處在於在反匯編、反編譯靜態分析的時候,無法判斷哪些代碼先執行哪些後執行,必須要通過動態運行才能記錄執行順序,從而加重了分析的負擔。

二、實現平臺

扁平化的實現是不能平地而起的,必須要基於一定的平臺。就是說,不是你隨便給我一段代碼,讓我混淆我就能混。之前的例子很簡單,遇到復雜一點的比如while循環裏有聲明局部變量,while內部的if和else分支都用到這個變量;當混淆後,while循環已經被我們用if改寫了,那這個局部變量的聲明放到哪裏?如果放到替代while的if分支裏,由於這個if分支和原來while內部的if-else分支是平級的,那麽這個局部變量就不能在if-else分支中使用了。這就是一個bug。所以在混淆前必須對源代碼進行分析。

那用什麽東西進行分析呢?答案是編譯器,更準確說是編譯(解釋)器的前端。

這裏要重溫一下很有趣的編譯原理。以編譯語言來講,從源代碼到可執行程序要經歷這麽幾步:預編譯——>編譯——>匯編——>鏈接。以GCC來說,預編譯對應-E參數,將源代碼所有的宏處理展開,包括include頭文件。編譯則是將預處理完的文件通過詞法分析、語法分析等前端處理,生成抽象語法書並轉化為中間語言,然後進入編譯器後端執行優化策略,輸出為匯編語言,對應的GCC參數為-S。匯編是將匯編語言(低級程序語言)轉化成對應的可執行的機器碼。鏈接則將生成的多個模塊(也可能是一個)間互相引用的部分處理好,讓不同的模塊可以相互調用。

//預編譯
gcc -E test.c -o test.i    
//編譯 
gcc -S test.i -o test.s     
//匯編
gcc -c test.s -o test.o     
//鏈接
ld -static test1.o test2.o tes3.o -start-group -lgcc -lgcc_eh -lc -end-group crtend.o crtn.o

我們平常所說的編譯器GCC其實是一套編譯體系,包括了編譯器、匯編器、鏈接器,狹義上的編譯器只處理從源代碼到匯編語言的過程。下文所述的編譯器均是狹義上的編譯器,不指編譯體系。

對於編譯器以中間語言為界限分為前端和後端。前端進行詞法分析、語法分析、中間語言生成,後端負責優化。我們所需要的就是詞法和語法分析。

詞法分析就是將源代碼切割成一個一個的單詞。語法分析就是研究源代碼的邏輯了。由於篇幅限制(已經很啰嗦了,不過似乎並不能講清楚),這裏就不詳細描述了,總之就是經過語法分析,編譯器前段會得到抽象語法樹,並且獲得控制流圖,也就是我們之前畫的那種。有了控制流圖才能在其基礎上進行修改,所以一般需要都是采用魔改編譯器的方式來完成代碼混淆。

要魔改,編譯器最好是開源的,擴展性要好,所以一般都采用clang作為基礎。clang是一個由C++編寫、基於LLVM編譯體系的C/C++/OC編譯器。文檔鏈接http://clang.llvm.org/docs/index.html。

三、算法抽象

在知曉了平臺之後我們就可以開始研究如何進行控制流扁平。一般扁平算法基本步驟如下:

1、將函數體拆分為多個基本塊,構建控制流圖。將這些原本屬於不同層級的基本塊放到同一層級;

2、將所有基本塊封裝到一個switch選擇分支當中;

3、用一個狀態變量來表示當前狀態,進行邏輯順序控制(上述代碼中的next變量)。

改變原有結構往往會帶來一些副作用,比如之前所說的局部變量的聲明要提前,否則不同分支無法使用同一個變量。除此之外的副作用還有:

1、由於聲明提前,聲明和賦值過程分離,而引用類型需要聲明的同時定義,代碼如下

while(k<m) {
    int& a = k;     //引用需同時聲明和定義
    if(...) {
        a += ...
    }
    else {
        a -= ...
    }
    ...
}
 
//混淆後變以下
 
int &a;             //錯誤
switch(next) {
case ...:   if (k<m) a = k;
...
case ...:   if(...) a+=...;else a-=...;next=...;
...
}

2、構造函數和析構函數會因為聲明位置而產生副作用。

3、帶來同名變量的問題,即原本不同作用域名稱相同的變量變成同作用域名稱相同的變量。

4、try-catch語句可能會遇到的執行順序問題。

除了要處理這些副作用之外,源代碼中本來的while、do-while、for循環包括原本的switch-case分支統統需要改為if-goto的形式。然後再進行switch-case的封裝。

最終的算法執行順序為

標識符重命名(解決變量名沖突)——>控制語句展開(全變成if)——>變量聲明提前——>控制流壓扁

3.1標識符重命名

這個目的很明顯就是為了解決變量名沖突,所以按照一定順序改就行了。

3.2控制語句展開

目的是將邏輯控制全變成if-goto邏輯,類似於下圖

技術分享

3.3變量聲明提前

針對基本類型和指針類型按以下步驟執行:

將聲明提前——>如果原來有初始化行為,則在原來的位置增加賦值語句,用初始化值賦值——>如果沒有初始化行為則賦值為0——>over

引用變量需要變為指針變量按上述步驟執行。

針對對象的構造和析構按照以下步驟執行:

在起始處用auto_ptr分配一段對象大小的內存——>在原來初始化的位置用placement new語句對auto_ptr的內存進行初始化——>原始代碼中引用對象的位置改為auto_ptr解引用——>在隱式析構的位置顯示調用析構函數——>over

3.4控制流壓扁

最後是控制流壓扁的偽代碼

對函數有控制流圖cfg
入口節點為entry
出口節點為exit
count = 0
構造一個switch,和控制值nextVar
foreach node in cfg:
    if node != exit:
        新建一個case,並包含node的全部內容
        若node有一個後繼節點:
            nextVar = x
            x為後繼節點的case
        若node有兩個後繼節點a1,a2:
            if condition:
                nextVar = x
            else
                nextVar = y
            x為a1的case,y為a2的case
    增加一個break;
將上述switch結構封裝到一個死循環中

四、不透明謂詞

上述的過程我們會發現一個問題,所有的next都是直接賦值出來的,看你next等於幾就知道下一個執行什麽了。。。。那還有什麽用。。。。

所以這裏就介紹另一件利器,來解決這個問題。

所謂不透明,就是對方難以推斷的。不透明謂詞就是代碼的編寫者知道是真是假是什麽,但是攻擊者難以從字面獲悉。

比如

if ((x * x + x) % 2 == 0) {
    ...
}
else {
    ...
}

對於公式x平方加x,等價於(x+1)*x,偶數乘奇數等於偶數,所以該判斷必然成立。好吧這個式子簡單了點,我們換個難點的

技術分享

總之翻開一本數論,找一個結論,作為if的判斷條件,知道為真或者為假。而攻擊者如果不知道的情況下就會難以琢磨。

應用到上述的扁平化,比如

int a[] = {1, 2, 4, 12, 16...}
int i = 0;
int next = a[0] - 1;
switch(next) {
case 0: ...; next = a[i + 1] - a[i]; i++; break;
case 1: ...; next = a[i + 2] / a[i + 1]; i+=2; break;
case 2: ...; return ;
case 3: ...; next = a[i + 1] - a[i]; i--; break;
case 4: ...; next = a[i - 2] * a[i - 1]; break;
}

定義一個數組,next第一次賦值為0,進入case0;

next為a[1]-a[0]進入case1;

next為a[3] / a[2]進入case3;

next為a[4] – a[3]進入case4;

next為a[0] * a[1]進入case2;

返回。

為了提高難度可以將數組定義為全局變量,在其他地方生成,甚至動態生成,只要保持一定的數學關系即可。

本文 完

PS:本文部分名詞解釋、圖片來自一下資料:

《軟件加密與解密》

張清泉的碩士論文《基於clang的C++代碼混淆工具》

宋亞齊的碩士論文《基於代碼混淆的軟件保護技術研究》

PSS:基於clang的混淆工具個GitHub上有一個,但是是clang3.3的,太老了,我最近在重構,版本為最新的clang3.9.1

不過以我不定期更新的情況看。。。。在實戰篇前重構完的難度較大,能不能重構完還兩說。。。。。。

https://github.com/penguin-wwy/cppobfuscator

git地址放出來,有想一起重構的可以一起,看熱鬧的可以幫我點個star,給點動力。

PSSS

不透明謂詞還有很多種算法,有時間再說。

代碼混淆之道——控制流扁平與不透明謂詞理論篇