Golang HTTP逆向Splatoon 2上传战绩

Splatoon 2项目

在写Golang的Splatoon 2小项目的时候,遇到了一个很奇怪的bug。

处理战斗数据有一段代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/* IMAGE RESULT */
if !s.Debug {
// normal scoreboard
// Step 1 请求本局图片分享
im, err := s.Splatoon.shareImageResult(battle.BattleNumber)
if err != nil {
return fmt.Errorf("error getting share image result: %w", err)
}
if s.Anonymous {
im = blackout(im, getMasks(&battle, payload.Players), true)
}
payload.ImageResult = imageToBytes(im)
if sendGears {
stage, _ := strconv.Atoi(battle.Stage.ID)
// Step 2 请求玩家Profile图片分享
im, err = s.Splatoon.shareImageProfile(stage, profileColorsEnum[rand.Intn(6)])
payload.ImageGear = imageToBytes(im)
}
}

处理每局的数据涉及到两次对任天堂的图片请求

  • Splatoon.shareImageResult
  • Splatoon.shareImageProfile

bug的表现是,只要调用了Splatoon.shareImageProfile后,之后对Splatoon的数据服务器的请求都会失败,服务端返回500 Internal Error,之前的调用却成功。这说明我们的请求参数是正确的,不然这个bug不会是有条件地出现。但服务端只返回了500代码,无从得知是HTTP请求的哪个部分出了问题。

获得玩家的战绩数据需要任天堂服务端的身份认证,这个认证是基于Cookie的。我写的认证逻辑参考了splatnet2statink这个项目,工作基本就是把Python代码port一份Go版本的,该repo应该是对任天堂的移动端app Nintendo Switch Online做了逆向工程,破解了生成Cookie的方法。

调戏这类移动端app有一个十分好用的Proxy工具Charles,简单介绍如何配置Charles监听ios端app的网络流量,又称MITM-Proxy(中间人攻击):

  1. 在官网下载Charles安装在macOS上
  2. 配置ios端的Proxy为Charles的监听端口,如192.168.31.207:8888。可以使用小火箭/QX或者直接在Settings>WIFI里设置
  3. 信任Charles的SSL Root Certificate,注意证书安装到ios之后还要到设置里允许信任,这一步很关键,必须要信任Charles的证书,否则你无法看到HTTPS连接的明文内容
  4. 直连任天堂服务器非常慢,所以建议在macOS端也要有一个机场代理,比如Clash,那么配置Charles使用External Proxy为Clash的代理端口,如127.0.0.1:7890,这样流量会走ios➡️Charles➡️Clash➡️Nintendo
  5. 以上做完之后,打开了手机上的Nintendo Switch Online,在Charles应该可以看到一连串对任天堂域名的请求

分析这些请求之后发现认证发生在这个请求中。

image-20211008115816394

可以看到,客户端发送了一个x-gamewebtoken请求头,服务端返回了一个set-cookie指令,存储了一个键为iksm_session的cookie。点进Splatoon 2服务随便一个界面,在Charles检查引发的请求,都带有这个iksm_sessioncookie,初步确定这个字符是身份认证的token。

上面提到的splatnet2statink项目就是用来模拟客户端产生这个iksm_session的,其中涉及到多次API请求和加密,需要随机生成一个seed,然后生成一个登陆URL让用户在浏览器打开,之前没有在浏览器登录过任天堂账号的话需要输入账号密码然后登陆,复制选择账号按钮对应的链接地址,输入给Python程序,依据浏览器生成的这个链接,最终获得该用户对应的iksm_session,这个cookie一旦生成就可以长期使用。模拟认证过程虽然比较复杂,而且涉及到浏览器操作,但每个用户只需要做一次即可。

image-20211008122246909

总结一下,这些逆向工程虽然复杂,用代码实现起来,目的很单纯:给定一个用户的账号密码(可能是之前输入过的,存储在浏览器里),计算出一个用以持久化认证的iksm_session。有了这个cookie之后,所有请求带上它就可以Auth该用户了。我用Golang重写了一遍这段逻辑,没有用Python里的requests那么high-level的库,基本借助http这个标准库就可以了。虽然写起来没有Python那么简洁,好处是可以了解到很多requests隐藏了的底层知识。

出人意料的是,并不是所有的计算逻辑都是在本地进行的。

首先“选择联动账号”这个过程显然需要浏览器参与,运行一些Javascript生成链接地址,浏览器虽然不是Go内部的,但姑且还算做“本地”吧。

