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

前言

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

但是许多人在搭建好一个自己的局域网之后,却发现进了游戏怎么也找不到房间。

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

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

本文以文明6这款游戏的找房机制为例,采用Hook技术,拦截游戏对UDP广播的发送、接收等操作,并用一系列的方法使得游戏的广播能够成功发送到所有网卡,并成功接收到来自其他客户端的回复。

本文会详细说明如何分析文明6的局域网联机找房机制,以及Hook相关函数的细节,所以内容可能比较冗长,希望你能耐心看完。

在开始之前,希望你能对文本涉及到的Windows编程、socket编程、Hook技术、网络抓包、反汇编调试等内容有所了解,这样可以便于理解。否则对于初学者来说可能会有一定的难度。

还有一点,本文所提出的方法,可能并不适用除文明6以外的其他游戏,因为每个游戏的具体机制有所不同,需要通过分析得出。如果你熟悉分析方法,可以很容易地对不同的游戏找到适用的解决方案。而且,本文的方法仅适用与真实的或者虚拟的局域网,而不适用于游侠这类同样使用Hook的对战平台。

如果有IPv6地址,可以考虑使用该项目提供的工具:xaxys/injciv6: 文明6联机 - 基于IP的游戏发现 (IPv4/IPv6) (github.com),相关视频:https://www.bilibili.com/video/BV1qV411X7FP,其在本文基础上添加了IPv6直连的功能,可以不需要组网。不过这里提醒一下,IPv6的入站比较复杂,目前绝大多数的光猫和路由器都是默认开启了防火墙的,需要依次到光猫和路由器关闭IPv6的防火墙(大部分都可以关),同时在系统放行游戏的网络包,为了安全,不建议关闭系统的防火墙。至于移动数据的IPv6上网,不确定能不能成功入站。

正文

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

https://gitcode.net/PeaZomboss/miscellaneous

源代码在文件夹230130-hookgamesendto
若要下载二进制,请到https://gitcode.net/PeaZomboss/miscellaneous/-/releases/civ6-hook-binary

未来有关的代码均可在上面的仓库找到。

起因

去年的时候我和哥们一起玩起了文明6,为了能够一起联机游玩,就找了许多方法,后来找到了由Bug侠基于N2N开发的EasyN2N(在这里感谢作者免费提供服务器供我们使用)。但是经常遇到找不到对方房间的问题,后来逐渐排查出了原因,就是UDP广播没有发出去的问题,也发现EasyN2N有一定的解决办法。

比如其集成了WinIPBroadcast这款广播转发工具,不过在实际使用中发现有的时候没有作用。但是手动把虚拟网卡的跃点改小,提高其优先级是一个有效的方案。但不知是哪边的bug,这个跃点会时不时的自动变回去,很是恼火,就想找一个方便的方法。

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

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

于是就上网查了一下资料,花了不少时间上手做了一个demo,自己用着感觉不错(其实有bug,能不能成功还是看玄学),然后几个月没怎么玩了,后来看到文明6正在更新领袖包,打算等更新完了再快乐联机,又想到之前写过的代码,就想着把原来的代码梳理一下,然后重新写个新的,顺便温习一下相关知识。

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

经过

正当我以为一切顺利的时候,我发现事情没有那么简单,实际上不是简单Hook并转发一下广播就能实现的。这不仅涉及到sendto函数的基本用法,还有一些比较复杂的细节,包括端口复用如何收发包的问题。于是又要涉及到对于收包函数recvfrom,以及select函数的拦截与使用。

后来我根据抓包的结果,推测文明6游戏的逻辑,仿制了一个类似的测试工具,并在此基础上不断调试。后来为了确保能实现一个比较准确的结果,拿出了x64dbg这一神器对游戏进程进行了一些调试,最终得出了游戏找房过程的具体方法,并复刻了一个在原理上几乎是完全一致的工具。

有兴趣可以阅读其源码。关于游戏的调试过程,我会在后文进行一个介绍。

总之,在一段时间的努力之后,我终于从sendto到recvfrom和select三位一体进行精准Hook,拿下了最终的成功。

技术介绍

本文的代码实现部分使用Hook技术(确切说是Inline Hook)以及socket技术,由于还要涉及对其他进程的Hook,所以还要用到注入技术。

本文的分析过程涉及到网络抓包及软件调试技术,这个会在游戏分析部分进行具体说明。

