“从驱动层改造键盘:一步步带你实现输入黑科技”
01
—
提出问题
假设你对汇编语言只了解皮毛,且没有写过任何 Windows 驱动程序,同时又缺乏编译工具的支持,但任务是要在没有事先准备的情况下,实时修改一个运行中的键盘驱动程序,并且改变键盘的行为。面对这样的挑战,你会如何应对呢?
在“CPU眼里python和C”中,我们介绍了WinDbg调试python应用程序的能力,这里我们就可以用它来调试、并在线修改一下Windows的键盘驱动,从而改变键盘的行为。
02
—
准备开发环境
首先准备一下开发环境,这里被调试设备,也就是目标设备,是一台Windows 7虚拟机。启动虚拟机后,运行命令:msconfig;在boot标签里面,选择:advanced options,并勾选Debug,如图所示:
注意,这里的调试端口是串口COM1,波特率为:115200。最后确认一下,我们就可以暂时把虚拟机关掉了。
随后就是把被调试的虚拟机和用于调试的主机连接起来。这里我们采用管道化的串口连接方式(也就是进程通信)。具体操作是:在虚拟机的设置中,找到串口COM1的设置,选择管道,并填写管道名称,这里我们输入:WinDbg;并拷贝下完整的管道路径,也就是:\\.\pipe\WinDbg,如图所示:
接着就是设置主机了,用administrator模式打开WinDbg;点击Kernel Debug,选择串口(COM)连接,并选中管道方式;然后在端口(port),输入刚才拷贝的管道路径(\\.\pipe\WinDbg)就好,如图所示:
一切顺利的话,点击OK按钮后,WinDbg就会开始等待:被调试设备的连接请求了。
03
—
代码调试
万事俱备,再次启动虚拟机,不出意外的话,WinDbg的状态,马上就会发生变化,此时WinDbg已经连接上了被调试的虚拟机了,如图所示:
虚拟机顺利进入Windows 7的界面后,打开设备管理器,看看我们将要修改的键盘驱动程序,点击driver和details,我们就可以找到这个驱动程序:i8042prt.sys文件,如图所示:
记住这个文件名(i8042prt.sys),我们马上就要用到了。需要注意的是,虚拟机模拟的是老旧的PS2接口的键盘,如图所示:
所以,无论你用的是蓝牙键盘还是USB键盘,虚拟机看到的都是PS2键盘。
再次打开WinDbg,点击暂停,可以让虚拟机瞬间石化,然后在命令行中,输入命令:x i8042prt!*read*
其中i8042prt就驱动程序的文件名,通过x命令,我们就可以检测出该驱动程序里面,所有包含read字样的函数接口了,如图所示:
很快,我们就发现了一个可疑的函数:I8xReadPortUchar,根据相关文档,我们知道这是用来从PS2中读取键盘数据的函数接口,如果我们能篡改读到的键值,就可以间接改变键盘的行为,这里我们将尝试,把键盘的所有输入都改成字符:a
需要说明一下的是:尽管Windows和Linux的驱动框架有很大的差异,但是数据源头都来自于硬件,我们控制了I/O信息,就相当于控制了操作系统的信息来源。
说干就干,通过命令:u i8042prt!I8xReadPortUchar,我们就可以看到函数I8xReadPortUchar在内存中的样子,也就是函数的CPU指令。WinDbg还非常贴心的把这些指令翻译成了汇编语言,如图所示:
代码出乎意料的简单,只有3条指令。根据“CPU眼里的参数传递”,可以知道:第一条指令是把函数的参数从寄存器cx(rcx的低8位)里面,读取到寄存器edx(rdx的低32位)里面。
这个参数(cx)的意义是PS2接口的端口号,根据文档,如图所示:
PS2有两个I/O端口。一个是0x64端口,用来读取PS2设备的状态;一个是0x60端口,用来读取PS2设备的数据,也包含PS2键盘的键值。
需要注意的是:不同于ARM的I/O统一编址,x86 CPU需要通过专门I/O指令来读取I/O数据。也就是第二条指令:in
它就是从PS2的I/O端口中,把键值或状态数据读入寄存器al(rax的低8位)里面。当然,如“CPU眼里的函数返回值”所说,寄存器al,也担负着存放返回值的职责。
最后一条ret指令,作函数返回。所以,这个函数的功能十分简单,就是读取0x64端口或0x60端口上的数值,对应的C语言,大概是这个样子:
int I8xReadPortUchar(int* port)
{
return *port;//in al, dx
}
至于0x64端口上的设备状态信息,我们并不关心,这里我们只关注从0x60端口,获得的键值,我们需要把从x60端口读出的键值,改成字符a的键值:0x1E。逻辑非常简单,让我们马上动手,在线修改驱动程序吧。
输入这个命令:a fffff880`03d0323c
这意味着我们将从这个ret指令所在的内存地址(fffff880`03d0323c)处输入汇编指令,该操作会覆盖ret及后面的CPU指令,WinDbg则会自动帮我们把汇编指令转成机器码(CPU指令)。如图所示:
这里,先判断寄存器dx是否是0x60端口,如果是的话,就跳转到后面的代码,去修改键值;修改键值的代码,距离函数首地址的偏移量是11(0xB)个字节,如果写错了这个偏移量也没有关系,我们还可以回过头来修改。
相反,如果不是0x60端口的话,我们就遵从老代码的处理方式:直接ret;随后,就是修改键值的部分了。
通过mov指令,把用来存储函数返回值的寄存器al(寄存器rax的低8位)设置为按键a的键值,也就是0x1E;最后通过ret指令,使函数正常返回。
这样随着函数的逐层返回,操作系统就会误以为读到的键值是:a。代码写完,记得再按一下回车键,结束汇编指令的输入。
最后,还可以通过命令:u i8042prt!I8xReadPortUchar,检查一下刚刚我们写的代码,如图所示:
其对应的C语言代码,大概是这个样子:
int I8xReadPortUchar(int* port)
{
int value = *port;//in al, dx
if(port == 0x60)
{
value = 0x1e;
}
return value;
}
好了,现在可以验证我们的工作成果了,输入命令go,让虚拟机继续运行。用鼠标在虚拟机中打开一个新建的空文本文件,让我们在键盘上输入:1,2,3
如果一切顺利的话,Windows果然认为我们输入的都是:a;由于我们的代码中没有区分键盘的按下,弹起,所以1次按键,会产生2个a;这时,即使我们随意按任何按键,按键的键值都被驱动程序改写成了a的键值。所以,文本文件中只有字符:a。如图所示:
最后,请不要担心你的键盘就会从此失灵了,因为我们只是修改了内存中的键盘驱动程序,并没有修改驱动文件:i8042prt.sys,所以,只要我们重新启动虚拟机,Windows再次把原有的驱动程序从文件i8042prt.sys加载到内存中后,你的键盘就可以重新恢复正常了。
04
—
总结
1. 驱动程序和应用程序都是程序,都需要使用系统资源,如内存、CPU、存储空间等。许多情况下,驱动程序也是用C/C++语言编写的,它们的运行原理和实现细节与普通应用程序非常相似,例如都需要函数堆栈的支持。
除了少数特殊的CPU指令外(例如本文中用来读取PS2端口的指令:in),许多CPU指令是通用的,既可以用于应用程序,也可以用于驱动程序。
2. 驱动程序运行在内核态,拥有对系统和硬件的完整、全面的控制和访问权限。不同于应用程序有操作系统作兜底的错误处理,一个应用程序的错误通常不会影响其他程序或整个系统。然而,驱动程序往往缺乏完善的错误处理机制,其错误可能导致系统蓝屏或死机。
3. 应用程序和驱动程序的分工不同。应用程序通常为用户提供直接的功能(如文档编辑、浏览网页),而驱动程序通常不与用户直接交互,而是为硬件设备提供必要的支持,以便这些设备能够被应用程序顺利使用。
驱动程序还在硬件与操作系统之间充当中介,帮助操作系统识别和使用计算机硬件,如显卡驱动、打印机驱动和网络适配器驱动等。没有适当的驱动程序,操作系统可能无法正常通信和使用硬件设备,从而导致应用程序无法正常使用这些设备。
总而言之,应用程序主要为用户提供操作和功能,而驱动程序为硬件提供操作系统访问的接口。应用程序通常是面向用户的,而驱动程序是面向系统和硬件的。尽管两者都是计算机软件,但由于功能和运行环境的不同,它们的开发和管理方式也有所差异。
05
—
更多知识
如果喜欢阿布这种解读方式,希望更加系统学习这些编程知识的话,也可以考虑看看由阿布亲自编写,并由多位微软大佬联袂推荐的新书《CPU眼里的C/C++》