简述
Windows在内核使用对象,而在用户模式下,为了保证安全,使用句柄。
进程创建或打开一个内核对象时,会获得一个句柄,通过这个句柄可以查找到对应的内核对象。
用户态下可以通过ntdll!NtQuerySystemInformation
遍历句柄,也可以查到句柄对应的内核对象地址。
私有句柄表
私有句柄表是基于进程的,每个进程都有私有句柄表。
位于_EPROCESS+0x570
处。
nt!_EPROCESS
...
+0x570 ObjectTable : Ptr64 _HANDLE_TABLE
...
类型_HANDLE_TABLE
//0x80 bytes (sizeof)
struct _HANDLE_TABLE
{
ULONG NextHandleNeedingPool; //0x0
LONG ExtraInfoPages; //0x4
volatile ULONGLONG TableCode; //0x8
struct _EPROCESS* QuotaProcess; //0x10
struct _LIST_ENTRY HandleTableList; //0x18
ULONG UniqueProcessId; //0x28
union
{
ULONG Flags; //0x2c
struct
{
UCHAR StrictFIFO:1; //0x2c
UCHAR EnableHandleExceptions:1; //0x2c
UCHAR Rundown:1; //0x2c
UCHAR Duplicated:1; //0x2c
UCHAR RaiseUMExceptionOnInvalidHandleClose:1; //0x2c
};
};
struct _EX_PUSH_LOCK HandleContentionEvent; //0x30
struct _EX_PUSH_LOCK HandleTableLock; //0x38
union
{
struct _HANDLE_TABLE_FREE_LIST FreeLists[1]; //0x40
struct
{
UCHAR ActualEntry[32]; //0x40
struct _HANDLE_TRACE_DEBUG_INFO* DebugInfo; //0x60
};
};
};
在windbg中定位句柄表
_HANDLE_TABLE.TableCode
低两位清零后指向句柄表(低两位是判断几级句柄表的,下面会有解释)。
句柄表中每个项(联合体_HANDLE_TABLE_ENTRY
)大小为0x10,一个表可以放0x1000 / 0x10 = 0x100 即256个项。那么如果进程中句柄的数量大于256个会怎么办呢? Windows采用了多级指针的方法,根据_HANDLE_TABLE.TableCode
的前两位判断,如果为0代表直接指向句柄表,第0位为1代表二级句柄表,前两位为1代表三级句柄表。
二级句柄表的意思是_HANDLE_TABLE.TableCode
(低两位清零后)指向的地址上的值是句柄表地址数组,句柄表地址再指向含有句柄表项的地址。三级句柄表同理,只不过是多了一级。
句柄除4,是在句柄表中的索引。
句柄表项是一个联合体_HANDLE_TABLE_ENTRY
,结构为:
//0x10 bytes (sizeof)
union _HANDLE_TABLE_ENTRY
{
volatile LONGLONG VolatileLowValue; //0x0
LONGLONG LowValue; //0x0
struct
{
struct _HANDLE_TABLE_ENTRY_INFO* volatile InfoTable; //0x0
LONGLONG HighValue; //0x8
union _HANDLE_TABLE_ENTRY* NextFreeHandleEntry; //0x8
struct _EXHANDLE LeafHandleValue; //0x8
};
LONGLONG RefCountField; //0x0
ULONGLONG Unlocked:1; //0x0
ULONGLONG RefCnt:16; //0x0
ULONGLONG Attributes:3; //0x0
struct
{
ULONGLONG ObjectPointerBits:44; //0x0
ULONG GrantedAccessBits:25; //0x8
ULONG NoRightsUpgrade:1; //0x8
ULONG Spare1:6; //0x8
};
ULONG Spare2; //0xc
};
句柄表里的成员并不是内核对象,如果要得到内核对象还需要对其解密。
解密方法为对低八字节右移16位,然后&0xfffffffffffffff0,指向的是对象头(_OBJECT_HEADER
),再加对象头的大小(0x30)就是内核对象的地址。
下面来用写的一个程序做验证。
代码如下:
#include <iostream>
#include <Windows.h>
int main()
{
HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, 0, GetCurrentProcessId());
std::cout << "句柄:" << hProcess << std::endl;
system("pause");
return 0;
}
获取的句柄:10C。
查找对应的内核对象:
(0x98046a32d0500001>>0x10)&0xfffffffffffffff0=0xFFFF98046A32D050
,
FFFF98046A32D050+0x30=0xFFFF98046A32D080
。
No Problem!
除了可以通过句柄找到内核对象之外,还可以根据句柄表中的权限操作内核对象。
在句柄表项HANDLE_TABLE_ENTRY
这个联合体中,修改对应的位(HANDLE_TABLE_ENTRY.GrantedAccessBits
,高8字节的第0到第24位,第0位到第20位都为1就是PROCESS_ALL_ACCESS
满权限。)就可以修改用户模式下句柄的权限。
内核对句柄权限的验证是通过ObReferenceObjectByHandle
进行的。这个函数内部就是通过遍历句柄表实现的。
全局句柄表
如果把私有句柄表学明白了,那么学全局句柄表是很简单的。
全局句柄表中只有进程与线程的内核对象。
线程的TID以及进程的PID相当于“句柄”,可以理解为全局句柄表的索引。
全局句柄表位于全局变量PspCidTable
中,也是一个_HANDLE_TABLE
结构。
与私有句柄表不同的是,全局句柄表项解密后直接指向内核对象而不是对象头。
来查找一下Dbgiew.exe 进程。
查找全局句柄表,并找到对应的表项。6852/4=1713,1713/256等于6余177,177转换为16进制为0xB1。
解密,(0xC38D32972080F703>>10)&0x&0xfffffffffffffff0=0xFFFFC38D32972080
。
No Problem!
拓展
ObReferenceObjectByHandleWithTag分析
ObReferenceObjectByHandleWithTag 例程递增由指定句柄标识的对象的引用计数,并将四字节标记值写入对象以支持对象引用跟踪。
NTSTATUS ObReferenceObjectByHandleWithTag(
[in] HANDLE Handle,
[in] ACCESS_MASK DesiredAccess,
[in, optional] POBJECT_TYPE ObjectType,
[in] KPROCESSOR_MODE AccessMode,
[in] ULONG Tag,
[out] PVOID *Object,
[out, optional] POBJECT_HANDLE_INFORMATION HandleInformation
);
这个函数其实就是调用了ObpReferenceObjectByHandleWithTag
,只不过加了层封装。
ObpReferenceObjectByHandleWithTag分析
R3的ReadProcessMemory
等函数进内核后会调用到ntoskrnl!MiReadWriteVirtualMemory
,在内部操作内核对象时就调用了ObpReferenceObjectByHandleWithTag
,判断权限是否充足,并接收到内核对象。
_QWORD* WriteSize
首先局部变量赋值等操作。如果提供了writesize 则将writesize置0(可能中途退出,所以先设置成0,执行成功的话再设置对应值)。
判断传入的参数_Handle
是否属于内核的句柄,如果属于内核的句柄,判断是否为当前进程句柄。
如果为_Handle是当前进程的句柄。 如果对象类型不是进程类型,则返回0xC0000024({类型错误}请求的操作所需的对象类型与请求中指定的对象类型不匹配。);如果是,则继续执行。如果(_DesiredAccessf&0xFFE00000)==0
(没有内核的权限)或者_AccessMode
为1(用户模式),则返回0xC0000022({访问被拒绝}进程请求访问对象,但未被授予这些访问权限。)。
如果(_DesiredAccessf&0xFFE00000)!=0
并且_AccessMode==0
(内核模式),则继续执行。
如果参数_HandleInformation
不为NULL时,将_HandleInformation->GrantedAccess
设置为0x1FFFFF
,将_HandleInformation->HandleAttributes
设置为0。
如果全局变量ObpTraceFlags
不为0,调用ObpPushStackInfo
(ETW追踪对象操作,取调用栈中的所有返回地址,记录调用信息等。)。
随后,增加引用计数,如果引用计数小于等于1,也就是说之前小于等于0,那么会蓝屏(0x18,对象的引用计数对于对象的当前状态是非法的。);如果大于1,将参数_Object
里的值设置为当前进程对象,然后返回0(成功状态)。
判断参数_Handle
是否是当前线程句柄。
如果_Handle是当前线程句柄。 如果对象类型不是线程类型,返回0xC0000024;如果是,继续执行。如果(_DesiredAccessf&0xFFE00000)==0
(没有内核的权限)或者_AccessMode
为1(用户模式),返回0xC0000022。
如果(_DesiredAccessf&0xFFE00000)!=0
并且_AccessMode==0
(内核模式),继续执行。如果参数_HandleInformation
不为NULL时,将_HandleInformation->GrantedAccess
设置为0x1FFFFF
,将_HandleInformation->HandleAttributes
设置为0。如果全局变量ObpTraceFlags
不为0,调用ObpPushStackInfo
。增加引用计数,如果引用计数小于等于1,也就是说之前小于等于0,那么会蓝屏(0x18);如果大于1,将参数_Object
里的值设置为当前进程对象,然后返回0(成功状态)。这些操作与上面的基本相同。
再判断一下_AccessMode
,如果为用户模式,返回0xC0000008(指定了无效的HANDLE。),因为在用户模式下是无法使用内核句柄的。
将句柄值跟0xFFFFFFFF80000000异或,开启内核APC,设置局部变量的值,随后跳转到LABEL_10处。
接着来看,执行出了if判断(是否属于内核句柄)即不属于内核句柄的。如果MmVerifierData
第八位为1并且为_AccessMode
为内核模式,则会调用VfCheckUserHandle
检查用户句柄。开启内核APC。判断当前进程是否为原始进程。
如果当前进程为原始进程,如果cr3的第58位是1,则跳转至LABEL_80处,调用KeLeaveCriticalRegionThread
离开线程临界区,然后返回0xC0000008(指定了无效的HANDLE)。
如果当前进程不为原始进程,调用ObReferenceProcessHandleTable
,进行了引用_EPROCESS.RundownProtect
(可以简单的理解为:引用了_EPROCESS.RundownProtect
后这个进程在没有释放前不能结束。对进程要进行什么操作的时候会先引用这个字段进行加保护,等操作结束后再进行解保护。看到有些资料解释为“停运保护”~ 也有点儿形象,哈哈。),获取当前进程的句柄表,判断获取的句柄表是否为0,如果为0,则返回0xC0000008。判断获得句柄表是否为内核句柄表,如果为内核句柄表,返回0xC0000008(如果正常用户模式的走到这儿,就不会等于内核句柄表,如果等于可能就是出了些非法情况)。
随后,执行到LABEL_10处。不管是R0还是R3正常情况找到句柄表后都是执行到 LABEL_10。判断句柄是否是按四字节对齐的,调用ExpLookupHandleTableEntry
(此函数比较重要,根据句柄表和句柄值来查询 _HANDLE_TABLE_ENTRY
句柄表项,这里不作详细解释,建议自己去分析分析,挺有意思的。),判断返回值是否为0。
如果没有按四字节对齐或者返回值为0,如果句柄存在的话会调用一下ExHandleLogBadReference
,然后返回0xC0000008。
如果按四字节对齐并且返回了正常的句柄表项,预取句柄表项至缓存,将句柄表项的低八字节跟0x1FFFE做与运算,如果等于0,跳转至LABEL_45处;如果不等于0,则继续执行。
随后,进入while循环,while循环里面,判断句柄表项的Unlocked位 & 1 是否为0(是否被锁住),如果为0,被锁住,如果为1,则没有被锁住。 这个while循环,并不是那么重要,但讲起来吧,又比较麻烦,这个自己去看看吧…
while(1)
循环结束后,有个判断,这个判断个人感觉是判断的_HANDLE_TABLE_ENTRY.RefCnt
成员,如果不等于0x10,会用(_HANDLE_TABLE_ENTRY.LowValue>> 16) & 0xFFFFFFFFFFFFFFF0
这种解密算法(大多数情况下会走这里的),然后跳转到LABEL_17处;如果等于0x10,则会是另外一种解密方法,这种解密方法很少会走到这里,后面还进行了增加指针引用计数,刷新句柄表等操作,不详细讲解,感兴趣的话可以自己去分析分析,并不难。
在LABEL_17处,判断全局变量ObpTraceFlags
的值,检查Trace标志是否有效,不为0则调用ObpPushStackInfo
(跟上面的这个操作类似)。
随后,对象类型索引解密。对象类型索引=全局变量ObHeaderCookie异或_OBJECT_HEADER.TypeIndex异或对象头地址的首个字节
。操作系统把绝大部分的对象类型放在了ObTypeIndexTable
表里,ObTypeIndexTable[Index] == 对应内核对象类型
。
之后,如果参数_ObjectType
为空或者参数的索引与解密的索引不同,会根据索引在ObTypeIndexTable
里获取对象类型,如果获取的对象类型为0或者是错误的指针值,会蓝屏(0x189,OBJECT_HEADER已损坏)。如果参数_ObjectType
不为0(也就意味着数的索引与解密的索引不同),会进行解引用等操作,然后返回0xC0000024。
随后,就是权限比较了。如果为内核模式,会直接跳转到LABEL_27处(也就是内核模式不会经过权限的验证);如果为用户模式,会根据句柄表项_HANDLE_TABLE_ENTRY.GrantedAccessBits
去判断。先将_HANDLE_TABLE_ENTRY.HighValue & 0x1FFFFFF
,也就是得到句柄表项的GrantedAccessBits成员(高八字节低25位),将其取反后跟参数_DesiredAccess
(渴望的权限)进行与操作,如果不为0(也就是权限不足),会进行解引用等操作后返回0xC0000022;如果等于0,则会继续执行。
再然后,根据对象头的掩码信息做一些判断,说不重要吧,也挺有用,这里就不再讲了,建议自己去分析分析。
在分析本函数时,有些相对较不重要的地方省略没讲,强烈建议自己去分析分析!