符號執行簡介

ZERO-A-ONE發表於2021-01-01

轉載自CTF ALL IN ONE

基本原理

符號執行起初應用於基於原始碼的安全檢測中,它通過符號表示式來模擬程式的執行,將程式的輸出表示成包含這些符號的邏輯或數學表示式,從而進行語義分析。

符號執行可分為過程內分析和過程間分析(或全域性分析)。過程內分析是指只對單個函式的程式碼進行分析,過程間分析是指在當前函式入口點要考慮當前函式的呼叫資訊和環境資訊等。當符號執行用於程式碼漏洞靜態檢測時,更多的是進行程式全域性的分析且更側重程式碼的安全性相關的檢測。將符號執行與約束求解器結合使用來產生測試用例是一個比較熱門的研究方向。(關於約束求解我們會在另外的章節中詳細講解)

符號執行具有代價小、效率高的有點,但缺點也是很明顯的。比如路徑狀態空間的爆炸問題,由於每一個條件分支語句都可能會使當前路徑再分出一條新的路徑,特別是遇到迴圈分支時,每增加一次迴圈都將增加一條新路徑,因此這種增長是指數級的。在實踐中,通常採用一些這種的辦法來解決路徑爆炸問題,比如規定每個過程內的分析路徑的數目上限,或者設定時間上限和記憶體上限等來進行緩解。

動態符號執行將符號執行和具體執行結合起來,並交替使用靜態分析和動態分析,在具體執行的同時堆執行到的指令進行符號化執行。

每一個符號執行的路徑都是一個 true 和 false 組成的序列,其中第 i 個 true(或false)表示在該路徑的執行中遇到的第 i 個條件語句。一個程式所有的執行路徑可以用執行樹(Execution Tree)表示。舉一個例子:

int twice(int v) {
    return 2*v;
}

void testme(int x, int y) {
    z = twice(y);
    if (z == x) {
        if (x > y+10) {
            ERROR;
        }
    }
}

int main() {
    x = sym_input();
    y = sym_input();
    testme(x, y);
    return 0;
}

