正文

重要提醒(2023-02-13):本文部分内容存在bug,目前正在调试修改,会在一段时间之后更新

重要提醒(2023-02-14):目前已修复主要bug,会在一段时间之后更新,本文计划重写大部分内容,敬请期待!

本文代码及编译好的二进制文件可以在下面这个仓库找到。

https://gitcode.net/PeaZomboss/miscellaneous

源代码在文件夹230113-civ6hooksendto

若要下载二进制,请到https://gitcode.net/PeaZomboss/miscellaneous/-/releases/civ6-hook-binary

不要用上述链接,请到230130-hookgamesendto文件夹查看。

关于更多Hook技术的讨论,以及上述更多的内容,本人将会在2023年上半年逐渐更新。未来有关的代码均可在上面的仓库找到。

起因

许多单机游戏都有局域网联机功能,尽管许多也提供了互联网联机功能,但是一般这些游戏的土豆服务器让玩家非常恼火,于是出现了诸如游侠等对战平台。使用这些平台使用局域网联机功能就可以获得比较稳定的联机体验。还有一种方法就是搭建虚拟局域网(VLAN)了,比如使用N2N就可以搭建一个(需要自备服务器)。

本人和哥们当时玩文明6就用到了由Bug侠基于N2N开发的EasyN2N,在这里感谢作者免费提供服务器供我们使用。尽管EasyN2N集成了WinIPBroadcast这款广播转发工具,不过在实际使用中发现有的时候没有作用,总是需要去改网卡跃点,这就造成了一定的麻烦。

局域网联机的游戏基本上是通过向 255.255.255.255 发送 UDP 广播数据包来传播游戏房间信息,但是 Windows 只会在首选的网络接口(网卡)上发送全局 IP 广播数据包,也就是说局域网游戏的信息没有被 Windows 在虚拟局域网接口上广播

以上摘自https://bugxia.com/3128.html,原出处https://www.bilibili.com/read/cv14633088

由于N2N是使用虚拟网卡来实现虚拟局域网的搭建,所以我们只要想办法把游戏的广播发到虚拟网卡就可以解决问题了。不过实际上把广播转发到每张网卡就可以了,因为这样比较方便实现。

于是我就想到了使用Hook技术,把游戏发的广播内容拦截了,再给他转发岂不是就能解决问题了?

还真是。

于是就上网查了一下资料,花了不少时间上手做了一个demo,自己用着感觉不错,然后几个月过去了,突然回想起来以前写过的程序,又想到文明6正在更新,打算等他更完了再快乐联机,就想着把原来的代码梳理一下,然后重新写了个新的,然后顺便温习一下相关知识。

好了事情的起因就是这样子了。

技术介绍

本方法是针对使用虚拟网卡技术的虚拟局域网,并不适用其他方案的虚拟局域网。

本方法和https://bugxia.com/3269.html里用到的ForceBindIP原理一致,不过这个软件似乎并不开源。

本方法使用Hook技术(确切说是Inline Hook)以及socket技术,需要对基本的Windows编程和socket编程有一定的知识。

Hook介绍

Hook技术一般翻译为钩子技术,就是提前在特定事件或消息处挂上钩子,等执行到此处就会触发钩子,执行钩子的代码。Windows系统提供了SetWindowsHookEx等一系列函数实现Hook的功能,不过这和我们实际用到的不太一样。

而所谓Inline Hook就是把任何函数调用的前几个字节改成一句跳转指令,跳转到自己的地方执行,然后返回到原来的主调函数,此时就获得了函数的参数等一系列信息。许多人做的微信Hook就是这么搞的,不过缺点就是一旦函数地址或者参数变了,就得编写相应的代码。

当然对于游戏来说,其发送广播必然离不开系统调用sendto或者WSASendTo,所以我们只要hook相应的系统调用就可以了,而大部分系统函数的地址都是完全公开的。

当然如果只是这样还只能hook自己的进程,想要hook其他进程就得先把hook代码编进一个dll,再想办法让目标进程加载这个dll,这个过程叫注入(inject)。