Hook介绍

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

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

有关Inline Hook的具体介绍,请看本人写的这篇文章。本文所用的方法就是以此为基础的。

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

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

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

socket介绍

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

摘自百度百科

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

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

本文要涉及到的socket函数有以下三个:sendto、select、recvfrom


关于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, // 发送的选项,一般为0
  [in] const sockaddr *to,   // 目标地址信息
  [in] int            tolen  // 目标地址信息长度
);

关于recvfrom函数,文档如下:
https://learn.microsoft.com/en-us/windows/win32/api/winsock/nf-winsock-recvfrom

函数原型如下:

int recvfrom(
  [in]                SOCKET   s,       // socket描述符
  [out]               char     *buf,    // 接收数据的缓冲区
  [in]                int      len,     // 要接收数据的长度
  [in]                int      flags,   // 接收的选项,一般为0
  [out]               sockaddr *from,   // 发送方的地址信息
  [in, out, optional] int      *fromlen // 地址信息长度
);

有关这两个函数的用法介绍,网上不计其数,就不再展开,重点说一下select函数。


select函数文档如下:
https://learn.microsoft.com/en-us/windows/win32/api/winsock2/nf-winsock2-select

函数原型:

int WSAAPI select(
  [in]      int           nfds,       // 用于兼容,winsock不关心
  [in, out] fd_set        *readfds,   // 输入一组socket集合,留下可读的socket
  [in, out] fd_set        *writefds,  // 输入一组socket集合,留下可写的socket
  [in, out] fd_set        *exceptfds, // 输入一组socket集合,留下异常的socket
  [in]      const timeval *timeout    // 最大等待时间
);

返回值有3种情况:

  1. 返回-1,说明函数出错,原因通过WSAGetLastError获取。
  2. 返回0,说明超过最大等待时间没有可用的socket。
  3. 返回n,n>0,说明有集合中有n个准备就绪的socket。

其中fd_set结构定义如下:

typedef struct fd_set {
  u_int  fd_count;             // 集合内socket的数量
  SOCKET fd_array[FD_SETSIZE]; // socket集合,最大容量为FD_SETSIZE,默认64
} fd_set, FD_SET, *PFD_SET, *LPFD_SET;

timeval结构定义如下:

typedef struct timeval {
  long tv_sec;  // 秒
  long tv_usec; // 微秒
} TIMEVAL, *PTIMEVAL, *LPTIMEVAL;

为了便于操作,winsock2提供了一系列的宏,具体就不多说了。

值得注意到是,readfds、writefds、exceptfds都是可选的,只要不全是NULL就可以了,一般对于接收,只需设置readfds就行了。而这个timeout参数的时间则是由timeval类型的秒和微秒加起来的时间,如果timeout参数为NULL,则会一直等待。

由于输入的集合会在调用select之后被改变,所以需要每次调用select之前重新设置集合。实际上fd_set的fd_count表明了可用的socket数量,而fd_array则依次保存了fd_count个socket。

这大概就是Windows下select的一些细节了。

注入介绍

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

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

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

直接注入一般是获取目标进程,使用OpenProcess打开进程,然后使用远程线程(Remote Thread)执行LoadLibraryA,具体代码会在后面说明。

游戏分析

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

工具

使用Wiresharkx64dbg软件。

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

对于x64dbg,我们需要在游戏运行后附加(Attach)到进程,然后对关键函数下断点并进行单步调试,需要熟悉一些基本的操作方法,同时建议装上反调试插件减少可能的麻烦。

抓包分析

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

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

一次抓包的结果

比如上图,我抓了一下包,发现了大量从10.31.23.127:49190发出的UDP广播,内容长度都是4字节,用ipconfig查一下发现这个IP是来自PPP拨号上网的网卡,其子网掩码为255.255.255.255,即不可能发出广播。在这种情况下,即使你使用虚拟局域网组网方案,但游戏UDP广播依然不走这个网卡,而是被系统安排到了默认网卡,所以根本找不到房间。

当然还有一种情况我也遇到过,就是通过局域网上网,IP地址为192.168.1.1,但是广播既不从这个地址发出,也不从虚拟网卡发出,而是从192.168.56.1发出,而查了发现此IP竟然是VirtualBox的网卡。这种情况下,不管是同一个物理局域网还是虚拟局域网,都不可能找到房间。

