函数调用进入内核层分析 | 二进制安全
2023-3-1 21:27:24 Author: 0x00实验室(查看原文) 阅读量:6 收藏

概述

在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函数,是否能达到拦截某些数据信息的目的

上述思想有待考究实现。


文章来源: http://mp.weixin.qq.com/s?__biz=Mzg5MDY2MTUyMA==&mid=2247489138&idx=1&sn=5bbe2dca94d0d626ab0b3cf156407c79&chksm=cfd8698df8afe09b4738013596fce18ff0ef82d15dfa1d5224418899be0984e4f82a4adaaa24#rd
如有侵权请联系:admin#unsafe.sh