有趣的是,splatnet2static项目里有一个对运行安卓模拟器的服务器的请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const (
endpointFlapg = "https://flapg.com/ika2/api/login?public"
)

// retrieve f token from external api
func callFlapgApi(idToken, guid string, timestamp int64, t string) (JSONResponse, error) {
hash, err := getHashFromS2sApi(idToken, timestamp)
if err != nil {
return nil, err
}
apiAppHead := makeHeader(map[string]string{
"x-token": idToken,
"x-time": strconv.Itoa(int(timestamp)),
"x-guid": guid,
"x-hash": hash,
"x-ver": "3",
"x-iid": t,
})
apiResponse, err := getHTTPJson(endpointFlapg, apiAppHead)
if err != nil {
return nil, err
}
return apiResponse.Json("result"), nil
}

The f token was introduced into the Nintendo Switch Online app last year in v1.1.0 as a parameter that must be sent in the request to Nintendo to generate an iksm_session cookie, which grants access to app.splatoon2.nintendo.net and thus a user’s battle log. We were able to figure out the generation method and create an API to return the f value for a given input.

阅读Issue之后,了解到,iksm的生成的需要一个f token。endpointFlapg是一个运行Andriod Simulator的服务器端,f由这个安卓模拟器生成。splat2statink的作者提到早些时间f口令的计算方式发生了变化,导致了整个项目的不可用,后来改为使用flapg的API才得以生成正确的口令,API内的具体计算逻辑笔者也不是很了解。

flowchart

压缩HTTP传输

在解析请求发回的JSON数据的时候,出现了一些decode错误,把请求的内容打印出来后发现是乱码,说明不是以UTF-8格式编码的,一番排查之后,发现请求头里的Content-Encoding字段是是gzip,原来如此,返回的正文是经过压缩的。用compress/gzip这个包解码之后就是正常的UTF-8字符串了。

这里也体现了go和python设计上的一些不同,参考的python项目做HTTP请求用的是requests这个第三方库,或者说python根本就没有一个易用的公认的HTTP标准库;但go环境就不同,作为一个设计之初就把web开发放在重要位置的语言,它的标准库http就已经足够易用了,http秉承了go的标准库的minimalist哲学,你不会享受到requests里的自动处理,像gzip解码这样的工作,需要你额外写几行代码,但另一方面,它给了你了解具体实现的机会

共享Header指针引发的问题

最后回到我们一开始讲的这个bug。定位这个bug的原因用了不少时间,我开始以为是服务端有什么漏洞,但显然不好解释为什么程序刚开始运行的时候是正常的,所以一定问题出在初次发送某些请求之后,http这个库的某个部分变化了。我尝试不使用共享的http.Client实例,在每次请求的时候都初始化一个http.Client,但没有解决。

这样问题就比较棘手了,既然不是http.Client的内部状态变化导致的bug,那会是什么原因呢。

再次祭出我们的大杀器Charles,我们不如截获发送出去的请求查看。打开Charles代理macOS的请求,运行我们的Splatoon程序上传多次战绩数据触发这个bug,然后找到被拒绝的请求

image-20211009194738506

没错,是我们请求Profile分享的图片URL的请求被500代码了。再看看Content > Headers

image-20211009194950593

这里一眼就能看出不对的地方了:iksm_session cookie竟然出现了两次!这显然不对劲,查看这次500请求之前的请求,iksm_session只出现了一次,服务器返回200 OK。

是哪里导致我们的cookie重复出现了两次呢?发送这个500请求的代码

1
shareResult, err := postHTTPMultipartForm(endpointSplatNetShareProfile, settings, appHeadShare, []*http.Cookie{s.client.IksmCookie()})

iksm作为Cookie参数(一个切片)传入postHTTPMultipartForm,长度应该为1,这里应该没有问题,那么问题出在postHTTPMultipartForm内部了

这是postHTTPMultipartForm的实现,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// post: body of multipart/form, text fields
func postHTTPMultipartForm(endpoint string, form multipartForm, header http.Header, cookies []*http.Cookie) (JSONResponse, error) {
var body = bytes.NewBuffer(nil)
writer := multipart.NewWriter(body)
for k, v := range form {
// only string key and string value allowed, int convert to string please
err := writer.WriteField(k, v)
if err != nil {
return nil, err
}
}
err := writer.Close()
if err != nil {
return nil, err
}
req, err := http.NewRequest("POST", endpoint, body)
if header != nil {
req.Header = header
}
// remember set content-type
// like multipart/form; boundary=...
req.Header.Set("Content-Type", writer.FormDataContentType())
return sendReqWithCookies(req, cookies)
}