总之你会发现,不论你以何种方式上网,似乎都可能会遇到这种问题。我在网上看到过一个解决方法,就是把无关的网卡都禁用了,这确实可以,但是正如我前面测试的,假如你是PPP上网,那么就算你关了所有多余的网卡,只留下虚拟网卡,广播依然可能会从PPP的网卡发出去,而你不可能禁用这张上网的网卡。

当然还有一个方法,就是修改网卡跃点。把一张网卡的跃点改小了,那么其优先级就会提高,这样广播就会从指定的网卡发出去了。不过你也不知道跃点到底什么时候会不会自动改回去,这点有时候比较恼火。

回到前面那张抓包的截图,如果你自己抓几次包,然后分析一下每次抓到的数据包,就可以得出一个结论:游戏每次发包,都是用同一个端口向62900-62999这100个端口发送4字节的实际内容。

很容易猜想到,游戏每次找房,都是用同一个端口发送广播,而收到广播的一端则向这个端口发送回信,提供房间信息。如果你有可以进行联机测试的条件的话,那就可以试试游戏是怎么接收房间信息的。如果可以成功联机的话,就先确定能联机的网卡,然后对该网卡抓包。

假如你现在在搜索房间,点一下刷新,如果看到了房间的话停止抓包,分析一下包的内容,可以看到有定向的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住这个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
}

Hook替换的伪代码:

int fake_sendto(/*参数列表*/)
{
    取消hook;
    if (不是广播)
        sendto();
    else {
        for (每一个地址) {
            创建socket;
            绑定地址;
            设置属性;
            sendto();
            关闭socket;
        }
    }
    重新hook;
}

提前透露一下,这个方法是有问题的,原因如下:

  1. 在调用sendto后立即关闭socket,极有可能根本发送不出去,这个可以用socket队列解决。
  2. 在调用sendto发送出去后,必须用同一个socket才能接收到回复,即使复用了端口。
  3. 取消hook再重新hook并不适用于多线程的情况

所以我们要用固定的socket来实现这个效果,而不是会变的socket。

调试分析

前面的网络抓包的结果只能猜测,实际的方法还得要调试才能知道。

打开游戏,待游戏加载完成后,打开x64dbg,选择文件-附加,找到游戏进程附加上去。之后就可以选择下断点的位置了。

首先可以确定的是,sendto函数大概率是会被调用的。而且在点击局域网游戏的时候就会发送广播,之后每次刷新都会发送。所以我们可以在x64dbg下方的命令框输入bp sendto,就可以在sendto函数处下一个断点。

这样当我们点击局域网或者刷新的一瞬间,游戏应该被阻塞,此时x64dbg已经成功拦截到了sendto函数并将RIP指向第一句。这时我们已经进入到sendto函数内部了,而我们对这个不感兴趣,只需在调试菜单选择运行到用户代码即可回到游戏调用sendto之后的一句了。

发送广播反汇编代码

注意这是x64的汇编,结合动态调试结果,稍微解释一下:

这是一个for循环,其中di寄存器为循环变量,si是循环的最大值,当di小于si就接着循环,di的初始值为62900,而si则为固定值63000。看140826160这一句mov ecx, di,把端口号作为参数传给了htons函数,将其转换到大端。接着就是给后面调用sendto函数传参的几句代码,就没什么好说了。这说明之前猜测的发送代码逻辑上基本没什么问题。

此时注意观察一下14082616D这一句mov rcx, qword ptr ds:[rbx+68],可以看一下rbx+68处的内容,就是发送时所用的那个socket描述符了。调试的时候可以记一下这个数值,一会还会出现。

之后游戏一定会等待这个socket收到的信息,于是你可以试着bp recvfrom,可是却发现根本没有被断下!

参考前面说的,实际上游戏用了select函数来判断这个socket能否读取数据,而不是直接调用recvfrom,因为recvfrom是一个阻塞函数,这肯定是不能直接用的。

不妨用bp select断下,再进行一次调试,在调用完sendto函数之后,按下F9让程序运行,果然到了select内部被断了下来。此时可以看一下调用参数的内容:

调用select时的参数

对照之前的函数原型,就可以知道第二个参数1476B0处的是可读取socket集合,到内存窗口看一下,内容如下:

select的fd集合

这个就是fd_set结构体了,第一个01说明只有一个socket,第二个E8 0F就是之前那个socket(每次都不一样)。

