admin 发表于 2024-12-17 22:02:44

WindowsX86内核16.API调用流程和HookAPI

WindowsAPI调用流程
[*]不管调那个函数都可以,只要能进内核就行了,但是一般是不会调Ntdll里的,因为Ntdll里的参数要求和3环是不一样的
[*]user32.dll里都是UI相关的API,Kernel.dll里是核心的功能,这两个是常用的,可以选择一个来分析
分析 ReadProcessMemoryIDA打开Kernel.dll,找到ReadProcessMemory
push    ebp
mov   ebp, esp
lea   eax,
push    eax             ; NumberOfBytesRead
push       ; NumberOfBytesToRead
push    ; Buffer
push    ; BaseAddress
push    ; ProcessHandle
call    ds:NtReadVirtualMemory; 发现它转换完参数就调了Nt这个导出函数然后点击导出表,搜索这个函数,发现它是由ntdll.dll导入的,也就是这里啥也没干,全部交给ntdll了

    IDA打开ntdll.dll,搜索NtReadVirtualMemory函数,在导出表搜索
    其实老版本用的都是这个,新版本都改成zw开头的了也就是ZwReadVirtualMemory,但是在IDA导出表搜索的时候发现它其实两个函数指向的地址是同一个地址;为了兼容老版本.所以就分析NtReadVirtualMemory就行了
ZwReadVirtualMemory proc near                        
mov   eax, 0BAh         ;186 API编号   
mov   edx, 7FFE0300h    ;全局变量
call    edx
retn    14h
ZwReadVirtualMemory endp
    这个全局变量要么通过KiIntSystemCall,要么通过KiFastSystemCall 或者直接在WinDbg里跟函数也行, 也就是rdmsr 176得到地址,然后跟这个函数.在WinDbg里发现它最终调到内核里的KiFastCallEntry了,所以就分析这个就行了
分析KiFastCallEntry_KiFastCallEntry proc near            
var_B= byte ptr -0Bh
mov   ecx, 23h ; '#'
push    30h ; '0'            把fs改成内核的,这样就可以访问kpcrb了
pop   fs ;                  fs本来指向的是3环的teb,改完就能访问kpcrb了
mov   ds, ecx ;
mov   es, ecx ;                        ds和es改成23
mov   ecx, large fs:40;   40就是kpcr里的TSS : +0x040 TSS:Ptr32 _KTSS
mov   esp, ;       +4就是TSS里的Esp0: +0x004 Esp0:Uint4B;也就是 ESP=ESP0切换栈
push    23h ; '#'
push    edx ;保存环境
pushf   ;下面有个加法,怕影响标志寄存器,所以这里就pushf了

loc_40770A:
push    2
;这里+8就说明它在3环就没有+8,栈顶是EBP+返回值,真正的参数就在+8的位置
add   edx, 8;    edx是3环的栈,
popf
or      , 2
push    1Bh
push    dword ptr ds:0FFDF0304h
push    0
push    ebp
push    ebx
push    esi
push    edi ;保存环境

;因为汇编里不能用 lea eax,fs,所以它就把首地址放到1C的位置,它就可以mov获取了
mov   ebx, large fs:1Ch;      这里是拿结构体首地址 +0x01c SelfPcr : Ptr32 _KPCR
push    3Bh ;

