内核模式 API 监视 - 终极技巧






4.96/5 (43投票s)
一篇关于内核模式 API 监视的文章。
摘要
在我发布了关于进程范围 API 监视的文章之后,我收到了许多鼓舞人心的消息——读者普遍接受了我的函数调用钩子模型。在本文中,我们将把我们的模型扩展到内核模式监视,并钩住我们的目标设备驱动程序进行的 API 调用。我们还将引入一种全新的内核模式驱动程序和用户模式应用程序之间的通信方式——我们将实现自己的异步过程调用迷你版本,而不是使用系统服务。这项任务并没有看起来那么复杂——事实上,它简直是出奇的简单。Windows 的平面内存模型为我们提供了许多激动人心的机会——我们唯一需要的是冒险精神(当然,还有良好的汇编语言知识)。本文中描述的所有技巧和窍门都是我自己的原创设计——你不会在任何地方找到与这些技巧或多或少相似的东西。
我考虑了那些说我应该为读者提供源代码的信息。因此,本文的源代码可供下载(请阅读文章末尾的安装说明)。
重要提示:本文基于一个大胆的假设,即您已经阅读了我之前关于进程范围 API 监视的文章。如果您还没有阅读,请现在就去阅读。这是绝对必须的——否则,您将无法理解这里的任何内容,整篇文章对您来说将显得完全 incoherent。
引言
首先,让我们看看由于我们的监视活动将在内核模式下进行而引起的复杂性。首先,Windows 是一个受保护的系统,这意味着用户模式应用程序无法访问内核地址空间。因此,上一篇文章中描述的监视 DLL 无法在内核模式下工作——为了在内核模式下开始监视,我们必须编写的不是 DLL,而是一个内核模式监视驱动程序。
我们的监视驱动程序必须与用户模式控制器应用程序通信,并且必须异步进行。进程地址空间下 2G 中的任何虚拟地址对于内核代码来说都是没有意义的——即使在同一个进程中也是如此。这意味着内核模式代码不能调用用户模式函数,因此我们的驱动程序不能像用户模式监视 DLL 那样向其控制器应用程序发送窗口消息,因为 SendMessage()
API 函数驻留在用户模式地址空间中。事实上,即使我们的驱动程序可以调用 SendMessage()
,这样做也是一个极其不明智的举动—— SendMessage()
在消息处理完成之前不会返回,而内核模式代码无法耐心等待用户模式代码完成其任务,除非我们急于使系统崩溃。因此,我们必须找到一种监视驱动程序及其控制器应用程序之间异步通信的方式。
此外,如果我们监视用户模式模块,我们可以将原始返回地址以及其他相关信息保存在线程本地存储中。然而,我们的内核模式驱动程序不能调用用户模式的 TlsGetValue()
或 TlsSetValue()
。似乎不存在可以由内核模式代码调用的与 TLS 相关的系统函数。如果您反汇编 TlsAlloc()
、TlsGetValue()
或 TlsSetValue()
,您会发现这些函数都没有调用 INT 2Eh,即管理 TLS 不涉及系统服务。一旦系统中每个用户模式线程都收到其自己的 CPU 堆栈和寄存器副本,FS 寄存器就会映射到存储线程特定数据的部分开头。这就是 kernel32.dll 实现线程本地存储的方式——所有内容都纯粹由用户模式代码实现,而我们的监视驱动程序无法调用这些代码。因此,我们必须找到其他线程安全的方式来保存原始返回地址以及所有其他相关信息。
此外,我们不应忘记我们的监视代码可能在任何 IRQL 级别运行。某些系统服务可以在任何 IRQL 级别调用,而某些则是 IRQL 依赖的。因此,我们在调用某些系统服务时必须考虑当前的 IRQL。此外,我们必须确保,如果我们的监视代码当前在高级 IRQL 级别运行,它不会接近分页池。如果我们的监视代码在高级 IRQL 级别运行时尝试访问分页内存,或者调用任何尝试这样做的函数,由于页面错误,蓝色死亡屏幕是不可避免的。
我们还应该记住内核模式代码是可中断的。如果我们的监视代码运行时发生中断,系统会将控制权转移给中断处理程序,该处理程序可能会调用我们已钩住的某个 API 函数。换句话说,系统可能会中断我们监视代码的执行,只是为了执行完全相同的监视代码。结果是,当我们的中断代码继续执行时,它可能会发现全局变量和资源处于与中断发生之前截然不同的状态。我们应该始终记住这一点,并在我们的代码运行时我们期望全局变量和资源不可变时禁用中断——否则,我们可能会遇到不愉快的意外(即,“蓝屏”,这是系统对我们在内核模式代码中犯的任何错误的标准反应)。
如您所见,为了在内核模式下工作,Prolog()
和 Epilog()
需要相当多的调整。虽然 ProxyProlog()
和 ProxyEpilog()
可以保持原样——它们唯一做的事情是保存和恢复 CPU 寄存器和标志,这在内核和用户模式下都是一样的。我们将从“理论基础”开始——首先,我们将研究如何实现自定义异步消息队列和 TLS,然后着手实际的监视任务。
异步消息队列
看看下面的代码(我希望,在阅读了我之前的文章之后,您能够识别手工制作的间接跳转指令)
BYTE chunk1[32]; chunk1[0]=0xFF;chunk1[1]=0x25;int i=(int)&chunk1[6]; memmove(&chunk1[2],&i,4);i=(int)&chunk1[0]; memmove(&chunk1[6],&i,4); CreateThread(0,0,(LPTHREAD_START_ROUTINE)&chunk1[0],0,0 ,&dw);
新创建的线程做了什么?什么都没做——在 chunk1
数组的开头,它找到了跳转到某个位置的指令,该位置的地址存储在 chunk1
数组开头上方 6 个字节处。然而,那里唯一存储的是 chunk1
数组本身的地址。因此,代码被指示跳转到其当前位置,在那里它找到了跳转到其当前位置的指令,依此类推...结果,线程进入了处理器级别的无限循环,无法移动——它在其起始地址处自旋。
如果在某个时刻,程序执行以下行(addr
是一个变量,包含指向某个函数的指针,为简单起见,该函数不带任何参数),会发生什么?
//fill the array with the machine codes chunk2[0]=0xFF;chunk2[1]=0x25;i=(int)&chunk2[6]; memmove(&chunk2[2],&i,4);i=(int)&chunk2[0]; memmove(&chunk2[6],&i,4); chunk2[10]=0x68;i=(int)&chunk2[0]; memmove(&chunk2[11],&i,4); chunk2[15]=0xFF;chunk2[16]=0x25;i=(int)&addr; memmove(&chunk2[17],&i,4); // what is going to happen??? i=(int)&chunk2[10]; memmove(&chunk1[6],&i,4);
当上述代码的最后一行执行时,线程将跳出其无限循环,并跳转到 chunk2
数组中手工编写的代码。为什么?因为线程被指示跳转到其地址存储在 chunk1
数组开头上方 6 个字节处的位置。我们把 chunk1
数组的地址写在那里,结果导致线程自旋。如果我们在 chunk1
数组开头上方 6 个字节处写入其他地址,代码流将跳转到新位置。
chunk2
数组中的代码(从第 10 字节开始,即我们的线程将跳转到的位置)指示线程将 chunk2
数组的地址推入堆栈,并跳转到目标函数。因此,在目标函数返回后,代码流将跳转到 chunk2
数组的开头,在那里它将找到跳转到某个位置的指令,该位置的地址存储在 chunk2
数组开头上方 6 个字节处。一旦那里只存储了 chunk2
数组的地址,线程就会再次开始自旋。
我们可以用 chunk3
、chunk4
、chunk5
等重复这个序列一次又一次——线程将跳出其无限循环,执行目标函数,进入无限循环,当下一段指令到来时跳出,执行目标函数,再次进入无限循环,如此循环往复。因此,我们可以通过将数据写入数组,而不是通过系统定义的 APC 来调度目标函数以供后续执行。我们可以随时这样做——我们的线程从不终止。我们唯一需要知道的是线程当前自旋的位置,或者,如果它当前正在执行目标函数,那么它在目标函数返回后将自旋的位置——我们必须将我们希望它跳转到的地址写入此位置上方 6 个字节处。实际上,将所有这些块从同一个池中分配是有意义的,并使用偏移索引作为指向此池的指针。当池完全填满时,我们可以重用它——我们所要做的就是将索引设置为零,然后重新开始。
假设线程在用户模式进程 X 中运行。可以理解,我们希望它执行的函数也必须驻留在用户模式进程 X 的地址空间中,但是实际用机器指令填充数组并使线程跳出其无限循环的代码呢?它应该驻留在哪里?它可以驻留在任何地方——在同一个模块中,在同一进程的不同模块中,在另一个用户模式进程中,甚至在内核模式驱动程序中。此外,此代码可以由不同进程中的不同线程并发执行。只要此代码对进程 X 的地址空间具有写入权限,它就可以发布一个异步消息,调度目标函数以供执行。为此,它不需要任何系统服务——它需要的是目标函数和池的地址(在进程 X 中已知),向此池写入数据的能力,以及偏移索引的当前值。这种方法是绝对通用的,可以在我们需要向应用程序发布异步消息但由于某种原因无法使用系统定义的 APC 和异步消息队列时使用。
现在,让我们看看如何在不使用系统定义 TLS 的情况下保存线程特定数据。
存储线程特定数据
任何使用 EBP
寄存器作为其局部变量基指针的函数的序言和尾声如下所示
push ebp; save ebp
mov ebp,esp
sub esp, XXX; allocate local variables
.......
do actual things...
..........
mov esp,ebp
pop ebp; restore ebp
ret
很容易看出,作为第一步,函数必须将 EBP
寄存器的值保存在堆栈上,并在返回控制之前将其恢复。这是绝对必须的——否则,函数返回后调用者将无法跟踪其局部变量。也很容易看出,函数将 EBP
的值保存在堆栈上,就在函数返回地址上方一个堆栈条目处,然后将 ESP
的当前值复制到 EBP
寄存器中。因此,EBP
寄存器的当前值始终指向存储 EBP
先前值的位置。
这与监视有什么关系?看看我们的监视代码流如何影响 EBP
的原始值,即 ProxyProlog()
开始执行时 EBP
的值(我希望您还记得上一篇文章中的“监视团队”)
ProxyProlog()
- 在调用Prolog()
之前不改变EBP
的值Prolog()
- 鉴于Prolog()
不是裸例程,编译器肯定会生成指令,将EBP
的值保存在Prolog()
的返回地址上方,并在Prolog()
返回之前将此值弹出到EBP
寄存器中。ProxyProlog()
- 在Prolog()
返回后不改变EBP
的值。实际被调用者 - 要么保存
EBP
的原始值并在返回控制之前恢复它,要么保持EBP
寄存器不变。无论哪种情况,EBP
的原始值对实际被调用者都不重要 - 只有客户端代码实际使用它。ProxyEpilog()
- 在调用Epilog()
之前不改变EBP
的值Epilog()
- 鉴于Epilog()
不是裸例程,编译器肯定会生成指令,将EBP
的值保存在Epilog()
的返回地址上方....你明白了吗?存储在
Prolog()
返回地址上方的值将始终等于存储在Epilog()
返回地址上方的值!!!此外,该值本身在程序流返回到客户端代码之前并不重要。这就是我们可以利用的优势。Prolog()
可以将EBP
的原始值,以及原始返回地址和所有其他相关信息,保存在Storage
结构中,并将指向此结构的指针写入其返回地址上方。一旦指向Storage
结构的相同指针将存储在Epilog()
的返回地址上方,并且可以从该结构中获取EBP
的原始值,Epilog()
就可以将此值写入其返回地址上方,以便在Epilog()
返回之前将其弹出到EBP
寄存器中。结果是,当程序流跳转到客户端代码时,EBP
的值将与ProxyProlog()
开始执行时完全相同。因此,我们可以将指向
Storage
结构的指针保存在EBP
寄存器指向的位置,而不是线程本地存储中——我们的监视代码无论如何都将保持线程安全。这种方法适用于内核模式和用户模式监视。毋庸置疑,在内核模式监视的情况下,Storage
结构必须从非分页池中分配——我们的监视代码可能在高 IRQL 级别运行。
有了这些理论知识,我们就可以在实践中实现它,并着手实际的内核模式监视任务。抱歉——我忘了告诉您,覆盖内核模式驱动程序导入函数的地址与修改用户模式模块的 IAT 略有不同。
内核模式监视
首先,我们来看看IAT是如何在加载时用导入函数的地址填充的。第一步,加载器必须找到模块的导入目录,即IMAGE_IMPORT_DESCRIPTOR
结构数组,从中可以获取指向导入名称表和导入地址表的指针。在从导入名称表获取导入函数的名称后,加载器可以从导出给定函数的模块的IMAGE_EXPORT_DIRECTORY
中获取其地址,并将此地址写入导入地址表,以便程序可以调用导入函数。可以理解,导入地址表在程序运行时是必需的,但是导入名称表和IMAGE_IMPORT_DESCRIPTOR
结构数组呢?在加载器用导入函数的地址填充导入地址表之后,它们还必需吗?实际上不是——它们只在加载时需要。模块加载后将它们保留在内存中有何意义?
只要它们驻留在可分页内存中,这并不是什么大问题——它们通常会被交换到磁盘,并且只有当我们想要访问它们时才会被加载到 RAM 中,也就是说,它们无论如何都不会占用 RAM 空间。由于用户模式模块可以分页到磁盘,因此在用户模块加载后,加载程序根本懒得将导入名称表和 IMAGE_IMPORT_DESCRIPTOR
结构数组从内存中丢弃——这不值得费力。因此,从目标用户模块的 IMAGE_OPTIONAL_HEADER
可用的指向 IMAGE_IMPORT_DESCRIPTOR
结构数组的指针始终有效。
然而,内核模式驱动程序,除了其明确标记为可分页的代码之外,必须持续加载在 RAM 中,与虚拟内存相比,RAM 是稀缺的。在这种情况下,将导入名称表和 IMAGE_IMPORT_DESCRIPTOR
结构数组保留在内存中简直是资源的不合理浪费。为了解决这个问题,链接器可以将导入名称表和 IMAGE_IMPORT_DESCRIPTOR
结构数组放置到驱动程序的 INIT 节中,以及 DriverEntry()
和 DriverReinitialize()
例程,即仅在驱动程序初始化期间需要的代码。驱动程序加载后,加载器会简单地从内存中丢弃其 .INIT 节,因为它不包含驱动程序初始化后可能需要的任何信息。结果是,驱动程序加载后,从目标驱动程序的 IMAGE_OPTIONAL_HEADER
可用的指向 IMAGE_IMPORT_DESCRIPTOR
结构数组的指针可能直接指向未知区域,因此不应访问——为了弄清楚这一点,我不得不在“蓝屏-重启循环”中度过两个晚上。
然而,IMAGE_IMPORT_DESCRIPTOR
结构数组包含定位导入地址表至关重要的信息。我们该怎么办?我们将目标驱动程序的文件映射到内存中,并从文件映射中获取所有必要信息。这可以通过用户模式控制器应用程序完成。请看下面的代码
typedef struct tagSYSTEM_MODULE_INFORMATION {
ULONG Reserved[2];
PVOID Base;
ULONG Size;
ULONG Flags;
USHORT Index;
USHORT Unknown;
USHORT LoadCount;
USHORT ModuleNameOffset;
CHAR ImageName[256];
} SYSTEM_MODULE_INFORMATION, *PSYSTEM_MODULE_INFORMATION;
int spy(char*path)
{
DWORD a,dw,x,num;
SYSTEM_MODULE_INFORMATION info;char buff[256];
DWORD* base=0;char* fullname; char* drivername1;char* drivername2;
//get the name of the target driver
a=strlen(path);
while(1)
{
if(path[a]=='/'||path[a]=='\\' )
break;
a--;
}
a++;
drivername1=&path[a];
//get the list of all loaded drivers
typedef DWORD (__stdcall*func)(DWORD,LPVOID,DWORD,DWORD*);
func ZwQuerySystemInformation=
(func)GetProcAddress(GetModuleHandle("ntdll.dll"),
"ZwQuerySystemInformation");
ZwQuerySystemInformation(11,&info,
sizeof(SYSTEM_MODULE_INFORMATION),&dw);
BYTE *array=new BYTE[dw];
ZwQuerySystemInformation(11,array,dw,&dw);
SYSTEM_MODULE_INFORMATION *sys=(PSYSTEM_MODULE_INFORMATION)&array[4];
num=dw/(sizeof(SYSTEM_MODULE_INFORMATION));
// check if the target driver is loaded
for(x=0;x<num;x++)
{
fullname=(char*)sys->ImageName;
a=strlen(fullname);
while(fullname[a]!='\\')
{
if(a==0)break;a--;
}
a++;
if(a==1)
drivername2=fullname;
else
drivername2=&fullname[a];
if(!stricmp(drivername1,drivername2))
{
base=(DWORD*)sys->Base;break;
}
sys++;
}
if(!base)
{
MessageBox(GetDesktopWindow(),
"This driver is not loaded",
"spy",MB_OK);return 0;
}
// map the target driver's file into memory
// and get the pointer to import directory
HANDLE filehandle=CreateFile(path,GENERIC_READ|GENERIC_WRITE,
0,0,OPEN_EXISTING,FILE_ATTRIBUTE_SYSTEM,0);
HANDLE maphandle=CreateFileMapping(filehandle,0,
PAGE_READONLY|SEC_IMAGE,0,0,"drivermap");
readbuff=(char*)MapViewOfFile(maphandle,FILE_MAP_READ,0,0,0);
IMAGE_DOS_HEADER * dosheader=(IMAGE_DOS_HEADER *)readbuff;
IMAGE_OPTIONAL_HEADER * opthdr =(IMAGE_OPTIONAL_HEADER *)
((BYTE*)dosheader+dosheader->e_lfanew+24);
IMAGE_IMPORT_DESCRIPTOR * descriptor=(IMAGE_IMPORT_DESCRIPTOR *)
((BYTE*)dosheader+
opthdr->DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress);
int totalcount=0;DWORD*ptr=(DWORD*)&buff[16];
// now we are filling the control array with offsets to IATs
// and numbers of entries in each IAT
while(descriptor->FirstThunk)
{
IMAGE_THUNK_DATA* thunk=( IMAGE_THUNK_DATA*)
((BYTE*)dosheader+descriptor->OriginalFirstThunk);
DWORD firstthunk=descriptor->FirstThunk;
x=0;
while(thunk->u1.Function)
{
char*functionname=(char*)((BYTE*)dosheader+
(unsigned)thunk->u1.AddressOfData+2);
functionbuff[totalcount]=functionname;
x++;thunk++;totalcount++;
}
memmove(ptr,&firstthunk,4);ptr++;
memmove(ptr,&x,4);ptr++;
descriptor++;
}
// create the thread and the pool
writebuff=(BYTE* )VirtualAllocEx(GetCurrentProcess(),
0,4096,MEM_RESERVE,PAGE_EXECUTE_READWRITE);
writebuff=(BYTE* )VirtualAllocEx(GetCurrentProcess(),
writebuff,4096,MEM_COMMIT,PAGE_EXECUTE_READWRITE);
writebuff[0]=0xFF;writebuff[1]=0x25;int i=(int)&writebuff[6];
memmove(&writebuff[2],&i,4);i=(int)&writebuff[0];
memmove(&writebuff[6],&i,4);
HANDLE threadhandle=CreateThread(0,0,
(LPTHREAD_START_ROUTINE)&writebuff[0],0,0 ,&dw);
structbuff[0]=0;structbuff[1]=(DWORD)&logthread;
// keep on filling the control array with relevant info
memmove(&buff[12],&totalcount,4);
memmove(&buff[8],&base,4);
i=(int)&structbuff[0];
memmove(&buff[4],&i,4);
i=(int)&writebuff[0];
memmove(&buff[0],&i,4);
// open the spying driver
device=CreateFile("\\\\.\\SPY1",GENERIC_READ|GENERIC_WRITE,
0,0,OPEN_EXISTING,FILE_ATTRIBUTE_NORMAL,0);
if((HANDLE)0xffffffff==device)
{
MessageBox(GetDesktopWindow(),
"Spying driver is not loadeded","spy",MB_OK);return 0;
}
//create the logfile
char namebuff[256];
GetModuleFileName(0,namebuff,256);
a=strlen(namebuff);
while(1)
{
if(namebuff[a]=='\\')
break;
a--;
}
a++;
strcpy(&namebuff[a], "spylogfile.txt");
logfile=CreateFile(namebuff,GENERIC_READ|GENERIC_WRITE,
0,0,CREATE_ALWAYS,FILE_ATTRIBUTE_NORMAL,0);
// send the control array to the spying driver
DeviceIoControl(device,IOCTL_START_SPYING,
buff,256,buff,256,&dw,0);
return 1;
}
作为第一步,我们找到目标驱动程序加载到内存中的地址。我们通过调用 ZwQuerySystemInformation()
本机 API 函数来完成此操作,该函数可以返回有关所有当前加载的驱动程序的信息。ZwQuerySystemInformation()
将此信息作为 SYSTEM_MODULE_INFORMATION
结构数组返回。SYSTEM_MODULE_INFORMATION
结构的 Base
字段指示给定驱动程序加载的地址,而 ImageName
字段可能包含驱动程序的名称或驱动程序文件的完整路径。如果 ImageName
字段包含路径,我们从路径中提取驱动程序的名称,并将其与作为参数接收到 spy()
的路径中提取的目标驱动程序的名称进行比较。我们一直这样做,直到找到目标驱动程序,并将其对应的 SYSTEM_MODULE_INFORMATION
结构的 Base
字段保存在局部变量中。
然后我们将目标驱动程序的 文件映射到内存中,并找到其导入目录,即 IMAGE_IMPORT_DESCRIPTOR
结构数组。每个 IMAGE_IMPORT_DESCRIPTOR
结构的 FirstThunk
字段指示对应于给定导入模块的导入地址表开头的偏移量,而 OriginalFirstThunk
字段指示 IMAGE_THUNK_DATA
结构数组开头的偏移量。通过计算 Function
字段非零值的 IMAGE_THUNK_DATA
结构的数量,我们可以找到对应于给定导入模块的 IAT 中的条目数量。对于每个导入模块,我们获取其对应导入地址表开头的偏移量,并计算此表中的条目数量。我们将这些值写入控制数组,从控制数组的第 16 个字节开始。我们还将指向导入函数名称的指针保存在全局 functionbuff
数组中。
然后我们分配一页虚拟内存,用机器码填充其前10个字节,并创建将处理我们的异步消息的线程——它将无限循环自旋直到消息到达。我们还将当前偏移索引值(当前为零)和将由监视驱动程序异步调用的日志函数的地址写入 structbuff
数组。这个函数太简单了,这里不列出——它只是将API函数的返回值和其名称在 functionbuff
数组中的位置作为参数,格式化为 ApiFunctionXXX
- returned YYY
形式的以 null 结尾的字符串,并将其写入日志文件。唯一值得一提的是,这个日志函数必须声明为 __stdcall
调用约定,以便它能从堆栈中弹出其参数。
然后,我们用剩余的相关数据填充控制数组的前 16 个字节。这包括目标驱动程序加载的地址、目标驱动程序导入的函数数量、池的地址,以及存储目标函数地址和偏移索引的数组的地址。然后我们在控制器应用程序的 .exe 文件所在的文件夹中创建日志文件。最后,我们通过使用我们用户定义的 IOCTL_START_SPYING
命令调用 DeviceIoControl()
将控制数组发送给监视驱动程序。
现在,让我们看看我们的监视驱动程序收到 IOCTL_START_SPYING
命令后在内核模式下会发生什么
typedef struct tagRelocatedFunction{
LONG address;
LONG function;
} RelocatedFunction,*PRelocatedFunction;
typedef struct tagStorage{
DWORD isfree;
DWORD retaddress;
DWORD prevEBP;
RelocatedFunction* ptr;
}Storage,*PStorage;
//global variables
char savebuff[64];KEVENT event1,event2;
long totalcount=0,base,userbuff,userstruct;
unsigned char *replacementchunks;DWORD *functionarray;
Storage storagearray[256];
BYTE retbuff[16];BOOLEAN ishooking;
DWORD * userstructptr; BYTE * userbuffptr;
NTSTATUS DrvDispatch(IN PDEVICE_OBJECT devobject,IN PIRP irp)
{
char*buff=NULL;PIO_STACK_LOCATION loc;
DWORD thunk, count,x,a;long num,addr;DWORD * ptr;
BYTE*byteptr; BYTE *array=0; RelocatedFunction * reloc;
loc=IoGetCurrentIrpStackLocation(irp);
if(loc->Parameters.DeviceIoControl.IoControlCode==IOCTL_START_SPYING)
{
buff=(char*)irp->AssociatedIrp.SystemBuffer;
//free resources that might be allocated
//by our previous call to DrvDispatch
if(totalcount)
{
MmUnmapIoSpace(userbuffptr,4096);
MmUnmapIoSpace(userstructptr,8);
ExFreePool(replacementchunks);ExFreePool(functionarray);
totalcount=0;
}
//map the addresses of user -mode writebuff and structbuff
//arrays into the kernel address space
memmove(&userbuff,&buff[0],4);
memmove(&userstruct,&buff[4],4);
userbuffptr= (BYTE *)
MmMapIoSpace(MmGetPhysicalAddress((void*)userbuff),
4096,FALSE);
userstructptr=(DWORD *)
MmMapIoSpace(MmGetPhysicalAddress((void*)userstruct),
8,FALSE);
//save the remaining relevant data
memmove(&base,&buff[8],4);
memmove(&totalcount,&buff[12],4);
memmove(&savebuff,&buff[16],64);
//allocate function replacement chunks
replacementchunks=(unsigned char*)ExAllocatePool(NonPagedPool,
totalcount*16);
//allocate the array that holds addresses of actual functions
functionarray=(DWORD*)ExAllocatePool(NonPagedPool,totalcount*4);
// overwrite IAT entries
count=0;ptr=(DWORD*)savebuff;
while(1)
{
if(count==totalcount)
break;
memmove(&thunk,ptr,4);ptr++;
memmove(&num,ptr,4);ptr++;
for(x=0;x<num;x++)
{
DWORD*IATentryaddress=(DWORD*)(base+thunk)+x;
memmove(&functionarray[count],IATentryaddress,4);
byteptr=&replacementchunks[count*16];
reloc=(RelocatedFunction *)&byteptr[6];
byteptr[0]=255;byteptr[1]=21;memmove(&byteptr[2],&reloc,4);
reloc->function=count;
a=(DWORD)&ProxyProlog;
memmove(&reloc->address,&a,4);
a=(DWORD)byteptr;memmove(IATentryaddress,&a,4);
count++;
}
}
ishooking=TRUE;
}
irp->IoStatus.Information=strlen(buff);
IoCompleteRequest(irp,IO_NO_INCREMENT);
return STATUS_SUCCESS;
}
作为第一步,我们将用户模式池的地址以及存储偏移索引当前值和目标函数地址的数组映射到内核地址空间。我们将这些指针保存在 userstructptr
和 userbuffptr
全局变量中——我们必须让 Epilog()
能够访问它们,因为它会需要它们。我们还将它们的用户模式代码已知虚拟地址保存在 userstruct
和 userbuff
全局变量中——当 Epilog()
用机器指令填充池时,它会需要它们。
然后,我们将控制数组中接收到的其余数据保存在全局变量中。我们需要这些数据来解除我们即将钩住的函数的钩子,因此我们必须确保 DrvClose()
可以访问这些数据,它将完成这项工作。然后我们分配将存储函数替换块和实际函数地址的数组——我们已经知道需要钩住的函数数量,因此也知道要分配的字节数。我们将这些指针保存在 replacementchunks
和 functionarray
全局变量中——我们必须使 Prolog()
和 DrvClose()
能够访问它们。
此时,我们可以着手实际覆盖 IAT 条目。我们甚至不需要处理与 PE 相关的结构——用户模式控制器应用程序已经为我们提供了所需的所有信息。我希望,在阅读了我之前的文章之后,您能够理解我们是如何做到的,所以我在这里不再详细介绍——唯一的区别是 RelocatedFunction
结构存储的不是导入函数的实际地址,而是该地址在 functionarray
表中可以找到的位置。
现在,让我们看看必须对 Prolog()
和 Epilog()
进行的修改。ProxyProlog()
和 ProxyEpilog()
不需要任何修改,所以我们在这里不讨论它们。
void __stdcall Prolog(DWORD * relocptr)
{
DWORD x;DWORD *ebpptr; int a=0;
RelocatedFunction * reloc=(RelocatedFunction*)relocptr[0];
DWORD *retaddessptr=relocptr+1;
Storage*storptr;
KIRQL irql=KeGetCurrentIrql( );
//find the first available Storage structure
if(irql<DISPATCH_LEVEL)
KeWaitForSingleObject (&event1,Executive,KernelMode,0,0);
_asm {
cli
lea ebx,storagearray
start: mov ecx,dword ptr[ebx]
cmp ecx,100
jne fin
add ebx,16
jmp start
fin: mov dword ptr[ebx],100
mov storptr,ebx
sti
}
if(irql<DISPATCH_LEVEL)
KeSetEvent(&event1,0,0);
//store all relevant information in the Storage structure
_asm mov ebpptr,ebp
storptr->retaddress=(*retaddessptr);
storptr->ptr=reloc;
storptr->prevEBP=ebpptr[0];
//modify the CPU stack
relocptr[0]=functionarray[reloc->function];
retaddessptr[0]=(DWORD)&retbuff;
ebpptr[0]=(DWORD)storptr;
}
作为第一步,Prolog()
在 storagearray
表中找到第一个可用的 Storage
结构,并将其 isfree
字段设置为 100,即将其标记为已占用。我们必须同步此操作,因此我们等待在 DriverEntry()
中初始化的同步事件(DriverEntry()
初始化两个同步事件,并用调用 ProxyEpilog()
的指令填充 retbuff
数组,即做的与上一篇文章中的 DllMain()
大致相同)。鉴于我们将等待直到我们的事件设置为 signaled 状态,即可能为非零间隔,我们必须确保只有在当前 IRQL 低于 DISPATCH_LEVEL
时才执行此操作。我们还必须确保此操作不会被中断,因此我们通过清除 IF
标志来禁用中断。鉴于我们必须尽快重新启用它们,定位第一个可用 Storage
结构的代码是用纯汇编写的。
然后我们将所有相关信息存储在 Storage
结构中。这包括原始返回地址、EBP
寄存器指向的值以及指向 RelocatedFunction
结构的指针。最后,我们以与上一篇文章中相同的方式修改 CPU 堆栈,并将指向 Storage
结构的指针写入 EBP
寄存器指向的位置,即 Prolog()
返回地址上方的一个堆栈条目。
现在,让我们看看 Epilog()
void __stdcall Epilog(DWORD*retvalptr)
{
DWORD *ebpptr;
DWORD*retaddessptr=retvalptr+1;DWORD retval=retvalptr[0];
Storage*storptr;RelocatedFunction * reloc;
DWORD i,a,b,pos,n; KIRQL irql;
// get the pointer to the Storage structure
_asm mov ebpptr,ebp
storptr=(Storage*)ebpptr[0];
reloc=(RelocatedFunction*)storptr->ptr;
//modify the CPU stack
retaddessptr[0]=storptr->retaddress;
ebpptr[0]=storptr->prevEBP;
// mark the Storage structure as free
storptr->isfree=0;
if (!ishooking)
return;
// now we are going to send data to
// the controller application:
irql=KeGetCurrentIrql();
if(irql<DISPATCH_LEVEL)
KeWaitForSingleObject(&event2,Executive,
KernelMode,0,0);
_asm{
cli
mov ebx,userstructptr
mov ecx,dword ptr[ebx]
mov a,ecx
add ecx,32
cmp ecx,4096
jl skip
sub ecx,4096
skip: mov pos,ecx
mov dword ptr[ebx],ecx
mov ebx,userbuffptr
add ebx,ecx
add ebx,6
mov edx,userbuff
add edx,ecx
mov dword ptr[ebx],edx
sti
}
if(irql<DISPATCH_LEVEL)
KeSetEvent(&event2,0,0);
// keep on filling the array with machine codes
// instructions to spin
userbuffptr[pos]=0xFF;userbuffptr[pos+1]=0x25;
i=userbuff+pos+6;
memmove(&userbuffptr[pos+2],&i,4);
// instructions to push arguments
userbuffptr[pos+10]=0x68;
memmove(&userbuffptr[pos+11],&reloc->function,4);
userbuffptr[pos+15]=0x68;
memmove(&userbuffptr[pos+16],&retval,4);
userbuffptr[pos+20]=0x68;i=userbuff+pos;
memmove(&userbuffptr[pos+21],&i,4);
//instruction to jump to the target function
userbuffptr[pos+25]=0xFF;
userbuffptr[pos+26]=0x25;i=userstruct+4;
memmove(&userbuffptr[pos+27],&i,4);
//finally, schedule the target function for execution
i=userbuff+pos+10;
memmove(&userbuffptr[a+6],&i,4);
}
Epilog()
从 EBP
寄存器指向的位置获取指向 Storage
结构的指针,从该结构中获取原始返回地址和 EBP
的原始值,并修改 CPU 堆栈——它将其返回地址上方的一个堆栈条目处存储 EBP
的原始值,并用原始返回地址替换 ProxyEpilog()
否则将返回控制的地址。然后将 Storage
结构的 isfree
字段设置为 0,即将其标记为可用。最后,它通知控制器应用程序给定 API 函数已返回,并向其发送此函数的返回值。其实现方式需要稍微多一点的关注。
我们通过将机器码写入池,以介绍中解释的方式,调度在控制器应用程序进程中运行的线程以异步执行目标函数。首先,我们需要获取当前偏移索引的值,以便找到我们要写入数据的块的地址,将该块的地址(在控制器应用程序中已知)写入其开头上方 6 个字节处,并更新偏移索引的值。我们必须同步此操作,并确保它不会被中断。因此,我们等待直到同步事件设置为 signaled 状态(当然,当且仅当当前 IRQL 低于 DISPATCH_LEVEL
时),然后禁用中断。由于我们必须尽快重新启用它们,因此执行上述任务的代码再次用纯汇编写成。
完成上述任务后,我们必须用手工编写的指令填充块的剩余部分,以将实际被调用者地址在 functionarray
表中的索引(可从 RelocatedFunction
结构中获取,指向该结构的指针已保存在 Storage
结构中)、实际被调用者的返回值以及当前块的地址(在控制器应用程序中已知)推入堆栈,然后跳转到日志函数。此时,我们已经不必担心中断或上下文切换——并发线程无论如何都无法覆盖我们的数据。因此,在继续执行上述任务之前,我们重新启用中断并将同步事件设置为 signaled 状态,以便其他线程不需要等待我们完成用机器码填充块。由于工作的剩余部分不再那么时间紧迫,我们可以选择用 C 而不是汇编来完成。
最后,我们通过将当前块的第 10 个字节的地址(在控制器应用程序中已知)写入前一个块的地址上方 6 个字节处,来调度驻留在控制器应用程序地址空间中的日志函数以执行。
结果是,当日志函数开始执行时,它将接收实际被调用者地址在 functionarray
表中的索引及其返回值作为参数。鉴于控制器应用程序地址空间中的 functionbuff
表存储了目标驱动程序导入的函数名称,其顺序与内核地址空间中的 functionarray
表存储其地址的顺序相同,因此前一个参数足以定位实际被调用者的名称。因此,日志函数将 null 终止字符串格式化为 ApiFunctionXXX
- returned YYY
的形式,并将其写入日志文件。
如您所见,内核模式监视并非一项极其复杂的任务,尽管它让我们担心在用户模式下监视时甚至不必了解的事情。最有趣的是,此模型也适用于用户模式监视。因此,本文提供的源代码也适用于我之前的文章。
为了运行示例应用程序,您必须将监视驱动程序复制到 C://WINNT/system32/drivers 目录。要创建按需启动服务,可以手动完成,也可以使用以下几行代码
SC_HANDLE manager=OpenSCManager(0,0,SC_MANAGER_ALL_ACCESS); CreateService(manager,"spyservice","spyservice", SERVICE_START|SERVICE_STOP,SERVICE_KERNEL_DRIVER, SERVICE_DEMAND_START,SERVICE_ERROR_NORMAL, "C://WINNT/system32/drivers/spydriver.sys",0,0,0,0,0);
在运行应用程序之前,您必须在命令提示符下手动输入 net start spyservice 命令来启动监视服务。点击 Spy 菜单中的 Start 按钮,选择您要监视的驱动程序,放松一下,然后点击 Stop 按钮,或者直接关闭程序。之后,您可以在文本编辑器中打开日志文件,并检查其内容。
警告:监视驱动程序已通过 Windows 2000 DDK 构建,并在 Windows 2000 上测试。我真的不知道如果您在任何其他 NT 平台上运行它会发生什么——这是您的任务去发现。如果它不起作用,您总可以为您的平台重新构建监视驱动程序——源代码肯定不需要任何修改。
此外,我不建议您使用此示例钩住 Ntfs.sys。我仍然需要弄清楚为什么会发生这种情况,但是如果您尝试钩住 Ntfs.sys,系统会开始变慢,很快停止响应,最后过一段时间后崩溃。在钩住所有其他驱动程序时,这不是问题。我尝试钩住键盘和鼠标类驱动程序、i8042prt、atapi、disk、CDROM、软盘、videoport 和显示器——它在任何地方都运行良好。我认为所有 Ntfs.sys 的问题都源于,一旦我们的监视代码同步了不同系统和用户进程中不同线程进行的所有调用,系统就无法应对由此产生的减速——在 Ntfs.sys 的情况下,其中一些调用是由高优先级系统线程进行的,但我们的监视代码目前没有考虑优先级。也许,在同步调用和禁用中断时,我们也应该考虑调用线程的优先级。然而,这只是一个建议——我还没有能力做出明确的结论(否则,我就会直接解决这个问题,而不是谈论它,对吧?)
如果您能给我发送电子邮件,提出您的评论和建议,我将不胜感激。
结论
总而言之,我必须说,尽管我们能够钩住用户模式模块和内核模式驱动程序进行的 API 调用,但我们对 Windows 的探索远未结束。难道钩住系统对目标驱动程序进行的调用不有趣吗?很明显,系统必须将驱动程序导出的所有函数的地址存储在某种服务表中,以便它可以调用驱动程序。因此,我们必须以某种方式找到这个表。
此外,即使我们的全进程 API 监视也远未完成——我们只能钩住老式的函数 API。监视 COM 接口难道不有趣吗?在接下来的文章中,我们将尝试实现上述功能。