当我们的dll打入敌人内部,就可以窥探其全部的虚拟地址空间,这样我们就可以大施拳脚,为所欲为了。

socket介绍

所谓套接字(Socket),就是对网络中不同主机上的应用进程之间进行双向通信的端点的抽象。一个套接字就是网络上进程通信的一端,提供了应用层进程利用网络协议交换数据的机制。从所处的地位来讲,套接字上联应用进程,下联网络协议栈,是应用程序通过网络协议进行通信的接口,是应用程序与网络协议栈进行交互的接口。

摘自百度百科

socket最早是伯克利在Unix引入的一套API,后来的Linux以及Windows都兼容了这套API,尽管Windows的有些许不同,不过大体上是相似的。

游戏发送UDP广播一般是调用了winsock的sendto函数,而现在新的winsock2兼容旧的winsock,所以对于绝大多数新老游戏,使用的sendto函数都可以被拦截。

而关于本文所讲到的sendto函数,则有微软官方介绍如下

https://learn.microsoft.com/en-us/windows/win32/api/winsock/nf-winsock-sendto

int sendto(
[in] SOCKET s, // socket描述符
[in] const char *buf, // 发送的数据
[in] int len, // 发送数据的长度
[in] int flags, // 发送的标识
[in] const sockaddr *to, // 目标地址信息
[in] int tolen // 目标地址信息长度
);

注入介绍

写好一个dll,想要加载一般有两种方法,静态链接和动态链接。而注入就是想办法让目标进程自己调用LoadLibrary函数来动态加载这个dll。而这个方法也就是最为简单的直接注入法。当然复杂的还有反射式注入和镂空注入等方法,这些方法就不是直接调用LoadLibrary了,一般是用一段shellcode,不过这个就有点做病毒的意味了。

关于一些介绍可以看这个https://bbs.kanxue.com/thread-274131.htm

所以我们选择使用简单方便的直接注入,具体的方法可以参考上面的链接,不过由于当时写程序的时候并没有看到此文,所以我并没有文中所说“给进程提权”的方法,但也可以实现效果,原因暂不清楚。

游戏分析

这一部分主要是说如何分析文明6这款游戏局域网联机的方式。

工具

主要是netstat命令行工具和Wireshark软件https://www.wireshark.org/

对于netstat,我们需要有游戏进程pid,pid可以从任务管理器找到,然后用下面的命令就可以分析用到的地址和端口:

netstat -ano | findstr "xxx"

其中这个xxx需要替换成游戏的pid。

对于Wireshark,我们需要找对网卡然后捕获一段时间内的数据进行分析,具体只要熟悉工具栏前四个按钮的功能就行了,其他更高级的操作也不需要。

当然如果看完本文后面的代码,完全可以据此写一个程序获取游戏pid,然后调用netstat。

分析过程

分析的时候最好不要有太多其他的网络活动,避免抓包的时候干扰太多。

首先打开Wireshark,选择Adapter for loopback traffic capture这个选项,然后打开游戏点刷新,过一会就停止捕获,从这些记录里找目的是255.255.255.255的UDP包,这样就可以发现这个包是从那个网卡发出来的了。

结果我试了一下发现包是从192.168.56.1这个地址发出来的,用ipconfig命令查一下,发现这个地址是VitualBox虚拟机的网卡。那我如果就这样和别人联机,找得到房间就怪了。

好了,那连找房间都找不到,还怎么分析呢?

办法呢肯定是有的,不然我也不可能在这里讲(chui)解(bi),且容我细细道来。

仔细看这些抓到的包可以发现,游戏是用同一个端口向62900-62999这100个端口发送了36字节的内容,除去协议部分,实际只有4字节的内容。

接着我们用natstat看一下,比如此时游戏进程pid是21628,那么就用如下命令:

netstat -ano | findstr "21628"

可以得到一些网络连接,不过都是些TCP的,但是如果你此时点一下刷新按钮,此时刷新变成了停止刷新,在此期间(否则不行)马上运行这句命令,就会发现一个UDP连接,其内容如下:

UDP    0.0.0.0:53182          *:*                                    21628

解释一下,就是程序正在监听53182端口的UDP消息,接受来自任何IP任何端口的UDP数据,当然每次点刷新这个端口会变。

这个时候你回到Wireshark看一下,会发现刚刚发出去的100个包用的端口,不就是这个端口吗?

这说明游戏用这个端口发送了广播,然后监听这个端口等待其他游戏客户端返回的信息。而等到界面上的停止刷新按钮又变回了刷新按钮,则停止监听,这个时间大概是1秒。

当然如果你有好哥们可以一起联机测试的话(或者用一台至少32G内存的电脑在真机和虚拟机之间测试),那就可以试试游戏是怎么接收房间信息的。

首先确定能联机的网卡,然后对该网卡抓包。

假如你现在在搜索你哥们的房间,点一下刷新,如果看到了房间的话停止抓包,分析一下包的内容,可以看到有定向的UDP包(通常有2个,因为数据比较大)来自哥们的IP,内容是JSON格式的房间信息。再看一下端口,可以看到对方用62900-62999之间的某一个端口给你发包的那个端口发送给了这些房间信息。

这个时候反过来让你的哥们找你的房间,可以看到当哥们点刷新的时候你会收到来自哥们的IP发送的那些包,找到最早收到的那几个包,记住端口,你的客户端大概率是用这里的最小的那个端口发了房间信息出去。

然后你进哥们的房间或者哥们进你的房间,可以看到双方都在用62056端口疯狂的你一句我一句,好是热闹。当然我也不清楚为什么到这里又是用固定端口通信了,我没试过占用这个端口会怎么样,也没试过作为主机的时候和多个客户机的通信过程,如果有好几个哥们的话可以试试看,如果有知道的也可以补充一下。

分析结果

基于以上内容,我们可以猜想游戏是用一个循环向这100个端口发送了广播数据,因为用了同一个端口,所以可以基本确定用了类似如下代码发送的数据:

SOCKET s = socket(AF_INET, SOCK_DGRAM, 0);
BOOL opt = TRUE;
setsockopt(s, SOL_SOCKET, SO_BROADCAST, (char *)&opt, sizeof(BOOL)); // 允许广播
setsockopt(s, SOL_SOCKET, SO_REUSEADDR, (char *)&opt, sizeof(BOOL)); // 允许地址(端口)复用
sockaddr_in to;
to.sin_family = AF_INET;
to.sin_addr.S_addr = INADDR_BROADCAST;
for (int i=62900;i<63000;i++){
to.sin_port = htons(i);
sendto(s,databuffer,4,flags,(sockaddr *)to,sizeof(to));
}

这里补充一些知识,一个socket想要获得相关功能必须用setsockopt,这里主要说一下SO_REUSEADDR(地址复用),其实在Linux还有一个SO_REUSEPORT(端口复用),而Windows只有SO_REUSEADDR,不过兼具SO_REUSEPORT功能。设置这个以后呢,就可以用另一个socket(也要设置SO_REUSEADDR)再次bind这个端口了,否则是不行的。

其次,获取一个socket的端口和地址可以用getsockname这个函数获取,这样就可以用一个新的socket来绑定这个端口监听了;但是由于一个没有绑定过的socket是没有端口信息的,所以要先执行sendto,然后系统就会给这个socket分配一个端口(其实是隐式绑定),这样就可以实现每次刷新都用系统提供的随机端口来发送和监听了,从而避免使用固定端口被其他进程占用的问题了。而我们就可以利用这个来捕获这个端口,甚至强行设定一个端口。

由于涉及到100个端口,所以接收方(创建了房间的客户端)就用100个socket分别监听来自这100个端口的信息(好处同样是防止端口占用,毕竟100个端口同时被占的情况几乎不存在),然后用select函数(这个自己hook一下常用的函数就知道了)来筛选第一个收到消息的端口(通常是62900),然后用这个端口发送房间信息给对方。当然有时候会有丢包,所以不一定是62900端口,具体取决于收到的先后顺序,由于select的精度大概在500微秒,所以同一个时间段的里面应该是用端口最小的那个。

