View on GitHub

破解 Starknet Fighter 游戏分数上报

功能介绍

分析过程

  1. 通过 Network 观察找到哪个是上报分数的请求。很显然,这个 send_score 嫌疑最大。找到对应请求的堆栈。

从 Network 中看堆栈

  1. 通过逐层查看堆栈对应的代码,这层有个 sign message,看着挺合适的。通过查看入参,era 分别是签名需要的内容,因此直接 console 里修改 这 3 个分数感觉能行得通。

代码中有调用签名

对应的签名如下:

签名信息

  1. 然而,签名后发现事情没那么简单,任务并不能完成。说明服务端还有一层校验逻辑,需要找到校验逻辑。

通过多次玩游戏发现,这个 a 参数每次都会改变,而且会把这个 message 传给后端。如果是随机生成的那么也不太需要传给后端啊,难道这个是校验的关键?但是 字符串做校验那也太新潮了。

{
    "score": 163.41334533691406,
    "duration": 163.41,
    "death_message": "It was nice knowing you all!\\nLooks like I've kicked the bucket!\\nI guess I underestimated the size of that asteroid.\\nTurns out, asteroids are the ultimate bear market for cryptocurrencies.",
    "address": "0xxxxx",
    "signature": [
        "xx",
        "xx"
    ]
}

跟踪这个 a 的来源,发现它是通过 getStringFromWasm0(t, n) 这个方法从 wasm 的内存中获取到的。也就是说,这个 death_message 是在 wasm 中生成出来的。那么这个是校验参数的可能性更大了。

imports.wbg.__wbg_postdeathstats_ee94906ed5a7c158 = function(e, t, n) {
    try {
        post_death_stats(e, getStringFromWasm0(t, n))
    } finally {
        wasm.__wbindgen_free(t, n)
    }
}

在调用堆栈中分析进入到 wasm(感谢 Chrome 支持了 wasm 的 debug),发现它是从 wasm 的 starkfighter::game::Game::end::h1b5aee7348e75e6f 这个方法生成出来的。

  1. Chrome 只可以把代码反编译成 wat 格式,阅读起来还是有点点困难,这时候需要上工具了,我使用的是 Ghidra。加载后通过搜索这个方法,找到对应的汇编代码。 这个方法的入参是 param1param2param3。通过 Chrome 的堆栈变量查看,推断 param3 就是当次游戏的成绩。
void starkfighter::game::Game::end::h1b5aee7348e75e6f(int param1, int param2, float param3) {
    iStack40 = ((uint)param3 >> 0x15 & 0x78) + 0x102860
    uStack48 = ((uint)param3 >> 0xd & 0x78) + 0x102490
    uVar3 = (uint)param3 >> 0x10 ^ 0xb080;
    iStack32 = (uVar3 >> 1 & 0x78) + 0x102d78;
    uStack56 = CONCAT44(2, (uVar3 >> 9 & 0x78) + 0x102264);
}

这个 0x102860 很像一个内存地址,跳转后看看:

ram:00102860 10 25 10 00
ram:00102864 2c 00 00 00

这个数据很像小端字节,我们推测一下,头 4 个字节应该是一个内存地址,也就是他的类型是指针,指向 0x102510;后 4 个字节是一个整数,值为 44。跳转到 0x102510 看看:

ram:00102510 57 68 79 20 Why did I think I could dodge that asteroid...

好家伙,那能确定 0x102860-0x102868 这 8 个字节是 CString 了,高 4 位为字符串的指针,第四位为长度。更加刺激是在这 8 个字节后下 8 个字节还是 CString,太像一个数组了。但是没有找到数组长度相关的数据,所以应该是定长数组。刚好类似的数组有 4 个,每个都是 16 个 CString,刚好对应 death_message 是 4 句话组合起来的。大胆推测这 4 句话是通过分数通过某些计算方法确定数组下标,然后取得下标指定的字符串。

  1. 验证假设。通过反编译后的代码,我们大致确定签名的计算方法,经过一些 case 验证没问题,那我这个推测就应该是成立的,最多可能是一些边缘 case 不对。
package signature

import (
	"strings"
	"unsafe"
)

func Score(score float32) string {
	ret := make([]string, 4)

	uintScore := *(*uint32)(unsafe.Pointer(&score))

	tmp := uintScore >> 0x10
	tmp ^= 0xb080

	ret[0] = getStrByIdx(strList1, tmp>>9)
	ret[1] = getStrByIdx(strList2, uintScore>>0xd)
	ret[2] = getStrByIdx(strList3, uintScore>>0x15)
	ret[3] = getStrByIdx(strList4, tmp>>1)

	return strings.Join(ret, "\n")
}

func getStrByIdx(list []string, idx uint32) string {
	idx = idx & 0x78
	idx = idx >> 3
	return list[idx]
}

总结