在windows10上 hook KeyboardInterruptService 的操作跟书上说的还有些区别,我大概记录了一下我的代码。
https://blog.wonderkun.cc/2019/04/02/Hook%20KeyboardClassServiceCallback%20实现内核态按键记录和模拟/
键盘接口
还记着以前的老式电脑,键盘鼠标音响全是拆卸,主机后面全是各种拔插的设备孔,当时的键盘鼠标通过 PS/2接口进行设备连接,就是圆头插孔,绿色是鼠标紫色是键盘。
Personal 2系列是IBM在80年代推出的,而且兼容性非常好,是可以做到无冲突,意思就是说同时按下两个键,会被精准识别。而USB来说只能说是逻辑无冲突,最多6个键同时按下无冲突,因为早期USB传输中继最大8bit,2bit用来记录状态,6bit用来记录键盘按下或弹起的扫描状码,USB6键就是这个说法。但是对于接收来说,不会同时传递两个数据的,后面在原理层面会讲解,USB便捷支持热拔插,USB传输效率会高,价格也不贵,还可以扩展USB HUB,PS/2算是完败。
而现在随着发展无线键盘鼠标更是非常普及,利用蓝牙连接,有些特殊的还用P2P来做为连接(长线连接)。
系统处理键盘过程:
下述是一段汇编代码,因为涉及两次硬中断与轮询,下述只是个伪汇编,为了介绍一些内容而已,内联汇编如下所示:
static byte scandata;
// 读数据
__asm
{
push eax
// 读出来数据
IN al, 0x64h
and al, 00000010b // 0x2
cmp al, 0 // 判断读取是否为真
// 我这里就不写失败jne or jnz,假设成功
mov scandata, al
pop eax
}
if(!(scandata & 2))
printf("%x", scandata);
// 假设写入到端口64h,其实这是不对的,DOS下直接就JJ
__asm
{
push eax
mov al, scandata
OUT 0x64, al
pop eax
}
键盘控制器KBC,Intel 8042这个东西负责读取键盘扫描缓冲区数据,ECE1007负责连接键盘和EC,将键盘动作转换成扫描码。所以说两个IO端口进行通信的,分别是0x60与0x64,引用一段上古转载,作者留下一首诗词不知家乡是何方....
#define I8042_COMMAND_REG 0x64
#define I8042_STATUS_REG 0x64
#define I8042_DATA_REG 0x60
通过8042芯片发布命令是通过64h,然后通过60h读取命令的返回结果。或者通过60h端口写入命令所需要的数据。可以看到2个数据分成了三个宏。
其中的64h就是分为读与写状态的。也就是说,当需要读取status寄存器的时候,就要从0x64读,也就是I8042_STATUS_REG.写入command寄存器的时候,要使用I8042_COMMAND_REG。这样做是为了清楚不同情况下自己的动作,归根结底,两个都是0x64,只是状态的区别。
而向8048发布命令,则需要通过60h.读取来自于Keyboard的数据(通过60h)。这些数据包括Scan Code(由按键和释放键引起的),对8048发送的命令的确认字节(ACK)及回复数据。Command分为发送给8042芯片的命令和发送给8048的命令。它们是不相同的,并且使用的端口也是不相同的(分别为64h和60h)。
有兴趣的可以写一个真正的键盘端口读写过滤,我记着王爽老师汇编在最后几章代码描述键盘DOS下描述全面,同样寒江独了一书中也进行了直接端口读写章节介绍,都有源码。
当按下键盘是会发送一个硬件外部中断,比如键盘中断、打印机中断、定时器中断等,然后内部会通过中断码去找对应的中断处理服务,如键盘管理中断服务等,如触发0x93。
PS/2键盘端口是60h,IN AL, 60h从端口输入,端口获取的数据最高位进行逻辑与比较,当我们按下键盘触发中断,CPU会读取0x60的扫描码,0x60有一个字节,扫描码保存可以是两个字节,键盘弹起的时候会有一个断码,断码 = 通码 + 0x80,这里深层原理不在深究。
ps/2键盘扫描码表:
寒江独钓书中是这样表述的:PDO字面意思就是说物理设备,然后是设备栈最下面的设备对象,csrss.exe进行中RawInputThread线程通过GUIDClass来获取键盘设备栈中的PDO符号链接,也就是最底层的设备对象。
RawInputThread执行函数OpenDevice,通过结构体OBJECT_ATTRIBUTES找到设备栈的PDO符号链接,这个对象我们在windbg看一下,写过ObjectHOOK的对这些理解结构体理解应该很简单。
kd> dt _OBJECT_ATTRIBUTES
nt!_OBJECT_ATTRIBUTES
+0x000 Length : Uint4B
+0x004 RootDirectory : Ptr32 Void
+0x008 ObjectName : Ptr32 _UNICODE_STRING 对象名称
+0x00c Attributes : Uint4B
+0x010 SecurityDescriptor : Ptr32 Void
+0x014 SecurityQualityOfService : Ptr32 Void
然后调用ZwCreateFile打开设备,返回句柄操作。ZwCreateFile调用NtCreateFile --> IoParseDevice --> IoGetAttachedDevice,然后就是得到了最顶端的设备对象,继续通过对象结构30 offset StackSize初始化irp。
ObCreateObject创建文件对象,offset 4 有一个DEVICE_OBJECT对象,这是一个比较有意思数据结构,可以通过_DRIVER_OBJECT对象找到一个驱动所全部的DEVICE_OBJECT,通过这个数据结构可以遍属于该驱动的全部的设备对象,赋值为键盘栈的PDO。调用IopfCallDriver将IRP发送驱动,对应的驱动处理,返回到ObOpenObjecyByName中继续执行,调用nt!ObpCreateHandle在进程csrss.exe的句柄表创建一个句柄,这个句柄就是对象DeviceObject指向的键盘设备栈PDO。
上述讲述的就是API层面或说windows如何通过进程来处理键盘响应的,其实你要做的与上述系统的处理试大差不差,也需要调用这些API来做。
kd> dt _DEVICE_OBJECT
nt!_DEVICE_OBJECT
+0x000 Type : Int2B
+0x002 Size : Uint2B
+0x004 ReferenceCount : Int4B
+0x008 DriverObject : Ptr32 _DRIVER_OBJECT 驱动指针
+0x00c NextDevice : Ptr32 _DEVICE_OBJECT 指向下一个设备对象
+0x010 AttachedDevice : Ptr32 _DEVICE_OBJECT
+0x014 CurrentIrp : Ptr32 _IRP
+0x018 Timer : Ptr32 _IO_TIMER
+0x01c Flags : Uint4B
+0x020 Characteristics : Uint4B
+0x024 Vpb : Ptr32 _VPB
+0x028 DeviceExtension : Ptr32 Void
+0x02c DeviceType : Uint4B
+0x030 StackSize : Char
+0x034 Queue : <unnamed-tag>
+0x05c AlignmentRequirement : Uint4B
+0x060 DeviceQueue : _KDEVICE_QUEUE
+0x074 Dpc : _KDPC
+0x094 ActiveThreadCount : Uint4B
+0x098 SecurityDescriptor : Ptr32 Void
+0x09c DeviceLock : _KEVENT
+0x0ac SectorSize : Uint2B
+0x0ae Spare1 : Uint2B
+0x0b0 DeviceObjectExtension : Ptr32 _DEVOBJ_EXTENSION
+0x0b4 Reserved : Ptr32 Void
然后就是按下键盘,通过一系列的中断就是我们上述说的那个,最后从端口读取扫描码在经过一些列处理数据给IRP,结束IRP。RawInputThread线程读操作后,会得到数据处理然后分下给合适的进程。一旦完成后会立刻调用ZwReadFile向驱动要求读入数据,等待键盘被按下,总结留给有心人吧......
设备栈情况:
最顶层:Kbdclass
中间层:i8042ptr
最底层:ACPI
在双机调试关机时候调试信息输出: Wait PDO address = xxxxx...数据,一直卡死等待,这时候你就要考虑是不是驱动绑定及解除出现了一些问题。
键盘数据过滤:
过滤串口时候,我们只用的设备名来作为绑定,返回的设备栈的顶层指针,那么如何找到所有的键盘设备呢?
- 绑定最顶层的设备栈Kbdclass ,先获取Object:
- 然后进行遍历打开、绑定保存:
3. 这个函数功能仅仅是绑定,而并非通过绑定函数触发过滤机制,通过READ去读的,触发的是派遣函数IRP_MJ_READ。
4. 调用IoSetCompletionRoutine函数,其实就是注册了IoCompletion例程,第二个参数就是我们处理Irp的函数:void IoSetCompletionRoutine( PIRP Irp, PIO_COMPLETION_ROUTINE CompletionRoutine, __drv_aliasesMem PVOID Context, BOOLEAN InvokeOnSuccess, BOOLEAN InvokeOnError, BOOLEAN InvokeOnCancel );
而c2pReadComplete函数主要截获了Irp保存在IRP栈中的扫描码,进行了替换(过滤),从而让通码成为我们指定的数据,达到效果:
动态卸载函数也很有意思,书中做稳妥的处理方式,如下所示:
设置全局标识,标识是否有请求处理为完成,如果有请求为处理完成,一直循环处理,这个很重要。如果你卸载了过滤设备,IRP请求还在处理状状态,ZwCreate仍即读,则会蓝屏,所以这个循环就尤为重要,使其内核睡眠。
上述代码风格与书保持一致,因为去年写键盘驱动过滤发笔记,因为代码风格不同,很多人阅读代码去参考书籍理解时候带来了许多困难。
#### Windbg动态调试:
为了更清楚了解释上述原理与代码,动态调试看代码运行流程:
##### 1. 打开、绑定PDO:
我们先打开了顶层设备栈对象Kbdclass,然后DEVICE_OBJECT中获取对象,然后打开设备对象,上述DEVICE_OBJECT则是Kbdclass的设备对象,Type是3代表这是设备对象,而DeviceType是0xb代表FILE_DEVICE_KEYBOARD ,下面就是绑定及生成过滤设备,如下:
##### 2. 键盘响应:
运行驱动,敲下键盘,这时候会在派遣的回调函数READ下发函数中断:
通过设置了回调函数,也就是例程起始地址,下面就是捕获IRP栈中的数据,看到键盘MakeCode= 0x1e如下所示:
windbg g运行,结束这个函数IRP,发现立刻会在下发Read函数中断下来,这也就是说,一旦完成后会立刻调用ZwReadFile向驱动要求读入数据.
#### HOOK手段:
##### 替换分发函数指针:
键盘HOOK这种方式,有很多帖子叫FSD键盘钩子?个人认为FSD HOOK应该是指FileSystemHOOK,也就是设备\FileSystem\Ntfs,后续文章中会说到。HOOK派遣函数指针其实本质是替换,与上述那种键盘过滤都是针对派遣函数调用指针进行替换与处理,本质没有区别,下述给出关键步骤解释,伪代码如下: - 定义全局变量先保存,这里只HOOK IRP_MJ_READ
PDRIVER_DISPATCH *OldReadAddress = NULL;
- 绑定过滤设备之后,也就是调用ObReferenceObjectByName之后,进行派遣函数保存:
OldReadAddress = KbdDriverObj->MajorFunction[IRP_MJ_READ];
- 然后派遣设置成自己的MyHook()
KbdDriverObj->MajorFunction[IRP_MJ_READ] = MyHook();
- 卸载驱动时候UnDriver时候还原指针:
##### 类驱动下端口指针HOOK:kbdDriver->MajorFunction[IRP_MJ_READ] = OldReadAddress;
内核曾又分为:执行体层、微内核层、还有HAL层,打个比方EPROCESS属于执行体层,而内嵌的KPROCESS属于微内核层。那么EPROCESS信息包含句柄表、虚拟内存、异常、I/O计时等,而内嵌KPROCESS保存的线程、进程调度信息、优先级等,Windows以这种结构方式对进程线程调度管理数据做分层式管理。那么再来下面就是HAL,顾名思义硬件抽象层,内核与硬件电路之间接口层,其目是将硬件抽象化,如下所示:
端口驱动是根硬件打交道,一般都在HAL层,PS/2键盘端口驱动是i8042prt,USB是Kbdhid,键盘驱动工作就是接收中断请求、端口读写扫描码数据,数据传输给IRP完成整个过程。i8042prt叫做端口输入数据队列,USB的叫类输入数据队列。
对于i8024ptr来说缓冲区来说,按下按键产生通码MakeCode,按键弹起BreakCode断码,都会有中断调用键盘中断服务例程,调用这些端口驱动。i8042ptr会调用I8042KeyboardInterruptService读取扫描码,然后放到输入队列,当请求大于缓冲区时候,那么读的时候就会直接从i8042prt读出全部的数据,还有就是这个i8024队列中的数据会被传送到KbdClass队列中,读请求来的时候直接从KbdClass键盘类驱动数据队列读取。
谭文老师书中的这块就是对层KeyboardInterruptService做HOOK,总的来说谁HOOK越底层谁就能把谁反了,你应用层HOOK我内核层反你,微内核HOOK我HAL在做手脚,这个就看你对Win系统到底理解有多深,又能够知道多少非常底层的函数,能写出比较稳定的替换方式那你就是赢家。
这个KeyboardInterruptService地址没有公开,这里就按照书中方式动态调试的找一找这个函数地址,这里本想贴代码动态调试,复现二次没成功,代码被重构乱了,第一次没截图,书中又有源码,有兴趣的可以去调试。
反过滤手段:
基础知识铺垫:
对于win可执行来说,有很多反调试手段,如检测窗口是否有OD、x64等窗口,获取PEB的数据,利用winApi检测等,而反HOOK显示要检验,比较常见的都是更早获取数据或者更晚获取数据两种方式,HOOK更底层与地址校验。
对于键盘反过滤来说经典的就是中断HOOK,软中断有除零(0号中断)、断点(3号中断)、系统调用(2e号中断)以及异常处理等,当发生异常时候,系统就会通过中断码去找对应的中断处理例程,所以这些处理中断异常的函数组成了一个表,IDT (Interrupt Descriptor Table),而硬中断被称为IRQ,这里不做细说。那么int 0x93,根据中断码去IDT找对应的中断处理函数,我们只需要HOOK处理IDT处理int 0x93中断的函数地址即可。
先来看看IDA表,windbg下用!pcr指令,就是查看当前KPCR结构,处理器控制域信息,这里不做多扩展,我们就可以发现IDT的基址,同样r idtr也可以读取:
查看一下0x80b95400内存中的数据:
IDT表中每一项都是一个门描述符,包含了任务门、中断门、陷阱门这些,而我们键盘int 0x93HOOK就是中断例程入口,IDT记录了0~255的中断号和调用函数之间的关系。
typedef struct _IDTENTRY
{
unsigned short LowOffset;
unsigned short selector;
unsigned char retention : 5;
unsigned char zero1 : 3;
unsigned char gate_type : 1;
unsigned char zero2 : 1;
unsigned char interrupt_gate_size : 1;
unsigned char zero3 : 1;
unsigned char zero4 : 1;
unsigned char DPL : 2;
unsigned char P : 1;
unsigned short HiOffset;
} IDTENTRY, *PIDTENTRY;
如何用汇编获取IDTR呢?汇编指令sidt
IDT HOOK(过PCHunter):
本文不用修改IDT中断处理表中的例程函数来做键盘HOOK,而介绍另一种IDT HOOK的方式,我们上述提到了GDT/LDT,这两个叫做全局描述符表/局部描述符表,GDT表中每项都是一个段描述符,因为索引号只有13bit,所以GDT数组最多有8192个元素,用来权限检测等,寄存器显示的是段选择子,16bit可显,以前51cto写过相关的资料,安全相关的文章被屏蔽了......,以后有机会在重写一下这块文章。
如何运作的呢,如下图所示,通过段选择子Segment Selector的TI标志位,如果是0意味着是GDT,如果是1意味着LDT表,GDTR Registe读取表基地址:
因为段描述符又分为系统段、代码段、数据段,根据标志位,下述贴出一个标准IA-32e下的Descriptor:
有了上述知识的铺垫,来说一说键盘IDT HOOK如何实现,先明确思路,对于IDT HOOK来说,中断描述符修改符号表中索引地址就可以了,因为端口与处理中断是一一对应。而针对GDT来说我们不可以直接修改段描述符中的基地址,也就是Base Address直接修改,因为GDT会被其它的操作调用,贸然更改则会蓝屏崩溃。
产生中断或异常后:
1. CPU中断号找到 IDT 表中的中断描述符 -- 这一步存可以HOOK
2. 获取门描述符中的段选择子. -- 这一步也可以HOOK
3. 段选择子找到 GDT 表中的段描述符,然后在取出段基地址 -- 这一步可以HOOK
4. 段基地址 + 门描述符中的函数偏移拿到函数地址.
5. 调用函数
kd> r gdtr
gdtr=80b95000
kd> dq gdtr
80b95000 00000000`00000000 00cf9b00`0000ffff
80b95010 00cf9300`0000ffff 00cffb00`0000ffff
80b95020 00cff300`0000ffff 80008b1e`500020ab
80b95030 84409316`6c003748 0040f300`00000fff
80b95040 0000f200`0400ffff 00000000`00000000
80b95050 84008916`40000068 84008916`40680068
80b95060 00000000`00000000 00000000`00000000
80b95070 800092b9`500003ff 00000000`00000000
.............................................
(1)首先我们还是要获取idt[0x93],也就是键盘中断处理例程函数地址,如下所示,IDTR的寄存器48bit,其中32bit是基址,后16bit是IDT长度,我们定义下述结构体:
typedef struct _IDTR {
USHORT IDT_limit;
USHORT IDT_LOWbase;
USHORT IDT_HIGbase;
}IDTR, *PIDTR;
ULONG GetkeyIdtAddress()
{
IDTR idtr;
IDTENTRY *pIdtr;
__asm SIDT idtr;
/*
MAKELONG
idtr.IDT_LOWbase; // 与操作 IDT_LOWbase | IDT_HIGbase << 16
idtr.IDT_HIGbase; // << 16bit
minwindef.h有该宏定义
*/
pIdtr = (IDTENTRY *)MAKELONG(idtr.IDT_LOWbase, idtr.IDT_HIGbase);
// 返回0x93门描述符的地址,如上一样
return MAKELONG(pIdtr[0x93].LowOffset, pIdtr[0x93].HiOffset);
}
动态结果如下:
注意的地方,IDT 表有时候没有通过IDTR来读取,多核CPU来说可能有多个IDT表,汇编指令idtr只能读取其中一个.
(2) 计算函数偏移,获取到了IDT中键盘处理中断的函数地址,用新得减去原地址,就可以得到偏移,韦伪代码如下:
// 裸函数声明
VOID __declspec(naked) FilterFunction();
// 获取IDT某个中断函数处理地址
g_OldDescriptAddressBase = GetkeyIdtAddress(Index);
// 段基地址 + g_uOrigInterruptFunc = NewInterruptFunc
OffsetBase = NewInterruptFunc - g_OldDescriptAddressBase;
// 跳转该地址保存在全局变量
*(ULONG*)g_Jmp = (ULONG)FilterFunction;
(3) 关于CR0~CR4,这里不多过介绍,写保护开启与关闭如下所示:
// 实现:关闭写保护
NTSTATUS MemoryPageProtectOff()
{
__asm
{
pushad;
pushfd;
mov eax, cr0;
// 前提内存保护一定是开启的 WP = 1 否则..就给开启了
and eax, ~0x10000;
mov cr0, eax;
popfd;
popad;
}
}
// 实现:开启写保护
NTSTATUS MemoryPageProtectOn()
{
__asm
{
pushad;
pushfd;
mov eax, cr0;
or eax, 0x10000;
mov cr0, eax;
popfd;
popad;
}
}
(4)构造一个新得段描述符,修改门描述符中的段选择子,跳转到我们构造得段描述符中,触发我们自定义得函数,完成IDT HOOK:
构造新得有两种方式: 一个手动填充中段描述符的各类属性,第二个是直接拷贝GDT[1]段属性描述符在修改,拷贝时候最好先看IDT段选择子对应的GDT中描述符,然后根据HOOK的函数在做拷贝。
// 实现自己的函数
void __declspec(naked) MyFinter()
{
// GDT中断描述符触发成功,HOOK
KdPrint(("Process: %s\n", (char*)PsGetCurrentProcess() + 0x16c));
DbgBreakPoint();
// 调用门描述符原函数地址
__asm CALL g_OldDescriptorAddress;
}
// 构造新得gdtr[xx],然后gdtr[xx].BaseAddress = MyFunction ,IDT[0x93].selector指向新建的段描述符
NTSTATUS InstallIDT()
{
// 修改IDT[0x93].selector段选择子偏移到我们新创建的段描述符
g_OldDescriptorAddress = GetkeyIdtAddress(1);
ULONG AddrNew = ((unsigned int)MyFinter - g_OldDescriptorAddress);
DbgBreakPoint();
// 读取gdtr表基地址
char sgdtr[6] = { 0, };
PKGDTENTRY sgdtrDataArr = NULL;
// 为了转换所以用了一下PIDTR结构体,根结构IDTR无关
PIDTR TempgdtrBaseaddress = NULL;
__asm SGDT sgdtr
// sgdt dgt
ULONG gdtrBaseAddr = 0;
sgdtrDataArr = (PKGDTENTRY)sgdtr;
TempgdtrBaseaddress = (PIDTR)sgdtr;
ULONG gdtrBase = MAKELONG(TempgdtrBaseaddress->IDT_LOWbase, TempgdtrBaseaddress->IDT_HIGbase);
// 500003ff`807f2abc
DbgBreakPoint();
// 1. 关闭写保护
MemoryPageProtectOff();
// 找到GDT[21]其实任意空 8个Bit 0都可以
ULONG gdtrBase21 = (gdtrBase + sizeof(KGDTENTRY) * 0x15);
DbgBreakPoint();
// 将GDT[1]拷贝到GDT[21],创建新得段描述符
RtlCopyMemory((PVOID)gdtrBase21, (PVOID)(gdtrBase + sizeof(KGDTENTRY)), sizeof(KGDTENTRY));
DbgBreakPoint();
// 将新创建段描述符的BaseAddress修改成我们自己的函数地址
sgdtrDataArr = (PKGDTENTRY)gdtrBase21;
sgdtrDataArr->HighWord.Bytes.BaseMid = (UCHAR)(((unsigned int)AddrNew >> 16) & 0xff);
sgdtrDataArr->HighWord.Bytes.BaseHi = (UCHAR)((unsigned int)AddrNew >> 24);
sgdtrDataArr->BaseLow = (USHORT)((unsigned int)AddrNew & 0x0000FFFF);
DbgBreakPoint();
MemoryPageProtectOn();
}
虽然能过键盘钩子及IDT检测,但是没有过GDT检测,其实过GDT也很简单,当然不是本篇幅讨论的内容:
上述中构建新GDT描述符位置索引0xA8或者0x4B(第九项)都可以,保证0~3Bit为0,涉及指令的权限检测,有兴趣的可以查一下Inter手册。其实中断门、调用门、任务门曾常常用于提权,切换选择子R3获取R0的权限。本身还想加一个HOOK检测逆向模块,但是就完全脱离了内容,所以后续有机会在分享讨论。