;+0x120 PrcbData: _KPRCB;然后在+4,就是KPRCB里的
;+0x004 CurrentThread : Ptr32_KTHREAD了
mov   esi, ;   获取当前线程CurrentThread
push    dword ptr
mov   dword ptr , 0FFFFFFFFh;    然后这里取内容得到了异常链表,再把它赋值为-1
;+0x018 InitialStack   : Ptr32 Void
mov   ebp, ;   当前线程结构体+18的位置,也就是拿栈
push    1
sub   esp, 48h
sub   ebp, 29Ch
;+0x140 PreviousMode   : Char
mov   byte ptr , 1;    这是获取之前的模式,记一下是从3环调的还是从0环调的
cmp   ebp, esp
jnz   loc_4076C8
and   dword ptr , 0
test    byte ptr , 0FFh
mov   , ebp
jnz   Dr_FastCallDrSave
loc_40776A:                              
mov   ebx,
mov   edi,
mov   , edx
mov   dword ptr , 0BADB0D00h
mov   , ebx
mov   , edi
sti;这里的代码都不是很重要
loc_407781:                           
mov   edi, eax ;eax给edi
shr   edi, 8;    右移8位
and   edi, 30h;再and30
;1111 1111 1111 1111右移八位=>1111 1111
;再&30 也就是 1100=> 1111 11 00 0000 0000 => 11 00 0000 0000
;&30就是取出11这两位,正常情况下 +0000 就是SSDT;要不就是10了+10就是ShadowSSDT
mov   ecx, edi
;KTHREAD 里的+0x0e0 ServiceTable: Ptr32 Void;这就是SSDT表,
;也就是这个表它是从ethread拿的,说明它在创建线程的时候就要把表地址放到这里了
;但是这个地址还要加上edi,从这里可以说明这个表有两个.两个表就通过上面的and结果来区分
;一个叫SSDT这个表放NTdll的所有函数,另外一个叫ShadowSSDT(这里放的都是UI的API)
add   edi, ;做了一个加法 esi此时是ethread+e0 就是ServiceTable
;如果调的是UI的API它就拿服务表的首地址再+16,否则就+0,+8的位置刚好是数量
mov   ebx, eax
             and   eax, 0FFFh ;与掉低12位,说明API编号只有12位有效,高位都丢弃了
cmp   eax, ;然后和edi+8比较,说明edi+8放的是函数的数量
jnb   _KiBBTUnexpectedRange
cmp   ecx, 10h
jnz   short loc_4077C0
mov   ecx, ds:0FFDFF018h
xor   ebx, ebx;这里是检查

loc_4077AE:                        
or      ebx,
jz      short loc_4077C0
push    edx
push    eax
call    ds:_KeGdiFlushUserBatch
pop   eax
pop   edx;这里是检查
loc_4077C0:                        
inc   dword ptr ds:0FFDFF638h
mov   esi, edx
mov   ebx, ;从第12的位置取出参数
xor   ecx, ecx ;ecx清0
mov   cl, ;然后直接拿出来用了
mov   edi, ;edi是函数表
mov   ebx, ;查出函数地址
sub   esp, ecx ;抬栈
shr   ecx, 2 ;除4
mov   edi, esp
cmp   esi, ds:_MmUserProbeAddress ;检查栈顶是不是有效的
jnb   loc_407990
loc_4077E8:                        
rep movsd ;开始拷贝参数
call    ebx ;然后就直接调用了
loc_4077EC:
mov   esp, ebp
loc_4077EE:                        
;调完就返回了   
mov   ecx, ds:0FFDFF124h
mov   edx,
mov   , edx
_KiFastCallEntry endp ;这个函数其实没有完,
它会走到_KiServiceExit里,然后在那里恢复环境,在返回
[*]UI的内核实现代码不在ntoskrnl.exe里, 它是重新封装了一个模块叫Win32k.sys这里放UI函数的实现代码
[*]win32k不是所有进程都有的.因为Windows上也有没有界面的软件,所以它做了两个表SSDT和ShadowSSDT
[*]
[*]也就是说当一个进程是控制台的时候它就只用SSDT表;
[*]当它是一个Win32应用程序的时候这两个表都存在