這段程式碼的執行樹如下圖所示,圖中的三條路徑分別可以被輸入 {x = 0, y = 1}、{x = 2, y = 1} 和 {x = 30, y = 15} 觸發:

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片儲存下來直接上傳(img-PG2tN4YS-1609501857186)(https://firmianay.gitbooks.io/ctf-all-in-one/content/pic/5.3_tree.png)]

符號執行中維護了符號狀態 σ 和符號路徑約束 PC,其中 σ 表示變數到符號表示式的對映,PC 是符號表示的不含量詞的一階表示式。在符號執行的初始化階段,σ 被初始化為空對映,而 PC 被初始化為 true,並隨著符號執行的過程不斷變化。在對程式的某一路徑分支進行符號執行的終點,把 PC 輸入約束求解器以獲得求解。如果程式把生成的具體值作為輸入執行,它將會和符號執行執行在同一路徑,並且以同一種方式結束。

例如上面的程式中 σ 和 PC 變化過程如下:

開始:  σ = NULL                    PC = true
第6行: σ = x->x0, y->y0, z->2y0    PC = true
遇到if(e)then{}else{}:σ = x->x0, y->y0    then分支:PC = PC∧σ(e) else分支:PC' = PC∧¬σ(e)

於是我們發現,在符號執行中,對於分析過程所遇到的程式中帶有條件的控制轉移語句,可以利用變數的符號表示式將控制轉移語句中的條件轉化為對符號取值的約束,通過分析約束是否滿足來判斷程式的某條路徑是否可行。這樣的過程也叫作路徑的可行性分析,它是符號執行的關鍵部分,我們常常將符號取值約束的求解問題轉化為一階邏輯的可滿足性問題,從而使用可滿足性模理論(SMT)求解器對約束進行求解。

檢測程式漏洞

程式中變數的取值可以被表示為符號值和常量組成的計算表示式,而一些程式漏洞可以表現為某些相關變數的取值不滿足相應的約束,這時通過判斷表示變數取值的表示式是否可以滿足相應的約束,就可以判斷程式是否存在相應的漏洞。

使用符號執行檢測程式漏洞的原理如下圖所示:

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片儲存下來直接上傳(img-7Y54Vk9F-1609501857192)(https://firmianay.gitbooks.io/ctf-all-in-one/content/pic/5.3_overview.png)]

舉個陣列越界的例子:

int a[10];
scanf("%d", &i);
if (i > 0) {
    if (i > 10)
        i = i % 10;
    a[i] = 1;
}

首先,將表示程式輸入的變數 i 用符號 x 表示其取值,通過分別對 if 條件語句的兩條分支進行分析,可以發現在賦值語句 a[i] = 1 處,當 x 的取值大於 0、小於 10 時,變數 i 的取值為 x,當 x 的取值大於 10 時,變數 i 的取值為 x % 10。通過分析約束 (x>10∨x<10)∧(0<x∧x<10) 和約束 (x%10>10∨x%10<10)∧x>10 的可滿足性,可以發現漏洞的約束是不可滿足的,於是認為漏洞不存在。

構造測試用例

在符號執行的分析過程中,可以不斷地獲得程式可能執行路徑上對程式輸入的約束,在分析停止時,利用獲得的對程式輸入的一系列限制條件,構造滿足限制條件的程式輸入作為測試用例。

在模擬程式執行並收集路徑條件的過程中,如果同時收集可引起程式異常的符號取值的限制條件,並將異常條件和路徑條件一起考慮,精心構造滿足條件的測試用例作為程式的輸入,那麼在使用這樣的輸入的情況下,程式很可能在執行時出現異常。

方法實現

使用符號執行技術進行漏洞分析,首先對程式程式碼進行基本的解析,獲得程式程式碼的中間表示。由於符號執行過程常常是路徑敏感的分析過程,在程式碼解析之後,常常需要構建描述程式路徑的控制流圖和呼叫圖等。漏洞分析分析的過程主要包括符號執行和約束求解兩個部分,並交替執行。通過使用符號執行,將變數的取值表示為符號和常量的計算表示式,將路徑條件和程式存在漏洞的條件表示為符號取值的約束。約束求解過程一方面判斷路徑條件是否可滿足,根據判斷結果對分析的路徑進行取捨,另一方面檢查程式存在漏洞的條件是否可以滿足。符號執行的過程常常需要利用一定的漏洞分析規則,這些規則描述了在什麼情況下需要引入符號,以及在什麼情況下程式可能存在漏洞等資訊。

正向的符號執行

正向的符號執行用於全面地對程式程式碼進行分析,可分為過程內分析和過程間分析。

過程內分析逐句地地過程內的程式語句進行分析:

  • 宣告語句分析
    • 通過宣告語句,變數被分配到一定大小的儲存空間,在檢測緩衝區溢位漏洞時,需要記錄這些儲存空間的大小。
    • 分析宣告語句的另一個目的是發現程式中的全域性變數,記錄全域性變數的作用範圍,這將有助於過程間分析。
  • 賦值語句分析
    • 將賦值變數的取值表示為符號和常量的表示式。
    • 在檢查程式漏洞時,常常對陣列下標進行檢查,判斷對陣列元素的訪問是否存在越界。
    • 對於和指標變數有關的賦值語句,不僅需要考慮指標變數本身的取值,還需要考慮其指向的內容。
  • 控制轉移語句分析
    • 將路徑條件表示為符號取值的約束並進行求解,可以判斷路徑是否可行,進而對待分析的路徑進行取捨。
  • 呼叫語句分析
    • 一些過程呼叫語句會進入符號,在分析過程中,將表示程式輸入的變數的取值用符號表示,而程式可以通過過程呼叫接收程式的輸入。對於指標變數,命令列引數同樣使用符號表示其取值。
    • 通過過程呼叫語句,變數被分配的儲存空間的大小常常是在分析時所需要記錄的。
    • 對於一些關鍵的過程呼叫,需要對其使用情況進行檢查,如 strcpy,需要檢查引數以判斷是否存在緩衝區溢位。
    • 對於一些庫函式或者系統呼叫等非程式程式碼實現的過程,用摘要描述所關心的分析過程和結果,可以避免重複分析。

過程間分析常常需要考慮按照怎樣的順序分析程式語句,如深度優先遍歷和廣度優先遍歷。另外在進行分析時,需要先確定一個分析的起始點,可以是程式入口點、程式中某個過程的起始點或者某個特定的程式點。

逆向的符號執行

逆向的符號執行用於對可能存在漏洞的部分程式碼進行有針對性的分析。通過分析這些程式語句,可以得到變數取值滿足怎樣的約束表示程式存在漏洞,將這樣的約束記錄下來,在之後的分析中,通過逆向分析判斷程式存在漏洞的約束是否是可以滿足的。通過不斷地記錄並分析路徑條件,檢查程式是否可能存在帶有程式漏洞的路徑。

例如下面的程式碼片段:

if (j > -6) {
    a = i;
    i = j + 6;
    if (i < 15) {
        if (flag == 0) {
            a [i] = 1;
        }
    }
}

我們可以從語句 a[i]=1 開始,逆推上去,判斷 i<0∨i>len(a) 是否可以滿足,直到碰到語句 if(i<15) 時,存在漏洞的約束被更新為 i<15∨flag==0∨i<0∨i>len(a),如果 len(a)≥15,則通過對約束進行求解可知當前約束是不滿足的,這時停止對該路徑的分析。否則如果 len(a)<15,則不能判斷程式是否存在漏洞,分析將繼續。

如果在碰到賦值語句且賦值變數和路徑條件相關時,可以根據賦值語句所示的變數取值之間的關係更新當前路徑條件。例如上面的 i=j+6,可以將其帶入到路徑條件中,得到 j+6<15∨flag==0∨j+6<0∨j+6>len(a)。而無關的賦值,如 a=i,則可以忽略它。然而變數之間的別名關係常常會對分析產生影響,所以可以在逆向分析之前,對程式進行別名分析或者指向分析。

逆向符號執行的過程間分析:

  • 當過程內分析中遇到不能根據語義進行處理的過程,這些過程是程式實現的,並且影響所關心的存在漏洞的約束時
    • 通常選擇直接對呼叫的過程進行過程內分析。
  • 當過程內分析已經到達過程的入口點,且仍然無法判斷存在漏洞的約束是否一定不可滿足時
    • 可以根據呼叫圖或其他呼叫關係找到呼叫該過程的過程,然後從呼叫點開始繼續逆向分析。

例項分析

我們來看一段緩衝區溢位漏洞的例子,分析規則和漏洞程式碼如下:

array[x];   len(array) = x
array[y];   0 < i < len(array)
#define ISDN_MAX_DRIVERS 32
#define ISDN_CHANNELS 64

static struct isdn driver *drivers[ISDN_MAX_DRIVERS];
static struct isdn driver *get_drv_by_nr(int di) {
    unsigned long flags;
    struct isdn driver *drv;
    if (di < 0)
        return NULL;
    spin_lock_irqsave(&drivers lock, flags);
    drv = drivers[di];
    ......
}
static struct isdn slot *get_slot_by_minor(int minor) {
    int di, ch;
    struct isdn driver *drv;
    for (di = 0; di < ISDN_CHANNELS; di++) {
        drv = get_drv_by_nr(di);
        ......
    }
}

漏洞很明顯,在語句 drv = drivers[di] 中,di 可能會超出陣列上界。

程式碼片段的過程呼叫關係如下:

--> get_slot_by_minor() --> get_drv_by_nr() --> spin_lock_irqsave()

我們首先用正向的分析方法,過程如下:

  • 將函式 get_drv_by_nr() 的引數 di 作為符號處理,用符號 a 表示其值。
  • 接下來宣告瞭兩個變數,但未對其賦值,所以不進行處理。
  • 語句 if(di<0) 對變數 di 加以限制,這裡記錄 a<0 時,函式返回空。然後遍歷語句的 false 分支。
  • spin_lock_irqsave() 函式呼叫語句,使用其摘要進行分析。
  • 然後是陣列訪問操作,是程式的檢查點,根據分析規則,將 a 的取值範圍限定在 0≤a<32。結合路徑條件得到約束,生成摘要 0≤a<32 程式是安全的。
  • 當函式 get_drv_by_nr() 分析完成後,將符號 a 替換為引數 di。生成摘要 di<0 時程式返回空,0≤di<32 時,程式安全。
  • 然後分析函式 get_slot_by_minor(),首先記錄迴圈變數 di 的範圍是 0≤di<64
  • 接下來通過分析 get_drv_by_nr() 的摘要,di≥32 時存在漏洞,於是得到約束 0≤di<64∧di≥32,求解約束得 di 為 32 時滿足約束條件,程式存在漏洞。

接下來採用逆向的分析方法,過程如下:

  • drv = drivers[di] 開始,根據規則得到約束 0≤di<32。而 di≥32∨di<0 程式存在漏洞。
  • 上一條語句與 di 無關,跳過。
  • 補充路徑條件 di≥0,此時約束為 (di≥32∨di<0)∧di≥0,即 di≥32 時存在漏洞。
  • 繼續向上,直到函式入口點,此時分析呼叫它的函式 get_slot_by_minor(),得到約束 0≤di<64,求解約束 0≤di<64∧di≥32,發現可滿足,認為程式存在漏洞。

參考資料

相關文章