I Pack You加密壳:实现页粒度的动态解密和惰性加密
上一篇文章:I Pack You:实现基本的软件壳框架
搭建好基本框架之后,我又向框架中添加了一些基础的静态反调试功能,这篇文章主要记录一下如何实现页粒度的动态解密与惰性加密。
需求与可行性分析
之前我们是在stub中一次性解密整个text段,这种方式很难对抗动态分析,因为解密之后内存中的代码就是明文了,很容易被dump下来。
为了增强壳的强度,我需要分多次解密text段,同时对使用较少的代码片段重新加密,待到其需要使用时在解密。这样一来,内存中很难出现完整的代码明文,对被加壳软件的保护力度得到了提升。
为了实现动态解密,我需要一种机制:在遇到加密的代码时暂停执行程序,将执行权转交到解密过程中。
操作系统的分页机制与Windows VEH很好地满足了我们的需求。我们将代码的状态(加密或解密)与内存的非法访问关联起来,通过VEH处理相关异常,实现动态解密和加密的功能。
采用VEH是因为它较于SEH更简单,不依赖于pdata等存储的信息,简化了stub的设计。
页粒度的动态解密
基本框架
基本思想是这样的:
- 将整个text进行分页,将每页的访问权限都设置为PAGE_NOACCESS
- 注册VEH,用于处理内存非法访问
- stub执行EP,触发内存非法访问异常,执行VEH回调
- VEH处理该异常,进行解密等相关操作,随后返回EXCEPTION_CONTINUE_EXECUTION
- 程序继续运行,若遇到内存非法访问则执行4
这是一个非常简单的策略,主要代码如下:
PVOID hHandler = g_MyAddVectoredExceptionHandler(1, MyVEH);
DWORD dwOld = 0;
g_MyVirtualProtect((PVOID)sg_ullTextStart, g_param.dwTextSize, PAGE_NOACCESS, &dwOld);
if (g_param.dwEP && g_ImageBase)
{
typedef void (*FUNC)();
FUNC ep = (FUNC)(g_param.dwEP + g_ImageBase);
ep();
}
上面的代码为动态解密搭建好了框架,MyVEH函数需要判断异常的类型是否为内存的非法访问,同时还要判断发生异常的地址是否位于text段中:
LONG MyVEH(PEXCEPTION_POINTERS pExceptionInfo)
{
if (pExceptionInfo->ExceptionRecord->ExceptionCode == EXCEPTION_ACCESS_VIOLATION)
{
void *faultAddr = (void *)pExceptionInfo->ExceptionRecord->ExceptionAddress;
if ((ULONGLONG)faultAddr >= sg_ullTextStart && (ULONGLONG)faultAddr < sg_ullTextEnd)
{
DecryptTextPage(faultAddr);
}
return EXCEPTION_CONTINUE_EXECUTION;
}
return EXCEPTION_CONTINUE_SEARCH;
}
解密流程由DecryptTextPage实现,在解密之后,我们需要把页面的权限设置为可读可执行:
DWORD dwOld = 0;
BOOL res = g_MyVirtualProtect((PVOID)faultAddr, g_param.dwTextSize, PAGE_EXECUTE_READWRITE, &dwOld);
if (!res)
{
g_MyExitProcess(1);
}
PBYTE ch = (PBYTE)sg_ullTextStart;
for (size_t i = 0; i < g_param.dwTextSize; i++)
{
ch[i] ^= KEY;
}
res = g_MyVirtualProtect((PVOID)sg_ullTextStart, g_param.dwTextSize, PAGE_EXECUTE_READ, &dwOld);
if (!res)
{
g_MyExitProcess(1);
}
难题:指令跨页
如果拿着上面的框架去给一个程序加壳,那么有的程序能运行,有的不能运行。
通过调试VEH发现,出错的地址都在页尾附近。这是因为如果刚好有一条指令,它刚好占用了当前页页末和下一页页头部分的内存,那么这条指令就会被划分为两部分:已经解密的前半部分和加密的后半部分。如果前半部分刚好可以解释为一条新的指令,那么在触发下一页的解密流程前,CPU就会执行这条错误的指令。
这带来了很严重的后果:破坏了线程的上下文。CPU执行了一条错误的指令,再次读取指令时即使下一页解密了,但是读取到的字节已经不是原指令的起始字节了。

解决指令跨页
主要矛盾是页面大小的固定性和amd64处理器指令的不定长性。我们在解密的时候不能只解密发生错误的页面,还要解密下一页面头部的若干字节,这样即使有指令跨页,那它也不会被分割为已解密的前半部分和未解密的后半部分。
为此我们需要将第二页面的权限设置为可读的,这样CPU就不会得到错误的指令。**但是当CPU尝试执行这条跨页指令时,依然会遇到违法访问,异常地址所在的页面仍旧是第一页面。也就是说,VEH会反复检查第一页面是否解密,发现已经解密了,返回让程序继续执行,程序执行跨页指令继续遇到访问违规异常,VEH又尝试解密第一页面。**这样就会得到一个死循环。