仔细观察,发现第18行有点不对劲,header参数是http.Header,查看http源码

1
2
3
4
5
// A Header represents the key-value pairs in an HTTP header.
//
// The keys should be in canonical form, as returned by
// CanonicalHeaderKey.
type Header map[string][]string

Header是一个map的类型的alias。众所周知,go里的map基本可以理解为一个”fat pointer”,类似一个指向实际哈希对象的指针,意味着

1
req.Header = header

只是拷贝了指针(浅拷贝),有潜在的alias问题。调用postHTTPMultipartForm传入的header参数appHeadShare,是我定义的一个全局变量。很多函数都要用这个变量作为请求头,所以我直接提取出来作为全局变量。问题也就出现在了这里,多个请求的Header字段都被设置为同一个http.Header对象,而cookie是存储在请求头里的iksm_session在上面的500请求中出现两次的原因也就明朗了:

  • appHeadShare作为header参数,被设定为第一个请求的头
  • 第二个请求也复制了appHeadShare作为请求头,但实际上只有指针被拷贝了,指针指向的对象是被两个请求共享的
  • 第一个请求被发送,cookie iksm_session写入到Header,也就是appHeadShare变量里
  • 第二个请求被发送,req.SetCookie被调用了一次,再加上appHeadShare里本来就有的一份,此时appShareHead里有个两个重复iksm_session值,引发了服务端的500错误

下面这个测试验证了我们的猜想

1
2
3
4
5
6
7
8
9
10
func TestSplatoonService_shareImageResultAfterShareProfile(t *testing.T) {
// make sure cookie is not set twice
_, err := splatoon.shareImageProfile(3, "yellow")
assert.NoError(t, err)
fmt.Println("cookie after request #1", appHeadShare.Get("cookie"))
image, err := splatoon.shareImageResult("4732")
fmt.Println("cookie after request #2", appHeadShare.Get("cookie"))
assert.NoError(t, err)
assert.NotNil(t, image)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
=== RUN   TestSplatoonService_shareImageResultAfterShareProfile
cookie after request #1 iksm_session=bc6456c0b5e23e226736f42c8ae529a9b75ed907
cookie after request #2 iksm_session=bc6456c0b5e23e226736f42c8ae529a9b75ed907; iksm_session=bc6456c0b5e23e226736f42c8ae529a9b75ed907
splatoon_test.go:42:
Error Trace: splatoon_test.go:42
Error: Received unexpected error:
cannot get share image url: nintendo response: 500 Internal Server Error
Test: TestSplatoonService_shareImageResultAfterShareProfile
splatoon_test.go:43:
Error Trace: splatoon_test.go:43
Error: Expected value not to be nil.
Test: TestSplatoonService_shareImageResultAfterShareProfile
--- FAIL: TestSplatoonService_shareImageResultAfterShareProfile (7.18s)

解决这个bug只需要一行就可以了,http.Header有一个Clone方法,会对map进行深拷贝。我们写一个setReqHeaderCopy函数,用来设置请求头,这样我们的全局变量就不会被修改了

1
2
3
func setReqHeaderCopy(req *http.Request, h http.Header) {
req.Header = h.Clone()
}

再次运行测试

1
2
3
4
=== RUN   TestSplatoonService_shareImageResultAfterShareProfile
cookie after request #1
cookie after request #2
--- PASS: TestSplatoonService_shareImageResultAfterShareProfile (6.27s)

测试通过。

Go 1.17 go install

在Go 1.17,使用go get来安装一个package的二进制文件会提示一个warning,意思是在将来的Go 1.8,go get只会用来获取源码,不再编译代码。

在Dockerfile里我有时候需要安装一个Golang库用来编译go代码,把版本信息注入到二进制里,以代替go build

1
2
3
RUN go install github.com/ahmetb/govvv@latest
# ...
RUN govvv build -o warehouse-bin -version $(go generate ./telegram/jerry) ./warehouse/main

这里的go install要加上具体版本,否则会出现提示说go.mod中没有相关的依赖。因为govvv只是用来编译的库,和项目本身没有任何依赖,生产的可执行文件也不包含它,所以它应该被安装在$HOME/go/bin/下即可,不需要也不应该修改项目的go modgo install with a specific version就是忽略当前go module,只安装二进制文件的模式。