前言
許多單機(jī)游戲都有局域網(wǎng)聯(lián)機(jī)功能,盡管有些也提供了互聯(lián)網(wǎng)聯(lián)機(jī)功能,但是一般這些游戲的土豆服務(wù)器讓玩家非常惱火,于是諸如游俠等對戰(zhàn)平臺則是其中一種選擇。使用這些平臺提供的局域網(wǎng)聯(lián)機(jī)功能就可以獲得比較穩(wěn)定的聯(lián)機(jī)體驗(yàn)。還有一種方法就是搭建虛擬局域網(wǎng)(VLAN)了,比如使用N2N就可以搭建一個(需要自備服務(wù)器),或者用ZeroTier、Radmin LAN這類工具。
但是許多人在搭建好一個自己的局域網(wǎng)之后,卻發(fā)現(xiàn)進(jìn)了游戲怎么也找不到房間。
局域網(wǎng)聯(lián)機(jī)的游戲基本上是通過向 255.255.255.255 發(fā)送 UDP 廣播數(shù)據(jù)包來傳播游戲房間信息,但是 Windows 只會在首選的網(wǎng)絡(luò)接口(網(wǎng)卡)上發(fā)送全局 IP 廣播數(shù)據(jù)包,也就是說局域網(wǎng)游戲的信息沒有被 Windows 在虛擬局域網(wǎng)接口上廣播
以上摘自https://bugxia.com/3128.html,原出處https://www.bilibili.com/read/cv14633088。
本文以文明6這款游戲的找房機(jī)制為例,采用Hook技術(shù),攔截游戲?qū)DP廣播的發(fā)送、接收等操作,并用一系列的方法使得游戲的廣播能夠成功發(fā)送到所有網(wǎng)卡,并成功接收到來自其他客戶端的回復(fù)。
本文會詳細(xì)說明如何分析文明6的局域網(wǎng)聯(lián)機(jī)找房機(jī)制,以及Hook相關(guān)函數(shù)的細(xì)節(jié),所以內(nèi)容可能比較冗長,希望你能耐心看完。
在開始之前,希望你能對文本涉及到的Windows編程、socket編程、Hook技術(shù)、網(wǎng)絡(luò)抓包、反匯編調(diào)試等內(nèi)容有所了解,這樣可以便于理解。否則對于初學(xué)者來說可能會有一定的難度。
還有一點(diǎn),本文所提出的方法,可能并不適用除文明6以外的其他游戲,因?yàn)槊總€游戲的具體機(jī)制有所不同,需要通過分析得出。如果你熟悉分析方法,可以很容易地對不同的游戲找到適用的解決方案。而且,本文的方法僅適用與真實(shí)的或者虛擬的局域網(wǎng),而不適用于游俠這類同樣使用Hook的對戰(zhàn)平臺。
如果有IPv6地址,可以考慮使用該項(xiàng)目提供的工具:xaxys/injciv6: 文明6聯(lián)機(jī) - 基于IP的游戲發(fā)現(xiàn) (IPv4/IPv6) (github.com),相關(guān)視頻:https://www.bilibili.com/video/BV1qV411X7FP,其在本文基礎(chǔ)上添加了IPv6直連的功能,可以不需要組網(wǎng)。不過這里提醒一下,IPv6的入站比較復(fù)雜,目前絕大多數(shù)的光貓和路由器都是默認(rèn)開啟了防火墻的,需要依次到光貓和路由器關(guān)閉IPv6的防火墻(大部分都可以關(guān)),同時在系統(tǒng)放行游戲的網(wǎng)絡(luò)包,為了安全,不建議關(guān)閉系統(tǒng)的防火墻。至于移動數(shù)據(jù)的IPv6上網(wǎng),不確定能不能成功入站。
正文
本文代碼及編譯好的二進(jìn)制文件可以在這個倉庫找到。
https://gitcode.net/PeaZomboss/miscellaneous
源代碼在文件夾230130-hookgamesendto
若要下載二進(jìn)制,請到https://gitcode.net/PeaZomboss/miscellaneous/-/releases/civ6-hook-binary。
未來有關(guān)的代碼均可在上面的倉庫找到。
起因
去年的時候我和哥們一起玩起了文明6,為了能夠一起聯(lián)機(jī)游玩,就找了許多方法,后來找到了由Bug俠基于N2N開發(fā)的EasyN2N(在這里感謝作者免費(fèi)提供服務(wù)器供我們使用)。但是經(jīng)常遇到找不到對方房間的問題,后來逐漸排查出了原因,就是UDP廣播沒有發(fā)出去的問題,也發(fā)現(xiàn)EasyN2N有一定的解決辦法。
比如其集成了WinIPBroadcast這款廣播轉(zhuǎn)發(fā)工具,不過在實(shí)際使用中發(fā)現(xiàn)有的時候沒有作用。但是手動把虛擬網(wǎng)卡的躍點(diǎn)改小,提高其優(yōu)先級是一個有效的方案。但不知是哪邊的bug,這個躍點(diǎn)會時不時的自動變回去,很是惱火,就想找一個方便的方法。
由于N2N這類技術(shù)是使用虛擬網(wǎng)卡來實(shí)現(xiàn)虛擬局域網(wǎng)的搭建,所以我們只要想辦法把游戲的廣播發(fā)到虛擬網(wǎng)卡就可以解決問題了。不過實(shí)際上把廣播轉(zhuǎn)發(fā)到每張網(wǎng)卡就可以了,因?yàn)檫@樣比較方便實(shí)現(xiàn)。
于是我就想到了使用Hook技術(shù),把游戲發(fā)的廣播內(nèi)容攔截了,再給他轉(zhuǎn)發(fā)豈不是就能解決問題了?
于是就上網(wǎng)查了一下資料,花了不少時間上手做了一個demo,自己用著感覺不錯(其實(shí)有bug,能不能成功還是看玄學(xué)),然后幾個月沒怎么玩了,后來看到文明6正在更新領(lǐng)袖包,打算等更新完了再快樂聯(lián)機(jī),又想到之前寫過的代碼,就想著把原來的代碼梳理一下,然后重新寫個新的,順便溫習(xí)一下相關(guān)知識。
好了事情的起因就是這樣子了。
經(jīng)過
正當(dāng)我以為一切順利的時候,我發(fā)現(xiàn)事情沒有那么簡單,實(shí)際上不是簡單Hook并轉(zhuǎn)發(fā)一下廣播就能實(shí)現(xiàn)的。這不僅涉及到sendto函數(shù)的基本用法,還有一些比較復(fù)雜的細(xì)節(jié),包括端口復(fù)用如何收發(fā)包的問題。于是又要涉及到對于收包函數(shù)recvfrom,以及select函數(shù)的攔截與使用。
后來我根據(jù)抓包的結(jié)果,推測文明6游戲的邏輯,仿制了一個類似的測試工具,并在此基礎(chǔ)上不斷調(diào)試。后來為了確保能實(shí)現(xiàn)一個比較準(zhǔn)確的結(jié)果,拿出了x64dbg這一神器對游戲進(jìn)程進(jìn)行了一些調(diào)試,最終得出了游戲找房過程的具體方法,并復(fù)刻了一個在原理上幾乎是完全一致的工具。
有興趣可以閱讀其源碼。關(guān)于游戲的調(diào)試過程,我會在后文進(jìn)行一個介紹。
總之,在一段時間的努力之后,我終于從sendto到recvfrom和select三位一體進(jìn)行精準(zhǔn)Hook,拿下了最終的成功。
技術(shù)介紹
本文的代碼實(shí)現(xiàn)部分使用Hook技術(shù)(確切說是Inline Hook)以及socket技術(shù),由于還要涉及對其他進(jìn)程的Hook,所以還要用到注入技術(shù)。
本文的分析過程涉及到網(wǎng)絡(luò)抓包及軟件調(diào)試技術(shù),這個會在游戲分析部分進(jìn)行具體說明。
Hook介紹
Hook技術(shù)一般翻譯為鉤子技術(shù),就是提前在特定事件或消息處掛上鉤子,等執(zhí)行到此處就會觸發(fā)鉤子,執(zhí)行鉤子的代碼。Windows系統(tǒng)提供了SetWindowsHookEx等一系列函數(shù)實(shí)現(xiàn)Hook的功能,不過這和我們實(shí)際用到的不太一樣。
本文所用的Inline Hook就是把任何函數(shù)調(diào)用的前幾個字節(jié)改成一句跳轉(zhuǎn)指令,跳轉(zhuǎn)到自己的地方執(zhí)行,然后返回到原來的主調(diào)函數(shù),此時就獲得了函數(shù)的參數(shù)等一系列信息。許多人做的微信Hook就是這么搞的,不過缺點(diǎn)就是一旦函數(shù)地址或者參數(shù)變了,就得重新編寫相應(yīng)的代碼。
有關(guān)Inline Hook的具體介紹,請看本人寫的這篇文章。本文所用的方法就是以此為基礎(chǔ)的。
當(dāng)然對于游戲來說,其發(fā)送廣播必然離不開系統(tǒng)調(diào)用sendto或者WSASendTo,而接收包多是通過recvfrom,所以我們只要hook相應(yīng)的系統(tǒng)調(diào)用就可以了,而大部分系統(tǒng)函數(shù)的地址都是完全公開的。
當(dāng)然如果只是這樣還只能hook自己的進(jìn)程,想要hook其他進(jìn)程就得先把hook代碼編進(jìn)一個dll,再想辦法讓目標(biāo)進(jìn)程加載這個dll,這個過程叫注入(inject)。
當(dāng)我們的dll打入敵人內(nèi)部,就可以窺探其全部的虛擬地址空間,這樣我們就可以大施拳腳,為所欲為了??。
socket介紹
所謂套接字(Socket),就是對網(wǎng)絡(luò)中不同主機(jī)上的應(yīng)用進(jìn)程之間進(jìn)行雙向通信的端點(diǎn)的抽象。一個套接字就是網(wǎng)絡(luò)上進(jìn)程通信的一端,提供了應(yīng)用層進(jìn)程利用網(wǎng)絡(luò)協(xié)議交換數(shù)據(jù)的機(jī)制。從所處的地位來講,套接字上聯(lián)應(yīng)用進(jìn)程,下聯(lián)網(wǎng)絡(luò)協(xié)議棧,是應(yīng)用程序通過網(wǎng)絡(luò)協(xié)議進(jìn)行通信的接口,是應(yīng)用程序與網(wǎng)絡(luò)協(xié)議棧進(jìn)行交互的接口。
摘自百度百科
socket最早是伯克利在Unix引入的一套API,后來的Linux以及Windows都兼容了這套API,盡管Windows的有些許不同,不過大體上是相似的。
游戲發(fā)送UDP廣播一般是調(diào)用了winsock的sendto函數(shù),而現(xiàn)在新的winsock2兼容舊的winsock,所以對于絕大多數(shù)新老游戲,使用的sendto函數(shù)都可以被攔截。
本文要涉及到的socket函數(shù)有以下三個:sendto、select、recvfrom
關(guān)于sendto函數(shù),微軟官方介紹如下:
https://learn.microsoft.com/en-us/windows/win32/api/winsock/nf-winsock-sendto
函數(shù)原型如下:
int sendto(
[in] SOCKET s, // socket描述符
[in] const char *buf, // 發(fā)送的數(shù)據(jù)
[in] int len, // 發(fā)送數(shù)據(jù)的長度
[in] int flags, // 發(fā)送的選項(xiàng),一般為0
[in] const sockaddr *to, // 目標(biāo)地址信息
[in] int tolen // 目標(biāo)地址信息長度
);
關(guān)于recvfrom函數(shù),文檔如下:
https://learn.microsoft.com/en-us/windows/win32/api/winsock/nf-winsock-recvfrom
函數(shù)原型如下:
int recvfrom(
[in] SOCKET s, // socket描述符
[out] char *buf, // 接收數(shù)據(jù)的緩沖區(qū)
[in] int len, // 要接收數(shù)據(jù)的長度
[in] int flags, // 接收的選項(xiàng),一般為0
[out] sockaddr *from, // 發(fā)送方的地址信息
[in, out, optional] int *fromlen // 地址信息長度
);
有關(guān)這兩個函數(shù)的用法介紹,網(wǎng)上不計(jì)其數(shù),就不再展開,重點(diǎn)說一下select函數(shù)。
select函數(shù)文檔如下:
https://learn.microsoft.com/en-us/windows/win32/api/winsock2/nf-winsock2-select
函數(shù)原型:
int WSAAPI select(
[in] int nfds, // 用于兼容,winsock不關(guān)心
[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,說明函數(shù)出錯,原因通過WSAGetLastError獲取。
- 返回0,說明超過最大等待時間沒有可用的socket。
- 返回n,n>0,說明有集合中有n個準(zhǔn)備就緒的socket。
其中fd_set
結(jié)構(gòu)定義如下:
typedef struct fd_set {
u_int fd_count; // 集合內(nèi)socket的數(shù)量
SOCKET fd_array[FD_SETSIZE]; // socket集合,最大容量為FD_SETSIZE,默認(rèn)64
} fd_set, FD_SET, *PFD_SET, *LPFD_SET;
timeval
結(jié)構(gòu)定義如下:
typedef struct timeval {
long tv_sec; // 秒
long tv_usec; // 微秒
} TIMEVAL, *PTIMEVAL, *LPTIMEVAL;
為了便于操作,winsock2提供了一系列的宏,具體就不多說了。
值得注意到是,readfds、writefds、exceptfds都是可選的,只要不全是NULL就可以了,一般對于接收,只需設(shè)置readfds就行了。而這個timeout參數(shù)的時間則是由timeval類型的秒和微秒加起來的時間,如果timeout參數(shù)為NULL,則會一直等待。
由于輸入的集合會在調(diào)用select之后被改變,所以需要每次調(diào)用select之前重新設(shè)置集合。實(shí)際上fd_set的fd_count表明了可用的socket數(shù)量,而fd_array則依次保存了fd_count個socket。
這大概就是Windows下select的一些細(xì)節(jié)了。
注入介紹
寫好一個dll,想要加載一般有兩種方法,靜態(tài)鏈接和動態(tài)鏈接。而注入就是想辦法讓目標(biāo)進(jìn)程自己調(diào)用LoadLibrary函數(shù)來動態(tài)加載這個dll。而這個方法也就是最為簡單的直接注入法。當(dāng)然復(fù)雜的還有反射式注入和鏤空注入等方法,這些方法就不是直接調(diào)用LoadLibrary了,一般是用一段shellcode,不過這個就有點(diǎn)做病毒的意味了。
關(guān)于一些詳細(xì)的介紹可以看這個https://bbs.kanxue.com/thread-274131.htm。
這里我們選擇使用簡單方便的直接注入,具體的方法可以參考上面的鏈接,不過由于當(dāng)時寫程序的時候并沒有看到此文,所以我并沒有文中所說“給進(jìn)程提權(quán)”的方法,但也可以實(shí)現(xiàn)效果,原因暫不清楚。
直接注入一般是獲取目標(biāo)進(jìn)程,使用OpenProcess
打開進(jìn)程,然后使用*遠(yuǎn)程線程(Remote Thread)*執(zhí)行LoadLibraryA
,具體代碼會在后面說明。
游戲分析
這一部分主要是說如何分析文明6這款游戲局域網(wǎng)聯(lián)機(jī)的方式。
工具
使用Wireshark和x64dbg軟件。
對于Wireshark,我們需要找對網(wǎng)卡然后捕獲一段時間內(nèi)的數(shù)據(jù)進(jìn)行分析,具體只要熟悉工具欄前四個按鈕的功能就行了,其他更高級的操作也不需要。
對于x64dbg,我們需要在游戲運(yùn)行后附加(Attach)到進(jìn)程,然后對關(guān)鍵函數(shù)下斷點(diǎn)并進(jìn)行單步調(diào)試,需要熟悉一些基本的操作方法,同時建議裝上反調(diào)試插件減少可能的麻煩。
抓包分析
分析的時候最好不要有太多其他的網(wǎng)絡(luò)活動,避免抓包的時候干擾太多。
首先打開Wireshark,選擇Adapter for loopback traffic capture這個選項(xiàng),然后打開游戲點(diǎn)刷新,過一會就停止捕獲,從這些記錄里找目的是255.255.255.255的UDP包,這樣就可以發(fā)現(xiàn)這個包是從那個網(wǎng)卡發(fā)出來的了。
比如上圖,我抓了一下包,發(fā)現(xiàn)了大量從10.31.23.127:49190發(fā)出的UDP廣播,內(nèi)容長度都是4字節(jié),用ipconfig查一下發(fā)現(xiàn)這個IP是來自PPP撥號上網(wǎng)的網(wǎng)卡,其子網(wǎng)掩碼為255.255.255.255,即不可能發(fā)出廣播。在這種情況下,即使你使用虛擬局域網(wǎng)組網(wǎng)方案,但游戲UDP廣播依然不走這個網(wǎng)卡,而是被系統(tǒng)安排到了默認(rèn)網(wǎng)卡,所以根本找不到房間。
當(dāng)然還有一種情況我也遇到過,就是通過局域網(wǎng)上網(wǎng),IP地址為192.168.1.1,但是廣播既不從這個地址發(fā)出,也不從虛擬網(wǎng)卡發(fā)出,而是從192.168.56.1發(fā)出,而查了發(fā)現(xiàn)此IP竟然是VirtualBox的網(wǎng)卡。這種情況下,不管是同一個物理局域網(wǎng)還是虛擬局域網(wǎng),都不可能找到房間。
總之你會發(fā)現(xiàn),不論你以何種方式上網(wǎng),似乎都可能會遇到這種問題。我在網(wǎng)上看到過一個解決方法,就是把無關(guān)的網(wǎng)卡都禁用了,這確實(shí)可以,但是正如我前面測試的,假如你是PPP上網(wǎng),那么就算你關(guān)了所有多余的網(wǎng)卡,只留下虛擬網(wǎng)卡,廣播依然可能會從PPP的網(wǎng)卡發(fā)出去,而你不可能禁用這張上網(wǎng)的網(wǎng)卡。
當(dāng)然還有一個方法,就是修改網(wǎng)卡躍點(diǎn)。把一張網(wǎng)卡的躍點(diǎn)改小了,那么其優(yōu)先級就會提高,這樣廣播就會從指定的網(wǎng)卡發(fā)出去了。不過你也不知道躍點(diǎn)到底什么時候會不會自動改回去,這點(diǎn)有時候比較惱火。
回到前面那張抓包的截圖,如果你自己抓幾次包,然后分析一下每次抓到的數(shù)據(jù)包,就可以得出一個結(jié)論:游戲每次發(fā)包,都是用同一個端口向62900-62999這100個端口發(fā)送4字節(jié)的實(shí)際內(nèi)容。
很容易猜想到,游戲每次找房,都是用同一個端口發(fā)送廣播,而收到廣播的一端則向這個端口發(fā)送回信,提供房間信息。如果你有可以進(jìn)行聯(lián)機(jī)測試的條件的話,那就可以試試游戲是怎么接收房間信息的。如果可以成功聯(lián)機(jī)的話,就先確定能聯(lián)機(jī)的網(wǎng)卡,然后對該網(wǎng)卡抓包。
假如你現(xiàn)在在搜索房間,點(diǎn)一下刷新,如果看到了房間的話停止抓包,分析一下包的內(nèi)容,可以看到有定向的UDP包(通常有2個,因?yàn)閿?shù)據(jù)比較大)來自對方的IP,內(nèi)容是JSON格式的房間信息。再看一下端口,可以看到對方用62900-62999之間的某一個端口給你發(fā)包的那個端口發(fā)送給了這些房間信息。
這個時候反過來讓對方來找你的房間,你會收到來自對方的IP發(fā)送的那些包,找到最早收到的那幾個包,記住端口,你的客戶端一般就是用第一個收到的端口回復(fù)了房間信息出去。
然后當(dāng)雙方在同一個房間的時候,就可以通過抓包看到看到雙方都在用62056端口瘋狂的你一句我一句,好是熱鬧。當(dāng)然我也不清楚為什么到這里又是用固定端口通信了,我沒試過占用這個端口會怎么樣,也沒試過作為主機(jī)的時候和多個客戶機(jī)的通信過程,如果你條件允許的話可以試試看,如果測出結(jié)果了可以補(bǔ)充一下。
基于以上內(nèi)容,我們可以猜想游戲是用一個循環(huán)向這100個端口發(fā)送了廣播數(shù)據(jù),因?yàn)橛昧送粋€端口,所以可以基本確定用了類似如下代碼發(fā)送的數(shù)據(jù):
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)); // 允許地址(端口)復(fù)用
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));
}
這里補(bǔ)充一些知識,一個socket想要獲得相關(guān)功能必須用setsockopt,這里主要說一下SO_REUSEADDR(地址復(fù)用),其實(shí)在Linux還有一個SO_REUSEPORT(端口復(fù)用),而Windows只有SO_REUSEADDR,不過兼具SO_REUSEPORT功能。設(shè)置這個以后呢,就可以用另一個socket(也要設(shè)置SO_REUSEADDR)再次bind這個端口了,否則是不行的。
其次,獲取一個socket的端口和地址可以用getsockname這個函數(shù)獲取,這樣就可以用一個新的socket來綁定這個端口監(jiān)聽了;但是由于一個沒有綁定過的socket是沒有端口信息的,所以要先執(zhí)行sendto,然后系統(tǒng)就會給這個socket分配一個端口(其實(shí)是隱式綁定),這樣就可以實(shí)現(xiàn)每次刷新都用系統(tǒng)提供的不同端口來發(fā)送和接收了。而我們就可以利用這個來捕獲這個端口,甚至強(qiáng)行設(shè)定一個端口。
由于涉及到100個端口,所以接收方(創(chuàng)建了房間的客戶端)就用100個socket分別監(jiān)聽來自這100個端口的信息(好處是防止端口占用,畢竟100個端口同時被占的情況幾乎不存在),然后用select函數(shù)來篩選第一個收到消息的端口,然后用這個端口發(fā)送房間信息給對方。
那我們的目標(biāo)就很明確了,就是hook住這個sendto函數(shù),在循環(huán)發(fā)送100個包的時候,把這100個包發(fā)到每一張網(wǎng)卡去,當(dāng)然也可以指定一張或幾張網(wǎng)卡。
hook之后程序的流程就變成了如下這樣:
// ...
for (int i=62900;i<63000;i++){
to.sin_port = htons(i);
sendto(s,databuffer,4,flags,(sockaddr *)to,sizeof(to)); // 會調(diào)用下面的fake_sendto
}
Hook替換的偽代碼:
int fake_sendto(/*參數(shù)列表*/)
{
取消hook;
if (不是廣播)
sendto();
else {
for (每一個地址) {
創(chuàng)建socket;
綁定地址;
設(shè)置屬性;
sendto();
關(guān)閉socket;
}
}
重新hook;
}
提前透露一下,這個方法是有問題的,原因如下:
- 在調(diào)用sendto后立即關(guān)閉socket,極有可能根本發(fā)送不出去,這個可以用socket隊(duì)列解決。
- 在調(diào)用sendto發(fā)送出去后,必須用同一個socket才能接收到回復(fù),即使復(fù)用了端口。
- 取消hook再重新hook并不適用于多線程的情況
所以我們要用固定的socket來實(shí)現(xiàn)這個效果,而不是會變的socket。
調(diào)試分析
前面的網(wǎng)絡(luò)抓包的結(jié)果只能猜測,實(shí)際的方法還得要調(diào)試才能知道。
打開游戲,待游戲加載完成后,打開x64dbg,選擇文件-附加,找到游戲進(jìn)程附加上去。之后就可以選擇下斷點(diǎn)的位置了。
首先可以確定的是,sendto函數(shù)大概率是會被調(diào)用的。而且在點(diǎn)擊局域網(wǎng)游戲的時候就會發(fā)送廣播,之后每次刷新都會發(fā)送。所以我們可以在x64dbg下方的命令框輸入bp sendto
,就可以在sendto函數(shù)處下一個斷點(diǎn)。
這樣當(dāng)我們點(diǎn)擊局域網(wǎng)或者刷新的一瞬間,游戲應(yīng)該被阻塞,此時x64dbg已經(jīng)成功攔截到了sendto函數(shù)并將RIP指向第一句。這時我們已經(jīng)進(jìn)入到sendto函數(shù)內(nèi)部了,而我們對這個不感興趣,只需在調(diào)試菜單選擇運(yùn)行到用戶代碼即可回到游戲調(diào)用sendto之后的一句了。
注意這是x64的匯編,結(jié)合動態(tài)調(diào)試結(jié)果,稍微解釋一下:
這是一個for循環(huán),其中di
寄存器為循環(huán)變量,si
是循環(huán)的最大值,當(dāng)di
小于si
就接著循環(huán),di
的初始值為62900,而si
則為固定值63000。看140826160這一句mov ecx, di
,把端口號作為參數(shù)傳給了htons
函數(shù),將其轉(zhuǎn)換到大端。接著就是給后面調(diào)用sendto
函數(shù)傳參的幾句代碼,就沒什么好說了。這說明之前猜測的發(fā)送代碼邏輯上基本沒什么問題。
此時注意觀察一下14082616D這一句mov rcx, qword ptr ds:[rbx+68]
,可以看一下rbx+68
處的內(nèi)容,就是發(fā)送時所用的那個socket描述符了。調(diào)試的時候可以記一下這個數(shù)值,一會還會出現(xiàn)。
之后游戲一定會等待這個socket收到的信息,于是你可以試著bp recvfrom
,可是卻發(fā)現(xiàn)根本沒有被斷下!
參考前面說的,實(shí)際上游戲用了select函數(shù)來判斷這個socket能否讀取數(shù)據(jù),而不是直接調(diào)用recvfrom,因?yàn)閞ecvfrom是一個阻塞函數(shù),這肯定是不能直接用的。
不妨用bp select
斷下,再進(jìn)行一次調(diào)試,在調(diào)用完sendto函數(shù)之后,按下F9讓程序運(yùn)行,果然到了select內(nèi)部被斷了下來。此時可以看一下調(diào)用參數(shù)的內(nèi)容:
對照之前的函數(shù)原型,就可以知道第二個參數(shù)1476B0處的是可讀取socket集合,到內(nèi)存窗口看一下,內(nèi)容如下:
這個就是fd_set
結(jié)構(gòu)體了,第一個01
說明只有一個socket,第二個E8 0F
就是之前那個socket(每次都不一樣)。
我們再看看調(diào)用完socket之后的邏輯:
可以看到在140827AB0處進(jìn)行了判斷,如果返回值為0,則140827AB2處的跳轉(zhuǎn)就會執(zhí)行。接著再調(diào)用__WSAFDIsSet
來判斷目標(biāo)socket是否還在集合中,不在的話同樣跳轉(zhuǎn)。這樣之后,就開始執(zhí)行memset
來清空一片內(nèi)存并用recvfrom
來接收數(shù)據(jù)了。
根據(jù)上述調(diào)試,簡單概括一下的話,就是在你點(diǎn)擊進(jìn)入局域網(wǎng)或者在局域網(wǎng)內(nèi)進(jìn)行刷新的時候,游戲就會先新建一個socket然后循環(huán)調(diào)用sendto發(fā)送數(shù)據(jù),然后在一個線程里用select函數(shù)來等待一段時間,判斷是否收到數(shù)據(jù),確認(rèn)收到后才會調(diào)用recvfrom進(jìn)行真正的接收。
我們可以Hook上述三個函數(shù),依次用fake_sendto
、fake_select
和fake_recvfrom
替換。
在第一次執(zhí)行fake_sendto
時枚舉當(dāng)前所有網(wǎng)卡,然后建立幾個socket分別綁定這些網(wǎng)卡,之后所有的發(fā)送都通過這幾個socket進(jìn)行。
在fake_select
中執(zhí)行真正的select,但是檢查的集合卻是我們在fake_sendto
中建立的那幾個socket,在檢測到有數(shù)據(jù)后,就記錄可以獲取數(shù)據(jù)的那個socket,并返回另一個集合的數(shù)量欺騙原來的程序,讓其以為收到了回復(fù)。
然后我們還在fake_recvfrom
中調(diào)用真正的recvfrom來接收數(shù)據(jù),但第一個參數(shù)被替換成了在之前fake_select
記錄下來的那個有數(shù)據(jù)的socket。
這樣一套組合拳,形象的解釋就是游戲用廣播喊話,但是麥克風(fēng)沒有連接到廣播,而是連到了我們這,我們根據(jù)游戲喊話的內(nèi)容再喊了一遍,當(dāng)然熱線電話就改成了我們自己的;游戲要等電話,實(shí)際是我們相當(dāng)于做了一個接線員,把聽眾打過來的電話轉(zhuǎn)接給了游戲;這樣下來,游戲以為自己的廣播喊出去了,也接到了聽眾的電話。
所以游戲還是按照它自己的邏輯進(jìn)行收發(fā)數(shù)據(jù)包,但是整個過程是我們幫它進(jìn)行了代理轉(zhuǎn)發(fā)。
代碼說明
這部分主要解釋一下實(shí)際使用的代碼邏輯和原理。
這部分踩了很多坑,重點(diǎn)說明一下大坑。
注入程序
先說注入程序,因?yàn)橹挥谐晒ψ⑷肓瞬拍軠y試dll有沒有問題。
直接上代碼,具體有注釋說明:
// 參數(shù)1:進(jìn)程的pid,參數(shù)2:dll絕對路徑,返回值:成功true,失敗false
bool inject_dll(DWORD pid, const char *dll_path)
{
int path_len = strlen(dll_path) + 1; // 獲取長度包括'\0'
HANDLE hproc = 0; // 目標(biāo)進(jìn)程句柄
LPVOID pmem = NULL; // 目標(biāo)進(jìn)程的虛擬內(nèi)存指針
HANDLE hthread = 0; // 遠(yuǎn)程線程句柄
bool result = false;
hproc = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid); // 打開進(jìn)程
if (hproc == 0) goto finally;
pmem = VirtualAllocEx(hproc, NULL, path_len, MEM_COMMIT, PAGE_READWRITE); // 申請內(nèi)存
if (pmem == NULL) goto finally;
WriteProcessMemory(hproc, pmem, dll_path, path_len, NULL); // 把dll路徑寫進(jìn)去
hthread = CreateRemoteThread(hproc, NULL, 0, (LPTHREAD_START_ROUTINE)LoadLibraryA, pmem, 0, NULL); // 創(chuàng)建遠(yuǎn)程線程注入
if (hthread == 0) goto finally;
WaitForSingleObject(hthread, INFINITE); // 等待線程執(zhí)行
DWORD threadres;
GetExitCodeThread(hthread, &threadres); // 獲取返回值
result = threadres != 0; // LoadLibraryA錯誤返回0
finally: // 安全釋放相應(yīng)資源
if (pmem)
VirtualFreeEx(hproc, pmem, 0, MEM_RELEASE);
if (hthread != 0)
CloseHandle(hthread);
if (hproc != 0)
CloseHandle(hproc);
return result;
}
根據(jù)進(jìn)程名獲取pid的方法(需要頭文件tlhelp32.h):
// 參數(shù):目標(biāo)進(jìn)程名,返回值:進(jìn)程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;
}
這段代碼比較清晰,建立快照,簡單循環(huán)查找,使用字符串比較,無需過多解釋。唯一要注意的是這里用的是char *
,所以不要用UNICODE模式,如果用VS的話要加上#undef UNICODE
再include頭文件。
上面兩段代碼是注入的關(guān)鍵內(nèi)容,剩下的就自行查看源代碼(都是一些基礎(chǔ)的邏輯),需要注意的是,如果被inject函數(shù)注入的進(jìn)程有管理員權(quán)限,那么調(diào)用inject的進(jìn)程也要有管理員權(quán)限,否則注入會失敗。
Hook DLL
這里就簡單貼一下關(guān)鍵代碼和相應(yīng)的注釋,詳細(xì)了解請看前面的說明。
重要提醒:這里的Hook代碼僅供參考,由于存在兼容性問題,不適合直接使用,新版已經(jīng)進(jìn)行了修改
Hook類
#include <windows.h>
#define HOOK_JUMP_LEN 5
class InlineHook
{
private:
void *old_entry; // 存放原來的代碼和跳轉(zhuǎn)回去的代碼
char hook_entry[HOOK_JUMP_LEN]; // hook跳轉(zhuǎn)的代碼
void *func_ptr; // 被hook函數(shù)的地址
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; // 根據(jù)DOS頭獲取PE信息偏移量
p += sizeof(IMAGE_FILE_HEADER) + ((IMAGE_FILE_HEADER *)p)->SizeOfOptionalHeader; // 跳過可選頭
WORD sections = ((IMAGE_FILE_HEADER *)p)->NumberOfSections; // 獲取區(qū)段長度
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; // 計(jì)算空白區(qū)域偏移量
offset += 16 - (INT_PTR)offset % 16; // 對齊16字節(jié)
UINT64 *buf = (UINT64 *)offset;
while (buf[0] != 0 || buf[1] != 0) // 找到一塊全是0的區(qū)域
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字節(jié)內(nèi)存可讀可寫可執(zhí)行
VirtualProtect(func_ptr, HOOK_JUMP_LEN, PAGE_EXECUTE_READWRITE, NULL);
// 使用VirtualAlloc申請內(nèi)存,使其可讀可寫可執(zhí)行
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); // 找到第一處空白區(qū)域
VirtualProtect(blank, 14, PAGE_EXECUTE_READWRITE, NULL); // 可讀寫
hook_entry[0] = 0xE9; // 跳轉(zhuǎn)代碼
*(DWORD32 *)&hook_entry[1] = (BYTE *)blank - (BYTE *)func_ptr - 5; // 跳轉(zhuǎn)到空白區(qū)域
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
// 寫入真正的跳轉(zhuǎn)代碼到空白區(qū)域
WriteProcessMemory(GetCurrentProcess(), blank, &blank_jump, 14, NULL);
// 保存原來的入口代碼
memcpy(old_entry, func_ptr, entry_len);
ptr64.ptr = (BYTE *)func_ptr + entry_len;
// 設(shè)置新的跳轉(zhuǎn)代碼
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; // 跳轉(zhuǎn)代碼
*(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
結(jié)合之前所述,我們需要以下全局變量來存放:
static SOCKET origin_sock = 0, fake_sock = 0;
static std::vector<SOCKET> socks;
其中origin_sock
是sendto函數(shù)的那個socket,游戲是通過這個socket來實(shí)現(xiàn)數(shù)據(jù)的收發(fā)的。
而fake_sock
則是用來在fake_select
和fake_recvfrom
中給游戲接收數(shù)據(jù)用的。
vector容器socks
則是存放每一個網(wǎng)卡對應(yīng)的socket,實(shí)現(xiàn)接收回復(fù)用的。
函數(shù)fake_sendto
是被hook的sendto跳轉(zhuǎn)過來的執(zhí)行的函數(shù),這是完成廣播轉(zhuǎn)發(fā)的核心。
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); // 非廣播直接原樣發(fā)送
int result = -1;
origin_sock = s; // 暫存這個socket
const std::vector<in_addr> &list = enum_addr(); // 枚舉網(wǎng)卡地址
if (socks.size() != list.size()) { // 數(shù)量不同則重新綁定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) {
// 如果沒有端口號,先原樣發(fā)送,這樣系統(tǒng)才會分配一個端口號
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]); // 關(guān)閉之前的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
}
}
// 向列表中的每一個地址轉(zhuǎn)發(fā)廣播
for (int i = 0; i < socks.size(); i++)
result = _sendto(socks[i], buf, len, flags, to, tolen);
return result;
}
注意在枚舉網(wǎng)卡數(shù)量后進(jìn)行判斷,如果網(wǎng)卡數(shù)量和socket數(shù)量不一致,則關(guān)閉并清空所有socket,然后重新綁定。這個情況一般只會發(fā)生在第一次Hook的時候,偶爾也會在網(wǎng)卡數(shù)量更改的時候。
枚舉網(wǎng)卡的代碼如下:
static const std::vector<in_addr> &enum_addr()
{
static std::vector<in_addr> list;
hostent *phost = gethostbyname(""); // 獲取本機(jī)網(wǎng)卡
if (phost) {
char **ppc = phost->h_addr_list; // 獲取地址列表
int addr_num = 0;
while (*ppc)
addr_num++, ppc++; // 獲取地址個數(shù)
if (addr_num == list.size()) // 數(shù)量相同直接返回
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;
}
函數(shù)fake_select
相對簡單,主要是注意不能誤傷其他正常調(diào)用的select就可以了。
static int WINAPI fake_select(int n, fd_set *rd, fd_set *wr, fd_set *ex, const TIMEVAL *timeout)
{
// 這里通過判斷數(shù)量和內(nèi)容,確保是我們需要修改的情況
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); // 把用于發(fā)送的socket放入集合
int r = _select(0, &fds, NULL, NULL, timeout);
if (r > 0) {
fake_sock = fds.fd_array[0]; // 暫存第一個有數(shù)據(jù)的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是我們暫存的且存在有數(shù)據(jù)的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
,不過運(yùn)行的函數(shù)是FreeLibrary
,參數(shù)是DLL的地址。
不過需要先獲取指定進(jìn)程加載的DLL,然后找到Hook的DLL是否已經(jīng)被加載了。
和枚舉進(jìn)程查找PID類似,使用CreateToolhelp32Snapshot
獲取指定進(jìn)程模塊快照,然后挨個字符串匹配找到指定模塊。
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;
}
參數(shù)module_handle
就是上一個函數(shù)獲取到的模塊基址。
調(diào)試方法
這類注入到其他進(jìn)程的DLL調(diào)試起來難度較大,這里給出幾種方案供參考:
- 使用文件IO操作,使用固定路徑如
D:\debug.txt
,將所有需要寫入的調(diào)試信息寫入這個文件,供后續(xù)分析。 - 使用TCP或UDP協(xié)議,編寫一個程序用來接收調(diào)試信息,把需要的內(nèi)容直接發(fā)送出來。
- 使用x64dbg進(jìn)行跟蹤調(diào)試,因?yàn)镠ook了函數(shù),所以可以跟進(jìn)DLL實(shí)現(xiàn)動態(tài)調(diào)試。
注意調(diào)試的時候最好全程使用Wireshark抓包,從而方便確定問題。
這里推薦方法1,因?yàn)楸容^簡單且可以配合Wireshark抓包結(jié)果;當(dāng)然能力夠強(qiáng)的話用方法3,配合抓包這個是最直觀明了的。
成品測試
打開游戲,雙擊injciv6.exe注入,打開Wireshark抓包。在游戲里進(jìn)入局域網(wǎng),刷新,可以看到Wireshark一瞬間出現(xiàn)了好多包,看到每個可用的地址都通過同一個端口向62900-62999這100個端口發(fā)送了廣播。
然后可以找人聯(lián)機(jī)測試,基本上是不會失敗的,尤其是在抓到了含房間信息的包之后,如果游戲房間列表還是沒有顯示,那一般是游戲的問題,這個時候再刷新幾次,如果不行就返回到主菜單重新進(jìn)去。
關(guān)于游戲穩(wěn)定性,這個幾乎是不會有影響的,畢竟單機(jī)的時候都經(jīng)常崩潰,在不用這個方法之前聯(lián)機(jī)也總是崩潰,而且聯(lián)機(jī)的時候網(wǎng)絡(luò)的穩(wěn)定性更加重要。
其他游戲
目前我沒有試過其他游戲是個什么樣的情況,因?yàn)椴煌挠螒蚩赡苡胁灰粯拥臋C(jī)制,但是如果找房間的機(jī)制是用的UDP廣播,且使用了select進(jìn)行判斷,那么理論上此方法是可以使用的。
否則需要針對性進(jìn)行一些修改,具體就要通過抓包+反匯編調(diào)試了。但是萬變不離其宗,最終都是讓游戲能成功發(fā)出信息并收到回復(fù)。
目前研究發(fā)現(xiàn)饑荒聯(lián)機(jī)版的局域網(wǎng)聯(lián)機(jī)和文明6使用類似機(jī)制,但不完全一樣,饑荒使用的是RakNet網(wǎng)絡(luò)庫,但是好巧不巧啊,廣播都是只能發(fā)一個網(wǎng)卡的。
總結(jié)
之前我對這塊知識的了解還是不夠,把一些問題想簡單了,所以提出了不正確的方法,而且在bug的作用下居然將錯就錯跑出了結(jié)果。不過好在基本原理沒有出錯,所以在進(jìn)一步的研究之后,終于還是成功解決了問題。這也讓我對相應(yīng)的知識有了更進(jìn)一步的認(rèn)識。
此次重寫(2023-03)花費(fèi)不少時間,尤其是中途事情比較多,所以進(jìn)展緩慢。而且工程量比較大,不亞于寫一篇新的文章。但是本著認(rèn)真負(fù)責(zé)的原則,對出現(xiàn)問題的內(nèi)容必須加以改正,才能不誤導(dǎo)他人。文章來源:http://www.zghlxwxcb.cn/news/detail-567979.html
本文未來應(yīng)該不會再有比較大的變動了,不過代碼倉庫還是會有更新的,有興趣了解更多東西可以到開頭的倉庫看一看。文章來源地址http://www.zghlxwxcb.cn/news/detail-567979.html
更新記錄
- 2023-01-19:修正一些錯別字,優(yōu)化部分表述問題,新增更多的解釋
- 2023-01-31:優(yōu)化部分代碼,添加新的源代碼鏈接,新增更新預(yù)告
- 2023-03-05:完成了對大部分內(nèi)容的重寫
- 2023-09-01:修正錯別字,優(yōu)化部分內(nèi)容,代碼優(yōu)化
- 2023-09-24:新增移除注入的DLL說明
- 2024-01-31:修正部分內(nèi)容,添加相關(guān)項(xiàng)目的簡要介紹
到了這里,關(guān)于使用Hook攔截socket函數(shù)解決虛擬局域網(wǎng)部分游戲聯(lián)機(jī)找不到房間的問題——以文明6為例的文章就介紹完了。如果您還想了解更多內(nèi)容,請?jiān)谟疑辖撬阉鱐OY模板網(wǎng)以前的文章或繼續(xù)瀏覽下面的相關(guān)文章,希望大家以后多多支持TOY模板網(wǎng)!