我们可以设计这样一个机制:如果一个页面反复进入VEH处理到达一定次数后,我们就对它的下一页进行解密并设置可执行权限。这样就跳出了死循环。
将上面的解决方案组合起来,我设计了一个结构体管理页面信息:
struct PAGE_INFO
{
ULONGLONG ullPageBase;
bool bHeadTag;
bool bDecrypted;
WORD wFaultCount;
};
每页对应一个结构体对象,这些对象通过数组管理。
同时更新我们的解密流程:
static void DecryptTextPage(PVOID faultAddr)
{
ULONGLONG ullFaultPageStart = (ULONGLONG)faultAddr & ~(PAGE_SIZE - 1);
ULONGLONG ullFaultPageEnd = (ULONGLONG)ullFaultPageStart + PAGE_SIZE - 1;
ULONGLONG ullNextPageStart = ullFaultPageEnd + 1;
ULONGLONG ullNextPageEnd = ullNextPageStart + PAGE_SIZE;
DWORD dwIndex = 0;
bool finded = false;
for (dwIndex; dwIndex < sg_dwTextPageNums; dwIndex++)
{
if (sg_pPageInfo[dwIndex].ullPageBase == ullFaultPageStart)
{
finded = true;
break;
}
}
sg_pPageInfo[dwIndex].wFaultCount++;
if (sg_pPageInfo[dwIndex].wFaultCount > 3)
{
DecryptTextPage((PVOID)sg_pPageInfo[dwIndex + 1].ullPageBase);
sg_pPageInfo[dwIndex].wFaultCount = 0;
return;
}
sg_pPageInfo[dwIndex].dwLastDecryptTime = g_MyGetTickCount();
sg_pPageInfo[dwIndex + 1].dwLastDecryptTime = g_MyGetTickCount();
DWORD dwOld = 0;
BOOL res = g_MyVirtualProtect((PVOID)ullFaultPageStart, PAGE_SIZE, PAGE_EXECUTE_READWRITE, &dwOld);
if (!res)
{
g_MyExitProcess(1);
}
if (!sg_pPageInfo[dwIndex].bDecrypted)
{
PBYTE ch = (PBYTE)ullFaultPageStart;
if (sg_pPageInfo[dwIndex].bHeadTag)
{
ch += HEAD_SIZE;
for (size_t i = 0; i < PAGE_SIZE - HEAD_SIZE; i++)
{
ch[i] ^= KEY;
}
}
else
{
for (size_t i = 0; i < PAGE_SIZE; i++)
{
ch[i] ^= KEY;
}
sg_pPageInfo[dwIndex].bHeadTag = true;
}
sg_pPageInfo[dwIndex].bDecrypted = true;
}
res = g_MyVirtualProtect((PVOID)ullFaultPageStart, PAGE_SIZE, PAGE_EXECUTE_READ, &dwOld);
if (!res)
{
g_MyExitProcess(1);
}
if (ullNextPageStart >= sg_ullTextStart && ullNextPageEnd <= sg_ullTextEnd)
{
res = g_MyVirtualProtect((PVOID)ullNextPageStart, PAGE_SIZE, PAGE_EXECUTE_READWRITE, &dwOld);
if (!res)
{
g_MyExitProcess(1);
}
if (!sg_pPageInfo[dwIndex + 1].bHeadTag)
{
PBYTE c = (PBYTE)ullNextPageStart;
for (size_t i = 0; i < HEAD_SIZE; i++)
{
c[i] ^= KEY;
}
sg_pPageInfo[dwIndex + 1].bHeadTag = true;
}
res = g_MyVirtualProtect((PVOID)ullNextPageStart, PAGE_SIZE, PAGE_READONLY, &dwOld);
if (!res)
{
g_MyExitProcess(1);
}
}
}
这样一来,就解决了跨页指令产生的问题。
惰性加密
有了页粒度的动态解密框架之后,惰性加密就很好实现了。
我们可以新开一个线程,结合计时器内核对象实现定期或不定期的加密,通过关键段或其它的同步机制避免解密流程的竞争。
还有更简单的,在页信息结构体中新增一个字段dwLastDecryptTime:
struct PAGE_INFO
{
ULONGLONG ullPageBase;
bool bHeadTag;
bool bDecrypted;
WORD wFaultCount;
DWORD dwLastDecryptTime;
};
在每次加密前都扫描一次页信息数组,加密活跃值最小的若干页面:
static void EncryptTextPage(PVOID pCurrentPage)
{
const DWORD dwThreshold = 7000;
DWORD dwCurentTime = g_MyGetTickCount();
for (size_t i = 0; i < sg_dwTextPageNums; i++)
{
if (sg_pPageInfo[i].ullPageBase != (ULONGLONG)pCurrentPage)
{
if (dwCurentTime - sg_pPageInfo[i].dwLastDecryptTime <= dwThreshold)
{
PBYTE ch = (PBYTE)sg_pPageInfo[i].ullPageBase;
if (sg_pPageInfo[i].bHeadTag)
{
for (size_t i = 0; i < PAGE_SIZE; i++)
{
ch[i] ^= KEY;
}
sg_pPageInfo[i].bHeadTag = false;
}
else
{
ch += HEAD_SIZE;
for (size_t i = 0; i < PAGE_SIZE - HEAD_SIZE; i++)
{
ch[i] ^= KEY;
}
}
sg_pPageInfo[i].bDecrypted = false;
DWORD dwOld = 0;
g_MyVirtualProtect((PVOID)sg_pPageInfo[i].ullPageBase, PAGE_SIZE, PAGE_NOACCESS, &dwOld);
}
}
}
}
这种方式要求设置一个合理的阈值。
效果演示
运行被加壳程序一段时间后,检查text段,发现存在未解密的页面:

传播安全知识、拓宽行业人脉——看雪讲师团队等你加入!