内核呓语系列4 —— 系统调用

2014-1-6 Nie.Meining Coding

内核通过系统调用向应用层提供接口,应用层代码通过中断(Windows int 0x2e,Linux int 0x80)或sysenter的形式进入系统调用。相信很多玩过rootkit的朋友最初就是从ssdt hook开始的,网上也有大量介绍ssdt的文章,因此本篇也不对ssdt的原理、调用流程什么的进行说明了,就挑一些有意思的周边东西讲讲。

首先Windows和Linux都有一张SSDT表,Linux的SSDT表中各个函数的地址可以在编译内核后的system.map文件中找到。Windows由于子系统集成到了内核中,因此win32k.sys还扩展了一张Shadow SSDT表。win32k.sys模块并不常驻内存,Shadow SSDT地址也未导出,因此hook其中的函数需要多做一些工作。关于子系统的问题可以参见本系列的前两篇文章。

此外,来自应用层的东西通常是不可信的,所以系统调用必须进行参数检查。可能为了效率问题,Windows引入了PreviousMode的概念。通常只在KeGetPreviousMode() != KernelMode时进行参数检查。写过驱动的朋友应该知道,Windows执行体中同样功能的函数通常有两套,NtXXX和ZwXXX。其实NtXXX才是真正完成功能的函数,而调用ZwXXX的目的就是改变PreviousMode为KernerMode再调用NtXXX,省去参数检查的麻烦。可以找个函数验证一下:

nhyy4_1.png

为了参数安全,针对用户模式传来的指针,Linux通过copy_from_user()/copy_to_user()来进行读写,Windows中需要先ProbeForWrite/ProbeForRead一下。很多刚开始接触内核编程的朋友喜欢滥用ProbeForWrite/ProbeForRead这两个函数,实际上这两个函数只是验证用户模式的地址是否真正位于用户模式中,并且是否对齐。ProbeForWrite还会尝试对地址进行写操作看看是否抛出异常,但ProbeForRead并不会尝试读取,也就是说并不保证地址有效。看看ida:

nhyy4_2.png

因此读写用户空间地址时,除了probe,最好全程try-catch。另外,如果错误出在内核地址上,try-catch也无法避免蓝屏。对于内核地址有效性的验证通常使用MmIsAddressValid函数。这也使一个被大量滥用的函数。实际上该函数只是说明访问某地址时是否会产生页错误,也就是说,该函数返回FALSE的地址不一定真的无效(可能只是换页出去了而已),返回TRUE的地址也不一定真的有效(除非把该地址锁住,或置于非分页池中),另外还有一点需要注意的是,横跨多个页面的地址需要多次MmIsAddressValid。

这里想起以前用过的一种变相摘钩技术:不少rk/ark在hook之后的过滤函数中需要调用MmIsAddressValid来验证一些地址是否有效,如果无效则会放行给原始例程处理。因此,我们可以hook MmIsAddressValid,并通过调用栈判断该函数的调用方,如果来自目标驱动则直接返回FALSE,实现安全的变相摘钩。这个方法主要是一种思路,hook本身还是老技术,所以就不贴代码了。

另外,再分享下我自己常用的判断地址有效性的函数(写的不好):


VOID MyProbeForWrite(IN PVOID pAddr,IN ULONG ulLen,IN ULONG ulAlignment,IN ULONG ulMemType) 
/*++

Routine Description:

	判断某内存地址是否可写


Arguments:

	pAddr - 内存地址
	
	ulLen - 内存长度
	
	ulAlignment - 对齐值

	ulMemType - MEM_TYPE_KERNELMODE 或 MEM_TYPE_USERMODE


Return Value:
	
	None.


Comments:

	当指定内存不可写时,将抛出异常。调用方应该将此函数包含在try块中。


--*/

{	
	// PAGED_CODE();
	
	PVOID pBegPage, pEndPage;
	
	if (ulLen == 0)
		return;
	
	if (((ULONG_PTR)pAddr & (ulAlignment - 1)) != 0) {
		ExRaiseDatatypeMisalignment();
		return;
	}

	if (((ULONG_PTR)pAddr + ulLen) < (ULONG_PTR)pAddr) {
		ExRaiseAccessViolation();
		return;
	}		

	// pBegPage = (PVOID)(((ULONG_PTR)pAddr) & (~(PAGE_SIZE - 1)));
	// pEndPage = (PVOID)(((ULONG_PTR)pAddr + ulLen) & (~(PAGE_SIZE - 1)))
	pBegPage = (PVOID)(((ULONG_PTR)pAddr / PAGE_SIZE) * PAGE_SIZE);
	pEndPage = (PVOID)((((ULONG_PTR)pAddr + ulLen) / PAGE_SIZE) * PAGE_SIZE);

	if (pAddr >= MmSystemRangeStart)			// kernel space
	{	
		if (!(ulMemType & MEM_TYPE_KERNELMODE)) {
			ExRaiseAccessViolation();
			return;
		}
		
		while (pBegPage <= pEndPage) {
			if (!MmIsAddressValid(pBegPage)) {
				ExRaiseAccessViolation();
				return;
			}
			pBegPage = (PVOID)((ULONG_PTR)pBegPage + PAGE_SIZE);
		}
	}
	else										// user space
	{
		if (!(ulMemType & MEM_TYPE_USERMODE)) {
			ExRaiseAccessViolation();
			return;
		}
		
		while (pBegPage <= pEndPage) {
			BYTE btTmp = *(PBYTE)pBegPage;
			*(PBYTE)pBegPage = btTmp;
			pBegPage = (PVOID)((ULONG_PTR)pBegPage + PAGE_SIZE);
		}
	}
}

还有个MyProbeForRead,实现上和MyProbeForWrite差不多,只是把写尝试换成读尝试。这些代码来自以前写的一个主动防御框架http://bbs.pediy.com/showthread.php?t=150414

另外,在地址有效性验证方面,Windows中还有一个广泛使用的DeviceIoControl方法,非常方便应用层与设备驱动进行通信。由于其广泛使用,Windows专门提供了三种IO方式来简化地址有效性验证:缓冲方式(复制用户缓冲区到内核缓冲区)、直接方式(锁MDL)、其它方式(高效、不保证安全性)。类似的,Linux下也有个ioctl函数,功能和DeviceIoControl一样,不过使用频率很低,因为这种多功能复合型函数与Linux的设计理念不符合,所以Linux程序员们很鄙视这个函数,并试图通过sysfs文件系统读写attributes的方法来替代ioctl。这个留到以后讲对象管理的时候再讲吧。

此外,并不是所有的API都需要经过系统调用,例如虽然linux的getpid是由ring0实现的,但windows的GetCurrentProcessId只是从teb中读取数据CLIENT_ID,参见我以前写的《获取进程ID和线程ID——直接、暴力》。

在添加系统调用方面,Linux需要重新编译内核,比较麻烦。而Windows似乎除了Hook KiSystemServiceRepeat更没啥好办法。虽然Windows提供了KeAddSystemServiceTable函数来增加系统服务表(不是表项!),但似乎Windows中的上限就是两张表,已经被SSDT和ShadowSSDT占满了……

虽然KeAddSystemServiceTable函数没啥用,但是在RK/ARK中的露脸频率还是比较高的,因为很多RK/ARK在进行ShadowSSDT Hook时都是在KeAddSystemServiceTable中查找特征码。相关内容可以参考本系列第二篇文章《内核呓语系列2 —— windows子系统》。

不过添加系统调用实际需求并不大,主要是针对原有系统调用的hook比较多一些。今天先讲到这儿了,改天再接着这个话题继续。

发表评论:

Powered by emlog