Windows驱动编程之键盘过滤杂谈

2019-10-23 约 772 字 预计阅读 4 分钟

声明:本文 【Windows驱动编程之键盘过滤杂谈】 由作者 一半人生 于 2019-10-23 09:20:39 首发 先知社区 曾经 浏览数 39 次

感谢 一半人生 的辛苦付出!

键盘接口

  还记着以前的老式电脑,键盘鼠标音响全是拆卸,主机后面全是各种拔插的设备孔,当时的键盘鼠标通过 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检测逆向模块,但是就完全脱离了内容,所以后续有机会在分享讨论。

关键词:[‘安全技术’, ‘二进制安全’]


author

旭达网络

旭达网络技术博客,曾记录各种技术问题,一贴搞定.
本文采用知识共享署名 4.0 国际许可协议进行许可。

We notice you're using an adblocker. If you like our webite please keep us running by whitelisting this site in your ad blocker. We’re serving quality, related ads only. Thank you!

I've whitelisted your website.

Not now