|
WindowsAPI调用流程- 不管调那个函数都可以,只要能进内核就行了,但是一般是不会调Ntdll里的,因为Ntdll里的参数要求和3环是不一样的
- user32.dll里都是UI相关的API,Kernel.dll里是核心的功能,这两个是常用的,可以选择一个来分析
分析 ReadProcessMemory- IDA打开Kernel.dll,找到ReadProcessMemory
- push ebp
- mov ebp, esp
- lea eax, [ebp+nSize]
- push eax ; NumberOfBytesRead
- push [ebp+nSize] ; NumberOfBytesToRead
- push [ebp+lpBuffer] ; Buffer
- push [ebp+lpBaseAddress] ; BaseAddress
- push [ebp+hProcess] ; 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, [ecx+4]; +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 [esp+0Ch+var_B], 2
- push 1Bh
- push dword ptr ds:0FFDF0304h
- push 0
- push ebp
- push ebx
- push esi
- push edi ;保存环境
- ;因为汇编里不能用 lea eax,fs[0],所以它就把首地址放到1C的位置,它就可以mov获取了
- mov ebx, large fs:1Ch; 这里是拿结构体首地址 +0x01c SelfPcr : Ptr32 _KPCR
- push 3Bh ;
- ;+0x120 PrcbData: _KPRCB;然后在+4,就是KPRCB里的
- ;+0x004 CurrentThread : Ptr32_KTHREAD了
- mov esi, [ebx+124h]; 获取当前线程CurrentThread
- push dword ptr [ebx]
- mov dword ptr [ebx], 0FFFFFFFFh; 然后这里取内容得到了异常链表,再把它赋值为-1
- ;+0x018 InitialStack : Ptr32 Void
- mov ebp, [esi+18h]; 当前线程结构体+18的位置,也就是拿栈
- push 1
- sub esp, 48h
- sub ebp, 29Ch
- ;+0x140 PreviousMode : Char
- mov byte ptr [esi+140h], 1; 这是获取之前的模式,记一下是从3环调的还是从0环调的
- cmp ebp, esp
- jnz loc_4076C8
- and dword ptr [ebp+2Ch], 0
- test byte ptr [esi+2Ch], 0FFh
- mov [esi+134h], ebp
- jnz Dr_FastCallDrSave
- loc_40776A:
- mov ebx, [ebp+60h]
- mov edi, [ebp+68h]
- mov [ebp+0Ch], edx
- mov dword ptr [ebp+8], 0BADB0D00h
- mov [ebp+0], ebx
- mov [ebp+4], 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+0E0h];做了一个加法 esi此时是ethread+e0 就是ServiceTable
- ;如果调的是UI的API它就拿服务表的首地址再+16,否则就+0,+8的位置刚好是数量
- mov ebx, eax
- and eax, 0FFFh ;与掉低12位,说明API编号只有12位有效,高位都丢弃了
- cmp eax, [edi+8];然后和edi+8比较,说明edi+8放的是函数的数量
- jnb _KiBBTUnexpectedRange
- cmp ecx, 10h
- jnz short loc_4077C0
- mov ecx, ds:0FFDFF018h
- xor ebx, ebx;这里是检查
- loc_4077AE:
- or ebx, [ecx+0F70h]
- 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, [edi+0Ch];从第12的位置取出参数
- xor ecx, ecx ;ecx清0
- mov cl, [eax+ebx];然后直接拿出来用了
- mov edi, [edi];edi是函数表
- mov ebx, [edi+eax*4];查出函数地址
- 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, [ebp+3Ch]
- mov [ecx+134h], 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
- 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[NUMBER_SERVICE_TABLES]; DECLSPEC_CACHEALIGN KSERVICE_TABLE_DESCRIPTOR KeServiceDescriptorTableShadow[NUMBER_SERVICE_TABLES]; 因为它是两个全局变量,所以它正好就是隔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, [esi+0E0h]这行代码,那么就可以扫特征码了 这个函数地址是很好拿的,可以通过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("[WANG] Unload! DriverObject:%p\n", DriverObject);
- }
- /*1.驱动入口函数*/
- NTSTATUS DriverEntry(
- __in struct _DRIVER_OBJECT* DriverObject,
- __in PUNICODE_STRING RegistryPath)
- {
- UNREFERENCED_PARAMETER(RegistryPath);
- DbgPrint("[WANG] DriverEntry DriverObject:%p\n", DriverObject);
- DriverObject->DriverUnload = Unload;
- PETHREAD Thread = NULL;
- //通过线程ID获取EThread;284是桌面的一个线程ID
- NTSTATUS Status = PsLookupThreadByThreadId((HANDLE)284,&Thread);
- DbgPrint("[WANG] 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("[WANG] KeServiceDescriptorTable:%p\n", KeServiceDescriptorTable);
- //Shadow表直接+1就行了,因为它是结构体指针
- DbgPrint("[WANG] KeServiceDescriptorShadowTable:%p\n", KeServiceDescriptorShadowTable);
- //ethread成功获取了的话对象的引用计数要--
- if (Thread) {
- ObfDereferenceObject(Thread);
- }
- return STATUS_SUCCESS;
- }
复制代码
可以选择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) {
- //这就说明是别的进程在操作了
- return STATUS_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[0x7a] = (ULONG_PTR)g_OldNtOpenProcess;
- EnableWP();
- DbgPrint("[WANG] Unload! DriverObject:%p\n", DriverObject);
- while (g_RefCount != 0); //引用计数不等于0就不卸载
- }
- /*1.驱动入口函数*/
- NTSTATUS DriverEntry(
- __in struct _DRIVER_OBJECT* DriverObject,
- __in PUNICODE_STRING RegistryPath)
- {
- UNREFERENCED_PARAMETER(RegistryPath);
- DbgPrint("[WANG] DriverEntry DriverObject:%p\n", DriverObject);
- DriverObject->DriverUnload = Unload;
- //PETHREAD Thread = NULL;
- ////通过线程ID获取EThread
- //NTSTATUS Status = PsLookupThreadByThreadId((HANDLE)284,&Thread);
- //DbgPrint("[WANG] 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("[WANG] KeServiceDescriptorTable:%p\n", KeServiceDescriptorTable);
- //Shadow表直接+1就行了,因为它是结构体指针
- DbgPrint("[WANG] KeServiceDescriptorShadowTable:%p\n", KeServiceDescriptorShadowTable);
- //修改SSDT表第7A项,就是OpenProcess先保存一下,要强转成函数指针
- g_OldNtOpenProcess = (NTSTATUS(*)(__out PHANDLE ,__in ACCESS_MASK ,
- __in POBJECT_ATTRIBUTES ,__in_opt PCLIENT_ID))
- KeServiceDescriptorTable->Base[0x7a];
- //因为SSDT表的内存地址是不可写的,所以要关闭写保护.然后在修改
- DisableWP();
- //再把它改掉
- KeServiceDescriptorTable->Base[0x7a] = (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可以获取所有的系统信息....不管是进程,线程,模块啥都能获取到
|
|