尝试做一做模拟类外挂检测

鼠标-键盘模拟监控

鼠标-键盘模拟外挂相较于直接读/写内存的外挂相比,更加安全和可靠,因为它本质还是模拟人去操作的,只要频率设置不过分,那么不管是客户端检测还是服务端检测都是难以察觉的,因此这几天在思考一个可以检测这类外挂的方案。

ETW

Windows (ETW) 的事件跟踪提供一种机制来跟踪和记录由用户模式应用程序和内核模式驱动程序引发的事件。 ETW 在 Windows 操作系统中实现,为开发人员提供了一组快速、可靠且通用的事件跟踪功能[1]。

经过深入的研究,发现 ETW 可以监控很多东西,上到进程创建,下到 UDP/TCP 数据包监控,无所不能。

想法构思

那么既然 ETW 给了一种可以在 r3 层监控大量信息的机制,那么是否可以做到监控真实的键盘按键呢?如果能做到,配合 Windows 的消息监控便可以达到检测键盘-鼠标模拟的功能,因为鼠标模拟本质是直接通过 Windows API 发送消息,而不会经过键盘,所以如果检测到了按键消息而没有检测到真实按键的话,就可以判断为使用了鼠标-键盘模拟。

如果去搜索 etw 键盘监控,大概率是只能搜到这个 8 年前的老项目 https://github.com/CyberPoint/Ruxcon2016ETW,这个项目是 C# 写的,虽然可以运行,但是监控是监控不了一点的。

但是很幸运的是,有人发布了最新的键盘模拟检测 https://github.com/Oliver-1-1/EtwKeyboardDetection,然而我自己测试下来是没有效果的,但是它的框架写的还是非常不错的,可以借用参考一下,它获取了 Microsoft-Windows-USB-UCX 这个 provider 的全部事件。

这里参考一个项目的框架,去打印 event 的信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void
PrintEventInfo(PTRACE_EVENT_INFO pInfo)
{
if (DecodingSourceWbem == pInfo->DecodingSource)
printf("EventInfo: MOF class event\n");
else if (DecodingSourceXMLFile == pInfo->DecodingSource)
printf("EventInfo: XML manifest event\n");
else if (DecodingSourceWPP == pInfo->DecodingSource)
printf("EventInfo: WPP event\n");
/*此处略去大量代码,具体信息可以查看项目代码*/
if (pInfo->RelatedActivityIDNameOffset > 0)
wprintf(L"Related activity ID name: %s\n",
(LPWSTR)((PBYTE)(pInfo) + pInfo->RelatedActivityIDNameOffset));
}

它会刷新大量的 26 号和 27 号的事件

官方也提供了抓取 usb 事件的方式[2]。

1
2
3
4
5
6
7
8
logman create trace -n usbtrace -o %SystemRoot%\Tracing\usbtrace.etl -nb 128 640 -bs 128
logman update trace -n usbtrace -p Microsoft-Windows-USB-USBXHCI (Default,PartialDataBusTrace)
logman update trace -n usbtrace -p Microsoft-Windows-USB-UCX (Default,PartialDataBusTrace)
logman update trace -n usbtrace -p Microsoft-Windows-USB-USBHUB3 (Default,PartialDataBusTrace)
logman update trace -n usbtrace -p Microsoft-Windows-USB-USBPORT
logman update trace -n usbtrace -p Microsoft-Windows-USB-USBHUB
logman update trace -n usbtrace -p Microsoft-Windows-Kernel-IoTrace 0 2
logman start -n usbtrace

停止抓取

1
2
3
logman stop -n usbtrace
logman delete -n usbtrace
move /Y %SystemRoot%\Tracing\usbtrace_000001.etl %SystemRoot%\Tracing\usbtrace.etl

最后会生成一个 usbtrace.etl 文件。

时间轴也可以见到一直是出现 26 和 27 号的事件,所以主要分析的就是这两个事件。