那我们的目标就很明确了,就是hook住这个sendto函数,在循环发送100个包的时候,把这100个包发到每一张网卡去,当然也可以指定一张或几张网卡。

hook之后程序的流程就变成了如下这样:

// ...
for (int i=62900;i<63000;i++){
to.sin_port = htons(i);
sendto(s,databuffer,4,flags,(sockaddr *)to,sizeof(to)); // 会调用下面的fake_sendto
}
int fake_sendto(/*参数列表*/)
{
取消hook;
if (不是广播)
sendto();
else {
for (每一个地址) {
创建socket;
绑定地址;
设置属性;
sendto();
关闭socket;
}
}
重新hook;
}

代码说明

这部分主要解释一下实际使用的代码逻辑和原理。

这部分踩了很多坑,重点说明一下大坑。

注入程序

先说注入程序,因为只有成功注入了才能测试dll有没有问题。

直接上代码,具体有注释说明:

// 参数:进程的pid,返回值:成功true,失败false
bool inject(DWORD pid)
{
char dll_name[MAX_PATH]; // dll绝对路径
DWORD len = GetModuleFileNameA(NULL, dll_name, MAX_PATH); // 获取exe绝对路径
for (int i = len - 1; i > 0; i--)
if (dll_name[i] == '\\') {
dll_name[i] = '\0'; // 把最后一个反斜杠换成0
break;
}
strcat(dll_name, "\\hookdll.dll"); // 拼接dll名
int namelen = strlen(dll_name) + 1; // 算上'\0'的长度
// 以上代码用于生成dll文件的绝对路径,必须和exe在同一目录,必须名为hookdll.dll
HANDLE hproc = 0; // 被注入进程句柄
LPVOID pmem = NULL; // 一片在目标进程申请的用来放dll名的内存
HANDLE hthread = 0; // 远程线程句柄
bool result = false; // 返回值
hproc = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid); // 打开进程
if (hproc == 0) goto finally;
pmem = VirtualAllocEx(hproc, NULL, namelen, MEM_COMMIT, PAGE_READWRITE); // 申请内存
if (pmem == NULL) goto finally;
WriteProcessMemory(hproc, pmem, dll_name, namelen, NULL); // 把dll路径写进去
hthread = CreateRemoteThread(hproc, NULL, 0, (LPTHREAD_START_ROUTINE)LoadLibraryA, pmem, 0, NULL); // 创建远程线程注入
if (hthread == 0) goto finally;
WaitForSingleObject(hthread, INFINITE); // 等待线程执行
DWORD threadres; // 线程运行返回值
GetExitCodeThread(hthread, &threadres); // 获取返回值
result = threadres != 0; // LoadLibraryA错误返回0
// 安全释放相应资源
finally:
if (pmem)
VirtualFreeEx(hproc, pmem, 0, MEM_RELEASE);
if (hthread != 0)
CloseHandle(hthread);
if (hproc != 0)
CloseHandle(hproc);
return result;
}

获取pid的方法(需要头文件tlhelp32.h):

DWORD get_civ6_proc()
{
HANDLE procsnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
PROCESSENTRY32 procentry;
procentry.dwSize = sizeof(PROCESSENTRY32);
Process32First(procsnapshot, &procentry);
// 文明6有DX11和DX12两个exe可以选择,所以都比较了
if (strcmp(procentry.szExeFile, "CivilizationVI.exe") == 0 ||
strcmp(procentry.szExeFile, "CivilizationVI_DX12.exe") == 0) {
CloseHandle(procsnapshot);
return procentry.th32ProcessID;
}
while (Process32Next(procsnapshot, &procentry)) {
if (strcmp(procentry.szExeFile, "CivilizationVI.exe") == 0 ||
strcmp(procentry.szExeFile, "CivilizationVI_DX12.exe") == 0) {
CloseHandle(procsnapshot);
return procentry.th32ProcessID;
}
}
CloseHandle(procsnapshot);
return 0;
}