我们再看看调用完socket之后的逻辑:

select调用后的逻辑

可以看到在140827AB0处进行了判断,如果返回值为0,则140827AB2处的跳转就会执行。接着再调用__WSAFDIsSet来判断目标socket是否还在集合中,不在的话同样跳转。这样之后,就开始执行memset来清空一片内存并用recvfrom来接收数据了。

根据上述调试,简单概括一下的话,就是在你点击进入局域网或者在局域网内进行刷新的时候,游戏就会先新建一个socket然后循环调用sendto发送数据,然后在一个线程里用select函数来等待一段时间,判断是否收到数据,确认收到后才会调用recvfrom进行真正的接收。

我们可以Hook上述三个函数,依次用fake_sendtofake_selectfake_recvfrom替换。

在第一次执行fake_sendto时枚举当前所有网卡,然后建立几个socket分别绑定这些网卡,之后所有的发送都通过这几个socket进行。

fake_select中执行真正的select,但是检查的集合却是我们在fake_sendto中建立的那几个socket,在检测到有数据后,就记录可以获取数据的那个socket,并返回另一个集合的数量欺骗原来的程序,让其以为收到了回复。

然后我们还在fake_recvfrom中调用真正的recvfrom来接收数据,但第一个参数被替换成了在之前fake_select记录下来的那个有数据的socket。

这样一套组合拳,形象的解释就是游戏用广播喊话,但是麦克风没有连接到广播,而是连到了我们这,我们根据游戏喊话的内容再喊了一遍,当然热线电话就改成了我们自己的;游戏要等电话,实际是我们相当于做了一个接线员,把听众打过来的电话转接给了游戏;这样下来,游戏以为自己的广播喊出去了,也接到了听众的电话。

所以游戏还是按照它自己的逻辑进行收发数据包,但是整个过程是我们帮它进行了代理转发。

代码说明

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

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

注入程序

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

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

// 参数1:进程的pid,参数2:dll绝对路径,返回值:成功true,失败false
bool inject_dll(DWORD pid, const char *dll_path)
{
    int path_len = strlen(dll_path) + 1; // 获取长度包括'\0'
    HANDLE hproc = 0;   // 目标进程句柄
    LPVOID pmem = NULL; // 目标进程的虚拟内存指针
    HANDLE hthread = 0; // 远程线程句柄
    bool result = false;
    hproc = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid); // 打开进程
    if (hproc == 0) goto finally;
    pmem = VirtualAllocEx(hproc, NULL, path_len, MEM_COMMIT, PAGE_READWRITE); // 申请内存
    if (pmem == NULL) goto finally;
    WriteProcessMemory(hproc, pmem, dll_path, path_len, 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):

// 参数:目标进程名,返回值:进程pid,失败为0
DWORD find_pid_by_name(const char *name)
{
    HANDLE procsnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
    PROCESSENTRY32 procentry;
    procentry.dwSize = sizeof(PROCESSENTRY32);
    Process32First(procsnapshot, &procentry);
    if (strcmp(procentry.szExeFile, name) == 0) {
        CloseHandle(procsnapshot);
        return procentry.th32ProcessID;
    }
    while (Process32Next(procsnapshot, &procentry)) {
        if (strcmp(procentry.szExeFile, name) == 0) {
            CloseHandle(procsnapshot);
            return procentry.th32ProcessID;
        }
    }
    CloseHandle(procsnapshot);
    return 0;
}

这段代码比较清晰,建立快照,简单循环查找,使用字符串比较,无需过多解释。唯一要注意的是这里用的是char *,所以不要用UNICODE模式,如果用VS的话要加上#undef UNICODE再include头文件。

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

Hook DLL

这里就简单贴一下关键代码和相应的注释,详细了解请看前面的说明。

重要提醒:这里的Hook代码仅供参考,由于存在兼容性问题,不适合直接使用,新版已经进行了修改

Hook类

#include <windows.h>

#define HOOK_JUMP_LEN 5

class InlineHook
{
private:
    void *old_entry; // 存放原来的代码和跳转回去的代码
    char hook_entry[HOOK_JUMP_LEN]; // hook跳转的代码
    void *func_ptr;  // 被hook函数的地址
public:
    InlineHook(HMODULE hmodule, const char *name, void *fake_func, int entry_len);
    ~InlineHook();
    void hook();
    void unhook();
    void *get_old_entry();
};

