24. Qt键盘记录器开发教程:Windows钩子与动态库应用

1.前言

这个键盘记录器同样是我以前的兴趣之作,研究了良久才完成的,现在拿来作为大家的一个学习Qt的项目。

同时该项目里面不仅仅含有Qt知识,还含有很多其它WIndows开发的神奇知识点,也当是给大家开开眼界了。

它的作用就是,可以记录你每天电脑敲击键盘的数量,并显示出你敲击数量最多的前十个键,成品如下:

在这里插入图片描述

是不是感觉还不错呢?

所以接下来,我就带大家一步一步将这个软件给做出来,当然建议你跟我一样,使用VS开发,因为这个解决方案里面将至少包含两个项目,用Qtcreator的话,确实不方便。

之所以说是至少,因为我本来还想给它写个服务器,弄个多人排行榜之类的东西。

也就是说,这个软件只是一个单机版的,单纯就是用来记录我们每天敲击键盘数量的软件,每天晚上看一看自己一天的成果,是不是也很有成就感呢!

2.理论知识

首先我们看这个项目的核心功能,是统计用户敲击键盘按键的次数。

为了实现这一功能,我们就需要用到Windows编程中的钩子概念。

在WIndows窗口编程,乃至MFC编程中,相信大家最记忆犹新的就是消息循环,我们的窗口应用程序启动后,是如何接收到用户的鼠标、键盘消息的呢?

这就是通过我们的Windows系统,将我们移动鼠标,点击键盘的消息收集起来,然后发送给你应用程序的。

最常见的就是,我们窗口上开了很多个应用程序,比如微信、QQ,那么我们的按键消息应该发送给哪个应用程序呢?

经验来看,一般是最前面的那一个窗口,我们称当前接收系统发送消息的窗口,为焦点窗口

一般来说,即使你开了很多程序,系统也只会将我们的按键消息发送给其中一个焦点窗口。

而所谓钩子,就是在系统发送给窗口的中途,设下了一个关卡,即系统无论发送给哪个窗口,都必须经过这个关卡。

现在你听着可能没啥感觉,举个例子,当你输入QQ账号密码时,你按下的每一个按键都将作为消息,发送给QQ,这是正常情况下。

然后当我们设下了一个钩子时,系统发送的消息就必须先经过我们这里,然后我们可以任意处理该消息,早期盗号的木马病毒,很多就是用的这个技术,你的QQ密码还没到QQ程序,在半路就被拦截下来了。

而想要做一个这样的钩子,就必须使用动态库,为什么呢?

这就涉及到了进程相关的知识,不懂的可以先看看本站的另一篇文章:进程与线程

我们平时说的内存8G,16G,32G,一般指电脑的运行内存,即电脑同时可以容纳多大的程序运行,比如我目前的电脑就是16G的:

image-20231012145015913

而我们平时在编写程序中,所说的申请内存,其实就是申请的这个内存,无论临时变量(栈中)还是全局变量,new变量(堆中),都是它,这些名称只是我们在特定情况下,给某些内存人为的赋予了不同的涵义而已。

一般32位应用程序最大为4G内存(2的32次方),而64位应用程序最大是很多T的内存(2的64次方)。

那为什么我们电脑运行这么多程序,电脑占用内存还是这么低呢?

这就涉及到了一个叫做虚拟内存的概念,可以查看本站的另一篇文章:逆向基础

为什么需要了解这些看起来枯燥且乏味的理论知识呢?就是因为我们这里要用到这个。

动态库的作用就是,当它被加载到内存中时,可以被多个exe程序,即进程,加载到自己的地址空间中,虽然一般它在内存中只有一份(只要你不改变动态库中的变量值),但它却可以同时被所有进程使用。

这就是使用动态库的优点:节约空间

由于进程之间的互不关联性,所以我们不能在我们的程序中,去统计系统发给别的应用程序的消息。

而这里通过动态库的这一特性:可以加载到所有exe程序(进程)中,我们就可以完成这样的任务!

我们将钩子写在动态库中,只要我们的exe程序启用并加载这个动态库,那么系统中其它所有的程序接收的系统信息,就都必须经由这个动态库。

因为一旦启用钩子,这个动态库就会被系统强制加载到所有接收消息的进程中。

这样我们就可以统计按键消息了!

说了这么多,可能你依旧还是云里雾里的,但没关系,我们边写代码边理解!

3.建立项目

还是创建Qt组件应用程序:

在这里插入图片描述

这里解决方案与项目直接同名即可,注意不要勾选将这两者放在同一个目录:

在这里插入图片描述

前面的选项都为默认即可,到下图的步骤时,选择QMainWindow比较方便,因为我们需要菜单等正常窗口所需要的东西,它里面就有现成的:

在这里插入图片描述

然后我还勾选了预编译头文件,因为如果你Qt用的比较多的话,就会发现它编译特别慢,每次编译运行都要等好久,而MFC就会比它快很多。

难道是MFC设计的比Qt好吗?那可不一定,最主要的原因就是MFC是自动开启了预编译的,而Qt我们一直没有使用过预编译,所以编译速度会相差很大。

所以这里我就带大家使用一下预编译,然后上图点击Finish即可。

这样我们的基本项目就建立好了,剩下的就不用管它了,后面我们再对它进行写代码之内的操作

然后我们还需要给这个解决方案再添加动态库项目,至于如何使用,我们后面会谈到:

在这里插入图片描述

选择动态链接库:

在这里插入图片描述

名字就叫KLEngine吧,即KeyLog Engine,键盘日志引擎:

在这里插入图片描述

虽然名字看起来高大上,其实就是个动态库而已,不用太过纠结,其作用我们后面马上就会谈到。

建好之后,如下图:

在这里插入图片描述

注意动态库这个项目,我将自动生成的那个dllMain文件删除了,然后分别添加了一个头文件与一个源文件,KLEngine

这么做也没啥其它原因,纯属是为了好看,因为动态库我们知道,要想给别人用,就得把头文件交给别人。

所以这里就有了这个KLEngine.h,目的是拿给其它项目使用,既然头文件都叫这个名字,源文件也跟它同名不是更好吗?所以就有了对应的源文件。

至于自动生成的那个文件,里面的代码我们这里用不着,留着也没用。

源文件中除了包含的头文件,没有任何代码:

在这里插入图片描述

这样,我们的项目就搭建好了,接着我们还需要添加一个Qt的组件,因为我们这个软件是采用的图表的形式展示数据,那些基本的Qt控件肯定是无法完成这一重任的。

经过浏览搜索,我们可以查到,Qt提供了一个额外的QChar组件,可以用来表示图表数据。

来到Qt的安装文件夹,点击下图这个程序:

image-20231012145605791

进入后,先登录,然后来到下图,选择第一个:

image-20231012145702615

然后找到我们当前安装的qt版本,从额外库中找到库Qt charts,然后进行下载安装即可:

image-20231012145843149

这样我们的前置工作就做好了。

4.动态库编写

前面建好了项目,下面我们就首先把动态库写好,方便我们的主程序使用。

首先介绍一下我们主要使用的一个函数,那就是win API函数SetWindowsHookEx

HHOOK SetWindowsHookExW(
  [in] int       idHook,
  [in] HOOKPROC  lpfn,
  [in] HINSTANCE hmod,
  [in] DWORD     dwThreadId
);

该函数的作用就是在我们的系统里面挂一个钩子,下面来看一看它的参数:

  1. 第一个参数,就是要挂的钩子类型,比如这里我们要挂的键盘钩子,还有其它的比如鼠标钩子等等,更多的请参考官方文档,这里我们要填的值是WH_KEYBOARD
  2. 第二个参数,为回调函数,即当系统拦截到消息给钩子后,用哪个函数进行处理?
  3. 第三个参数,即本DLL的模块句柄,和前面提到的窗口句柄一样,用来标识的,可以通过函数GetModuleHandle(L"KLEngine.dll")获取,它唯一的参数就是模块的名称,如果你的DLL项目名称与我不同,那么这里这个名字就得改为你自己的1
  4. 第四个参数,启动这个钩子的线程ID,这里直接填NULL即可。

所以总结来说,填好参数的函数为:

SetWindowsHookEx(WH_KEYBOARD,keyfun,GetModuleHandle(L"KLEngine.dll"), 0);

keyfun为回调函数。

而对应的回调函数也有讲究,格式如下:

LRESULT CALLBACK  keyfun(int code, WPARAM wParam, LPARAM lParam) {

	return CallNextHookEx(hookKey,code,wParam,lParam);
}

上面是一般写法,调用了一个API函数CallNextHookEx,然后参数就是这个回调函数的参数。

看这个函数的名字我们也能大概猜到,将接收到的内容再交给下一个钩子,如果不用这个函数的话,就会导致你的键盘会彻底失效,因为系统所有的键盘消息都发送到你这个函数中,然后你就不往后面传了。

