戴记严选 GM3323D 驱动逆向,并实现在 Linux 上调整灯的颜色
2026-10-15 #re我手里有一个几年前买的戴记严选 GM3323D鼠标,这个鼠标为数不多的缺点就是他只能在 Windows 上进行设置鼠标的灯光特效,所以趁着现在有空赶紧对他的驱动进行逆向和移植。
逆向工程
查看安装目录可以看到 "GM3323D.exe" 和 "hiddriver_1.dll", "hiddriver_2.dll" 这几个明显的文件,还有 "DuiLib.dll" 这种第三方库,"res" 文件夹里放的是资源文件,包括程序布局,贴图。我们先都记下来。
安装目录下还放了第三方库的开源许可协议,给这个公司点赞!
GM3323D.exe
首先是逆向 "GM3323D.exe",配合安装目录下 "res/wirelesslightpage.xml" 文件里出现的 "wireless_lightmode1" 字符串,我们在 IDA 中 xref 定位到下面函数。这里我猜测程序是读取选项的下标,然后将下标塞入格式化字符串 L"wireless_lightmode%d",来选择对应模式,并通过 PostMessageW 传递给消息处理函数进行后续的操作。使用 IDA 定位字符串的时候,可以在 String 窗口右键选择 Setup 选项,勾上 "unicode c style" 选项,这样才能搜索到 utf16 字符串。
void __thiscall sub_40FF50(int this, int a2)
{
int v3; // edi
int v4; // esi
int v5; // eax
_BYTE v6[132]; // [esp+10h] [ebp-94h] BYREF
int v7; // [esp+A0h] [ebp-4h]
if ( *(_DWORD *)(a2 + 264) == *(_DWORD *)(this + 1692) )
{
v3 = *(_DWORD *)(a2 + 280);
*(_DWORD *)(32872 * **(_DWORD **)(this + 1900) + *(_DWORD *)(this + 1900) + 2364) = v3;
DuiLib::CDuiString::CDuiString(this: (DuiLib::CDuiString *)v6);
v7 = 0;
DuiLib::CDuiString::Format(this: (DuiLib::CDuiString *)v6, a2: L"wireless_lightmode%d", v3 + 1);
v4 = **(_DWORD **)(this + 1692);
v5 = DuiLib::CDuiString::operator wchar_t const *(this: (DuiLib::CDuiString *)v6);
(*(void (__thiscall **)(_DWORD, int))(v4 + 196))(a1: *(_DWORD *)(this + 1692), a2: v5);
sub_4107A0(a1: v3);
PostMessageW(hWnd: *(HWND *)(this + 1904), Msg: 0x535u, wParam: 0, lParam: 0);
v7 = -1;
DuiLib::CDuiString::~CDuiString(this: (DuiLib::CDuiString *)v6);
}
}
知道消息 ID 为 0x535 之后继续搜索,在程序的反编译结果里搜索 0x535 没结果,搜索 1333 定位到了下面的代码。
case 1333:
PostThreadMessageW(*(_DWORD *)(this + 3044), 0x670u, wParam, lParam);
*(_DWORD *)a5 = 1;
return 0;
再次将 0x670 转换为 1648 进行搜索,定位到了处理消息的函数。里面有些代码片段比较可疑,因为程序里默认的配置数量就是 5 个。
DWORD __stdcall StartAddress(char *lpThreadParameter)
{
while ( GetMessageW(lpMsg: &Msg, hWnd: nullptr, wMsgFilterMin: 0, wMsgFilterMax: 0) )
{
if ( Msg.message == 1648
&& (*((_DWORD *)lpThreadParameter + 90518) == 1 || *((_DWORD *)lpThreadParameter + 90517) == 1)
&& *((_DWORD *)lpThreadParameter + 90520) != 1 )
{
// ...
for ( i = 0; i < 5; ++i )
{
if ( lpThreadParameter[i + 214] == 1 )
sub_416010(this: (int)lpThreadParameter, a2: i);
if ( lpThreadParameter[i + 219] == 1 ) // lightmode 变化
sub_416410(this: (int)lpThreadParameter, a2: i);// 组包 05 0D <profile+1> <lightmode> ...
if ( lpThreadParameter[i + 224] == 1 )
sub_4165B0(this: (int (__cdecl **)(_WORD *, int))lpThreadParameter, a2: i);
if ( lpThreadParameter[i + 209] == 1 )
sub_416500(this: (int)lpThreadParameter, a2: i);
}
// ...
}
}
}
我们接下来看一下 sub_416410 函数,里面有一个函数指针。
int __thiscall sub_416410(int this, int a2)
{
// ...
if ( v5 )
v6 = (*(int (__cdecl **)(char *, int))(this + 60))(a1: v9, a2: 13);// hiddriver_2.SetFeature
else
v6 = (*(int (__cdecl **)(char *, int))(this + 32))(a1: v9, a2: 13);
// ...
}
通过搜索 + 60 的偏移搜索不到赋值语句,我猜可能是 IDA 在反编译指针运算的时候抽风了。存放同一个地址,但是不同类型的指针,在运算的时候,得加上不同大小的偏移才能得到同一个地址,所以我按照 int* 指针运算的偏移搜索一下 + 15 看看,结果真看到了下面代码。可以猜测这个函数指针是指向了 "hiddriver_2.dll" 文件的 SetFeature 函数。
// ...
v3 = (const DuiLib::CDuiString *)DuiLib::CDuiString::operator+(v22, v20, L"hiddriver_2.dll");
LOBYTE(v23) = 3;
DuiLib::CDuiString::operator=((DuiLib::CDuiString *)v21, v3);
LOBYTE(v23) = 1;
DuiLib::CDuiString::~CDuiString((DuiLib::CDuiString *)v20);
v4 = (const WCHAR *)DuiLib::CDuiString::operator wchar_t const *((DuiLib::CDuiString *)v21);
LibraryW = LoadLibraryW(v4);
*(this + 19) = LibraryW;
// ...
SetFeature = GetProcAddress(v18, "SetFeature");
v19 = *(this + 19);
*(this + 15) = (HMODULE)SetFeature;
// ...
使用 x32dbg 动态附加到程序上,给 sub_416410 函数加上断点,发现前面的推理是正确的,并且得到了给 SetFeature 函数传递的参数。
0706FC54 05 0D 01 01 04 08 FF 48 B0 06 01 00 00 00 00 00 ......ÿH°....... hiddriver_2.dll
SetFeature 函数朴实无华,调用系统函数 HidD_SetFeature 将 Feature 传递给鼠标,鼠标就可以接收。
int __cdecl SetFeature(PVOID ReportBuffer, ULONG ReportBufferLength)
{
if ( HidDeviceObject != nullptr )
return HidD_SetFeature(HidDeviceObject: HidDeviceObject, ReportBuffer, ReportBufferLength);
else
return 0;
}
接下来我通过给 sub_416410 里加上断点,并改变设置,来检查传递给 SetFeature 的数据找出规律。除了下面发现的变化,a1[2] 是配置索引,a1[6:9] 是颜色的 16 进制值,下面数据中的颜色是粉色 #FF48B0。数据长度则是 13 字节。
// vv= 改变了模式后,这里变了
0706FC54 05 0D 01 01 04 08 FF 48 B0 06 01 00 00 00 00 00 ......ÿH°.......
0706FC54 05 0D 01 02 04 08 FF 48 B0 06 01 00 00 00 00 00 ......ÿH°.......
0706FC54 05 0D 01 03 04 08 FF 48 B0 06 01 00 00 00 00 00 ......ÿH°.......
// vv= 改变了循环时间
06B3F884 05 0D 01 01 04 08 FF 48 B0 06 01 00 00 00 00 00 ......ÿH°.......
06B3F884 05 0D 01 01 04 06 FF 48 B0 06 01 00 00 00 00 00 ......ÿH°.......
06B3F884 05 0D 01 01 04 03 FF 48 B0 06 01 00 00 00 00 00 ......ÿH°.......
06B3F884 05 0D 01 01 04 01 FF 48 B0 06 01 00 00 00 00 00 ......ÿH°.......
// vv= 改变了亮度
06B3F884 05 0D 01 02 06 08 FF 48 B0 06 01 00 00 00 00 00 ......ÿH°.......
06B3F884 05 0D 01 02 08 08 FF 48 B0 06 01 00 00 00 00 00 ......ÿH°.......
06B3F884 05 0D 01 02 03 08 FF 48 B0 06 01 00 00 00 00 00 ......ÿH°....... 编写程序
通过分析 "hiddriver_2.dll" 我们可以知道程序是通过 HidD_SetFeature 函数将设置发送给设备的,在 Linux 下则是通过万能的 ioctl 往 /dev/ 下的设备里写入设置。下面的代码是通过 HID_ID 来找到鼠标设备,并通过 ioctl 函数将设置传给鼠标。
HIDIOCSFEATURE = lambda n: (3 << 30) | (n << 16) | (0x48 << 8) | 0x06
def find_dev(vid: int, pid: int):
for folder in Path("/sys/class/hidraw/").iterdir():
uevent = folder / "device/uevent"
if f"HID_ID=0003:{vid:08X}:{pid:08X}" in uevent.read_text():
return "/dev/" + folder.name
def write_report5(fd, buf: bytearray):
assert len(buf) == 13 and buf[0] == 5
n = ioctl(fd, HIDIOCSFEATURE(13), buf, True)
return n
接下来的主函数就简单了,直接找到设备,读取要修改的参数,应用到设置模板里,写入到设备。
def main():
path = find_dev(0x1D57, 0xFA60)
if not path:
print("Device not found")
return
parser = ArgumentParser()
parser.add_argument("-p", "--profile", type=int, default=1)
parser.add_argument("-m", "--mode", type=int, default=1)
parser.add_argument("-c", "--color", default="FF 48 B0")
parser.add_argument("-d", "--duration", type=int, default=4)
parser.add_argument("-b", "--brightness", type=int, default=8)
parser.add_argument("--battery", action="store_true")
args = parser.parse_args()
if args.battery:
monitor_battery(path)
return
profile = int(args.profile)
mode = int(args.mode)
color = bytes.fromhex(args.color)
duration = int(args.duration)
brightness = int(args.brightness)
tmpl = bytearray.fromhex("05 0D 01 02 04 08 FF 48 B0 06 01 00 00")
tmpl[2] = profile
tmpl[3] = mode
tmpl[4] = duration
tmpl[5] = brightness
tmpl[6:9] = color
print("Writing", tmpl, "to", path)
with open(path, "rb+", buffering=0) as fd:
n = write_report5(fd, tmpl)
print(f"Write {n} bytes")附加题:设备电量
设备电量有点迷惑,我在驱动程序里看到有显示电量数字和图标的地方,于是就搜索 "power" 相关字段,但是只能定位到和 GUI 相关的地方,然后再搜索 "%d%%" 才定位到 "sub_416C90" 函数,这个函数负责将电量数字显示到 GUI 上。通过 xref 找到上游 "sub_40B140" 函数,这个函数跑到 const CMainWnd::`vftable' 里去了。因为又有一堆的 switch, 所以我猜测又是一个处理事件的函数。
int __thiscall sub_40B140(int this, int a2, WPARAM wParam, LPARAM lParam, DuiLib::CMenuWnd *a5)
{
// ...
case 1331:
sub_415B30(this: this + 2964, a2: (unsigned __int16)lParam ^ (wParam << 16));
*(_DWORD *)a5 = 1;
// ...
}
但是这一次搜索 PostMessage 还是 1331, 0x533 都搜索不到结果了,于是求助于大模型。
大模型:
你看程序在初始化的时候执行了
*(this + 11) = (HMODULE)Open_DevMonitor;,然后程序里又调用了(*((void (__cdecl **)(_DWORD, int, int))lpParameter + 11))(*((_DWORD *)lpParameter + 89), 1331, 1332);函数指针,等价于调用了Open_DevMonitor函数,并将 1331 作为第二个参数a2传递了。再看看
Open_DevMonitor函数里dword_1001410C = a2;有这样的赋值,最后在调用PostMessageA(hWnd, dword_1001410C, (unsigned __int16)v5, v6);的时候,就相当于把电池数据传递给了sub_40B140
我:妙啊。我还在想看不出来就去动态调试了,没想到静态分析也能看出来这么离谱的函数指针调用。
按照这个思路在 "hiddriver_2.dll" 找到了 sub_10001A80 函数,这个函数读取了设备文件。我们也照猫画虎,直接读取设备文件并解析。
def monitor_battery(p: str):
f = os_open(p, O_RDONLY)
data = read(f, 64)
sub = data[1]
group = data[2]
charging = bool(data[3])
level = int(data[4])
if group != CMD_GROUP:
return
print(f"Battery status: {level * 10}%, Charging: {charging}")效果演示

总结
这次是是对一个不太熟悉的领域进行了逆向工程:Windows GUI 程序和驱动。学习到了硬件和系统的各种交互,也领会到了各种函数指针回调的坑爹。我只能说该动态调试的时候就动态调试,可能的地方全部下断点,程序总该停下来的,当然,现在还有大模型这种强力外援,能给人省下很多时间。
完整代码在这:https://github.com/chenx6/gadget/blob/master/gm3323d/mouse.py