前言
在安全研发的过程中,难免会使用内存分配函数 malloc、重载的运算符 new 开启堆内存用于长时间驻留一些数据,但这些数据可能对于防御者来说比较敏感,比如有时候,这些堆内存中可能会出现回连地址等。所以有必要对这些保留在堆内存中的敏感数据进行加密。
在进入堆分配内存加密之前,首先了解以下程序中堆栈的概念;
堆和栈是程序运行时保存数据的方式。
栈:通常来说栈用于存储临时数据,比如局部变量和函数调用的上下文信息,栈上的数据的生命周期随着函数的调用和返回而自动管理,一般在作用域结束后被自动释放;
堆:堆通常用于存储想长期驻留在内存中的数据,比如一些需要长期存在的配置信息和一些需要共享的数据,堆上的数据的生命周期不受作用域的限制,一般在整个程序运行期间都存在,直到显示地释放它们。
由于堆上的数据的生命周期不受限制,除非显式地释放它们,否则它们将一直驻留在内存中,如果我们想保护这些数据,可以在内存中将存储这些数据的堆内存进行加密,防止数据暴露。
堆分配内存加密
枚举堆
在对堆进行操作之前,首先需要先枚举进程内存中堆的信息,可以通过 HeapWalk 函数枚举指定堆中的内存块,其函数签名如下:
BOOL HeapWalk( [in] HANDLE hHeap, [in, out] LPPROCESS_HEAP_ENTRY lpEntry );
可以使用以下代码枚举进程内存中堆的信息
void EnumHeaps() { PROCESS_HEAP_ENTRY entry; SecureZeroMemory(&entry, sizeof(entry)); while (HeapWalk(GetProcessHeap(), &entry)) { printf("heap addr: %p size: %d ", (char*)entry.lpData, entry.cbData); } }
获取了进程内存中的堆信息,就可以对指定类型的堆进行操作了,一般加密已分配的堆,需要注意的是,由于堆中的数据是线程共享的,所以,在加密堆数据之前,需要挂起所有线程(除了当前执行的线程),待操作结束后,恢复所有线程的执行。
挂起线程
可以使用 SuspendThread 挂起指定线程,需要注意的是,需要排除当前执行的线程。
void DoSuspendThreads(DWORD targetProcessId, DWORD targetThreadId) { HANDLE h = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0); if (h != INVALID_HANDLE_VALUE) { THREADENTRY32 te; te.dwSize = sizeof(te); if (Thread32First(h, &te)) { do { if (te.dwSize >= FIELD_OFFSET(THREADENTRY32, th32OwnerProcessID) + sizeof(te.th32OwnerProcessID)) { // Suspend all threads EXCEPT the one we want to keep running if (te.th32ThreadID != targetThreadId && te.th32OwnerProcessID == targetProcessId) { HANDLE thread = ::OpenThread(THREAD_ALL_ACCESS, FALSE, te.th32ThreadID); if (thread != NULL) { SuspendThread(thread); CloseHandle(thread); } } } te.dwSize = sizeof(te); } while (Thread32Next(h, &te)); } CloseHandle(h); } }
恢复线程
使用 ResumeThread 函数,可以让挂起的线程恢复。
void DoResumeThreads(DWORD targetProcessId, DWORD targetThreadId) { HANDLE h = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0); if (h != INVALID_HANDLE_VALUE) { THREADENTRY32 te; te.dwSize = sizeof(te); if (Thread32First(h, &te)) { do { if (te.dwSize >= FIELD_OFFSET(THREADENTRY32, th32OwnerProcessID) + sizeof(te.th32OwnerProcessID)) { // Suspend all threads EXCEPT the one we want to keep running if (te.th32ThreadID != targetThreadId && te.th32OwnerProcessID == targetProcessId) { HANDLE thread = ::OpenThread(THREAD_ALL_ACCESS, FALSE, te.th32ThreadID); if (thread != NULL) { ResumeThread(thread); CloseHandle(thread); } } } te.dwSize = sizeof(te); } while (Thread32Next(h, &te)); } CloseHandle(h); } }
在安全研发的过程中,为了降低自动化分析对代码的敏感度,通常会使用延迟代码的执行速度,比如使用Sleep函数。
根据以上,进程中堆分配内存的步骤如下:
挂钩 Sleep 函数
在 HookedSleep 函数内部:
挂起当前进程内所有线程(排除当前执行线程)
对已分配的堆进行加密
调用原始 Sleep 进行睡眠操作
恢复所有线程执行
void WINAPI HookedSleep(DWORD dwMiliseconds) { DWORD time = dwMiliseconds; if (time > 1000) { printf("HookedSleep: suspend threads, strat encrypt heaps. "); DoSuspendThreads(GetCurrentProcessId(), GetCurrentThreadId()); HeapEncryptDecrypt(); OldSleep(dwMiliseconds); HeapEncryptDecrypt(); DoResumeThreads(GetCurrentProcessId(), GetCurrentThreadId()); printf("HookedSleep: decrypt heaps success, resume threads run. "); } else { OldSleep(time); } }
当代码中存在使用堆相关函数,比如 HeapAlloc 分配内存时,再比如使用 CRT 函数 malloc 和 重载的运算符 new 开辟堆空间时(其最终也是通过HeapAlloc函数分配),都可通过堆分配内存加密保护我们的数据。
下图是在挂钩了 Sleep 后,程序进入HookedSleep 函数未进行对加密之前我们在程序中分配的堆空间的原始数据。
在进行堆加密后,效果如下,可以看到,我们堆分配的内存已被加密。
目标线程堆分配空间加密
上文可以看到通过 HeapWalk 遍历了进程中所有的堆信息,并在堆加密之前比如挂起所有的线程,这样有一个缺点,就是只能针对自己写的程序。
有时候我们的需要执行的功能代码是通过注入到其它进程实现的,如果这样冒然的将所有线程挂起,这有太多的不可预料性,可能会导致宿主程序崩溃。
所以需要有一种方式来实现只加密我们执行功能线程代码中分配的堆内存。
由于通过注入 shellcode/dll 的方式执行功能,所以首要目标是获取执行功能的线程ID,之后就是在代码中 Hook 堆分配/释放相关的函数,RtlAllocateHeap,RtlReAllocateHeap,RtlFreeHeap 函数,这样就可以跟踪线程代码中堆分配释放的状态了。
获取了线程堆分配释放的情况,目标线程堆分配内存加密的代码思路如下:
获取当前执行线程ID
Hook RtlAllocateHeap,RtlReAllocateHeap,RtlFreeHeap
在 HookedRtlAllocateHeap、HookedRtlReAllocateHeap 函数内部
执行原始 OldRtlAllocateHeap、OldRtlReAllocateHeap,并记录堆分配的地址和大小
筛选出我们的线程分配的堆内存:将堆分配信息添加进维护的堆分配信息数组中
HookedRtlFreeHeap 函数内部
得到需要释放的地址
执行原始 OldRtlFreeHeap
筛选出我们的线程释放的堆内存:将其移除维护的堆分配信息信息数组
当筛选出目标线程堆分配的内存信息时,就可以通过在 HookedSleep 函数中对填充好堆分配信息数组进行操作了。
审核编辑:刘清
全部0条评论
快来发表一下你的评论吧 !