这段代码比较清晰,简单循环查找,使用字符串比较,无需过多解释。如果要找其他进程的话只要改一下字符串就行了。

上面两端代码是注入的关键内容,剩下的就自行查看源代码(都是一些基础的逻辑),需要注意的是,如果被inject函数注入的进程有管理员权限,那么调用inject的进程也要有管理员权限,否则注入会失败。

Hook Dll

这里是重点和难点,还有坑。

Hook类

重点是x64汇编的硬编码。注意,不支持x86。

class Hook
{
private:
char old_code[14]; // 存放原来的代码
char new_code[14]; // hook跳转的代码
void *func_ptr; // 被hook函数的地址
public:
// 参数1,被hook的函数地址;参数2,hook后跳转的函数地址
Hook(void *func_ptr, void *fake_func);
void dohook(); // 进行hook
void unhook(); // 取消hook
}; // 用于获取64位指针的高低32位
union ptr64_union
{
void *ptr;
struct
{
long lo;
long hi;
};
}; Hook::Hook(void *func_ptr, void *fake_func):func_ptr(func_ptr)
{
ptr64_union ptr64;
ptr64.ptr = fake_func;
// 允许func_ptr处前14字节虚拟内存可运行可读可写
VirtualProtect(func_ptr, 14, PAGE_EXECUTE_READWRITE, NULL);
new_code[0] = 0x68; // push dword xxx
*(long *)&new_code[1] = ptr64.lo; // xxx,即地址的低4位
new_code[5] = 0xC7;
new_code[6] = 0x44;
new_code[7] = 0x24;
new_code[8] = 0x04; // mov dword [rsp+4], yyy
*(long *)&new_code[9] = ptr64.hi; // yyy,即地址的高4位
new_code[13] = 0xC3; // ret
/* 打个比方,假如生成的代码是这样的
68 44332211 push dword 0x11223344
C7442404 88776655 mov dword [rsp+4], 0x55667788
C3 ret
那么实际跳转到的地址是0x5566778811223344
*/
// 保存原来的入口代码
ReadProcessMemory(GetCurrentProcess(), func_ptr, &old_code, 14, NULL);
} void Hook::dohook()
{
// 把跳转的代码写入到函数入口
WriteProcessMemory(GetCurrentProcess(), func_ptr, &new_code, 14, NULL);
} void Hook::unhook()
{
// 把原来的代码写回到函数入口
WriteProcessMemory(GetCurrentProcess(), func_ptr, &old_code, 14, NULL);
}

注意push指令虽然只能把最大4字节的内容压到栈中,但是由于栈的对齐,实际上有8个字节,其中高4字节补0,所以用mov dword [rsp+4], xxx来把剩下的高4字节写入。这样栈顶就是我们要跳转的地址了,然后用ret指令即可实现跳转。

用这个代码的好处是不会修改任何通用寄存器的值,缺点是相比最短12字节的指令多了2个字节。

补充一下最短的方法:

mov rax, xxx
jmp rax

或者

mov rax, xxx
push rax
ret

但他们都会改变rax的值。

fake_sendto

fake_sendto是被hook的sendto跳转过来的执行的函数,这是完成广播转发的核心。