[*]获取win32k模块 可以遍历所有进程,找一个带窗口的进程,最直接的就是找桌面进程或者登录界面;explorer 切换到这个进程 .process /p /i 822e7020
然后再lm 就可以看到有一个win32k了:bf800000 bf9c2800 win32k (deferred)
切换到这个进程后查看它的eprocess里面的ethread表,它就会有两个
[*]
[*]查看SSDT表
[*]在WinDbg里查看KiFastCallEntry函数,然后设置一个断点,断下来就查看edi这个地址的值 可以断在判断成功的地址比如: 804df791 8bd8 mov ebx,eax;这个地址就可以 然后就查看edi就行了,edi指向的地址是一个结构体 此时eip = 8055b1e0 kd> dd 8055b1e0 8055b1e0 804e36a8 00000000 0000011c 80511088 上面分析已经知道了+8的地址是函数数量,也就是0000011c 第一个地址指向的是一个函数指针表;可以使用dds 804e36a8,它就会把每个成员符号解析一下. kd> dds 804e36a8 804e36a8 80590df5 nt!NtAcceptConnectPort 804e36ac 8057a0f1 nt!NtAccessCheck .......省略 可以这里放的确实是所有API的实现 第四个成员也是一个表,放的是参数的大小,可以通过 db 80511088查看
[*]如果eip+16的位置有表,就说明它是个Win32的程序,如果没有表,就是控制台程序. 而它+16的地址就是8055b1f0,正好有数据 8055b1f0 bf999b80 00000000 0000029b bf99a890 和上面查看的流程一样 kd> dds bf999b80 bf999b80 bf935f7e win32k!NtGdiAbortDoc bf999b84 bf947b29 win32k!NtGdiAbortPath ;这里确实是Win32k的API
[*]
[*]不在函数里下断点的查看流程
[*]直接遍历所有进程,然后随便找一个进程,打开它的ETHREAD 也可以先看eprocess;直接dt _eprocess 864ac578;然后就可以在结构体里找到线程了, 或者直接kd>!process 864ac578 7 ;这样就直接可以看到它的线程了 然后随便找一个线程 kd> dt _kthread 8223b648 就能找到它的服务表了,在e0的位置+0x0e0 ServiceTable: 0x8055b220 Void 然后dd查看它,kd>dd 0x8055b220 kd> dd 0x8055b220A ReadVirtual: 8055b220 not properly sign extended 8055b220 804e36a8 00000000 0000011c 80511088 8055b230 00000000 00000000 00000000 00000000 可以看到它是一个控制台程序,没有第二个表
[*]HookAPI
[*]
[*]HookApi就有很多方案了

[*]
[*]hook msr
[*]inline hook KiFastCallEntry;在这个函数里找一个位置改jmp,可以在call ebx这里改jmp,因为这里环境准备好了,Erie参数也在栈顶,而且判断 ebx就知道他要调哪个函数了;有时候hook可能并不只为了拦截,如果是行为监控的话,就可以call ebx之后改jmp,只为了获取它调了什么,打个日志
[*]hook修改SSDT表,或者ShadowSSDT表 改SSDT或者ShadowSSDT表是最稳定的,改表兼容性更好,只是麻烦一点.

[*]这三种HOOK也有缺点,比如内核里调NT是拦截不到的,因为改的是表,而它不走表,除非它调zw
[*]
[*]做Hook首先要搞清楚的是如何拿到这两个表SSDT和ShadowSSDT
[*]这两个表是全局变量,可以看一下WRK,看它是怎么做的 表在ethread里,所以应该看kthread; 看它在kthread什么时候填的 kthread里有个服务表,就是ServiceTable,可以参考引用,看谁填了这个表 这样就找到了这两个表 DECLSPEC_CACHEALIGN KSERVICE_TABLE_DESCRIPTOR KeServiceDescriptorTable; DECLSPEC_CACHEALIGN KSERVICE_TABLE_DESCRIPTOR KeServiceDescriptorTableShadow; 因为它是两个全局变量,所以它正好就是隔16 如果是控制台,它就把KeServiceDescriptorTable的地址给ethread 如果是Win32的就是给完一个,再+16给第二个
[*]它的结构体就是 typedef struct _KSERVICE_TABLE_DESCRIPTOR { PULONG_PTR Base; PULONG Count; ULONG Limit; PUCHAR Number; } KSERVICE_TABLE_DESCRIPTOR, *PKSERVICE_TABLE_DESCRIPTOR;
[*]要拿到这两个表的话很简单,在老版本里这个表KeServiceDescriptorTable它直接导出了 所以要拿到这个表的地址直接extern就行了 extern PKSERVICE_TABLE_DESCRIPTOR KeServiceDescriptorTable; 而KeServiceDescriptorTableShadow表没有导出,但是它在内存中是连续的,所以直接+16就是它了 有了这两个表的地址就可以hook了
[*]但是从Win7开始它就不导出了这个表了,Win7拿的话就得从kthread里拿了 从kthread里拿得话要先搞定偏移,第二个问题就是不一定能拿到Shadow表.因为ethread里不一定有 所以从etherad里拿也是不完整.但是可以强制切进程到桌面,然后再去拿这个表就可以了,所以难点就是偏移如何解决,要兼容所有版本的话写switch也可以,但是太麻烦了,其实可以从KiFastCallEntry里拿;因为它里面肯定会用这个偏移的也就是 add edi, 这行代码,那么就可以扫特征码了 这个函数地址是很好拿的,可以通过msr拿,就是rdmsr 176,拿到地址后就扫特征码,拿出偏移. 拿这个表的时候要把进程切到一个Win32的程序上,这样的话这两个表都能拿到了 注意要拿ethread就得先拿eprocess,遍历进程的话又有偏移问题了,不过这个遍历进程线程得话它有未公开函数来用;可以用PsLookupthreadByThread();给个线程ID就返回ethread,要拿线程ID还是不难的. 而Ps这些函数都是导出的,所以拿地址就很方便
[*]拿线程的话拿谁的线程ID也很关键,都是拿Win32程序的,没有的话也可以自己写一个.
[*]获取表地址代码示例typedef struct _KSERVICE_TABLE_DESCRIPTOR {
PULONG_PTR Base;
PULONG Count;
ULONG Limit;
PUCHAR Number;
} KSERVICE_TABLE_DESCRIPTOR, * PKSERVICE_TABLE_DESCRIPTOR;

