在windows系统上,涉及到内核对象的功能函数,都需要从应用层权限转换到内核层权限,然后再执行想要的内核函数,最终将函数结果返回给应用层。本文就是用OpenProcess函数来观察函数从应用层到内核层的整体调用流程。
OpenProcess函数,根据指定的进程ID,返回进程句柄。源码如下:
HANDLE WINAPI OpenProcess(DWORD dwDesiredAccess,BOOL bInheritHandle,DWORD dwProcessId)
{
NTSTATUS Status; //保存函数执行状态
OBJECT_ATTRIBUTES Obja; //待打开对象的对象属性
HANDLE Handle; //存储打开的句柄
CLIENT_ID ClientId; //进程、线程ID
ClientId.UniqueThread = NULL;
ClientId.UniqueProcess = LongToHandle(dwProcessId); //将Long类型强转成Handle类型
//初始化对象属性
InitializeObjectAttributes(
&Obja,
NULL,
(bInheritHandle ? OBJ_INHERIT : 0),
NULL,
NULL
);
//尝试打开进程
Status = NtOpenProcess(
&Handle, //保存进程句柄
(ACCESS_MASK)dwDesiredAccess, //预打开进程并获取对应的权限
&Obja, //对象属性
&ClientId //根据进程ID打开进程
);
//判断是否打开进程成功
if ( NT_SUCCESS(Status) ) {
return Handle;
}
else {
//设置GetLastError()函数值
BaseSetLastNTError(Status);
return NULL;
}
}
其中一个重点问题是,OpenProcess函数直接调用了NtOpenProcess内核函数,没有看到权限转换相关代码。NTOpenProcess源码如下:
NTSTATUS NtOpenProcess (
OUT PHANDLE ProcessHandle,
IN ACCESS_MASK DesiredAccess,
IN POBJECT_ATTRIBUTES ObjectAttributes,
IN PCLIENT_ID ClientId OPTIONAL
)
{
HANDLE Handle;
KPROCESSOR_MODE PreviousMode;
NTSTATUS Status;
PEPROCESS Process;
PETHREAD Thread;
CLIENT_ID CapturedCid={0};
BOOLEAN ObjectNamePresent;
BOOLEAN ClientIdPresent;
ACCESS_STATE AccessState;
AUX_ACCESS_DATA AuxData;
ULONG Attributes;
//当前代码执行环境IRQL必须在APC_LEVEL等级以下,否则将触发断言
PAGED_CODE();
//宏函数调用,获取当前线程先前模式,是内核线程还是用户线程
PreviousMode = KeGetPreviousMode();
//用户线程
if (PreviousMode != KernelMode) {
try {
//句柄写入探针,将用户地址空间,从交换空间中置换到内存中
//如果传入的ProcessHandle地址是0,那么在访问时,会出现访问0地址异常,进而被catch捕获
ProbeForWriteHandle (ProcessHandle);
//判断第二个参数,是否属于1/2/4/8/16,不符合前述要求则执行断言
//判断读取的结构体是否为0或者大于0x10000,符合前述要求则执行断言
//判断ObjectAttributes地址是否和指定的第三个参数大小对齐,不对齐则抛出异常
//判断地址是否是内核地址,如果是内核地址,则将用户探针地址值修改为0
ProbeForReadSmallStructure (ObjectAttributes,
sizeof(OBJECT_ATTRIBUTES),
sizeof(ULONG));
//从对象结构中,获取对象名称
//如果是OpenProcess函数调用,那么此刻的名称为空
ObjectNamePresent = (BOOLEAN)ARGUMENT_PRESENT (ObjectAttributes->ObjectName);
//计算应用层句柄权限
Attributes = ObSanitizeHandleAttributes (ObjectAttributes->Attributes, UserMode);
//判断ClientId指针是否为空
if (ARGUMENT_PRESENT (ClientId)) {//不为空
//比对参数地址是否对齐,结构大小是否过大
ProbeForReadSmallStructure (ClientId, sizeof (CLIENT_ID), sizeof (ULONG));
//没有问题的话,将参数赋值给局部变量CapturedCid
CapturedCid = *ClientId;
ClientIdPresent = TRUE;//设置标志位
} else {//指针为空
ClientIdPresent = FALSE;//设置标志位
}
} except (EXCEPTION_EXECUTE_HANDLER) {
//捕获异常后,返回异常代码
return GetExceptionCode();
}
} else {//内核线程
ObjectNamePresent = (BOOLEAN)ARGUMENT_PRESENT (ObjectAttributes->ObjectName); //判断对象名称是否为空
Attributes = ObSanitizeHandleAttributes (ObjectAttributes->Attributes, KernelMode); //调用者不是内核模式,那么打开句柄也不是内核句柄
//判断ClientId是否为空指针
if (ARGUMENT_PRESENT (ClientId)) {
CapturedCid = *ClientId; //获取进程ID
ClientIdPresent = TRUE; //成功获取
} else {
ClientIdPresent = FALSE; //获取失败
}
}
//如果对象当前名称为空并且进程ID为空,那么就返回错误
if (ObjectNamePresent && ClientIdPresent) {
return STATUS_INVALID_PARAMETER_MIX; //状态无效参数混合
}
//创建一个访问状态,该状态可用于系统调试,因为函数调用者可能有调试权限
Status = SeCreateAccessState(
&AccessState,
&AuxData,
DesiredAccess,
&PsProcessType->TypeInfo.GenericMapping
);
//如果访问状态创建失败,返回失败状态码
if ( !NT_SUCCESS(Status) ) {
return Status;
}
//调试访问进入,设置对应权限和给予对应权限
if (SeSinglePrivilegeCheck( SeDebugPrivilege, PreviousMode )) {
if ( AccessState.RemainingDesiredAccess & MAXIMUM_ALLOWED ) {
AccessState.PreviouslyGrantedAccess |= PROCESS_ALL_ACCESS;
} else {
AccessState.PreviouslyGrantedAccess |= ( AccessState.RemainingDesiredAccess );
}
AccessState.RemainingDesiredAccess = 0;
}
//如果对象名称存在的话
if (ObjectNamePresent) {
//使用对象名称打开对象
Status = ObOpenObjectByName(
ObjectAttributes, //对象属性
PsProcessType, //待打开对象类型
PreviousMode, //线程模式
&AccessState, //访问状态
0,
NULL,
&Handle //获取打开对象句柄
);
//删除访问状态
SeDeleteAccessState( &AccessState );
//如果对象打开成功
if ( NT_SUCCESS(Status) ) {
try {
//将句柄值写入,指定的地址空间
//如果地址为0,那么将抛出异常
*ProcessHandle = Handle;
} except (EXCEPTION_EXECUTE_HANDLER) {
return GetExceptionCode ();
}
}
return Status;
}
if ( ClientIdPresent ) {
Thread = NULL;
//如果是OpenProcess调用,那么CapturedCid.UniqueThread为0
if (CapturedCid.UniqueThread) {
//根据线程ID,获取EPROCESS和ETHREAD结构
Status = PsLookupProcessThreadByCid(
&CapturedCid,
&Process,
&Thread
);
//如果函数执行失败,则删除访问状态,并返回函数执行状态
if (!NT_SUCCESS(Status)) {
SeDeleteAccessState( &AccessState );
return Status;
}
} else {//OpenProcess函数调用就是使用此处分支
//根据进程ID获取EPROCESS
Status = PsLookupProcessByProcessId(
CapturedCid.UniqueProcess,
&Process
);
//如果函数执行失败,则删除访问状态,并返回函数执行状态
if ( !NT_SUCCESS(Status) ) {
SeDeleteAccessState( &AccessState );
return Status;
}
}
//
// OpenObjectByAddress
//
//根据EPROCESS结构,获取一个句柄
Status = ObOpenObjectByPointer(
Process,
Attributes,
&AccessState,
0,
PsProcessType,
PreviousMode,
&Handle
);
//删除一个访问状态
SeDeleteAccessState( &AccessState );
//如果线程被打开了,减少对ETHREAD结构引用
if (Thread) {
ObDereferenceObject(Thread);
}
//减少对指定EPROCESS结构的引用
ObDereferenceObject(Process);
//判断EPROCESS是否打开成功
if (NT_SUCCESS (Status)) {
try {
//将句柄值写入,指定的地址空间
//如果地址为0,那么将抛出异常
*ProcessHandle = Handle;
} except (EXCEPTION_EXECUTE_HANDLER) {
return GetExceptionCode ();
}
}
return Status;
}
return STATUS_INVALID_PARAMETER_MIX;
}
出现上述情况,可能是源码版本问题。
在IDA工具观察OpenProcess函数执行,OpenProcess函数经过一系列调用最终会调到跳板函数ZwOpenProcess:
; __stdcall ZwOpenProcess(x, x, x, x)
public _ZwOpenProcess@16
_ZwOpenProcess@16 proc near ; CODE XREF: RtlpChangeQueryDebugBufferTarget(x,x,x,x)-18874↑p
; RtlQueryProcessDebugInformation(x,x,x)+8F↑p
; RtlpChangeQueryDebugBufferTarget(x,x,x,x)+62DD8↓p
; DATA XREF: .text:off_77EF5618↑o
mov eax, 0BEh ; NtOpenProcess
mov edx, 7FFE0300h ; 获取_KUSER_SHARED_DATA空间中的SystemCall函数地址
; KiFastSystemCall或KiIntSystemCall
call dword ptr [edx] ; 进行函数调用
retn 10h
_ZwOpenProcess@16 endp
可以看到,该函数保存了函数的功能号(SSDT/SSSDT表索引)0xBE,调用了地址为0x7FFE0300的函数。
首先了解下_KUSER_SHARED_DATA空间,该空间被用户层和内核层所共享,通俗来讲就是两个虚拟地址挂载了同一张物理页,最终达到数据共享的目的。两个固定的地址如下:
用户_KUSER_SHARED_DATA虚拟地址:0x7FFE0000(只读权限)
内核_KUSER_SHARED_DATA虚拟地址:0xFFDF0000(读写权限)
1: kd> dt _KUSER_SHARED_DATA 0x7FFE0000
hal!_KUSER_SHARED_DATA
......
+0x300 SystemCall : 0x775464f0 <======0x7FFE0300
+0x304 SystemCallReturn : 0x775464f4
......
1: kd> u 0x775464f0
ntdll!KiFastSystemCall:
775464f0 8bd4 mov edx,esp
775464f2 0f34 sysenter
可以看到_KUSER_SHARED_DATA.SystemCall字段上的函数地址是KiFastSystemCall函数。之所以此处不是将进入内核的地址写死,而是使用一个类似变量的SystemCall字段来记录地址,就是为了方便改变进入内核的方式。如果当前处理器不支持快速调用进入内核,那么SystemCall保存的就应该是KiIntSystemCall函数地址。
KiFastSystemCall函数首先保存了应用层的栈顶地址,然后执行了sysenter指令,该指令就是修改段寄存器中的段选择子。(具体内容可以参考intel白皮书的sysentry指令)
public [email protected]
[email protected] proc near ; DATA XREF: .text:off_77EF5618↑o
mov edx, esp ; 保存用户空间的esp到寄存器edx中
sysenter ; 调用汇编指令sysenter
; 1.将 SYSENTER_CS_MSR 的值装载到 cs 寄存器
; 2.将 SYSENTER_EIP_MSR 的值装载到 eip 寄存器
; 3.将 SYSENTER_CS_MSR 的值加 8(Ring0 的堆栈段描述符)装载到 ss 寄存器。
; 4.将 SYSENTER_ESP_MSR 的值装载到 esp 寄存器
[email protected] endp
快速调用进入内核和中断调用进入内核,区别就是快速调用使用寄存器记录段选择子,相比如中断调用访问内存,执行效率更高。
其中修改的EIP指向内核函数KiFastCallEntry,该函数初始化刚刚进入到内核的线程环境等。
windows系统GDT表的0x30项,记录了处理器当前核的KPCR结构。
mov ecx, 23h ; '#'
push 30h ; '0'
pop fs ; fs指向kpcr结构
拿出KPCR结构中的_KTRAP_FRAME结构,将应用层寄存器环境保存到该缓存空间中。
mov ecx, large fs:KPCR.TSS ; 获取任务段
mov esp, [ecx+_KTSS.Esp0] ; 获取处理器中记录的内核ESP
; 此时esp指向了内核的_KTRAP_FRAME结构的HardwareSegSs位置
push 23h ; '#' ; 保存ss
push edx ; 保存应用层的esp
pushf ; 保存标志寄存器
loc_43568B: ; CODE XREF: _KiFastCallEntry2+23↑j
push 2
add edx, 8 ; edx指向参数地址
popf ; 设置标志寄存器为2
or byte ptr [esp+1], 2 ; 标志寄存器低二位(索引为1),必须为1
push 1Bh ; 保存应用层cs段选择子
push dword ptr ds:0FFDF0304h ; 保存SystemCallReturn函数地址
push 0 ; 设置错误码ErrCode
push ebp ; 保存寄存器环境
push ebx
push esi
push edi
mov ebx, large fs:KPCR.SelfPcr ; ebx = 当前kpcr地址
push 3Bh ; ';' ; 保存fs段选择子
mov esi, [ebx+124h] ; 获取当前线程的ETHREAD结构
push dword ptr [ebx] ; 保存Used_ExceptionList地址
mov dword ptr [ebx], 0FFFFFFFFh ; 将KPCE中的异常链记录抹除
mov ebp, [esi+_ETHREAD.Tcb.InitialStack] ; 获取内核的初始化堆栈
push 1 ; 保存先前模式到KTRRAP_FRRAME结构中
; 0:KernelMode
; 1:UserMode
sub esp, 48h
sub ebp, 29Ch
mov [esi+_ETHREAD.Tcb.PreviousMode], 1 ; 设置线程先前模式为用户模式
cmp ebp, esp ; 不相等,就跳转异常处理
jnz short loc_43566B
and [ebp+_KTRAP_FRAME.Dr7], 0 ; dr7 = 0
test byte ptr [esi+3], 0DFh ; 线程头部说明中的Index字段
mov [esi+_KTHREAD.TrapFrame], ebp ; 保存TRAP_FRAME结构地址到线程对象的字段中
jnz Dr_FastCallDrSave
loc_4356E8: ; CODE XREF: Dr_FastCallDrSave+D↑j
; Dr_FastCallDrSave+79↑j
mov ebx, [ebp+_KTRAP_FRAME._Ebp]
mov edi, [ebp+_KTRAP_FRAME._Eip]
mov [ebp+_KTRAP_FRAME.DbgArgPointer], edx
mov [ebp+_KTRAP_FRAME.DbgArgMark], 0BADB0D00h ; 设置调试掩码
mov [ebp+_KTRAP_FRAME.DbgEbp], ebx
mov [ebp+_KTRAP_FRAME.DbgEip], edi ; SystemCallRet覆盖调试eip
sti ; 允许发生中断
当一切环境保存结束后,开始根据应用层传入的索引值,完成对SSDT/SSSDT表的函数检索与执行。
loc_4356FF: ; CODE XREF: _KiBBTUnexpectedRange+18↑j
; _KiSystemService+7F↑j
mov edi, eax ; edi = 函数表索引
shr edi, 8 ; 表索引,用了13位,其中
; 13位-0:ssdt表查询
; 13位-1:sssdt表查询
and edi, 10h
mov ecx, edi ; 此时ecx是一个标志寄存器,
; ecx = 0x0;ssdt表
; exc = 0x10;sssdt表
add edi, [esi+_KTHREAD.ServiceTable] ; ssdt表地址 + 0(ssdt)/10(sssdt)
; ssdt表地址
; sssdt表地址
mov ebx, eax ; ebx = 函数表索引
and eax, 0FFFh ; 去除索引号第十三位的标志位,使其成为一个单纯的索引号
cmp eax, [edi+8] ; 判断当前索引号是否在函数个数范围内
jnb _KiBBTUnexpectedRange ; 函数索引越界,跳转
cmp ecx, 10h ; 判断是哪一个表项函数
jnz short SSDT
mov ecx, [esi+_ETHREAD.Tcb.Teb] ; 获取线程TEB结构
xor esi, esi ; esi = 0;
上述代码比较精彩的部分就是,ssdt表和sssdt表的抉择处理。ssdt表和sssdt表在内存上的排放情况如下所示:
0: kd> dd KeServiceDescriptorTable
841ac940 840a95f4 00000000 00000191 840a9c3c <===SSDT表 :函数地址表地址 略 函数个数 函数参数表地址
841ac950 00000000 00000000 00000000 00000000
841ac960 00000011 00000100 5385d2ba d717548f
841ac970 00000000 00000000 00000210 00000000
841ac980 840a95f4 00000000 00000191 840a9c3c
841ac990 95a65000 00000000 00000339 95a6602c
841ac9a0 00000200 866f1f78 00000000 00000000
841ac9b0 866f1eb0 866f1c58 866f1de8 866f1d20
0: kd> dd KeServiceDescriptorTableShadow
841ac980 840a95f4 00000000 00000191 840a9c3c <===SSDT表 :函数地址表地址 略 函数个数 函数参数表地址
841ac990 95a65000 00000000 00000339 95a6602c <===SSSDT表:函数地址表地址 略 函数个数 函数参数表地址
841ac9a0 00000200 866f1f78 00000000 00000000
841ac9b0 866f1eb0 866f1c58 866f1de8 866f1d20
841ac9c0 00000000 866f1b90 00000000 00000000
841ac9d0 840a2e49 840b7b60 840dc199 00000003
841ac9e0 86645000 86646000 00000120 ffffffff
841ac9f0 00000001 00000000 00000002 00000000
如果传递到内核的表索引是一个SSSDT表索引,那么表地址就会加上0x10,地址就会从ssdt起始地址指向sssdt起始地址,而后续代码不需要做调整。
接下来就是将用户空间的数据拷贝到内核空间,并且根据索引值查询待调用的函数地址:
inc large dword ptr fs:6B0h ; KeSystemCalls
mov esi, edx ; esi = 用户空间参数首地址
xor ecx, ecx ; ecx = 0;
mov edx, [edi+0Ch] ; edx = 函数参数表地址
mov edi, [edi] ; edi = 函数地址表地址
mov cl, [eax+edx] ; 在参数个数表中,根据索引查询函数参数个数
mov edx, [edi+eax*4] ; 在函数地址表中,查询函数地址
sub esp, ecx ; 堆栈向低地址提高指定字节空间,提升的空间用于存放函数参数
shr ecx, 2 ; 参数个数 = 字节数 / 4
mov edi, esp ; edi = esp
cmp esi, ds:_MmUserProbeAddress ; 判断参数地址是否在用户空间
jnb loc_435995 ; 参数地址不在用户就可能出现错误
loc_435767: ; CODE XREF: _KiFastCallEntry+329↓j
; DATA XREF: _KiTrap0E:loc_4389D8↓o
rep movsd ; 将参数从用户空间拷贝到内核空间
一切准备完毕之后,开始函数调用:
mov ecx, large fs:124h ; ecx = 当前线程
mov edi, [esp]
mov [ecx+_KTHREAD.SystemCallNumber], ebx
mov [ecx+_KTHREAD.FirstArgument], edi
loc_435785: ; CODE XREF: _KiFastCallEntry+FD↑j
mov ebx, edx ; ebx = 函数地址
test byte ptr ds:dword_52D048, 40h
setnz byte ptr [ebp+12h]
jnz loc_435B24
loc_435798: ; CODE XREF: _KiFastCallEntry+4BB↓j
call ebx ; <===============进行函数调用
函数调用结束后,会进行系列的权限和标记检查。如果和预期不服,则跳转KeBugCheck2,执行蓝屏代码。
本文总体上梳理了函数调用的流程。但是仅仅讲述了快速调用的执行流程,关于int 2e的中断调用并未提及。
其中在书写文章过程中,涉及到的几个有意思的点,具体如下:
(1)内核线程中所记录的GDT、IDT表地址,如果将这两个地址更换,是不是能达到类似内核重载的效果
(2)修改_KUSER_SHARED_DATA结构中的SystemCall,不对SSDT表进行Hook操作,是否也能达到过滤并且绕过检测的效果
(3)同理,修改_KUSER_SHARED_DATA结构中的SystemCallReturn函数,是否能达到拦截某些数据信息的目的
上述思想有待考究实现。