最近又有个手游停服了,是一款日本音游,叫 HoneyWorks Premium Live。为了能在停服后继续玩这款游戏(搓屏幕),我们尝试搭建游戏的私服。在我们搭建到一半的时候,被 HoneyWorks 国内代理制止,只好就此作罢(毕竟是实名上网的人,不太想在国内搞事情)。不过在这个过程中学到了不少东西,也证明了私服的可行性,故写一篇博客作作记录。

音游和其他游戏(如 LOL、绝地求生等)不同的一点是,LOL 这类多人竞技游戏需要较复杂的协议和技术来保证多人游戏时能同时和服务器进行交互,同时保持低时延等特性。音游的核心玩法是跟着音乐点击屏幕,这个过程其实是不需要联网的,只有在进入游戏时加载一些玩家状态等需要和服务器进行交互。所以音游的服务器更偏向传统的 REST API 后端服务,这也为我们搭建私服提供了可能性。

# 对手机 app 进行抓包

搭建手游私服的第一步,是通过对手游 app 的进行抓包,分析 app 到服务端是如何交互的。常用的抓包工具如 Fiddler、Charles、mitmproxy 都可以用。

不过,抓包有一个难点在解密 HTTPS。在没有信任自定义 CA 的前提下,HTTPS 可以说是非常安全,现阶段可以认为是不可破解的(要是能破解 HTTPS,可以去计算机密码学顶会发论文了,谁还来搭建私服啊)。上面说的三款抓包工具都能生成自己的 CA 证书,我们将证书安装到手机,并让 app 信任证书。

由于安卓 7+ 系统对 CA 管得比较严,app 默认不接受用户安装的证书,实践后总结有下面三种方案可以实现抓包:

  1. 使用 iOS 设备抓包
  2. root 安卓设备后安装 Magisk 模块,参考博客
  3. 对安卓 apk 拆包后修改 AndroidManifest.xml 使其接受用户 CA,然后再封装为 apk(封装前可以顺便修改一下包名,避免和原 app 冲突),参考Android APK HTTPS user certificates how-to (opens new window),后面实操的时候会细说

在开发阶段,由于我没有 iOS 设备,而且我有 Magisk,因此在选择了方案二。在发布私服时,为了让非 root 安卓用户也能玩上私服,也需要按照方案三做一个接受用户证书的 apk。

# 简单分析 API 格式

用 Fiddler(或另外两款工具)抓一下包就能看到,HoneyWorks Premium Live 客户端主要和两个后端进行交互。一个是 production.arisa-project.net 提供业务数据,request URL 和 response body 都符合 REST API 规范,可读性非常高(在此夸下日本程序员!)。另一个是 d1fsdx0i2ajtn2.cloudfront.net 是静态资源服务器,提供游戏所需的美术音乐素材。

picture 1

对应地,私服的后端需要做两件事情:一是对于每个客户端可能发出的请求,服务端能给出合法的 response body。response body 可以是静态的,但一定要是合法的,只要客户端接受到以后能正常解析、不报错就行。二是提供静态资源,这些静态资源需要预先下载到私服上,然后用 nginx 起一个静态资源服务器就行。

# 搭建提供重定向服务的代理服务器

在真正开始搭建私服之前,还有一件事情:如何让客户端去访问私服,这就是所谓中间人攻击(Man-in-the-middle attack)。有三个方向:

  1. 修改游戏本身:如果能直接修改游戏访问的服务器 URL,直接改了就行,能少很多事情;不过据说这个游戏安装包有加密,不太方便。
  2. DNS 污染:搭建一个 DNS 服务器,将官服域名解析为私服的 IP,再配置手机使用这个 DNS 服务器。TLS 解密由私服来实现。
  3. 代理:搭建一个代理服务器,将所有访问官服的流量“重定向”到私服。TLS 解密由代理服务器实现。

由于代理的现成解决方案很多,我们选择的是代理方案。不过实际使用中,发现所有流量都会从代理中转一次,这个影响还挺大的。各位如果想搭建私服,可以尝试一下 DNS 污染的方案。

我们采用的是 mitmproxy (opens new window),它除了抓包和解密 TLS 以外,还能用 Python 编写插件,十几行代码就能实现把官方的域名重定向到我们的服务器上。重定向的代码如下:

import logging
from mitmproxy.http import HTTPFlow

REDIRECT_HOSTS = [
    ['production.arisa-project.net', PRIVATE_SERVER],
    ['d1fsdx0i2ajtn2.cloudfront.net', PRIVATE_ASSETS_SERVER]
]


