I Pack You加密壳:实现页粒度的动态解密和惰性加密

I Pack You加密壳:实现页粒度的动态解密和惰性加密

上一篇文章:I Pack You:实现基本的软件壳框架

搭建好基本框架之后,我又向框架中添加了一些基础的静态反调试功能,这篇文章主要记录一下如何实现页粒度的动态解密与惰性加密。

需求与可行性分析

之前我们是在stub中一次性解密整个text段,这种方式很难对抗动态分析,因为解密之后内存中的代码就是明文了,很容易被dump下来。

为了增强壳的强度,我需要分多次解密text段,同时对使用较少的代码片段重新加密,待到其需要使用时在解密。这样一来,内存中很难出现完整的代码明文,对被加壳软件的保护力度得到了提升。

为了实现动态解密,我需要一种机制:在遇到加密的代码时暂停执行程序,将执行权转交到解密过程中。

操作系统的分页机制与Windows VEH很好地满足了我们的需求。我们将代码的状态(加密或解密)与内存的非法访问关联起来,通过VEH处理相关异常,实现动态解密和加密的功能。

采用VEH是因为它较于SEH更简单,不依赖于pdata等存储的信息,简化了stub的设计。

页粒度的动态解密

基本框架

基本思想是这样的:

  1. 将整个text进行分页,将每页的访问权限都设置为PAGE_NOACCESS
  2. 注册VEH,用于处理内存非法访问
  3. stub执行EP,触发内存非法访问异常,执行VEH回调
  4. VEH处理该异常,进行解密等相关操作,随后返回EXCEPTION_CONTINUE_EXECUTION
  5. 程序继续运行,若遇到内存非法访问则执行4

这是一个非常简单的策略,主要代码如下:

cpp 复制代码
// 安装VEH
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)();
	// 必然引发访问异常,在VEH中动态解密
	FUNC ep = (FUNC)(g_param.dwEP + g_ImageBase);
	ep();
}

上面的代码为动态解密搭建好了框架,MyVEH函数需要判断异常的类型是否为内存的非法访问,同时还要判断发生异常的地址是否位于text段中:

cpp 复制代码
LONG MyVEH(PEXCEPTION_POINTERS pExceptionInfo)
{
	if (pExceptionInfo->ExceptionRecord->ExceptionCode == EXCEPTION_ACCESS_VIOLATION) // 是否为访问异常
	{
		void *faultAddr = (void *)pExceptionInfo->ExceptionRecord->ExceptionAddress;

		// 发生异常的地址是否在text节区中
		if ((ULONGLONG)faultAddr >= sg_ullTextStart && (ULONGLONG)faultAddr < sg_ullTextEnd)
		{
			// 解密
			DecryptTextPage(faultAddr);
		}

		return EXCEPTION_CONTINUE_EXECUTION;
	}

	return EXCEPTION_CONTINUE_SEARCH;
}

解密流程由DecryptTextPage实现,在解密之后,我们需要把页面的权限设置为可读可执行:

cpp 复制代码
DWORD dwOld = 0;
BOOL res = g_MyVirtualProtect((PVOID)faultAddr, g_param.dwTextSize, PAGE_EXECUTE_READWRITE, &dwOld);
if (!res)
{
	g_MyExitProcess(1);
}

// 解密text
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处理到达一定次数后,我们就对它的下一页进行解密并设置可执行权限。这样就跳出了死循环。

将上面的解决方案组合起来,我设计了一个结构体管理页面信息:

cpp 复制代码
struct PAGE_INFO
{
	ULONGLONG ullPageBase;
	bool bHeadTag; // 头部15字节是否已解密
	bool bDecrypted; // 页是否解密
	
	// 当前页错误的次数,如果是3次以上,说明有指令跨页了,此时解密下一页并给与其执行权限
	WORD wFaultCount;
};

每页对应一个结构体对象,这些对象通过数组管理。

同时更新我们的解密流程:

cpp 复制代码
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; // 注意处理最后一个页面的情况

	// 定位页对应的PAGE_INFO
	DWORD dwIndex = 0;
	bool finded = false;
	for (dwIndex; dwIndex < sg_dwTextPageNums; dwIndex++) // 这里的查找可以优化,因为ullPageBase是递增的
	{
		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) // 当前页未解密
	{
		// 解密text
		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);
	}

	// 检查下一页面是否还在text中
	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:

cpp 复制代码
struct PAGE_INFO
{
	ULONGLONG ullPageBase;
	bool bHeadTag; // 头部15字节是否已解密
	bool bDecrypted;
	
	// 当前页错误的次数,如果是3次以上,说明有指令跨页了,此时解密下一页并给与其执行权限
	WORD wFaultCount;

	// 最后一次解密时间
	DWORD dwLastDecryptTime;
};

在每次加密前都扫描一次页信息数组,加密活跃值最小的若干页面:

cpp 复制代码
// 遍历页面信息数组,如果页面的活跃度小于设定的阈值,则加密该页面
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)
			{
				// g_MyMessageBoxA(nullptr, AddressToString((PVOID)sg_pPageInfo[i].ullPageBase),
				//	"EncryptTextPage", MB_OK);
				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段,发现存在未解密的页面:

相关推荐
阿昭L7 天前
I Pack You:实现基本的软件壳框架
逆向工程·pe文件·加壳
booksyhay19 天前
串口调试助手注册机制研究(一)
逆向工程·串口调试助手·uartassist
带娃的IT创业者21 天前
当不可能成为可能:我将 Mac OS X 移植到了 Nintendo Wii
逆向工程·mac os x·极客·nintendo wii·操作系统移植·powerpc·硬件破解
三维频道24 天前
柔性材料3D数字化:蓝光扫描在内衣胸垫设计与质检中的应用
人工智能·3d·逆向工程·蓝光3d扫描仪·服装数字化·内衣设计·柔性材料检测
带娃的IT创业者1 个月前
逆向工程与数字考古:以3万美元收购Friendster为例的技术重构实战
重构·数据清洗·逆向工程·数字考古·架构重构·friendster·技术迁移
曾阿伦1 个月前
AES 加密解密详解及示例
python·加密解密
曼岛_1 个月前
[逆向工程]160个CrackMe入门实战之aLoNg3x.2解析(七)
逆向工程
曼岛_1 个月前
[逆向工程]160个CrackMe入门实战之Andrnalin.1解析(八)
逆向工程
Pure_White_Sword1 个月前
[NSSRound#6 Team]void(V1)
网络安全·ctf·reverse·逆向工程