//SSDT表
PKSERVICE_TABLE_DESCRIPTOR KeServiceDescriptorTable;
//ShadowSSDT表
PKSERVICE_TABLE_DESCRIPTOR KeServiceDescriptorShadowTable;

//声明函数
NTSTATUS __stdcall PsLookupThreadByThreadId(
__in HANDLE ThreadId,
    __deref_out PETHREAD* Thread
    );
/*驱动卸载函数 clean_up*/
VOID Unload(__in struct _DRIVER_OBJECT* DriverObject)
{
    DbgPrint(" Unload! DriverObject:%p\n", DriverObject);

}
/*1.驱动入口函数*/
NTSTATUS DriverEntry(
__in struct _DRIVER_OBJECT* DriverObject,
__in PUNICODE_STRINGRegistryPath)
{
    UNREFERENCED_PARAMETER(RegistryPath);

    DbgPrint(" DriverEntry DriverObject:%p\n", DriverObject);
    DriverObject->DriverUnload = Unload;

    PETHREAD Thread = NULL;
    //通过线程ID获取EThread;284是桌面的一个线程ID
    NTSTATUS Status = PsLookupThreadByThreadId((HANDLE)284,&Thread);
    DbgPrint(" Thread:%p\n", Thread);
    if (!NT_SUCCESS(Status)) {
      return STATUS_SUCCESS;
    }

    //成功了以后就可以开始拿表了 就是ethread + 偏移
    KeServiceDescriptorTable = *(PKSERVICE_TABLE_DESCRIPTOR*)((char*)Thread + 0xe0);
    //Shadow表的话就拿指针+1就可以了
    KeServiceDescriptorShadowTable = KeServiceDescriptorTable + 1;

    DbgPrint(" KeServiceDescriptorTable:%p\n", KeServiceDescriptorTable);
    //Shadow表直接+1就行了,因为它是结构体指针
    DbgPrint(" KeServiceDescriptorShadowTable:%p\n", KeServiceDescriptorShadowTable);

    //ethread成功获取了的话对象的引用计数要--
    if (Thread) {
      ObfDereferenceObject(Thread);
    }
    return STATUS_SUCCESS;
}


[*]
[*]接下来就可以HookAPI了

[*]可以选择Hook:OpenProcess,模拟读写进程内存做保护的情况,因为HookAPi的话太多了,读啊写啊的,但是不管
[*]读还是写都要先打开获取句柄,但是不知道OpenProcess的API编号是多少,可以通过Ntdll导出函数来查看
[*]Ntdll里有个ZwOpenProcess,它里面就写了API编号,是7Ah.得到API编号后就可以对它进行Hook了
[*]
[*]Hook的话要知道它的参数,可以通过WRK查一下就知道了
[*]这样就可以自己写一个伪造的了,然后把SSDT对应的表项改为自己的就可以了
[*]
[*]代码示例#include <ntddk.h>

