还记着以前的老式电脑,键盘鼠标音响全是拆卸,主机后面全是各种拔插的设备孔,当时的键盘鼠标通过 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...数据,一直卡死等待,这时候你就要考虑是不是驱动绑定及解除出现了一些问题。
过滤串口时候,我们只用的设备名来作为绑定,返回的设备栈的顶层指针,那么如何找到所有的键盘设备呢?
void IoSetCompletionRoutine( PIRP Irp, PIO_COMPLETION_ROUTINE CompletionRoutine, __drv_aliasesMem PVOID Context, BOOLEAN InvokeOnSuccess, BOOLEAN InvokeOnError, BOOLEAN InvokeOnCancel );
PDRIVER_DISPATCH *OldReadAddress = NULL;
OldReadAddress = KbdDriverObj->MajorFunction[IRP_MJ_READ];
KbdDriverObj->MajorFunction[IRP_MJ_READ] = MyHook();
kbdDriver->MajorFunction[IRP_MJ_READ] = OldReadAddress;
##### 类驱动下端口指针HOOK:端口驱动是根硬件打交道,一般都在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,而介绍另一种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检测逆向模块,但是就完全脱离了内容,所以后续有机会在分享讨论。