C/C++程序中內存被非法改寫的一個檢測方法
本文所討論的“內存”主要指(靜態)數據區、堆區和棧區空間(詳細的布局和描述參考《Linux虛擬地址空間布局》一文)。數據區內存在程序編譯時分配,該內存的生存期為程序的整個運行期間,如全局變量和static關鍵字所聲明的靜態變量。函數執行時在棧上開辟局部自動變量的儲存空間,執行結束時自動釋放棧區內存。堆區內存亦稱動態內存,由程序在運行時調用malloc/calloc/realloc等庫函數申請,並由使用者顯式地調用free庫函數釋放。堆內存比棧內存分配容量更大,生存期由使用者決定,故非常靈活。然而,堆內存使用時很容易出現內存泄露、內存越界和重復釋放等嚴重問題。
數據區的內存訪問越界可以分為讀越界和寫越界,數據區內存越界主要指讀寫某一數據區內存(如全局或靜態變量、數組或結構體等)時,超出該內存區域的合法範圍。讀越界表示讀取不屬於自己的數據,如讀取的字節數多於分配給目標變量的字節數。若所讀的內存地址無效,則程序立即崩潰;若所讀的內存地址有效,則可讀到隨機的數據,導致不可預料的後果。寫越界亦稱“緩沖區溢出”,所寫入的數據對目標地址而言也是隨機的,因此同樣導致不可預料的後果。
內存越界訪問會嚴重影響程序的穩定性,其危險在於後果和癥狀的隨機性。這種隨機性使得故障現象和本源看似無關,給排障帶來極大的困難。你永遠也不知道是不是有其他線程操作時候偷偷改動了你的數據。如果是一般的業務數據,唔,一個bug。但是是如果該內存塊指向一個對象,然後就呵呵了——你持有了一個無效的內存地址,一般來說會crash,無止境的debug在等待你。
寫越界的主要原因有兩種:1) memset/memcpy/memmove等內存覆寫調用;2) 數組下標超出範圍。
#include <string.h>
#include <stdio.h>
#define NAME_SIZE 8
#define NAME_LEN 9
char name1[NAME_SIZE] = "ABCDEFGH";
char name2[NAME_LEN] = "123456789";
int main() {
strncpy(name1, name2, NAME_LEN);
printf("name2: %s\n", name2);
return 0;
}
輸出結果顯然是name2: 923456789。常見的所謂數組越界方法實現起來比較繁瑣。用工具(VALGRIND等)可以發現,但是對於生產系統(采用了全局數組+多線程之類的高級技巧……),一般來說是難以查找到的,特別是如果其他線程由其他團隊成員開發,你對其代碼缺少相關知識的時候。
對於這個問題,gdb提供了一種可能的方法:觀察點(watch命令)。用法如下:watch name2[0]。這樣當該變量被改寫的時候進程將會停下來。當然你也可以watch某個地址:watch *(data type*)addr。如果你懷疑是特定線程改寫了該變量的時候,可以使用watch expr thread threadnum,在某個線程改寫的時候讓進程停止。使用這個方法,在絕大多數情況下可以發現未知的變量改寫問題。
(gdb) watch name2[0] Hardware watchpoint 1: name2[0] (gdb) r Starting program: /home/afreet/sourcecodes/memdemo/build/bin/memdemo Hardware watchpoint 1: name2[0] Old value = 49 ‘1‘ New value = 57 ‘9‘ __strncpy_ssse3 () at ../sysdeps/x86_64/multiarch/strcpy-ssse3.S:2443 2443 ../sysdeps/x86_64/multiarch/strcpy-ssse3.S: No such file or directory. (gdb) bt #0 __strncpy_ssse3 () at ../sysdeps/x86_64/multiarch/strcpy-ssse3.S:2443 #1 0x000000000040080e in main () at /home/afreet/sourcecodes/memdemo/memdemo.c:11
如果在調試狀態下運行仍然沒有發現問題或者是嵌入式環境根本無法調試,那麽是不是就只能去燒香?或者拜基督(取決於你的宗教信仰,但是財神我相信大多數現代中國人是不會拒絕去拜拜的)。Linux還提供了一個殺手鐧級的API:mprotect。
mprotect函數的原型如下:
int mprotect(const void *addr, size_t len, int prot);
其中addr是待保護的內存首地址,必須按頁對齊;len是待保護內存的大小,必須是頁的整數倍,prot代表模式,可能的取值有PROT_READ(表示可讀)、PROT_WRITE(可寫)等。
不同體系結構和操作系統,一頁的大小不盡相同。如何獲得頁大小呢?通過PAGE_SIZE宏或者getpagesize()系統調用即可。下面是另一個簡單的例子:
#include <string.h> #include <stdio.h> #include <unistd.h> #include <assert.h> #include <thread> #define BUF_LEN 4096 using namespace std; int buf[BUF_LEN] = {0}; int* p = &buf[2048]; void func1() { char* q = reinterpret_cast<char*>(p); *q = 0xFF; } void func2() { sleep(5); for ( auto x: buf) { assert(x == 0); } } int main() { std::thread t1(func1); std::thread t2(func2); t1.join(); t2.join(); return 0; }
由於buf[2048]在func1中被改寫,所以斷言會失敗。因此引入mprotect函數,對問題所在進行檢測。改進後的版本如下:
#include <string.h> #include <stdio.h> #include <unistd.h> #include <assert.h> #include <sys/mman.h> #include <thread> #define BUF_LEN 1024 using namespace std; int buf[BUF_LEN] = {0}; int* p = &buf[512]; void func2() { char* q = reinterpret_cast<char*>(p); *q = 0xFF; } void func1() { long pageSize = sysconf(_SC_PAGESIZE); void *pageStart = (void*)((long)p - (long)p % pageSize); int rst = mprotect(pageStart, pageSize, PROT_READ); if ( rst == -1 ) printf("mprotect failed: %s", strerror(errno)); sleep(10); } int main() { std::thread t1(func1); std::thread t2(func2); t1.join(); t2.join(); return 0; }
然後再來測試一下:
(gdb) r Starting program: /home/afreet/sourcecodes/memdemo/build/bin/memdemo [Thread debugging using libthread_db enabled] Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1". [New Thread 0x7ffff6ff1700 (LWP 2794)] [New Thread 0x7ffff67f0700 (LWP 2795)] [Thread 0x7ffff67f0700 (LWP 2795) exited] Program received signal SIGSEGV, Segmentation fault. [Switching to Thread 0x7ffff6ff1700 (LWP 2794)] _dl_fixup (l=<optimized out>, reloc_arg=<optimized out>) at ../elf/dl-runtime.c:148 148 ../elf/dl-runtime.c: No such file or directory. (gdb) bt #0 _dl_fixup (l=<optimized out>, reloc_arg=<optimized out>) at ../elf/dl-runtime.c:148 #1 0x00007ffff7df02e5 in _dl_runtime_resolve () at ../sysdeps/x86_64/dl-trampoline.S:45 #2 0x000000000040740b in func1 () at /home/afreet/sourcecodes/memdemo/memdemo.cpp:33 #3 0x0000000000408aac in void std::_Bind_simple<void (*())()>::_M_invoke<>(std::_Index_tuple<>) (this=0x60eda8) at /usr/include/c++/4.9/functional:1700 #4 0x00000000004089d2 in std::_Bind_simple<void (*())()>::operator()() (this=0x60eda8) at /usr/include/c++/4.9/functional:1688 #5 0x0000000000408939 in std::thread::_Impl<std::_Bind_simple<void (*())()> >::_M_run() (this=0x60ed90) at /usr/include/c++/4.9/thread:115 #6 0x00007ffff796a970 in ?? () from /usr/lib/x86_64-linux-gnu/libstdc++.so.6 #7 0x00007ffff7bc70a4 in start_thread (arg=0x7ffff6ff1700) at pthread_create.c:309 #8 0x00007ffff70da87d in clone () at ../sysdeps/unix/sysv/linux/x86_64/clone.S:111
註意調用棧#2,明確的指出了試圖改寫buf[256]的函數名。這樣就可以輕松的找到犯罪分子早點下班吃飯了。
但是還是沒有徹底解決問題:如果被測代碼無法使用調試器運行怎麽辦?另一個問題,mprotect需要保護整個頁面,那麽很多時候被保護的數據會和其他全局數據共存在一個頁面上。如果其他線程訪問了這個頁面,一樣會發生segment fault。顯然這不是我們需要的結果。如果在被保護的數據之前人為加padding,讓被改寫的數組後退到某個頁面起始處,那麽越界訪問往往就不會發生了——因為訪問到了padding上,這樣也無法重現錯誤。辦法還是有的:利用信號處理函數,判定發生頁面訪問錯誤的地址是否是我們期望的某個元素所在,如果不是,那麽什麽都不做就可以了;如果是,那就打印調用棧到指定文件。用一個包裝類來實現這個目的:
class MemoryDetector { public: typedef void (*segv_handler) (int sig, siginfo_t *si, void *unused); static void init(const char *path) { register_handler(handler); fd_ = open(path, O_RDWR|O_CREAT, 777); } static int protect(void *ptr, int len) { address_ = reinterpret_cast<uint64_t>(ptr); len_ = len; uint64_t start_address = (address_ >> PAGE_SHIFT) << PAGE_SHIFT; return mprotect(reinterpret_cast<void *>(start_address), PAGE_SIZE, PROT_READ); } static int umprotect(void *ptr, int len) { uint64_t addr = reinterpret_cast<uint64_t>(ptr); uint64_t start_address = (addr >> PAGE_SHIFT) << PAGE_SHIFT; return mprotect(reinterpret_cast<void *>(start_address), PAGE_SIZE, PROT_READ | PROT_WRITE); } static int umprotect() { uint64_t start_address = (address_ >> PAGE_SHIFT) << PAGE_SHIFT; return mprotect(reinterpret_cast<void *>(start_address), PAGE_SIZE, PROT_READ | PROT_WRITE); } static void finish() { close(fd_); }
private: static void register_handler(segv_handler sh) { struct sigaction act; act.sa_sigaction = sh; sigemptyset(&act.sa_mask); act.sa_flags = SA_SIGINFO; if(sigaction(SIGSEGV, &act, NULL) == -1){ perror("Register hanlder failed"); exit(EXIT_FAILURE); } } static void handler(int sig, siginfo_t *si, void *unused) { uint64_t address = reinterpret_cast<uint64_t>(si->si_addr); if (address >= address_ && address < address_ + len_) { umprotect(si->si_addr, PAGE_SIZE); my_backtrace(); } } static void my_backtrace() { const int N = 100; void* array[100]; int size = backtrace(array, N); backtrace_symbols_fd(array, size, fd_); } static uint64_t address_; static int len_; static int fd_; }; uint64_t MemoryDetector::address_; int MemoryDetector::len_; int MemoryDetector::fd_;
隨後我們把測試程序改成這個樣子:
void func() { char* q = reinterpret_cast<char*>(p); *q = static_cast<char>(0xFF); //Line 101 } int main() { MemoryDetector::init("memdemo.rst"); MemoryDetector::protect(p, 4); std::thread t(func); t.join(); sleep(5); MemoryDetector::finish(); return 0; }
再運行一把,得到了memdemo.rst文件,內容如下:
./memdemo(_ZN14MemoryDetector12my_backtraceEv+0x2b)[0x407b79] ./memdemo(_ZN14MemoryDetector7handlerEiP9siginfo_tPv+0x64)[0x407b4c] /lib/x86_64-linux-gnu/libpthread.so.0(+0xf8d0)[0x7fb039e928d0] ./memdemo(_Z4funcv+0x1c)[0x4076fc] ./memdemo(_ZNSt12_Bind_simpleIFPFvvEvEE9_M_invokeIIEEEvSt12_Index_tupleIIXspT_EEE+0x2a)[0x408fa4] ./memdemo(_ZNSt12_Bind_simpleIFPFvvEvEEclEv+0x22)[0x408eca] ./memdemo(_ZNSt6thread5_ImplISt12_Bind_simpleIFPFvvEvEEE6_M_runEv+0x21)[0x408e31] /usr/lib/x86_64-linux-gnu/libstdc++.so.6(+0xb6970)[0x7fb039c2e970] /lib/x86_64-linux-gnu/libpthread.so.0(+0x80a4)[0x7fb039e8b0a4] /lib/x86_64-linux-gnu/libc.so.6(clone+0x6d)[0x7fb03939e87d]
接著addr2line命令看看:
addr2line -e memdemo 0x4076fc
/home/afreet/sourcecodes/memdemo/memdemo.cpp:101
也很輕松的找到了肇事者所在。
C/C++程序中內存被非法改寫的一個檢測方法