typedef struct _KSERVICE_TABLE_DESCRIPTOR {
PULONG_PTR Base;
PULONG Count;
ULONG Limit;
PUCHAR Number;
} KSERVICE_TABLE_DESCRIPTOR, * PKSERVICE_TABLE_DESCRIPTOR;

//SSDT表
PKSERVICE_TABLE_DESCRIPTOR KeServiceDescriptorTable;
//ShadowSSDT表
PKSERVICE_TABLE_DESCRIPTOR KeServiceDescriptorShadowTable;

//声明函数
NTSTATUS __stdcall PsLookupThreadByThreadId(
__in HANDLE ThreadId,
    __deref_out PETHREAD* Thread
    );

//要保存一下旧的OpenProcess函数
NTSTATUS (*g_OldNtOpenProcess)(
__out PHANDLE ProcessHandle,
__in ACCESS_MASK DesiredAccess,
__in POBJECT_ATTRIBUTES ObjectAttributes,
__in_opt PCLIENT_ID ClientId);

LONG g_RefCount = 0;//引用计数

//自己伪造的OpenProcess函数
NTSTATUS FakeNtOpenProcess(
__out PHANDLE ProcessHandle, //句柄
__in ACCESS_MASK DesiredAccess,
__in POBJECT_ATTRIBUTES ObjectAttributes,//路径在这里
__in_opt PCLIENT_ID ClientId //进程ID在这里写了
)
{
    InterlockedIncrement(&g_RefCount);//引用计数++,为了避免多线程同步问题,用延迟锁来++
    //拦截的话可以不返回失败,返回成功,让对方找不到bug

    //判定一下参数
    if (ClientId != NULL) {
      //当前ID不等于指定的线程ID就说明不是自己操作
      if (PsGetCurrentProcessId() != (HANDLE)1984) {
            //并且这个参数PID也等于指定的线程ID
            if (ClientId->UniqueProcess == (HANDLE)1984) {
                //这就说明是别的进程在操作了
                returnSTATUS_INVALID_BUFFER_SIZE; //给它随便返回一个错误码
            }
      }
      //这样就对指定进程做了个保护,就是只要不是自己操作,就返回错误码,是自己操作就正常调旧的

      DbgPrint("FakeNtOpenProcess UniqueProcess %d\n", ClientId->UniqueProcess);
    }
    else {
      //打印一下日志就行了
      DbgPrint("FakeNtOpenProcess %wZ\n", ObjectAttributes->ObjectName);
    }


    //做监控的话就记录一下,然后给它调旧的就行了
    NTSTATUS Status= g_OldNtOpenProcess(ProcessHandle, DesiredAccess, ObjectAttributes, ClientId);;

    InterlockedDecrement(&g_RefCount);//返回之前--
    return Status;
};


//开启写保护
void EnableWP() {
    //获取CR0寄存器.
    ULONG_PTR cr0 = __readcr0();
    //把第16位置为1
    cr0 |= 0x10000;
    //在写回去
    __writecr0(cr0);
}

//关闭写保护
void DisableWP() {
    //获取CR0寄存器.
    ULONG_PTR cr0 = __readcr0();
    //把第16位清0
    cr0 &= ~0x10000;
    //在写回去
    __writecr0(cr0);
}

/*驱动卸载函数 clean_up*/
VOID Unload(__in struct _DRIVER_OBJECT* DriverObject)
{

    //卸载的时候把他还原回去
    DisableWP();
    //再把它改掉
    KeServiceDescriptorTable->Base = (ULONG_PTR)g_OldNtOpenProcess;
    EnableWP();

    DbgPrint(" Unload! DriverObject:%p\n", DriverObject);
    while (g_RefCount != 0); //引用计数不等于0就不卸载
}


