功能介绍
分析过程
- 通过 Network 观察找到哪个是上报分数的请求。很显然,这个 send_score 嫌疑最大。找到对应请求的堆栈。
- 通过逐层查看堆栈对应的代码,这层有个 sign message,看着挺合适的。通过查看入参,
e
、r
、a
分别是签名需要的内容,因此直接 console 里修改 这 3 个分数感觉能行得通。
对应的签名如下:
- 然而,签名后发现事情没那么简单,任务并不能完成。说明服务端还有一层校验逻辑,需要找到校验逻辑。
通过多次玩游戏发现,这个 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
这个方法生成出来的。
- Chrome 只可以把代码反编译成 wat 格式,阅读起来还是有点点困难,这时候需要上工具了,我使用的是 Ghidra。加载后通过搜索这个方法,找到对应的汇编代码。
这个方法的入参是
param1
、param2
、param3
。通过 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 句话是通过分数通过某些计算方法确定数组下标,然后取得下标指定的字符串。
- 验证假设。通过反编译后的代码,我们大致确定签名的计算方法,经过一些 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]
}
总结
- 相比起传统的干巴巴的签名结果,比如是
0x1234
这样,使用一段字符串作为签名更加不显眼不容易被发现。 - 把签名算法逻辑放在 WASM 中并不是一劳永逸,可以多加几层指针跳转增加难度。