福禄科技罗宇翔: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:github.com/poembro/ope…

解决的痛点

使用 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,推荐下面的库:github.com/chronolaw/l…

关于长连接超时,我们做 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
复制代码

其实超时是无害的,当时春哥在 github.com/openresty/l… 里提过,这里的情况是用到 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下载:

OpenResty 游戏反外挂应用 - 又拍云

猜你喜欢

转载自blog.csdn.net/weixin_34356555/article/details/91368001