// 用于枚举当前所有网卡的IP地址
// 返回引用减少开销
const std::vector<in_addr> &enum_addr()
{
static std::vector<in_addr> list;
hostent *phost = gethostbyname(""); // 获取本机网卡
if (phost) {
if (phost->h_length == list.size()) // 数量相同直接返回,避免每次重新获取
return list;
char **ppc = phost->h_addr_list; // 获取地址列表
if (ppc) {
list.clear();
// 遍历列表添加到容器
while (*ppc) {
in_addr addr;
memcpy(&addr, *ppc, sizeof(in_addr));
list.push_back(addr);
ppc++;
}
}
}
return list;
} int WINAPI fake_sendto(SOCKET s, const char *buf, int len, int flags, const sockaddr *to, int tolen)
{
sendto_hook->unhook(); // 暂时取消hook,这个方法对多线程效果不好
int result = -1;
sockaddr_in *toaddr = (sockaddr_in *)to;
if (toaddr->sin_addr.S_un.S_addr != INADDR_BROADCAST) {
result = sendto(s, buf, len, flags, to, tolen); // 非广播直接原样发送
}
else {
sockaddr_in addr_self;
int namelen = sizeof(sockaddr_in);
getsockname(s, (sockaddr *)&addr_self, &namelen); // 获取原sockaddr
if (addr_self.sin_port == 0) {
// 如果没有端口号,先原样发送,这样系统才会分配一个端口号
// 当然我们也可以给他强行绑定一个地址端口,不过并不推荐
result = sendto(s, buf, len, flags, to, tolen);
getsockname(s, (sockaddr *)&addr_self, &namelen); // 重新获取
// 这里最好用getsockopt确定socket设置了SO_REUSEADDR,
// 否则用setsockopt设置一下,文明6默认就设置了
}
const std::vector<in_addr> &list = enum_addr();
// 向列表中的每一个地址转发广播
for (int i = 0; i < list.size(); i++) {
addr_self.sin_addr = list[i]; // 把新的地址换上去,然后发送
SOCKET sock = socket(AF_INET, SOCK_DGRAM, 0);
BOOL opt = TRUE;
// 必须要分开设置,切不可SO_BROADCAST|SO_REUSEADDR这样,必然失败
setsockopt(sock, SOL_SOCKET, SO_BROADCAST, (char *)&opt, sizeof(BOOL)); // 广播
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, (char *)&opt, sizeof(BOOL)); // 地址端口复用
bind(sock, (sockaddr *)&addr_self, sizeof(sockaddr)); // 绑定到地址端口
result = sendto(sock, buf, len, flags, to, tolen);
// 这里千万不能直接closesocket(sock); 不然数据大概率会发不出去!
// 以前的代码就是简单调用了closesocket,结果就100个包只能出去没几个
sockqueue.add(sock); // 加到socket队列里
}
}
sendto_hook->dohook(); // 重新hook
return result;
}

注意到函数的开头结尾的取消hook和重新hook,这意味着这个函数执行的时候是不能hook的,假如此时另一个线程调用sendto函数,是不会被拦截的。所以这个方法还有可以改进的空间,不过对于文明6来说则是没有问题的。

这里的一个大坑就是转发广播的时候,最后千万不能直接调用closesocket(),不然大概率这个数据是发不出去的。虽然官方说closesocket会优雅地关闭,实际上就是暴力关门,前脚刚出门后脚还没迈出去的时候,刚刚关掉的socket又给拿去复用了(官方说关闭后可能立即复用),然后就引发冲突。那么为了防止这个现象,我们只要用一个循环队列来保存要关闭的socket,每次排队一个就关闭一个。实际上这样用效果非常好。

Socket队列

#define MAX_SOCK_QUEUE 64

class SockQueue
{
private:
SOCKET socks[MAX_SOCK_QUEUE];
int current;
public:
SockQueue();
~SockQueue();
void add(SOCKET s);
}; SockQueue::SockQueue()
{
memset(&socks, 0, MAX_SOCK_QUEUE * sizeof(SOCKET));
current = 0;
} SockQueue::~SockQueue()
{
for (int i = 0; i < MAX_SOCK_QUEUE; i++)
if (socks[i] != 0)
closesocket(socks[i]);
} void SockQueue::add(SOCKET s)
{
if (socks[current] != 0)
closesocket(socks[current]); // 来一个新的关一个旧的
socks[current++] = s;
if (current == MAX_SOCK_QUEUE)
current = 0;
}

这个代码比较简单,很容易理解。

调试方法

可以在dll里创建文件如D:\debug.txt,然后执行过程中需要log的地方往这个文件写就行了,退出以后再看看,同时调试的时候还要全程用Wireshark分析发包时存在的问题。