def request(flow: HTTPFlow):
    origin_host = flow.request.pretty_host
    replaced_host = next((x[1] for x in REDIRECT_HOSTS if x[0] == origin_host), origin_host)
    if origin_host != replaced_host:
        logging.info(f"Redirect connection to {origin_host} with {replaced_host}")
        flow.request.host = replaced_host

# 搭建私服服务器

私服服务器就是一个常规 REST API 后端,可以用任何 REST API 框架实现(Java/Spring、Python/Flask、TypeScript/express、……),然后在前面用 nginx 处理 HTTPS 然后反向代理即可。顺便说一句,既然是 REST API,也需要注意 HTTP 方法(GET、POST),抓包时游戏客户端请求是什么方法,私服就应该对什么方法进行监听。

对于某些不重要的 API,可以直接把前面抓包的结果 hardcode 到代码里,只要游戏客户端不报错就行。对于某些影响到游戏运行的 API(比如当前用户体力、抽卡等),可以动态实现一下。

如果部分动态 API 需要从数据库拿数据(比如抽卡,服务端需要从数据库拿到这个卡池有哪些卡,然后抽一张),还需要对官服进行爬虫,这就是另一个话题了。

如果还想在动态 API 中针对不同用户返回不同结果,一方面需要研究客户端如何把认证信息发送给服务器(常见的实现如放在 HTTP Request Headers 里的 tokensessioncookies 字段),另一方面也需要把不同用户的数据落到一个数据库里,这个方向会把私服做的比较重,甚至可以搭建一个完整的私服。如果只希望私服能跑,静态 API 其实是搭建起来最快的。

调试静态服务器的过程,需要客户端和代理服务器配合,所以记得先搭建这两个东西。

# 搭建静态资源服务器 / 由代理服务器提供静态资源

搭建静态资源服务器比较简单,根据抓包记录,写个脚本用把所有静态资源下载到私服上,然后起一个 nginx 就行。它的调试同样需要客户端和代理服务器。

不过,在私服跑起来以后,我们发现在 app 通过代理向资源服务器请求较大的资源(10~100MB)时,代理还没下载完,app 就主动关闭了连接。这可能是 app 或者 mitmproxy 的实现有问题。

因此,我们将静态资源服务器改为集成到代理服务器中,由代理服务器直接从本地读静态资源。也是写一个小二十行的 mitmproxy 插件:

from mitmproxy.http import HTTPFlow, Response
from config import OFFICIAL_ASSETS_DOMAIN, ASSETS_DIR
import os

def request(flow: HTTPFlow):
    if flow.request.pretty_host == OFFICIAL_ASSETS_DOMAIN:
        file_path = flow.request.path.split('?')[0]
        file_fullpath = ASSETS_DIR + file_path
        if os.path.exists(file_fullpath):
            print(f'reading {file_path}')
            with open(file_fullpath, 'rb') as f:
                content_type = 'application/json' if file_path.endswith('json') else 'binary/octet-stream'
                flow.response = Response.make(
                    200,  # (optional) status code
                    f.read(),  # (optional) content
                    {"Content-Type": content_type},  # (optional) headers
                )
        else:
            print(f'{file_path} not found, return 404')
            flow.response = Response.make(404)

# 拆包 apk

在 Magisk 信任证书 + 代理服务器 + 私服服务器链路框架完成后,我们回过头来,考虑最后一步,拆包 apk 信任用户证书,然后就可以把修改后的 apk 分发给用户了。

Android APK hacking how-to (opens new window) 讲解了如何拆包、重新打包、对齐、生成个人的签名 key、实施签名。在这个过程中你需要安装 Android SDK 和 apktool。

Android APK HTTPS user certificates how-to (opens new window) 讲解了如何在拆包后如何修改 AndroidManifest.xml 来让 app 接受用户证书。

由于我们使用了自己的 key 对 apk 进行签名,这个 apk 不能直接覆盖掉原来官服的 apk,只能卸掉重装。为此,我使用 NP 管理器 (opens new window)修改了 apk 的包名(也可以使用其它工具修改包名),修改包名后两个包就能共存了。

# 后记

搭建私服的整个过程可以说是“麻雀虽小,五脏俱全”,从抓包、代理、后端实现、解包都有。

不过被国内代理制止了,我也打算摆烂了,一方面是不想实名搞事,另一方面开始工作了,平时只有周末有比较多的时间,周末还想摸鱼、看番、Steam、出去逛逛 hhhhh。所以最后水一篇博客记录一下这个过程,也希望能帮助读到这里的各位。