Windows驱动编程之键盘过滤杂谈
2019-10-23 10:20:39 Author: xz.aliyun.com(查看原文) 阅读量:242 收藏

键盘接口

  还记着以前的老式电脑,键盘鼠标音响全是拆卸,主机后面全是各种拔插的设备孔,当时的键盘鼠标通过 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...数据,一直卡死等待,这时候你就要考虑是不是驱动绑定及解除出现了一些问题。

键盘数据过滤:

  过滤串口时候,我们只用的设备名来作为绑定,返回的设备栈的顶层指针,那么如何找到所有的键盘设备呢?

  1. 绑定最顶层的设备栈Kbdclass ,先获取Object:
  2. 然后进行遍历打开、绑定保存:

      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派遣函数指针其实本质是替换,与上述那种键盘过滤都是针对派遣函数调用指针进行替换与处理,本质没有区别,下述给出关键步骤解释,伪代码如下:
  3. 定义全局变量先保存,这里只HOOK IRP_MJ_READ
    PDRIVER_DISPATCH *OldReadAddress = NULL;
  4. 绑定过滤设备之后,也就是调用ObReferenceObjectByName之后,进行派遣函数保存:
    OldReadAddress = KbdDriverObj->MajorFunction[IRP_MJ_READ];
  5. 然后派遣设置成自己的MyHook()
    KbdDriverObj->MajorFunction[IRP_MJ_READ] = MyHook();
  6. 卸载驱动时候UnDriver时候还原指针:
    kbdDriver->MajorFunction[IRP_MJ_READ] = OldReadAddress;
    ##### 类驱动下端口指针HOOK:
      内核曾又分为:执行体层、微内核层、还有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检测逆向模块,但是就完全脱离了内容,所以后续有机会在分享讨论。


文章来源: http://xz.aliyun.com/t/6581
如有侵权请联系:admin#unsafe.sh