这就会导致电脑里面所有的应用程序都接收不到键盘消息,从而导致键盘失效。

看到这里有没有什么想法呢?如果再将鼠标消息也拦截了,不就无法操作电脑了……

回到主题,这三个参数的含义是根据你设置的什么钩子决定的,在官方文档,你就应该点击下面这个回调函数:

在这里插入图片描述

  1. 第一个参数,如果为HC_ACTION ,就说明有按键消息。
  2. 第二个参数,虚拟键码,即前面我们提到过的 字符’A’代表的ASCII数值,就代表了我们的按下的A键,可以点击这里查看所有的虚拟键码。
  3. 第三个参数,按键消息标志,有很多,可以自己去官方查看,我们这里用到的是它的第31位(从1开始,共32位),代表先前键的状态,比如你按下A键并放开,会有有两个消息,一个是按下的,一个是弹起的。

然后我们的回调函数就可以写为:

LRESULT CALLBACK  keyfun(int code, WPARAM wParam, LPARAM lParam) {

	if (code== HC_ACTION&&(lParam&0x40000000)) {
		keyLogs.k[wParam]++;
	}
	return CallNextHookEx(hookKey,code,wParam,lParam);
}

意思就是,如果有键盘消息,而且上一次该键是按下状态,那么就将对应的键盘值加1,这是我的写的一个结构体,暂时不用管。

下面来看一看0x400000000这个值怎么来的:

0100 0000 0000 0000 0000 0000 0000 0000

上方是二进制的表示方法,根据2进制与16进制的关系,4位变一位,所以16进制最高位为0100,即4,其它都是0,所以就有了这个值,通过与运算,就能判断对应的参数位上,是否也是1

然后我们再来看一看上方提到的结构体:

struct Key
{
	unsigned k[0xFF];//存储按键次数
	unsigned Checksum;//和校验
};

这个结构体很简单,就一个数组和一个值,这里使用的unsigned ,等价于unsigned int,即无符号整型,说人话就是正数。

那个数组的作用就是存储所有键值按下的数量,为什么是0xFF个呢?因为官网说的最大值就是0xFE

在这里插入图片描述

所以前面我们就可以直接根据回调函数传入的虚拟键码值进行传参。

至于另一个值,我注释写的校验和,即防止被人逆向篡改(只是说增大其篡改的难度),至于怎么用,马上会讲到的,原理很简单,就是将所有的键值按键数量相加就行了。

然后就是很重要的,申请变量环节,因为前面我们理论已经提到过,各个进程虽然位于同一电脑内存中,但却是互不干涉的。

而动态库却可以用一份内存,加载到不同的进程中,但也仅限于加载使用,一旦你涉及更改动态库中的变量,系统为了防止影响其它进程,就会单独拷贝一份出来到内存里专门供该进程使用。

这就会导致,我们根本无法统计整合数据,因为动态库在内存中不再是一份内存,各个程序的按键值都是自己加自己的,没办法求和。

所以这里我们就得禁止系统的这一行为:

#pragma data_seg("Shared")
Key keyLogs{};
HHOOK hookKey = NULL;
#pragma data_seg()

#pragma comment(linker,"/section:Shared,rws")

详细介绍可以参考我的这一篇文章:共享内存

上方的格式基本是固定的,只需要将变量申请在两个#pragma data_seg之间即可,注意这里面的变量一定要进行初始化!

这里除了一个Key 变量,还有一个HHOOK ,即钩子句柄,用于标识我们挂的钩子。

上面基本就是动态库的所有重要内容,其实并不是很难,可能原理有点抽象,但你可以看一看我的那篇window共享内存的解释,应该会有所感悟。

首先是头文件:

在这里插入图片描述

#pragma once
struct Key
{
	unsigned k[0xFF];//存储按键次数
	unsigned Checksum;//和校验
};
extern "C" __declspec(dllexport) bool SetHook();
extern "C" __declspec(dllexport) bool UnSetHook();
extern "C" __declspec(dllexport) Key GetKeyNum();
extern "C" __declspec(dllexport) void Clear();

然后是源文件:

在这里插入图片描述

#include "pch.h"
#include"KLEngine.h"

#pragma data_seg("Shared")
Key keyLogs{};
HHOOK hookKey = NULL;
#pragma data_seg()

#pragma comment(linker,"/section:Shared,rws")


LRESULT CALLBACK  keyfun(int code, WPARAM wParam, LPARAM lParam) {

	if (code== HC_ACTION&&(lParam&0x40000000)) {
		keyLogs.k[wParam]++;
	}
	return CallNextHookEx(hookKey,code,wParam,lParam);
}