#ifdef _CPU_X64
static void *FindModuleTextBlankAlign(HMODULE hmodule)
{
    BYTE *p = (BYTE *)hmodule;
    p += ((IMAGE_DOS_HEADER *)p)->e_lfanew + 4; // 根据DOS头获取PE信息偏移量
    p += sizeof(IMAGE_FILE_HEADER) + ((IMAGE_FILE_HEADER *)p)->SizeOfOptionalHeader; // 跳过可选头
    WORD sections = ((IMAGE_FILE_HEADER *)p)->NumberOfSections; // 获取区段长度
    for (int i = 0; i < sections; i++) {
        IMAGE_SECTION_HEADER *psec = (IMAGE_SECTION_HEADER *)p;
        p += sizeof(IMAGE_SECTION_HEADER);
        if (memcmp(psec->Name, ".text", 5) == 0) { // 是否.text段
            BYTE *offset = (BYTE *)hmodule + psec->VirtualAddress + psec->Misc.VirtualSize; // 计算空白区域偏移量
            offset += 16 - (INT_PTR)offset % 16; // 对齐16字节
            UINT64 *buf = (UINT64 *)offset;
            while (buf[0] != 0 || buf[1] != 0) // 找到一块全是0的区域
                buf += 16;
            return (void *)buf;
        }
    }
    return 0;
}
#endif

InlineHook::InlineHook(HMODULE hmodule, const char *name, void *fake_func, int entry_len)
{
    func_ptr = (void *)GetProcAddress(hmodule, name);
    // 范围检查
    if (entry_len < HOOK_JUMP_LEN)
        entry_len = HOOK_JUMP_LEN;
    if (entry_len > HOOK_PATCH_MAX)
        entry_len = HOOK_PATCH_MAX;
    // 允许func_ptr处最前面的5字节内存可读可写可执行
    VirtualProtect(func_ptr, HOOK_JUMP_LEN, PAGE_EXECUTE_READWRITE, NULL);
    // 使用VirtualAlloc申请内存,使其可读可写可执行
    old_entry = VirtualAlloc(NULL, 32, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
#ifdef _CPU_X64
    union
    {
        void *ptr;
        struct
        {
            DWORD32 lo;
            DWORD32 hi;
        };
    } ptr64;
    void *blank = FindModuleTextBlankAlign(hmodule); // 找到第一处空白区域
    VirtualProtect(blank, 14, PAGE_EXECUTE_READWRITE, NULL); // 可读写
    hook_entry[0] = 0xE9; // 跳转代码
    *(DWORD32 *)&hook_entry[1] = (BYTE *)blank - (BYTE *)func_ptr - 5; // 跳转到空白区域
    ptr64.ptr = fake_func;
    BYTE blank_jump[14];
    blank_jump[0] = 0x68; // push xxx
    *(DWORD32 *)&blank_jump[1] = ptr64.lo; // xxx,即地址的低4位
    blank_jump[5] = 0xC7;
    blank_jump[6] = 0x44;
    blank_jump[7] = 0x24;
    blank_jump[8] = 0x04; // mov dword [rsp+4], yyy
    *(DWORD32 *)&blank_jump[9] = ptr64.hi; // yyy,即地址的高4位
    blank_jump[13] = 0xC3; // ret
    // 写入真正的跳转代码到空白区域
    WriteProcessMemory(GetCurrentProcess(), blank, &blank_jump, 14, NULL);
    // 保存原来的入口代码
    memcpy(old_entry, func_ptr, entry_len);
    ptr64.ptr = (BYTE *)func_ptr + entry_len;
    // 设置新的跳转代码
    BYTE *new_jump = (BYTE *)old_entry + entry_len;
    new_jump[0] = 0x68;
    *(DWORD32 *)(new_jump + 1) = ptr64.lo;
    new_jump[5] = 0xC7;
    new_jump[6] = 0x44;
    new_jump[7] = 0x24;
    new_jump[8] = 0x04;
    *(DWORD32 *)(new_jump + 9) = ptr64.hi;
    new_jump[13] = 0xC3;
#endif
#ifdef _CPU_X86
    hook_entry[0] = 0xE9; // 跳转代码
    *(DWORD32 *)&hook_entry[1] = (BYTE *)fake_func - (BYTE *)func_ptr - 5; // 直接到hook的代码
    memcpy(old_entry, func_ptr, entry_len); // 保存入口
    BYTE *new_jump = (BYTE *)old_entry + entry_len;
    *new_jump = 0xE9; // 跳回去的代码
    *(DWORD32 *)(new_jump + 1) = (BYTE *)func_ptr + entry_len - new_jump - 5;
#endif
}

fake_xxx

结合之前所述,我们需要以下全局变量来存放:

static SOCKET origin_sock = 0, fake_sock = 0;
static std::vector<SOCKET> socks;

其中origin_sock是sendto函数的那个socket,游戏是通过这个socket来实现数据的收发的。
fake_sock则是用来在fake_selectfake_recvfrom中给游戏接收数据用的。
vector容器socks则是存放每一个网卡对应的socket,实现接收回复用的。


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

static int WINAPI fake_sendto(SOCKET s, const char *buf, int len, int flags, const sockaddr *to, int tolen)
{
    sockaddr_in *toaddr = (sockaddr_in *)to;
    if (toaddr->sin_addr.S_un.S_addr != INADDR_BROADCAST)
        return _sendto(s, buf, len, flags, to, tolen); // 非广播直接原样发送
    int result = -1;
    origin_sock = s; // 暂存这个socket
    const std::vector<in_addr> &list = enum_addr(); // 枚举网卡地址
    if (socks.size() != list.size()) { // 数量不同则重新绑定socket
        sockaddr_in addr_self;
        addr_self.sin_family = AF_INET;
        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); // 重新获取
        }
        for (int i = 0; i < socks.size(); i++)
            closesocket(socks[i]); // 关闭之前的socket
        socks.clear(); // 清空
        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;
            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)); // 绑定到地址端口
            socks.push_back(sock); // 存入socket
        }
    }
    // 向列表中的每一个地址转发广播
    for (int i = 0; i < socks.size(); i++)
        result = _sendto(socks[i], buf, len, flags, to, tolen);
    return result;
}