这里我又参考了另一项目,是 C# 写的,但是略微可以看出一二。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
private static UsbData GetData(TraceEvent eventData)
{
ulong hndl;
object field;
uint vid=0,pid=0;
byte usbver = 0;

//try to determine device handle and IDs
field = GetItem(eventData, "fid_USBPORT_Device");
if (field != null)
{
Dictionary<string, string> deviceInfo = _expose(field);

if (!ulong.TryParse(deviceInfo["DeviceHandle"], out hndl) && hndl <= 0)
return null;

vid = UInt32.Parse(deviceInfo["idVendor"]);
pid = UInt32.Parse(deviceInfo["idProduct"]);
}
else
{
hndl = (ulong)GetItem(eventData, "fid_PipeHandle");
if (hndl <= 0) return null;
}

//try to get event parameters
field = GetItem(eventData, "fid_USBPORT_URB_BULK_OR_INTERRUPT_TRANSFER"); //2.0
usbver = 2;
if (field == null)
{
field = GetItem(eventData, "fid_UCX_URB_BULK_OR_INTERRUPT_TRANSFER"); //3.0
usbver = 3;
}
Dictionary<string, string> urb = _expose(field);//transform parameter string to dictionary

//determine transferred data length
int xferDataSize = 0;
if (!int.TryParse(urb["fid_URB_TransferBufferLength"], out xferDataSize))
return null;
if ((xferDataSize > 8) && (usbver == 2)) xferDataSize = 8; //USB 2.0 sometimes gives wrong size

if (xferDataSize > 8) return null; //data is too large for mouse / keyboard

byte[] data2=eventData.EventData();
byte[] xferData = new byte[xferDataSize];
Array.Copy(data2, eventData.EventDataLength - xferDataSize, xferData, 0, xferDataSize);

bool HasNonZero = false;
for (int i = 0; i < xferDataSize; i++)
if (xferData[i] != 0) { HasNonZero = true; break; }
if (HasNonZero == false) return null; //data is empty

/* Construct UsbData object*/
UsbData data = new UsbData(eventData.TimeStamp, hndl, xferData);
data.usbver = usbver;
data.datalen = (uint)xferDataSize;
data.vid = vid;
data.pid = pid;
return data;
}

刚好和我主要参考的框架的事件处理中有相似之处。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
VOID
EtwCallback(
__in PEVENT_RECORD Event
)
{
DWORD size;
PTRACE_EVENT_INFO trace;
PWCHAR provider;
EVENT_PROPERTY_INFO property;
EVENT_PROPERTY_INFO iProperty;

trace = GetEventData(Event, &size);

provider = (PCHAR)trace + trace->ProviderNameOffset;

for (UINT countIndex = 0;
countIndex < trace->TopLevelPropertyCount;
countIndex = countIndex + 1)
{
EVENT_PROPERTY_INFO property = trace->EventPropertyInfoArray[countIndex];

if (!wcscmp(L"fid_UCX_URB_BULK_OR_INTERRUPT_TRANSFER", (PCHAR)trace + property.NameOffset))
{

for (INT propertyIndex = property.structType.StructStartIndex;
propertyIndex < property.structType.StructStartIndex + property.structType.NumOfStructMembers;
propertyIndex = propertyIndex + 1)
{
iProperty = trace->EventPropertyInfoArray[propertyIndex];

if (!wcscmp(L"fid_URB_TransferBufferLength", (PCHAR)trace + iProperty.NameOffset))
{

//
//Get value of fid_URB_TransferBufferLength
//

LPCWSTR string = GetPropertyData(
trace,
Event,
iProperty,
20); // index for fid_URB_TransferBufferLength

if (string == NULL)
{
continue;
}

//
// Filter out for only keyboard packets by size
//

if (!wcscmp(string, L"0xC"))
{
etwCount = etwCount + 1;
}

free(string);
}
}
}
}

free(trace);
}

前者,会判断 fid_URB_TransferBufferLength 这个属性的属性值,进行一些判断,根据注释也可以看明白,在当时的年代,可能鼠标事件的这个长度是 4 或者 5,键盘事件的长度是 8,超出则该事件不是键盘或者鼠标的点击或移动事件。

后者同样是取出值判断是否为 0xC,来检查是不是键盘事件,若是则让 etwCount+1。这里需要说明的是,这个项目的原理和我的构思是差不多的,它hook了windows消息,每次获得 F1 键的消息时会判断这次点击是不是 F1,然后根据 etwCount 和win32Count 的值做比对,看看是不是四倍的关系,若是则没有使用键盘模拟。

实际测试的时候,键盘按下时消息长度的对应属性值应该是 0x25,鼠标移动和点击事件的长度对应的属性值为 0x9。今非昔比了,改了也可以理解。

而按下按键的时候有概率触发 8 个事件,也有可能触发 6 个事件,也有可能是4个事件,这个好像还真是看脸了。但是通过一个驱动模拟按键则是不会触发任何 etw 的事件。鼠标点击来说,每次触发 4 个事件是没有问题的。

检测键盘的思路就是,每次收到键盘的etw事件,让etw计数器 +1,同时 hook windows 消息,每次接收到非F1的按键也让按键计数器 +1,最后只要 etwCount < 6*Win32Count 则可以直接判定为使用了键盘模拟。

测试

这里让etw每次记录时输出,keyboard每次收到消息时输出当前按键次数。

首先是正常情况下的按键。

