可以看到ReadFile函数头部被改为jmp了, 跳转至到防封的hid.dll内了, 在他的函数内处理完后再返回出去, 标准的Hook流程.
这些API函数都是十分常用的并且网上资料丰富, 不作过多叙述, 下面我会演示其中两个比较复杂的API函数用法和Hook后的处理方式
1. SetupDiGetDeviceInstanceIDA 功能: 检索指定设备实例Id此函数原型:
BOOL WINAPI SetupDiGetDeviceInstanceIdA(
HDEVINFO DeviceInfoSet, // 设备信息集合的句柄
PSP_DEVINFO_DATA DeviceInfoData, // SP_DEVINFO_DATA结构体的指针
PSTR DeviceInstanceId, // 用于接收返回的设备Id字符串, 此参数为我选取的处理点
DWORD DeviceInstanceIdSize, // 缓冲区大小
PDWORD RequiredSize // 接收返回Id的实际大小
)
void Test()
{
HDEVINFO hDevInfo = SetupDiGetClassDevsA(&GUID_DEVCLASS_NET, nullptr, nullptr, DIGCF_PRESENT);
cout << "hDevInfo: " << hDevInfo << endl;
if (hDevInfo == INVALID_HANDLE_VALUE)
{
cout << "hDevInfo Error!" << endl;
return;
}
SP_DEVINFO_DATA DevInfoData{ sizeof(SP_DEVINFO_DATA) };
char szBuf[MAX_PATH]{ 0 };
for (int i = 0; SetupDiEnumDeviceInfo(hDevInfo, i, &DevInfoData); i++)
{
cout << i << "、" << endl;
if (SetupDiGetClassDescriptionA(&DevInfoData.ClassGuid, szBuf, MAX_PATH, nullptr))
{
cout << "SetupDiGetClassDescriptionA - > "
<< "ClassDescription: " << szBuf << endl;
}
if (SetupDiGetDeviceInstanceIdA(hDevInfo, &DevInfoData, szBuf, MAX_PATH, nullptr))
{
cout << "SetupDiGetDeviceInstanceIdA - > "
<< "DeviceInstanceId: " << szBuf << endl;
}
if (SetupDiGetDeviceRegistryPropertyA(hDevInfo, &DevInfoData, SPDRP_DEVICEDESC, nullptr, reinterpret_cast<PBYTE>(szBuf), MAX_PATH, nullptr))
{
cout << "SetupDiGetDeviceRegistryPropertyA - > "
<< "DeviceRegistryProperty: " << szBuf << endl;
}
}
SetupDiDestroyDeviceInfoList(hDevInfo);
}
可以看到SetupDi系列函数配合使用可以将电脑中所有适配器(包括你在设备管理器禁用的设备)相关的信息枚举出来.
以下是该函数的HookFun编写示例:
BOOL WINAPI HookFun_SetupDiGetDeviceInstanceIdA(
HDEVINFO DeviceInfoSet,
PSP_DEVINFO_DATA DeviceInfoData,
PSTR DeviceInstanceId,
DWORD DeviceInstanceIdSize,
PDWORD RequiredSize
)
{
DbgPrintEx("TP - > SetupDiGetDeviceInstanceIdA");
BOOL bResult = g_oSetupDiGetDeviceInstanceIdA(DeviceInfoSet, DeviceInfoData, DeviceInstanceId, DeviceInstanceIdSize, RequiredSize);
if (DeviceInstanceId != nullptr)
{
for (int i = 0; i < 20; i++)
DeviceInstanceId[i] = FakeSN[i]; // 将DeviceInstanceId改为预先设好的随机字符
}
return bResult;
}
此函数原型:
BOOL WINAPI DeviceIoControl(
HANDLE hDevice, // 设备句柄
DWORD dwIoControlCode, // 控制码
LPVOID lpInBuffer, // 指向所执行操作所需的数据缓冲区的指针
DWORD nInBufferSize, // 输入缓冲区的大小
LPVOID lpOutBuffer, // 输出缓冲区, 接收返回来的设备数据的, 此处为我的处理点
DWORD nOutBufferSize, // 输出缓冲区大小
LPDWORD lpBytesReturned, // 输出数据的大小
LPOVERLAPPED lpOverlapped // OVERLAPPED结构体指针
)
此函数可以做的事情太多了, 这里我只演示获取网络适配器原始MAC的写法:
voidGetRealMac()
{
LPVOID pBuf = nullptr;
PIP_ADAPTER_INFO pAdapterInfo = nullptr;
char szFileName[MAX_PATH]{ 0 };
ULONG ulOutLen = 0;
GetAdaptersInfo(nullptr, &ulOutLen);
pBuf = new char[ulOutLen];
pAdapterInfo = reinterpret_cast<PIP_ADAPTER_INFO>(pBuf);
if (GetAdaptersInfo(pAdapterInfo, &ulOutLen) == NO_ERROR)
{
while (pAdapterInfo)
{
strcpy_s(szFileName, "\\.\");
strcat_s(szFileName, pAdapterInfo->AdapterName);
HANDLE hFile = CreateFileA(szFileName, GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, 0, nullptr);
DWORD dwInBuf = OID_802_3_PERMANENT_ADDRESS;
BYTE outBuf[6];
DWORD dwRetLen;
DeviceIoControl(hFile, IOCTL_NDIS_QUERY_GLOBAL_STATS, &dwInBuf, sizeof(dwInBuf), outBuf, sizeof(outBuf), &dwRetLen, nullptr);
CloseHandle(hFile);
// 输出MAC地址
cout << setw(2) << setfill('0') << setiosflags(ios::uppercase) << hex << outBuf[0] + 0 << "-"
<< outBuf[1] + 0 << "-"
<< outBuf[2] + 0 << "-"
<< outBuf[3] + 0 << "-"
<< outBuf[4] + 0 << "-"
<< outBuf[5] + 0 << endl;
pAdapterInfo = pAdapterInfo->Next;
}
delete[] pBuf;
}
}
效果图:
如上图, 即使你修改了注册表内的Network Address或网络适配器中的Network Address, 利用此函数依旧可以获取出正确的原始MAC.
接下来是该函数的HookFun示例代码:
BOOL WINAPI HookFun_DeviceIoControl(
HANDLE hDevice,
DWORD dwIoControlCode,
LPVOID lpInBuffer,
DWORD nInBufferSize,
LPVOID lpOutBuffer,
DWORD nOutBufferSize,
LPDWORD lpBytesReturned,
LPOVERLAPPED lpOverlapped
)
{
BOOL bResult = g_oDeviceIoControl(hDevice, dwIoControlCode, lpInBuffer, nInBufferSize, lpOutBuffer, nOutBufferSize, lpBytesReturned, lpOverlapped);
if (dwIoControlCode == IOCTL_STORAGE_QUERY_PROPERTY || dwIoControlCode == SMART_RCV_DRIVE_DATA)
{
//DbgPrintEx("DeviceIoControl - > 获取序列号");
memcpy((((PIDINFO)lpOutBuffer)->sSerialNumber) + (*lpBytesReturned) - IDENTIFY_BUFFER_SIZE, FakeSN, 20);
}
if (dwIoControlCode == IOCTL_NDIS_QUERY_GLOBAL_STATS)
{
//DbgPrintEx("DeviceIoControl - > 获取原生MAC");
((PBYTE)lpOutBuffer)[0] = FakeMAC[0]; // 改为伪造的MAC
((PBYTE)lpOutBuffer)[1] = FakeMAC[1];
((PBYTE)lpOutBuffer)[2] = FakeMAC[2];
((PBYTE)lpOutBuffer)[3] = FakeMAC[3];
((PBYTE)lpOutBuffer)[4] = FakeMAC[4];
((PBYTE)lpOutBuffer)[5] = FakeMAC[5];
}
return bResult;
}
我自己也写了个Dll针对这些获取设备信息的API逐个进行了Hook, 注入方式同样采用更名hid方式, 因为这个注入时机很好, 可以避免出现你还未对游戏进行Hook, 游戏检测就已经获取完你电脑设备信息的情况, 下面是Hook后的各个API的调用情况图:
可以看到游戏中频繁调用这些API获取设备信息作为机器封禁判断依据的检测模块是PolicyProbe, 说明T*P对玩家游戏设备信息采集并上报是由该模块完成的.
处理方式: Hook相关API, 使其得到我们伪造后的设备信息.
Step2. 观察其对第三方模块检测的处理 防封软件本身也是通过载入游戏的hid.dll实现防封, 而脚本也是通过注入的Dll实现脚本功能的, 显然这就要处理对这些第三方非法模块的检测了. 防封Dll也Hook了一些枚举进程模块的API, 如Module32First, Module32FirstW, Module32Next, Module32NextW. 这一类通过快照枚举进程模块的API(详细资料自行百度, 本文不作过多叙述) 可以看到该防封做法十分多此一举, Module32First和Module32Next明明只需要Hook其中一个即可达到目的. 另外还有EnumProcessModules和ZwQueryVirtualMemory也是枚举进程模块的, x32dbg附加游戏后, 我对Module32Next, Module32NextW, EnumProcessModules, ZwQueryVirtualMemory这四个API分别设置了断点, 观察它们的调用情况.
如上图2, 可以看到EnumProcessModules函数断下了, 观察堆栈返回地址 可以发现是来自GameRpcs模块的调用, 第一个参数进程句柄为0xFFFFFFFF 代表游戏进程本身的伪句柄, 显然是GameRpcs正在枚举游戏进程中的所有模块, 拿到这些模块句柄后肯定是去模块头部采取数据比较是否有主流脚本Dll的特征进而作出相应封号处罚, 并且可以通过句柄获取模块路径, 读取文件数据判断是否有脚本Dll特征进而作出相应处罚. 处理方案: 1. Hook修改返回值为失败 2. Hook将获取到的模块列表中擦去你的模块和脚本的模块, 达到欺骗效果.
如上图1, 可以看到ModuleNextW被TCJ调用了, 检测套路同上, 处理方式同上.
如上图3, ZwQueryVirtualMemory被TCJ调用, 根据堆栈参数可以看出, 是用于获取MemorySectionName, 也就是模块名, 是TCJ用于配合ModuleNextW得到的模块句柄来获取模块名的, 未发现暴力枚举所有内存页属性的行为, 所以只需处理ModuleNextW即可.
Step3. 观察其对Call检测的处理方式
脚本要做到实现自动走A, 躲避, 连招, 必然是要调用到游戏本身的移动Call和技能Call的, 所以防封肯定要处理Call检测.
首先定位到游戏较内层的移动call:
如上图, 可以看到游戏移动Call头部被下了钩子, 跳向TenRpcs模块, 显然就是Call检测了, 跟进Call内部看看代码
可以看到进来后, 堆栈被抬高了4, 为了平衡call进来的堆栈, 随后pushad pushfd保存寄存器状态, 然后push了两个参数最后进入又进入了一个Call 出来后平衡堆栈并 popfd popad还原寄存器状态 执行原移动Call头部原指令, push 要返回的地址, ret返回. 关键还是在于中间调用的Call 于是继续跟进
如上图, 可以看到该函数内部关键在于中间的Call edx, 会进入对应的GameRpcs模块中的检测函数, 继续跟进马上就会碰到人见人爱的TP版虚拟机了.
现在先倒回去分析一下上一层函数的内部结构:
__asm
{
lea esp, ss: [esp + 0x4] // 恢复Call进来减小的4字节的堆栈
pushad // 保存8个通用寄存器
pushfd // 保存eflags寄存器
push structPtr //压入的是应是一个结构体指针, 其中有代表此处的Id
push 0 // 应是TenRpcs保留的一个参数, 方便以后需要用到
call funx // 进入一个全局钩子的分配区, 根据传入的结构体中的成员判断去往对应的检测函数
lea esp, ss: [esp + 0x8] // 上面的call是外平栈, 恢复上面两个参数的压栈
popfd // 还原eflags寄存器
popad // 还原8个通用寄存器
// 以下执行挂钩处覆盖的原指令, 并跳回
mov esp, dword ptr ss : [esp - 0x14]
sub esp, 0xE0
push 0x586316
ret
}
如上代码注释, 该结构基本解析完毕, TenRpcs模块在游戏很多功能函数处都下了这种钩子, 内部结构都是这样(以此作为特征码, 可以搜出游戏中所有此类结构的地址), 实际分析中, 此类结构都是相邻的, 间距为0x190个字节, 压入的结构体指针为各个TenRpcs钩子中转结构的"身份证", 最后由上图中的Call edx跳向各个"身份证"所对应的函数或检测函数.
该防封并未对这些TenRpcs钩子相关处下Hook, 更别说伪造堆栈, 伪造调用链一类的操作了, 首先来到移动Call钩子的中转处, 目光聚焦到上述结构中的push structPtr处, 跳转至该指针指向, 直觉告诉我: 此处的结构体成员数据被他改变了 从而影响了原来要跳向的对应检测函数. 此处正好为数据段, PCHunter的钩子扫描是会跳过这些位置的, 所以要靠自己发现, 但由于其hid.dll的注入处理时机太早, 不方便观察, 所以我预先把该防封所产的hid.dll从游戏目录抠了出来, 等我进入游戏记录完TenRpcs钩子处的结构体成员数据, 再用其他工具将其hid.dll注入游戏进程, 最终发现该处数据的确被他改变了, 导致游戏中脚本需调功能函数在经过内部的Call edx时不会执行至原先对应的检测函数, 其实就是绕开了检测, 所以该防封处理Call检测的方式是使脚本所调用的几个功能函数绕开原先TenRpcs钩子中必经的检测函数继续往下执行.
如上解释, 该防封直接跳过Call检测并不是一种明智的的做法, 想办法欺骗检测应该是较合理的方式, 要知道T*P的本质就是发包检测, 必然涉及到各种检测与其服务器的通讯, 强撸只会触发其它校验导致的异常. 如果这样能过掉Call检测, 只能说T*P的大佬们在故意放水.
Step4. 观察其对虚表数据校验和代码CRC校验的处理
这里我简单解释一下什么是虚表检测, LXL主流脚本Hook了游戏中大量对象头部的虚表指针, 替换为指向自己表的指针, 以此来实时获取所需数据与执行脚本功能的最佳时机. 显然T*P反作弊引擎可以从这入手对关键对象头部处的虚表指针进行校验, 以此可以推断出玩家是否有作弊行为.
未发现该防封有相关处理.
0x3 结束语
虽然此次粗略分析只能窥出LXL的T*P反作弊引擎的冰山一角, 但也是对其有了最基本的认知, 希望本文能对热爱逆向的读者有所帮助.
再次声明: 文章内容无任何不正当目的与非法企图, 仅仅是为了公开交流学习, 请读者不要以本文内容去做出任何违法行为
原文始发于微信公众号(零羊Web):以某款防封为基础对LXL检测的展开分析
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论