注意在枚举网卡数量后进行判断,如果网卡数量和socket数量不一致,则关闭并清空所有socket,然后重新绑定。这个情况一般只会发生在第一次Hook的时候,偶尔也会在网卡数量更改的时候。

枚举网卡的代码如下:

static const std::vector<in_addr> &enum_addr()
{
    static std::vector<in_addr> list;
    hostent *phost = gethostbyname(""); // 获取本机网卡
    if (phost) {
        char **ppc = phost->h_addr_list; // 获取地址列表
        int addr_num = 0;
        while (*ppc)
            addr_num++, ppc++; // 获取地址个数
        if (addr_num == list.size()) // 数量相同直接返回
            return list;
        ppc = phost->h_addr_list;
        list.clear();
        // 遍历列表添加到容器
        while (*ppc) {
            in_addr addr;
            memcpy(&addr, *ppc, sizeof(in_addr));
            list.push_back(addr);
            ppc++;
        }
    }
    return list;
}

函数fake_select相对简单,主要是注意不能误伤其他正常调用的select就可以了。

static int WINAPI fake_select(int n, fd_set *rd, fd_set *wr, fd_set *ex, const TIMEVAL *timeout)
{
    // 这里通过判断数量和内容,确保是我们需要修改的情况
    if (rd && rd->fd_count == 1 && origin_sock == rd->fd_array[0]) {
        fd_set fds;
        FD_ZERO(&fds);
        for (int i = 0; i < socks.size(); i++)
            FD_SET(socks[i], &fds); // 把用于发送的socket放入集合
        int r = _select(0, &fds, NULL, NULL, timeout);
        if (r > 0) {
            fake_sock = fds.fd_array[0]; // 暂存第一个有数据的socket
            return fds.fd_count; // 只要大于0都可以
        }
        // 否则全部置为0即可
        fake_sock = 0;
        rd->fd_count = 0;
        return 0;
    }
    return _select(n, rd, wr, ex, timeout);
}

同理,对于fake_recvfrom,我们只要判断是不是之前暂存的两个socket就行了。

static int WINAPI fake_recvfrom(SOCKET s, char *buf, int len, int flags, sockaddr *from, int *fromlen)
{
    // 要接收的socket是我们暂存的且存在有数据的socket
    if (s == origin_sock && fake_sock != 0)
        return _recvfrom(fake_sock, buf, len, flags, from, fromlen);
    return _recvfrom(s, buf, len, flags, from, fromlen);
}

移除注入的DLL