或者也可以写一个程序监听TCP的一个端口,在dll里用TCP向这个端口发送调试log,再由那个程序显示。

反正方法无外乎这样,不是写一个文件就是进程间通信。

成品测试

打开游戏,双击inject2civ6.exe注入,打开Wireshark抓包。在游戏里进入局域网,刷新,可以看到Wireshark一瞬间出现了好多包,看到每个可用的地址都通过同一个端口向62900-62999这100个端口发送了广播。

再注意一下发出第一个包到最后一个包所经过的时间,可以看到在0.1秒左右(我有4张网卡,3张是虚拟机的),虽然这个时间比不hook多了好多倍,但依然处于一个较短的时间,而在实际游戏过程中是不会用广播的,一次正常发送UDP包的时间小于1毫秒,完全可以忽略。

然后可以找人联机测试,基本上是不会失败的,尤其是在抓到了含房间信息的包之后,如果游戏房间列表还是没有显示,那一般是游戏的问题,这个时候再刷新几次,如果不行就返回到主菜单重新进去。

关于游戏稳定性,这个几乎是不会有影响的,毕竟单机的时候都经常崩溃,在不用这个方法之前联机也总是崩溃,而且联机的时候网络的稳定性更加重要。

其他游戏

目前我没有试过其他游戏是个什么样的情况,因为不同的游戏可能有不一样的机制,但是如果找房间的机制是用的UDP广播,那么理论上此方法是可以使用的。但不排除每个游戏在函数调用上的细节处理不一样,使用无法保证同样使用UDP广播的游戏可以使用。

如果有兴趣可以改一下注入程序的代码,使其更具有通用性并测试其他游戏的可行性。比如可以加入32位的hook代码,不然一些老游戏是hook不了的。

总结

这个东西确实比较难搞,当时花费了不少时间,梳理起来加上深入研究加上重写测试等又花了好几天(甚至中途EasyN2N软件更新了,似乎更新之后更好用了,还集成了前面所说的ForceBindIP)。还发现了以前代码的不少bug,没想到当时也能跑起来。不过折腾这么多现在对socket确实有了更多的一些认识了,但和熟练掌握依然差很远,只能说再接再厉。

这次包含不少我的探索内容,肯定存在纰漏,如发现错误欢迎指出,如有任何疑问也请提出。

更新记录

  • 2023-01-19:修正一些错别字,优化部分表述问题,新增更多的解释
  • 2023-01-31:优化部分代码,添加新的源代码链接,新增更新预告