/*1.驱动入口函数*/
NTSTATUS DriverEntry(
__in struct _DRIVER_OBJECT* DriverObject,
__in PUNICODE_STRINGRegistryPath)
{
    UNREFERENCED_PARAMETER(RegistryPath);

    DbgPrint(" DriverEntry DriverObject:%p\n", DriverObject);
    DriverObject->DriverUnload = Unload;

    //PETHREAD Thread = NULL;
    ////通过线程ID获取EThread
    //NTSTATUS Status = PsLookupThreadByThreadId((HANDLE)284,&Thread);
    //DbgPrint(" Thread:%p\n", Thread);
    //if (!NT_SUCCESS(Status)) {
    //    return STATUS_SUCCESS;
    //}

    //因为没用到Shadow表,所以可以拿自己的
    PETHREAD Thread = PsGetCurrentThread();

    //成功了以后就可以开始拿表了 就是ethread + 偏移
    KeServiceDescriptorTable = *(PKSERVICE_TABLE_DESCRIPTOR*)((char*)Thread + 0xe0);
    //Shadow表的话就拿指针+1就可以了
    KeServiceDescriptorShadowTable = KeServiceDescriptorTable + 1;

    DbgPrint(" KeServiceDescriptorTable:%p\n", KeServiceDescriptorTable);
    //Shadow表直接+1就行了,因为它是结构体指针
    DbgPrint(" KeServiceDescriptorShadowTable:%p\n", KeServiceDescriptorShadowTable);
    //修改SSDT表第7A项,就是OpenProcess先保存一下,要强转成函数指针
    g_OldNtOpenProcess =(NTSTATUS(*)(__out PHANDLE ,__in ACCESS_MASK ,
    __in POBJECT_ATTRIBUTES ,__in_opt PCLIENT_ID))
    KeServiceDescriptorTable->Base;

    //因为SSDT表的内存地址是不可写的,所以要关闭写保护.然后在修改
    DisableWP();
    //再把它改掉
    KeServiceDescriptorTable->Base = (ULONG_PTR)FakeNtOpenProcess;
    EnableWP();

    //ethread成功获取了的话对象的引用计数要--
    //if (Thread) {
    //    ObfDereferenceObject(Thread);
    //}
    return STATUS_SUCCESS;
}

    //这段代码在卸载的时候还是有问题的,考虑不够.这些都是在做保护,如果病毒作者他HOOK了API的话,ARK工具就需要检测了 可以先遍历SSDT表,要知道那个API被HOOK了的话,就要判断地址了 只要判断API地址是不是在Nt范围内,如果在NtKernel范围内就没有被HOOK,因为要Hook的话肯定要把自己的函数地址写在这里,所以它的地址肯定不是在主模块. 但是它完全可以在NtKernel里找一个空的地址,然后jmp过去,这样的话ARK就检测不出来了 更深层次的检测应该是对比文件,就是重载内核.重载内核可以得到一个干净的SSDT表,然后和它做对比. 说白了检测APIHook就是扫描这个SSDT表. 检测出来就要恢复了.● ARK工具遍历SSDT表显示函数名的方法 不同版本表是不一样的,所以显示出函数名是个难点 1.最老的办法就是写死,写一个数组,什么版本用什么数组. 2.遍历Ntdll的导出表.Ntdll里肯定有个函数进内核,而且它也写了函数编号,所以就可以遍历它的导出表,找到每 个函数对应的编号,然后编号和SSDT表的下标对比,这样就知道函数名称了,但是这种方法有缺点,就是不全,比如 SSDT表里的有些函数它是非公开的,那么在Ntdll里就没有导出.这样的话拿函数名就不全了 3.解析PDB文件,根据系统版本自动从Windows服务器把PDB下载下来,只下载NtKernel和Win32k的PDB 下载下来以后就可以解析PDB里的符号信息,把RVA传递进去就能查出它的函数名了 微软提供了API可以自动下载,下载完还需要解析,微软也做好库了,在VS里有个完整的工具能解析所有符号. 路径就是Microsoft Visual Studio\2019\Community\DIA SDK\Samples 它有个示例程序DIA2Dump,直接打开就可以查看了 要运行的话需要把dll注册一下,要不然就自己加载dll● 绕过ARK工具检测是否被Hook的方法 1备份原SSDT表 2目标进程的ETHREAD.ServiceTable = 备份的SSDT表 也就是改表的地址,不改表项了. ● ZwQuerySystemInformation可以获取所有的系统信息....不管是进程,线程,模块啥都能获取到



页: [1]
查看完整版本: WindowsX86内核16.API调用流程和HookAPI