bool SetHook() {
	hookKey=SetWindowsHookEx(WH_KEYBOARD,keyfun,GetModuleHandle(L"KLEngine.dll"), 0);
	if (hookKey == NULL) {
		return false;
	}
	return true;
}

Key GetKeyNum(){
	return keyLogs;
}

bool UnSetHook() {
	if (hookKey == NULL) {
		return false;
	}
	return UnhookWindowsHookEx(hookKey);
}

void Clear() {
	memset(&keyLogs,0,sizeof(keyLogs));
}

这些函数封装了相应的操作,方便我们在程序中使用:

  1. SetHook:设置钩子
  2. UnSetHook:卸载钩子
  3. GetKeyNum:获取统计的数据
  4. Clear:清空数据

这样,我们的动态库就写好了,代码不难,难的可能是这个概念,需要自己好好理解一下。

然后我们就可以点击生成该动态库项目(这个不会还不会吧?解决方案右键该项目,点击生成即可),将头文件以及生成的动态库与静态库复制到主项目中:

在这里插入图片描述

在这里插入图片描述

注意这里是Debug版本,这个一般需要对应,即主程序是Debug版本,那么动态库也要是Debug,release同理,不要混用

5.主程序

由于本程序不似前面的程序,涉及的知识面相当广。

我尝试过从头开始讲,但发现那会相当繁琐,而且能学到这里,相信各位已经有很强的自学能力了以及一定的看源码能力了。

所以我打算采取直接将项目文件上传,你可以直接点击keylog进行下载。

接下来,我将直接对着项目中的源码,根据逻辑直接一步一步进行讲解。

5.1 准备动态库的使用

首先将动态库的头文件添加进来,注意这里将dllexport改为dllimport:

在这里插入图片描述

然后在预编译头文件中,添加动态库的使用:

在这里插入图片描述

注意这里预编译文件名字为stdafx,不是pch,这个名字其实可以自己在设置里面改,不过一般我们用默认的即可。

然后在项目属性中,添加Qt Charts库的使用:

image-20231012150714398

注意,release版本与debug版本需要分别添加,即需要操作两次,并且这个项目默认用的6.2.4版本,你可以直接在这里更改为你当前所使用的版本。

首先我们来看看如何使用这个图表库,首先在预编译头文件中添加头文件:

在这里插入图片描述

QtCharts这个头文件里面就包含了所有图表类,比如常见的扇形、柱形、折现等等。

然后我们就开始从构造函数开始讲起。

5.2 构造函数

构造函数很简单,仅仅只是调用了三个函数:

在这里插入图片描述

这三个函数都是我写的初始化函数,用于初始化三个不同方面的数据

至于第三个,那是我们前面编写的动态库里面的函数,就不多说了,这肯定是必须的。

首先是InitFileData函数,这个函数用于初始化本软件的相关文件数据:

在这里插入图片描述

首先我们尝试随便创建一个文件:

    //判断是否具有足够权限
    QFile file("t.dat");
    bool ret = file.open(QIODevice::WriteOnly);
    if (!ret) {
        QMessageBox::warning(this, "错误", "无法读写数据,请尝试以管理员权限启动!\n");
        Exit();
    }
    file.close();
    remove("t.dat");
    //判断数据目录是否存在

这是为了判断当前程序是否有对当前目录写文件的权限,如果没有,那就直接退出,调用Exit函数,这个函数也为自己写的,很简单:

void KeyLog::Exit()
{
    this->close();
    tray->hide();
    UnSetHook();
    QApplication::exit(0);
    ExitProcess(0);
}

先是关闭当前程序,然后关闭托盘tray,这个在后面的UI初始化会讲到,然后UnSetHook删除钩子,最后 QApplication::exit(0);退出程序,以及ExitProcess退出进程,格式很固定。

接着就是正式读取或写入数据文件的代码了:

    QDir dir(".\\Data\\");
    ret = dir.exists();
    if (!ret) {
        dir.mkdir(".\\Data\\");
    }
    //判断当天文件数据是否存在
    date = "Data\\" + QDate::currentDate().toString("yyyyMMdd");
    file.setFileName(date);
    ret = file.exists();
    if (ret) {
        ret = ReadFromFile();
        if (!ret) {
            bool ret = WriteToFile(true);
            if (!ret) QMessageBox::warning(this, "错误", "写入数据失败");
        }
    }
    else {
        bool ret = WriteToFile(true);