和注入程序类似,使用CreateRemoteThread,不过运行的函数是FreeLibrary,参数是DLL的地址。

不过需要先获取指定进程加载的DLL,然后找到Hook的DLL是否已经被加载了。

和枚举进程查找PID类似,使用CreateToolhelp32Snapshot获取指定进程模块快照,然后挨个字符串匹配找到指定模块。

HMODULE find_module_handle_from_pid(DWORD pid, const char *module_name)
{
    HMODULE h_result = 0;
    HANDLE hsnap = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE, pid);
    MODULEENTRY32 module_entry;
    module_entry.dwSize = sizeof(MODULEENTRY32);
    Module32First(hsnap, &module_entry);
    do {
        if (strcmp(module_entry.szModule, module_name) == 0) {
            h_result = module_entry.hModule;
            break;
        }
    } while (Module32Next(hsnap, &module_entry));
    CloseHandle(hsnap);
    return h_result;
}

然后使用以下代码移除:

bool remove_module(DWORD pid, HMODULE module_handle)
{
    HANDLE hproc = 0;
    HANDLE hthread = 0;
    bool result = false;
    hproc = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);
    if (hproc == 0) goto finally;
    hthread = CreateRemoteThread(hproc, NULL, 0, (LPTHREAD_START_ROUTINE)FreeLibrary, module_handle, 0, NULL);
    if (hthread == 0) goto finally;
    WaitForSingleObject(hthread, INFINITE);
    DWORD threadres;
    GetExitCodeThread(hthread, &threadres);
    result = threadres != 0;
finally:
    if (hthread != 0)
        CloseHandle(hthread);
    if (hproc != 0)
        CloseHandle(hproc);
    return result;
}

参数module_handle就是上一个函数获取到的模块基址。

调试方法

这类注入到其他进程的DLL调试起来难度较大,这里给出几种方案供参考:

  1. 使用文件IO操作,使用固定路径如D:\debug.txt,将所有需要写入的调试信息写入这个文件,供后续分析。
  2. 使用TCP或UDP协议,编写一个程序用来接收调试信息,把需要的内容直接发送出来。
  3. 使用x64dbg进行跟踪调试,因为Hook了函数,所以可以跟进DLL实现动态调试。

注意调试的时候最好全程使用Wireshark抓包,从而方便确定问题。

这里推荐方法1,因为比较简单且可以配合Wireshark抓包结果;当然能力够强的话用方法3,配合抓包这个是最直观明了的。

成品测试

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

Hook后部分抓包结果

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

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

其他游戏

目前我没有试过其他游戏是个什么样的情况,因为不同的游戏可能有不一样的机制,但是如果找房间的机制是用的UDP广播,且使用了select进行判断,那么理论上此方法是可以使用的。

否则需要针对性进行一些修改,具体就要通过抓包+反汇编调试了。但是万变不离其宗,最终都是让游戏能成功发出信息并收到回复。

目前研究发现饥荒联机版的局域网联机和文明6使用类似机制,但不完全一样,饥荒使用的是RakNet网络库,但是好巧不巧啊,广播都是只能发一个网卡的。

总结

之前我对这块知识的了解还是不够,把一些问题想简单了,所以提出了不正确的方法,而且在bug的作用下居然将错就错跑出了结果。不过好在基本原理没有出错,所以在进一步的研究之后,终于还是成功解决了问题。这也让我对相应的知识有了更进一步的认识。

此次重写(2023-03)花费不少时间,尤其是中途事情比较多,所以进展缓慢。而且工程量比较大,不亚于写一篇新的文章。但是本着认真负责的原则,对出现问题的内容必须加以改正,才能不误导他人。

本文未来应该不会再有比较大的变动了,不过代码仓库还是会有更新的,有兴趣了解更多东西可以到开头的仓库看一看。

更新记录

  • 2023-01-19:修正一些错别字,优化部分表述问题,新增更多的解释
  • 2023-01-31:优化部分代码,添加新的源代码链接,新增更新预告
  • 2023-03-05:完成了对大部分内容的重写
  • 2023-09-01:修正错别字,优化部分内容,代码优化
  • 2023-09-24:新增移除注入的DLL说明
  • 2024-01-31:修正部分内容,添加相关项目的简要介绍
posted @ 2023-01-18 02:41  PeaZomboss  阅读(12269)  评论(6编辑  收藏  举报