因为etw接受消息有一定的延迟,所以中间输出结果可能有点不对,但是最终结果是在偏差范围内的,即 4*Win32Count<=etwCount<=8*Win32Count

一般上限不会超,所以检测下限即可。

自然是不会超的。

如果是按键模拟,这里我找了一个开源项目的模拟按键来测试。

它可以一直触发消息而不触发 etw。

结果自然而然的是会触发到模拟器检测的。

虽然不知道它按键数据怎么分析,而且会有一定的随机性,但是对于检测模拟类外挂来说足矣。只要取一段区间,它的模拟按键次数大于实际键盘按键次数,那么必能检测到的,通过一定的数学推导也不难得到。

深入了解

因为这个协议没有被文档话,而根据 8 年前的 poc 去解析协议得到的是全 0 数据,那么只能是想办法自己找准具体的协议了。

首先按 aba 得到所有跟键盘事件相关的事件包。

1
2
3
98 af cf b8 77 4a 00 00 d8 df c0 b9 77 4a 00 00 b0 5a 60 4d 88 b5 ff ff 60 69 57 4d 88 b5 ff ff e0 65 9c 61 88 b5 ff ff 80 00 09 00 00 00 00 40 d8 df c0 b9 77 4a 00 00 00 00 00 00 00 00 00 00 b0 5a 60 4d 88 b5 ff ff 03 00 00 00 25 00 00 00 90 14 4f 4d 88 b5 ff ff 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 04 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
98 af cf b8 77 4a 00 00 d8 df c0 b9 77 4a 00 00 b0 5a 60 4d 88 b5 ff ff 60 69 57 4d 88 b5 ff ff 50 43 b5 56 88 b5 ff ff 80 00 09 00 00 00 00 40 d8 df c0 b9 77 4a 00 00 00 00 00 00 00 00 00 00 b0 5a 60 4d 88 b5 ff ff 03 00 00 00 25 00 00 00 90 14 4f 4d 88 b5 ff ff 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 04 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
98 af cf b8 77 4a 00 00 d8 df c0 b9 77 4a 00 00 b0 5a 60 4d 88 b5 ff ff 60 69 57 4d 88 b5 ff ff f0 63 d8 61 88 b5 ff ff 80 00 09 00 00 00 00 40 d8 df c0 b9 77 4a 00 00 00 00 00 00 00 00 00 00 b0 5a 60 4d 88 b5 ff ff 03 00 00 00 25 00 00 00 90 14 4f 4d 88 b5 ff ff 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 04 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00

这里都取第一个抓到的数据,可以发现第一个数据包只有 +20 开始往后 4 个字节不一样,而且相同按键之间似乎没有太多联系。一次键盘事件可以看作是键盘按下和弹起两个子事件,而这两个子事件分别会触发 2 或者 4 个etw事件。

通过研究事件的关系可以看出,按下的时候,若开始事件为 26,则接下来到来的事件还是 26,随后是 27,26。如果开始事件是 27,则之后单走一个 26。弹起大部分情况都是 (27,26)*2,长度为 4 的比较难复现出来,但是可以通过排除连续的 2 个 26 事件达到尽可能地使得随机性减少。

这里解释一下我描述的事件,我上面两段话所说的“到来的事件”均指的是携带了键盘数据的 etw 事件。根据时间轴来看,事件到来的顺序是 26 与 27 交替到来,那么从时间轴来看,“连续的两个 26 事件”指的是两个携带了键盘数据的 26 事件中间夹杂了一个没有携带键盘数据的 27 事件。

而携带键盘数据正如最开始解释的,fid_URB_TransferBufferLength 属性值为 0x25 的事件。

从抓包数据可以分析。

  • 27 事件的数据长度为 172。
  • 26 事件的数据长度为 168。

从上面分析的抓包信息得知,抓到的数据包都是 26 事件,因此可以尝试抓 aba 键盘按下所产生的 27 事件。

1
2
3
98 af cf b8 77 4a 00 00 d8 df c0 b9 77 4a 00 00 b0 5a 60 4d 88 b5 ff ff 60 69 57 4d 88 b5 ff ff 50 24 f0 5d 88 b5 ff ff 80 00 09 00 00 00 00 00 d8 df c0 b9 77 4a 00 00 00 00 00 00 00 00 00 00 b0 5a 60 4d 88 b5 ff ff 03 00 00 00 25 00 00 00 90 14 4f 4d 88 b5 ff ff 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 04 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
98 af cf b8 77 4a 00 00 d8 df c0 b9 77 4a 00 00 b0 5a 60 4d 88 b5 ff ff 60 89 57 4d 88 b5 ff ff e0 4c f0 5d 88 b5 ff ff 80 00 09 00 00 00 00 00 d8 df c0 b9 77 4a 00 00 00 00 00 00 00 00 00 00 b0 5a 60 4d 88 b5 ff ff 03 00 00 00 25 00 00 00 d0 14 4f 4d 88 b5 ff ff 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 04 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
98 af cf b8 77 4a 00 00 d8 df c0 b9 77 4a 00 00 b0 5a 60 4d 88 b5 ff ff 60 89 57 4d 88 b5 ff ff e0 4c f0 5d 88 b5 ff ff 80 00 09 00 00 00 00 00 d8 df c0 b9 77 4a 00 00 00 00 00 00 00 00 00 00 b0 5a 60 4d 88 b5 ff ff 03 00 00 00 25 00 00 00 d0 14 4f 4d 88 b5 ff ff 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 04 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00

