如何在任意程序中修改記憶體保護屬性
最近,我們在進行一項安全研究時,需要在任意程序中修改記憶體空間的保護標誌。起初,我們發現這項任務看起來很簡單,但在實際操作中,卻發現困難重重,還好這些都不是什麼大問題。在解決這些問題的過程中,我們還學到了一些新的東西,主要是關於Linux機制和核心開發的。在以下的詳解中,我們會介紹我們所採取的三種方法以及每次尋求更好解決方案的原因。
背景介紹
在現代作業系統中,每個程序都有自己的虛擬地址空間(從虛擬地址到實體地址的對映)。此虛擬地址空間由記憶體頁面(某些固定大小的連續記憶體塊)組成,且每個頁面都有保護標誌,這些保護標誌決定了允許對該頁面的訪問型別(讀取、寫入和執行)。不過,這種機制依賴於架構頁表(architecture page table)。不過要注意的是,在x64的架構中,你不能只進行頁面寫入,即使你是特意從作業系統請求的,也都同時具有頁面寫入和可讀的功能。
在Windows中,你可以使用API函式VirtualProtect或VirtualProtectEx修改記憶體空間的保護。VirtualProtectEx使我們的修改任務變得非常簡單:因為它的第一個引數hProcess是“ofollow,noindex">要修改其記憶體保護的程序的控制代碼 ”。
不過,在Linux中,修改過程就沒有這麼簡單了,因為修改記憶體保護的API是系統呼叫mprotect或pkey_mprotect的結果,並且這兩個函式始終在當前程序的地址空間上執行。現在讓我們想辦法解決一下如何在x64架構上的Linux中解決修改的問題,不過前提條件是,我們具有修改裝置的root許可權。
方法一:程式碼注入
如果mprotect總是在當前程序中執行,我們需要讓目標程序從它自己的上下文中呼叫它。這時就要用到程式碼注入了,該方法可以通過許多不同的方式實現。我們可以選擇使用ptrace機制實現它,該機制允許一個程序“觀察和控制另一個程序的執行”,包括修改目標程序的記憶體和暫存器的能力。這種機制用於偵錯程式(如gdb)和跟蹤實用程式(如strace),使用ptrace注入程式碼所需的步驟如下:
1.使用ptrace附加到目標程序,如果程序中有多個執行緒,那麼最好停止所有其他執行緒;
2.找到一個可執行的記憶體空間(通過檢查/ proc / PID / maps),並在這個空間編寫操作碼syscall(十六進位制:0f05);
3.根據呼叫約定來修改暫存器,首先,將rax修改為mprotect的系統呼叫號(即10);然後,前三個引數(即起始地址、長度和所需的保護)分別儲存在rdi、rsi和rdx中;最後,將rip修改為步驟2中使用的地址;
4.繼續這個過程,直到系統呼叫返回(ptrace允許你跟蹤系統呼叫的進入和退出);
5.恢復被修改的記憶體和暫存器,從程序中將其分離並恢復正常執行;
這種方法是我們的採用的第一個也是最直觀的方法,並且非常有效。不過在我們發現了Linux中的另一種完全破壞機制:利用seccomp進行破壞之後,該方法就不是我們的最優選擇了。基本上,它是Linux核心中的一個安全工具,允許程序輸入某種形式的“監獄”,除了read,write,_exit和sigreturn之外,它不能進行任何系統呼叫。還有一個選項,可以指定任意的系統呼叫及針對它們的過濾引數。
因此,如果程序啟用了seccomp模式並且我們嘗試將一個對mprotect的呼叫注入其中,那麼核心將終止程序,因為該程序是不允許使用此係統呼叫的。因此,要對這些程序進行呼叫,就要採用方法二。
方法二:在核心模組中模擬mprotect系統呼叫
seccomp(全稱securecomputing mode)是linuxkernel從2.6.23版本開始所支援的一種安全機制。
在Linux系統裡,大量的系統呼叫直接暴露給使用者態程式。但是,並不是所有的系統呼叫都被需要,而且不安全的程式碼濫用系統呼叫會對系統造成安全威脅。通過seccomp,我們限制程式使用某些系統呼叫,這樣可以減少系統的暴露面,同時是程式進入一種“安全”的狀態。
由於Linux中存在另一種完全破壞機制:利用seccomp進行破壞,因此這個方法肯定要在核心模式中進行。在Linux核心中,每個執行緒(包括使用者執行緒和核心執行緒)都由一個名為task_struct的結構表示,並且當前執行緒(任務)可以通過pointer current訪問。核心中mprotect的內部實現使用了pointer current,因此我們的第一個想法是,只要將mprotect的程式碼複製貼上到核心模組中,並將每次出現的current替換為指向目標執行緒task_struct的指標,不就可以了嗎?
接下來的事情你可能已經猜到了,就是複製C程式碼,不過複製過程並不是你想的那麼簡單,因為其中存在大量使用我們無法訪問的未匯出的函式、變數和巨集。某些函式說明會在標標頭檔案中匯出,但是它們的實際地址不是由核心匯出的。如果核心是用linux核心符號表kallsyms編譯的,那麼通過檔案/ proc / kallsysm匯出所有內部符號,這個特定的問題就可以解決。因為kallsyms在進行原始碼除錯時具有相當重要的作用,它可以描述所有不處在堆疊上的核心符號。linux核心在編譯的過程中,將核心中所有的符號(所有的核心函式以及已經裝載的模組)及符號的地址以及符號的型別資訊都儲存在了/proc/kallsyms檔案中。
儘管存在這個特定問題,我們仍然試圖實現mprotect呼叫。為此,我們特意編寫一個核心模組,利用該模組獲取目標PID和引數以進行mprotect,並模仿其呼叫行為。首先,我們需要獲取所需的記憶體對映物件,用它表示執行緒的地址空間:
/* Find the task by the pid */ pid_struct = find_get_pid(params.pid); if (!pid_struct) return -ESRCH; task = get_pid_task(pid_struct, PIDTYPE_PID); if (!task) { ret = -ESRCH; goto out; } /* Get the mm of the task */ mm = get_task_mm(task); if (!mm) { ret = -ESRCH; goto out; } … … out: if (mm) mmput(mm); if (task) put_task_struct(task); if (pid_struct) put_pid(pid_struct);
現在我們已經獲得了記憶體對映物件,這大大方便了以後的操作。 Linux核心實現了一個抽象層來管理記憶體空間,每個空間由結構vm_area_struct表示。為了找到正確的記憶體空間,我們使用函式find_vma,該函式會根據所需地址搜尋記憶體對映。
vm_area_struct包含欄位vm_flags,它以獨立於架構的方式來表示記憶體空間的保護標誌,vm_page_prot也以獨立於架構的方式來表示記憶體空間的保護標誌。單獨修改這些欄位並不會真正影響頁表(但會影響/proc/PID/maps的輸出,我們已經嘗試過了),詳情請點選這裡 。
在對核心程式碼進行了一些閱讀和深入研究之後,我們發現要真正攻破記憶體空間的保護,最重要的工作是以下3方面:
1.將欄位vm_flags修改為所需的保護;
2.呼叫函式vma_set_page_prot_func,再根據vm_flags欄位來更新欄位vm_page_prot;
3. 呼叫change_protection_func函式來實際修改頁表中的保護位;
雖然以上的那段程式碼很有效,但其中也存在著很多問題。首先,我們只實現了mprotect的基本部分,但原始函式的基本功能卻比我們能開發的要多得多,例如,通過保護標誌分離和連線記憶體空間。其次,我們使用了兩個核心函式(vma_set_page_prot_func和change_protection_func),這些函式不是由核心匯出的。此時,我們可以使用kallsyms來呼叫它們,但是這很容易出現問題,因為將來我們可能會修改它們的名稱,或者將記憶體空間的整個內部實現進行修改。不過,我們想要一個更通用的解決方案,即不考慮內部結構的方案,此時,就有了方法三。
方法三:使用目標程序的記憶體對映
方法三與第一種方法非常相似,即都要目標程序的上下文中執行程式碼。雖然,這兩個方法都可以在我們自己的執行緒中執行程式碼,但在方法三中,我們使用的是目標程序的“記憶體上下文”,這意味著,我們要使用記憶體中的地址空間。
我們通過幾個API函式就可以在核心模式下修改地址空間,其中就用到了use_mm。正如use_mm的介紹中明確指出的那樣“此例程僅會被用於從核心執行緒上下文中進行呼叫”。由於這些執行緒是在核心中建立的,不需要任何使用者地址空間,因此可以修改它們的地址空間(地址空間內的核心區域在每個任務中都以相同的方式對映)。
在核心執行緒中執行程式碼的一種簡單方法就是通過核心的執行佇列介面(queue interface),它允許你使用特定例程和特定引數來進行程序呼叫。我們的工作例程也非常簡單,它會獲取所需程序的記憶體對映物件和mprotect的引數,並執行以下操作(do_mprotect_pkey是核心中實現mprotect和pkey_mprotect系統呼叫的內部函式):
use_mm(suprotect_work->mm); suprotect_work->ret_value = do_mprotect_pkey(suprotect_work->start, suprotect_work->len, suprotect_work->prot, -1); unuse_mm(suprotect_work->mm);
當我們的核心模組在某個程序(通過一個特殊的IOC/">IOCTL)獲得修改保護的請求時,該請求首先會找到所需的記憶體對映物件(正如我們在前面的方法中所解釋的那樣),然後再使用正確的引數來呼叫程序。
不過這個解決方案仍有一個小問題,即函式do_mprotect_pkey_func不會由核心匯出,需要使用kallsyms獲取。與第一個解決方案不同,這個解決方案中的內部函式不太容易被修改,因為該函式與系統呼叫pkey_mprotect有關,而且我們也不用處理內部結構,因此我們只能將其稱為“小問題”。
我們希望你在這篇文章中找到一些有趣的資訊和技巧,學會如何在任意程序中修改記憶體保護屬性。如果你有興趣,可以在github 中找到這個概念驗證核心模組的原始碼。