使用Hook拦截sendto函数解决虚拟局域网部分游戏联机找不到房间的问题——以文明6为例的更多相关文章

  1. 简单全局HOOK拦截大部分键盘消息

    前言:学习HOOK中,万一老师讲解HOOK入门教程:http://www.cnblogs.com/del/category/124150.html http://www.cnblogs.com/del ...

  2. 【Java EE 学习 69 上】【struts2】【paramsPrepareParamsStack拦截器栈解决model对象和属性赋值冲突问题】

    昨天有同学问我问题,他告诉我他的Action中的一个属性明明提供了get/set方法,但是在方法中却获取不到表单中传递过来的值.代码如下(简化后的代码) public class UserAction ...

  3. 基于 HTTP 请求拦截,快速解决跨域和代理 Mock

    近几年,随着 Web 开发逐渐成熟,前后端分离的架构设计越来越被众多开发者认可,使得前端和后端可以专注各自的职能,降低沟通成本,提高开发效率. 在前后端分离的开发模式下,前端和后端工程师得以并行工作. ...

  4. php的ord函数——解决中文字符截断问题

    php的ord函数——解决中文字符截断问题 分类: PHP2014-11-26 12:11 1033人阅读 评论(0) 收藏 举报 utf8字符截取 函数是这样定义的: int ord ( strin ...

  5. Oracle中使用Table()函数解决For循环中不写成 in (l_idlist)形式的问题

    转: Oracle中使用Table()函数解决For循环中不写成 in (l_idlist)形式的问题 在实际PL/SQL编程中,我们要对动态取出来的一组数据,进行For循环处理,其基本程序逻辑为: ...

  6. oracle之简null空值问题,用nvl(a,b)函数解决

    oracle之简null空值问题,用nvl(a,b)函数解决 原文链接:https://blog.csdn.net/u013821825/article/details/48766749 oracle ...

  7. ES7异步函数解决进程等待相关业务问题

    业务需求场景描述: 在接口只能单一检测的情况下,批量检测资源名称是否存在数据库,如果资源群中某一个资源已存在:给出交互让用户决定是否覆盖资源,最后形成不存在的资源和用户确定覆盖的资源群,进行提交. 业 ...

  8. TCP粘包问题的解决方案02——利用readline函数解决粘包问题

      主要内容: 1.read,write 与 recv,send函数. recv函数只能用于套接口IO ssize_t recv(int sockfd,void * buff,size_t len,i ...

  9. python实例:解决经典扑克牌游戏 -- 四张牌凑24点 (二)

    Hey! 如果你还没有看这篇的上文的话,可以去稍稍瞅一眼,会帮助加速理解这一篇里面涉及到的递归结构哦!(上一篇点这里:<python实例:解决经典扑克牌游戏 -- 四张牌凑24点 (一)> ...

  10. 解决SQL订阅过程中找不到已经创建的订阅

    原文:解决SQL订阅过程中找不到已经创建的订阅 之前有写过一篇博客,主要是图解SQL复制技术:图解SQL 2008数据库复制,当时的测试环境是在我本地同一个服务器上面,所以测试的时候可谓是一帆风顺,最 ...

随机推荐

  1. mysql 基础明细

    1.mysql 没有 TOP,用limit实现 2.mysql having 聚合之后,对组操作,和GROUP By搭配 mysql where  聚合之前,对表和视图操作 3.where 子句的作用 ...

  2. windows10 ftp文件夹错误

    遇到问题: 解决办法: 1. cmd直接访问 ftp ip 2. 启用tftp client 从文件夹访问 注直接访问会弹出如之前报错一样的失败:ftp://ip ftp://用户:密码@ip 使用如 ...

  3. oracle 分析函数——ration_to_report 求占有率(百分比)

    oracle 的分析函数有很多,但是这个函数总是会忘记,我想通过这种方式能让自己记起来,不至于下次还要百度. 创表.表数据(平时练手的表): prompt PL/SQL Developer impor ...

  4. chrom jsonview的使用

    在开发中,我们可能要为不同的系统提供接口,并以说明文档的形式提供接口说明,但我们提供的返回json往往会在页面上乱成一团. 这里我们推荐chrome浏览器的小插件jsonview,他不但有利于我们对接 ...

  5. TinyShell(CSAPP实验)

    简介 CSAPP实验介绍 学生实现他们自己的带有作业控制的Unix Shell程序,包括Ctrl + C和Ctrl + Z按键,fg,bg,和 jobs命令.这是学生第一次接触并发,并且让他们对Uni ...

  6. 错误:org.springframework.beans.factory.BeanDefinitionStoreException:

    在练习尚硅谷雷丰阳老师的SSM-CRUD整合的时候,因为使用的Thymeleaf,而不是jsp,跟着老师操作所有会出现一些错误,现在我把这些错误都整理一下,希望能帮助到有用的朋友. org.sprin ...

  7. 03.Javascript学习笔记2

    1.逻辑运算符 在javascript中与或非对应的逻辑运算符是: && || ! const a = true; const b = false; console.log(a &am ...

  8. PPT排版技巧

  9. 3、mysql着重号解决关键字冲突

    1.着重号(`  `): 使用着重号(` `)将字段名或表名括起来解决冲突:保证表中的字段.表名等没有和保留字.数据库系统名或常用方法名冲突

  10. 【Java技术专题】「原理专题」深入分析Java中finalize方法的作用和底层原理

    finalize方法是什么 finalize方法是Object的protected方法,Object的子类们可以覆盖该方法以实现资源清理工作,GC在首次回收对象之前调用该方法. finalize方法与 ...