发现只有 +80 字节有差异,要么是 0xd0 要么是 0x90,没有别的情况,其它的数据基本一模一样了。

因此从协议去分析,直接卒了。

鼠标模拟检测

前面分析过,长度为 9 的数据是鼠标信息,经过测试,每次移动(大约 0.5 - 1.5 个像素),点击或者其它的鼠标操作都会触发 etw,当然模拟的同样不会触发,那么消息钩子和etw事件数是否会有一定的联系呢?答案是肯定的,这里稍微改一下,然后让左键输出 etwCount 和 win32Count 的值以及比值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
LRESULT
Win32Callback(
__in INT Code,
__in WPARAM WParam,
__in LPARAM LParam
)
{
KBDLLHOOKSTRUCT* key = (KBDLLHOOKSTRUCT*)LParam;
if (WParam == WM_LBUTTONUP) {
printf("%d %d\n", win32Count, etwCount);
printf("rate: %f\n", 1.0 * etwCount / win32Count);
}
win32Count++;
return CallNextHookEx(MouseHook,
Code,
WParam,
LParam);
}

基本是可以得到结论 etwCount / win32Count 大概是在 2 左右的比值,可能有略微的浮动,

可以设置一个比较低的阈值,比如 1.5,如果发现低于这个值则直接判定为使用了鼠标模拟,这里略微改一下左键的事件可以达到。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
LRESULT
Win32Callback(
__in INT Code,
__in WPARAM WParam,
__in LPARAM LParam
)
{
KBDLLHOOKSTRUCT* key = (KBDLLHOOKSTRUCT*)LParam;
if (WParam == WM_LBUTTONUP) {
double rate = 1.0 * etwCount / win32Count;
system("cls");
printf("%d %d\n", win32Count, etwCount);
printf("rate: %f\n", 1.0 * etwCount / win32Count);
if (rate < 1.5) {
printf("Mouse sim detected\n");
}
else {
printf("Mouse sim not found\n");
}
etwCount = 0;
win32Count = 0;


}
win32Count++;
return CallNextHookEx(MouseHook,
Code,
WParam,
LParam);
}

正常情况刚才已经试验过了,肯定是不会触发的,这里使用一款鼠标模拟器[3]去点击看看能否检测到,因为存在延时的缘故,建议移动之后等待 2s 左右再按下左键。

在经过一系列移动之后点击左键显然也可以成功检测到模拟器。

方案实现

鼠标模拟检测方案需要上最终实现的话还有很多问题需要解决,大概罗列了一些。

  • etw 事件是异步的,etw事件上报延时需要考虑。
  • 不同操作系统的事件上报协议可能有所不同,需要对大部分主流的 windows 版本都做适配才能放入最终方案。
  • 因为只检测了指定 usb3.0 的provider 给定的事件,如果鼠标-键盘设备不是走 usb 协议(如蓝牙鼠标或键盘)则可能会误报。但是个人测试下来,一般笔记本内置和常见的有线外接键盘鼠标都可以被检测到。

对于以上的问题,也提出几个可能解决的方案。对于第一个异步的问题,既然没有办法让 etw 变为同步上报,那么可以考虑适当放宽检测条件平衡这个误差。例如,取 60 秒时间内产生的鼠标-键盘消息和etw事件,计算比例看是否在合理的范围内。或者取 10w 次的etw事件为阈值,判断这期间内的鼠标键盘消息是否高于 5w(5w为鼠标,键盘则需要放低到 2.5w 左右),若高于则直接判定为使用了模拟器。

对于第二个问题,不同版本的操作系统协议确实也是一个大问题,因为微软没有官方的文档指示如何解析上报事件的协议,因此对于不同版本的操作系统只能是尽可能去测试完善得到最终方案。

对于第三个问题,游戏方如果强制玩家使用 usb3.0 协议的鼠标,则会显得不够亲民,但是是最简单暴力的办法。或者去研究蓝牙设备的etw事件,对此做适配。

参考文献