福祿科技羅宇翔:OpenResty 遊戲反外掛應用
2019 年 5 月 11 日,OpenResty 社群聯合又拍雲,舉辦 OpenResty × Open Talk 全國巡迴沙龍武漢站,福祿科技服務端研發工程師羅宇翔在活動上做了《 OpenResty 遊戲反外掛應用 》的分享。
OpenResty x Open Talk 全國巡迴沙龍是由 OpenResty 社群、又拍雲發起,邀請業內資深的 OpenResty 技術專家,分享 OpenResty 實戰經驗,增進 OpenResty 使用者的交流與學習,推動 OpenResty 開源專案的發展。活動已先後在深圳、北京、武漢舉辦,後續還將陸續在上海、廣州、杭州等城市巡迴舉辦。
羅宇翔,福祿科技服務端研發工程師,喜歡折騰 php、lua、nginx 等技能,目前負責公司遊戲反外掛服務端業務。專注於後端高併發,高可用服務設計,對 OpenResty 應用到 Web 專案有較多經驗。
以下是分享全文:
業務場景
福祿科技應用 OpenResty 主要在以下兩個業務場景:
-
使用 OpenResty 做了安全模組,目前市面上大多遊戲開發商把精力放在遊戲業務場景上,忽略了安全模組, 如防止外掛,防盜號;
-
遊戲賬號租賃。
經常玩遊戲的使用者可能會遇到這個介面,如果你開了外掛被封禁了,再次登陸就是這個頁面;另一種情況是裝備厲害的使用者去了某個網咖後,再次登陸游戲發現裝備被盜了,我們的產品就是針對這樣的場景使用的。
反外掛產品功能
福祿科技的反外掛產品主要功能點包括:
-
外掛規則庫。服務端會有一個外掛規則庫,有一個逆向的客戶端,會配合抓取遊戲的使用者環境,即遊戲執行過程中啟用的哪些程序的資訊,然後把這些資訊傳到服務端作為校驗。
-
使用者遊戲環境檢查。我們會從反外掛規則庫里拉取一些遊戲的目錄路徑,掃描關鍵的遊戲檔案,比如外掛也是一個檔案。
-
TCP 網路校驗。有些外掛是需要聯網的,因此會有服務端的 IP 地址,加埠,我們會記錄下來並加到外掛規則庫裡,一旦發現客戶端某個程序連上這個服務端 IP ,我們會從外掛規則庫對比把他踢下線。
-
特徵碼校驗。
-
掃記憶體程式碼校驗。
特徵碼掃描
在 Windows 環境下,所有 exe 或者 dll(linux .so 動態庫檔案)檔案,都是固定格式的 PE 檔案格式。任何一個 C/C++ 程式碼,編譯之後都會遵守 PE 檔案格式,會劃分為幾個區,包括全域性區、常量區、程式碼區等。我們會從已編譯的二進位制檔案中提取特徵碼,上傳到服務端,在遊戲執行前和執行的過程中各掃描一次。在拿到特徵碼後,會全盤掃描正在執行的 PE 檔案,匹配到特徵後上傳至服務端做校驗。
text 程式碼段校驗
編譯完的 PE 會有固定的結構,其中有一個區段 text 是存放執行程式碼的,在正常情況下不會更改,而外掛可能會修改遊戲程式碼來改變遊戲的邏輯,所以我們可以通過校驗 text 段的程式碼來確認是不是被修改了,校驗的方式一般有 2 種,一種是與檔案比較,另外一種是 hash 整個程式碼段的值與正確的值做比較。
保護遊戲程序
為防止 dll 注入到遊戲程序空間,對遊戲程序做保護,我們會先啟動反外掛程式,然後由反外掛程式拉起遊戲程序,此時會預埋一些回撥,以此來監控遊戲 dll 載入情況和監控外部程式往遊戲內部分配記憶體情況。
檢查函式呼叫堆疊
某些外掛會呼叫遊戲函式,比如實現自動撿物、自動走路等功能,這時需要在關鍵函式位置回溯堆疊,判斷是否有非法的呼叫。我們可以逆向出自動撿物的函式,在函式預埋一個點,通過在執行過程中進行回溯,回溯可以得到正常的慢函式呼叫、text 函式呼叫等,再加上執行過程中,其他地方會呼叫過來,此時可以進行對比,如果出現不一樣就可以認定為它有非法呼叫的情況。
使用 OpenResty 升級服務端架構
V1 版本
第一版開發週期短,業務新穎,迭代速度快,重點是自動發現作弊使用者。
上圖是我們 V1 版本的架構圖,請求從客戶端進來到負載均衡,選了 WebSocket server 節點,這個節點首先需要驗證,呼叫 Auth 服務。然後 WebSocket 服務獲取客戶端傳上來的特徵碼資訊,與外掛規則庫裡的特徵碼做對比,如果對比發現和規則庫中有不一致會發起一個返回值,比如返回“您的遊戲執行環境異常”。
執行一段時間後,我們發現 V1 版本的架構會遇到一些問題。
-
將 WebSocket 和 Worker 寫在一個專案裡,耦合性太高,不方便擴充套件維護;
-
突發流量。放假期間,流量特別高,就會遇到 Redis 記憶體消耗高、掃描時間過長和服務掉線頻繁的問題;
-
外部服務訊息推送接入複雜;
-
業務功能耦合。
於是,針對這個架構我們用 OpenResty 重構了接入層。
V2 版本
上圖是我們用 OpenResty 重構接入層後的服務端 V2 版本。使用 OpenResty 做與客戶端直連的接入層,主要負責:
-
使用者遊戲環境資訊檢查,從規則庫下發該檢查的目錄列表,把這些目錄的可執行檔案取特徵碼,因為在執行過程中,一部分是已經在運行了,一部分還在本地,所以此時會進行兩次對比掃描;
-
流控,異常告警,之前我們線上客戶端釋出的一個新版本,它不斷地掃描不同的目錄,目錄是固定死的,且客戶端的目錄檔案結構非常大。此時,流量一直在告警,我們用 OpenResty 做了流量限制,比如我們取一個檔案 size,放在 ngx.ctx 上,變數會一直往上加,當達到我們限制的數量時就會立馬阻斷這款連結;
-
大檔案分片上傳,大檔案分片上傳可能會遇到一個情況,比如第一片上傳了,中間空了一片,沒有達到完整的檔案上傳,而我們又不能一直讓其他分片佔用著記憶體,於是我做了一個定時器,如果 60 秒內檔案還沒有上傳完成(用已上傳的總大小對比第一個分片裡檔案總量的大小),如果符合就把這個檔案放棄;
-
FFi 呼叫加、解密庫與客戶端保持一致,客戶端是 C++寫的,它有一些自己寫的加解密演算法,我們用 OpenResty 做的事情是用 FFI 呼叫,兩個搭配起來非常方便,零切入;
-
已知外掛攔截,之前我們的做法是直接讀取庫,現在將換成 ES,加上 Redis 快取,這裡不需要查 ES,直接從快取裡面走,因為已知的是規則庫裡面配好的,已經知道哪些特徵是外掛,哪些不是,所以可以直接從快取裡面讀取。這裡沒有用到共享記憶體,主要原因是快取數量太大,不適用於 OpenResty 的共享記憶體;
-
廣播訊息,因為我們的 worker 層一直在掃描客戶端發過來的程序相關的資料,比如視窗大小、檔名路徑等,程序的基本資訊都會傳上來。此處的推送是直接用共享記憶體,一共兩個執行緒,一個主執行緒就能完成一個連線,可以讀可以寫,但是如果一個外部的訊息需要介入主執行緒的讀寫,就需要一個子執行緒一直讀取共享內容裡的 message。
因為我們要保護遊戲帳號不被盜,而在客戶端輸入的帳號密碼可能都會被逆向抓捕到,所以這裡其實用的是臨時的訂單號/登入碼。在遊戲登陸時,先啟動反外掛,由反外掛提供輸入,這裡輸入的不是帳號密碼,因為遊戲都會有自己的帳號密碼,而這裡會把遊戲的帳號密碼機制獨立出來,通過生成一個 訂單號/登入碼,經由 passwd server 轉換,在 Web server 服務裡,客戶端傳一個 訂單號/登入碼進來,拿到對應的帳號密碼,再進入登入流程。
推送
推送我們沒有用到 Redis 的訂閱和釋出,這裡使用的最簡單的共享記憶體。
客戶端接入 OpenResty 的服務的時候,就已經儲存了 session 服務, session 服務裡面存在的是它是在哪個節點、埠以及它的個人資訊,包括使用者 ID、使用者名稱等。此時首先要獲取 session 服務的地址,如果要推送訊息過來就 push 一下,push 過來的時候一定是有房間號的,房間號其實就是 session 服務裡拿到的 session_id,另外要加上訊息的內容體,包括髮送訊息的物件、訊息的內容等。通過共享記憶體的兩個 set 值,相當於字串的標記,第一個是偏移,以 session_id 作為偏移,比如發第一條訊息是 1 ,第二條訊息是 2 ,第三條是 3 ,對應的 room_id 有 001、002、003。接下來還有 set 訊息體,比如 set 001 的時候,拼接的 set 訊息體的 K=(m:room_id:001),每次由子執行緒不停地迴圈拉取最新的值,最新的值是 ICR ,他是一直在增加的,這裡會一直獲取最新的偏移值。
關於線上資料的儲存,每個應用都會有線上列表,我們把它放在 Redis zset 裡面;訊息是使用 ngx.shared.DICT;訊息可能會出現遺漏的情況,如果房間號超時了,而此時又有一條訊息傳送過來,最新的訊息是讀不到的,產生這種情況,新發布過來的訊息需要記錄下來,防止漏訊息,進行訊息日誌記錄。下面是 Demo:https://github.com/poembro/openresty-websocket-demo。
解決的痛點
使用 OpenResty 實現的新的版本解決了以下的痛點:
-
開發效率,從 V1 版本到 V2 版本,其實已有的協議已經和客戶端對應,協議沒有更改,唯一更改的是推送,所以開發效率比較高;
-
高併發,我們從 Nodejs 換成 OpenResty,主要原因是週末流量突增的情況,Nodejs 記憶體佔用非常高。因為我們業務發過來的資料,需要一直掃描記憶體裡面的資訊,會造成單程序,記憶體增加,響應遲緩。還有 Nodejs 那種 WebSocket 服務,Web 服務要推送一條訊息過去,還需要 WebSocket client 連過去,如果使用 OpenResty 來實現,只需要加一個 location,push 一個訊息過去操作共享記憶體,把偏移加一下,資料寫過去就好了;
-
資源佔用少;
-
外部服務訊息走 HTTP 接入推送;
-
熱更新,已線上不受影響。如果是 Nodejs,一旦資料量大就掛了,正在玩遊戲的使用者就會掉線。使用 OpenResty 它是直接 Reload ,雖然共享記憶體裡面的訊息會丟失,但是我們有日誌記錄,並且外部接入的訊息不是很頻繁的情況下,我們是允許它丟失的。並且用 Reload 舊的程序,它不是立即結束的,而是等到已連結的客戶端全部斷開,沒有任何連結的時候才會結束。
問題與總結
下面是使用 OpenResty 開發這個應用的時候,遇到的一些問題。
lua-resty-string 模組有 to_hex (轉十六進位制),但是我沒有找到反轉的方法,反轉的方法是我在提問msgpack問題時發現的。
MessagePack v4 是我們的反外掛協議,資料傳輸的過程中我們會用 MessagePack 進行序列化,然後使用 zlib 壓縮。當我用這個庫時發現會出現報錯,這裡的問題在於 MessagePack 裡有多級,當解壓了一級之後還有第二級,而第二級也需要解壓,這裡二級的資料使用 MessagePack v4 是有問題的。後來作者回應說是需要相容,所以我們解決的方法是使用 V5,推薦下面的庫:https://github.com/chronolaw/lua-resty-msgpack。
關於長連線超時,我們做 WebSocket 的時候,經常會遇到以下的場景:
local data,typ, err
while true do
data, typ, err = wb:recv_frame()
if not data then
if not string.find(err, ‘timeout‘, 1, true) then
ngx.log(ngx.ERR, ‘--> timeout :’, err)
break
end
end if typ == 'close' then
break
end ngx.sleep(1)
--多餘的,有lua_yield(L, 0);end
其實超時是無害的,當時春哥在 https://github.com/openresty/lua-resty-websocket/issues/32 裡提過,這裡的情況是用到 ngx.req.socket 這個 API 的時候返回 tab,tab 會有幾個屬性包括 receive、send、close 等,每個方法綁定了一個 C 函式,比如 receive 接管了 Nginx 的 accept 返回的連線控制代碼,它會初始化一些例如讀事件、寫事件回撥,加入到 Nginx event model ,追加的時候又會涉及到超時的引數,比如 lua 中 set timeout 5 秒,在 5 秒內還沒有讀寫事件觸發,receive 會繼續往下執行,此時發生超時事件。
還有關於 ngx.sleep 一般為了防止太消耗計算機資源才這麼做,其實是多餘的,比如上面 receive 函式執行完後就會有一個 lua_yield(L, 0); 的協程呼叫,直到 epoll 有讀寫事件過來才恢復。
演講視訊觀看及PPT下載:
https://www.upyun.com/opentalk/424.