lyldalek 发表于 2024-8-22 23:01

记一次简单的游戏逆向与修改

最近沉迷于一个老游戏,FC版的《三国志-霸王的大陆》,有大佬做了个Android版的,还添加了很多东西,比如技能系统。

游戏文件:

(https://tieba.baidu.com/f/good?cid=0&ie=utf-8&kw=%E9%9C%B8%E7%8E%8B%E7%9A%84%E5%A4%A7%E9%99%86)

我想刷一个组合出来,但是靠随机不太可能:

![]( https://imgsrc.baidu.com/forum/pic/item/c2cec3fdfc0392456355062ec194a4c27d1e252b.jpg)

于是就想用修改存档的方式来改下技能。

## 直接覆盖

首先,刷新前保存一次存档,刷新后再保存一次存档,用 010 对比一下,发现有4处不同,猜想这4处就是储存技能的位置了。

先尝试直接替换第2处不同,看看能够修改第二个技能。记载替换后的存档,失败了,不仅第2个技能没替换,其他的技能也变化了。再重新加载一次,发现技能又不一样了,于是确定是技能数据修改有问题。

所以直接替换是不行的,应该是游戏对技能数据做了校验,发现被修改之后,就又重新随机了。

## 反编译游戏

该游戏作者在贴吧做了介绍,是使用 godot 做的,查一下官方文档:

(https://docs.godotengine.org/en/stable/)

看着非常像Unity,解压一下base.apk,看一下asset下的文件结构,发现有个 script 文件夹,里面有很多 .gdc 文件。

网上搜索一下 gdc decompiler:

(https://github.com/bruvzg/gdsdecomp)

按照项目的说明,直接使用命令反编译 base.apk,再用 code 打开:

![]( https://imgsrc.baidu.com/forum/pic/item/c995d143ad4bd113a60450131cafa40f4afb05c2.jpg)

非常的nice,gd 是 gdscript,还是能看懂的,与源码几乎没区别了。

## 查看 sav 存档

游戏的存档,使用 010 打开后,明显可以看出是一个 json 文件:

![]( https://imgsrc.baidu.com/forum/pic/item/d439b6003af33a87acabe73b805c10385243b5c9.jpg)

直接使用 code 打开,定位到之前对比过的有区别的位置:

![]( https://imgsrc.baidu.com/forum/pic/item/bf096b63f6246b603cce5555adf81a4c500fa2cb.jpg)

diy_skills 是做了校验的。我们需要搞定它。

## 查找 diy_skills 逻辑

安装一下 gdscript 的插件,可以高亮代码。

一通搜索之后,找到如下逻辑:

```jsx
                        var encrypted = PoolByteArray(Global.hex_decode(skills))
                        var sign_version = encrypted
                        var signed_len = encrypted
                        var signed = encrypted.subarray(encrypted.size() - signed_len - 2, encrypted.size() - 3)
                        encrypted = encrypted.subarray(0, encrypted.size() - signed_len - 3)
                        if not StaticManager.crypto_verify_short(encrypted, signed):
                                return {}
                        if sign_version == 1:
                                decrypted = clAes.ECB_Decrypted(encrypted)
                                priorVersion = true
                        elif sign_version == 2:
                                encrypted = Global.hex_decode(encrypted.get_string_from_ascii())
                                decrypted = clAes.ECB_Decrypted(encrypted)
                        else :
                                return {}
```

可以看到是使用了 AES 的 ECB 加密。

数据段的最后一个字节是 sign_version。

数据段的倒数第二个字节是 signed_len。

数据段的真实数据长度是 size - 2 - signed_len。

数据段校验逻辑:

```jsx
func crypto_verify_short(data:PoolByteArray, signature:PoolByteArray)->bool:
        crypto_init()
        var hashed = data.get_string_from_utf8().md5_buffer()
        return crypto.verify(HashingContext.HASH_MD5, hashed, signature, signer)
```

md5 校验,还加了私钥签名,非常的合理

## 编写解密脚本

一通搜索与实验,作者使用的 godot 是 3.x 的版本,4.0 api有些变化,找到了一个online playground(我最讨厌加密的地方就是每个平台算法都可能不一致,所以最好找一样的环境进行算法逆向):

(https://gdscript-online.github.io/)

解密脚本如下:

```jsx
extends Node

func _ready():
        var data = "6163656664396236353766666537393731353866303334613136656562666339363734393235333431626362646562323732656234313732316562613862613138353535653737323162333661353635356133386333643035336532326431383365623231373765326237633961366430363434613765323639653261326637333834363638376239393533346133386630316437363638633362623932616266373037316261333434303664323937313366393765656135343663333334326163633233336436363836653839636563326336626338393662306563333630c314c379b14f65cf9830e28af007b4411382d678a0133bf521a37ed32e1db8e3a1e754217315abd656f8e5dbcb1a509c54483bbd39328c50bbf5b7d150f7f7834002"
        var d = decrypt_skill_data(data)
        print(d)
       
func decrypt_skill_data(skills:String) -> String :
        var encrypted = PoolByteArray(hex_decode(skills))
        var sign_version = encrypted
        var signed_len = encrypted
        var signed = encrypted.subarray(encrypted.size() - signed_len - 2, encrypted.size() - 3)
        encrypted = encrypted.subarray(0, encrypted.size() - signed_len - 3)
        encrypted = hex_decode(encrypted.get_string_from_ascii())
        var decrypted = ECB_Decrypted(encrypted)

        return decrypted.get_string_from_ascii()
       
func ECB_Decrypted(encrypted:PoolByteArray)->PoolByteArray:
        if encrypted.empty():
                return PoolByteArray([])
        var aes = AESContext.new()
        var key = "gd secret key!!!"
       
        aes.start(AESContext.MODE_ECB_DECRYPT, key.to_utf8())
        var decrypted:PoolByteArray = aes.update(encrypted)
        aes.finish()
        return decrypted

       
func hex_decode(data:String)->PoolByteArray:
        var ret = []
        var b:int = 0
        var odd = true
        for c in data.to_lower():
                if c in "abcdef":
                        b += ord(c) - ord("a") + 10
                else :
                        b += int(c)
                if odd:
                        b *= 16
                        odd = false
                else :
                        ret.append(b)
                        odd = true
                        b = 0
        return ret
```

神奇的事情出现了,发现 diy_skils 里面解出来的全部是同一个字符串:

```jsx
{"LV1":"","LV2":"","LV3":"","LV4":"","LV5":"","LV6":"","LV7":"","LV8":""}
```

有点不对劲,打印hex数据出来看看,果然有区别,这个在线编辑器真辣鸡:

```jsx


{    "   L   V   1   ":    "   x    x    x    x    x    x    "   ,
使用 java 打印数组:
{"LV1":"�攻","LV2":"","LV3":"刚直","LV4":"","LV5":"备战","LV6":"","LV7":"白毦","LV8":""}
```

可以看到上面标记 x 的地方应该就是技能的 id 之类的了,所以我们需要替换这个id,再加密后覆盖应该就可以。

找两个有同样技能的存档验证一下,解密出来比较一下数组,发现技能相同的数组值确实是一样的,这样就确定可以直接替换了。

先尝试搜索一下这些技能数组,看看源码里面有没有,尝试无果。只能自己手动刷技能,然后填进去了。

## 替换数据

首先,拿到存档里面的技能数据,然后拼接成字节数组:

```jsx


// 智迟
skill1 =



// 藤甲
skill2 =



// 白毦
skill3 =



// 玄阵
skill4 =


```

编写加密逻辑:

```jsx
func ECB_Encrypted(data:PoolByteArray)->PoolByteArray:
        var aes = AESContext.new()
        var key = "gd secret key!!!"
        while data.size() % 16 != 0:
                data.append(0)
        aes.start(AESContext.MODE_ECB_ENCRYPT, key.to_utf8())
        var encrypted:PoolByteArray = aes.update(data)
        aes.finish()
        return encrypted
```

编写签名逻辑:

```jsx
func crypto_sign_short(data:PoolByteArray)->PoolByteArray:
        var crypto = Crypto.new()
        var signer = CryptoKey.new()
        signer.load_from_string("""
        -----BEGIN RSA PRIVATE KEY-----
MIIBOwIBAAJBAOTW56JN2BCV/G//PQn4/Kz06h92jmdbUIM+KmzQrbvNVwiobwEd
3VvEsmDa6pQ0JFgVY8dr66Hc18HLShwJEq8CAwEAAQJADMHUQO6RBH+wBnhWqUcp
ouS2ZpGf57AmAWMGT3GktcrmOR+W4vjS9B2iFH/JhJDBMkQ+5py9+fMCE5gc0gMS
RQIhAPZUCEJAAl6y1FggoiVpaSUT9g9TdBYJfr/6wOPfqXebAiEA7dMVioDfqQ5t
zH2KySLtEVe2ANWroJLwL8Ts3vUVxH0CIQC7hlWTOe+T8Eg/nvhRyuHE3GFiYYHq
lOftdxQJZmg5KQIgWg+fjq2zBSAzsEaycezJ/dFLWRGRRuOeFVjropsJPTkCIQDt
p9I6LkIJqfid8y4YC1mSFF0g4ClEoAIv918R47hAEA==
-----END RSA PRIVATE KEY-----
        """, false)
        var hashed = data.get_string_from_utf8().md5_buffer()
        return crypto.sign(HashingContext.HASH_MD5, hashed, signer)
```

测试签名逻辑是否正确:

```jsx
func test():
        var skills_data = "6336336237386135643733383165303730626537356434643035633430373237363734393235333431626362646562323732656234313732316562613862613134333632323035333335646265613765303838336432303637643639623239363633356637653430313730346238616462643564623530383764326261383138333834363638376239393533346133386630316437363638633362623932616233626634623139663539613231316664366663356166306635303232393735326163633233336436363836653839636563326336626338393662306563333630"
       
        skills_data = hex_decode(skills_data)
       
        var sign_data = "ba172a595d70137e650f34567238b2c6112bf15a8f1a238d4c0f9502a5b4bc5366235529243de0cb3e5cd797b9d85f5b36b7be899cda27065b0a505b722e9ddc"
       
        var my_sign_data = crypto_sign_short(skills_data)
       
        print(my_sign_data.hex_encode())
```

运行是匹配的,完美。

最后,生成技能代码:

```jsx
func generate_skills_data():
        var result_data = PoolByteArray([])
        var skills_data = get_skills();
        skills_data = ECB_Encrypted(skills_data).hex_encode().to_ascii()
        result_data.append_array(skills_data)
        var sign_data = crypto_sign_short(skills_data)
        result_data.append_array(sign_data)
        result_data.append(64)
        result_data.append(2)
        print(result_data)
        var hex_string = result_data.hex_encode()
        print(hex_string)
```

替换存档数据,查看:

![]( https://imgsrc.baidu.com/forum/pic/item/359b033b5bb5c9ea2bc8e5c29339b6003af3b3dc.jpg)

嗯???有bug,再重新读档一下,发现技能没变,说明签名逻辑没问题,数据也没问题。看来是技能id还有些东西没搞出来,需要再研究研究。

## 技能修正

突然想到游戏逻辑的一段代码,猜测技能代码里面的那6个字节数组其实是汉字:

```jsx
var skillsData = JSON.print(skills).to_utf8()
```

测试一下,那个 godot 在线编辑器居然不能输出汉字,辣鸡。

使用 js 测试一下看看:

![]( https://imgsrc.baidu.com/forum/pic/item/d31b0ef41bd5ad6e60ce82cbc7cb39dbb6fd3cd8.jpg)

果然如此,那我们就可以重构代码了,在 4.x 版本的编辑器((https://gd.tumeo.space/#))里输出数组对比一下:

```jsx
extends Node

func _ready():
#        var data = "6336336237386135643733383165303730626537356434643035633430373237363734393235333431626362646562323732656234313732316562613862613134333632323035333335646265613765303838336432303637643639623239363633356637653430313730346238616462643564623530383764326261383138333834363638376239393533346133386630316437363638633362623932616233626634623139663539613231316664366663356166306635303232393735326163633233336436363836653839636563326336626338393662306563333630ba172a595d70137e650f34567238b2c6112bf15a8f1a238d4c0f9502a5b4bc5366235529243de0cb3e5cd797b9d85f5b36b7be899cda27065b0a505b722e9ddc4002"
#        var d = decrypt_skill_data(data)
#        print(d)
        var skills = {
                        "LV1":"智迟",
                        "LV2":"",
                        "LV3":"藤甲",
                        "LV4":"",
                        "LV5":"白毦",
                        "LV6":"",
                        "LV7":"玄阵",
                        "LV8":""
                };
        var skillsData = JSON.stringify(skills).to_utf8_buffer()
print(PackedByteArray(skillsData))

```

与我们自己拼接的是一样的,看来我们的数据是没错。

## 补充

后来将技能调整了下顺序就可以了,有点奇怪,懒得调试了,查了下神武是默认值。使用字符串来生成数组的方式没有出过错了。上面代码可用。

## 完整代码

```jsx
extends Node

func _ready():
#        var data = "6336336237386135643733383165303730626537356434643035633430373237363734393235333431626362646562323732656234313732316562613862613134333632323035333335646265613765303838336432303637643639623239363633356637653430313730346238616462643564623530383764326261383138333834363638376239393533346133386630316437363638633362623932616233626634623139663539613231316664366663356166306635303232393735326163633233336436363836653839636563326336626338393662306563333630ba172a595d70137e650f34567238b2c6112bf15a8f1a238d4c0f9502a5b4bc5366235529243de0cb3e5cd797b9d85f5b36b7be899cda27065b0a505b722e9ddc4002"
#        var d = decrypt_skill_data(data)
#        print(d)
        test()
        generate_skills_data()

func test():
        var skills_data = "6336336237386135643733383165303730626537356434643035633430373237363734393235333431626362646562323732656234313732316562613862613134333632323035333335646265613765303838336432303637643639623239363633356637653430313730346238616462643564623530383764326261383138333834363638376239393533346133386630316437363638633362623932616233626634623139663539613231316664366663356166306635303232393735326163633233336436363836653839636563326336626338393662306563333630"
       
        skills_data = hex_decode(skills_data)
       
        var sign_data = "ba172a595d70137e650f34567238b2c6112bf15a8f1a238d4c0f9502a5b4bc5366235529243de0cb3e5cd797b9d85f5b36b7be899cda27065b0a505b722e9ddc"
       
        var my_sign_data = crypto_sign_short(skills_data)
       
        print(my_sign_data.hex_encode())
       
func decrypt_skill_data(skills:String) -> String :
        var encrypted = PoolByteArray(hex_decode(skills))
        var sign_version = encrypted
        var signed_len = encrypted
        var signed = encrypted.subarray(encrypted.size() - signed_len - 2, encrypted.size() - 3)
        encrypted = encrypted.subarray(0, encrypted.size() - signed_len - 3)
        encrypted = hex_decode(encrypted.get_string_from_ascii())
        var decrypted = ECB_Decrypted(encrypted)
        print(decrypted)
        return decrypted.get_string_from_ascii()
       
func ECB_Encrypted(data:PoolByteArray)->PoolByteArray:
        var aes = AESContext.new()
        var key = "gd secret key!!!"
        while data.size() % 16 != 0:
                data.append(0)
        aes.start(AESContext.MODE_ECB_ENCRYPT, key.to_utf8())
        var encrypted:PoolByteArray = aes.update(data)
        aes.finish()
        return encrypted
       
func ECB_Decrypted(encrypted:PoolByteArray)->PoolByteArray:
        if encrypted.empty():
                return PoolByteArray([])
        var aes = AESContext.new()
        var key = "gd secret key!!!"
       
        aes.start(AESContext.MODE_ECB_DECRYPT, key.to_utf8())
        var decrypted:PoolByteArray = aes.update(encrypted)
        aes.finish()
        return decrypted

func hex_decode(data:String)->PoolByteArray:
        var ret = []
        var b:int = 0
        var odd = true
        for c in data.to_lower():
                if c in "abcdef":
                        b += ord(c) - ord("a") + 10
                else :
                        b += int(c)
                if odd:
                        b *= 16
                        odd = false
                else :
                        ret.append(b)
                        odd = true
                        b = 0
        return ret

func generate_skills_data():
        var result_data = PoolByteArray([])
        var skills_data = get_skills();
        skills_data = ECB_Encrypted(skills_data).hex_encode().to_ascii()
        result_data.append_array(skills_data)
        var sign_data = crypto_sign_short(skills_data)
        result_data.append_array(sign_data)
        result_data.append(64)
        result_data.append(2)
        print(result_data)
        var hex_string = result_data.hex_encode()
        print(hex_string)
       
       
func get_skills()->PoolByteArray:
        var skill1 =
        var skill2 =
        var skill3 =
        var skill4 =
       
        var skills =
        skills.append_array(skill1)
        skills.append_array()
        skills.append_array(skill2)
        skills.append_array()
        skills.append_array(skill3)
        skills.append_array()
        skills.append_array(skill4)
        skills.append_array()
        return PoolByteArray(skills)
       
func crypto_sign_short(data:PoolByteArray)->PoolByteArray:
        var crypto = Crypto.new()
        var signer = CryptoKey.new()
        signer.load_from_string("""
        -----BEGIN RSA PRIVATE KEY-----
MIIBOwIBAAJBAOTW56JN2BCV/G//PQn4/Kz06h92jmdbUIM+KmzQrbvNVwiobwEd
3VvEsmDa6pQ0JFgVY8dr66Hc18HLShwJEq8CAwEAAQJADMHUQO6RBH+wBnhWqUcp
ouS2ZpGf57AmAWMGT3GktcrmOR+W4vjS9B2iFH/JhJDBMkQ+5py9+fMCE5gc0gMS
RQIhAPZUCEJAAl6y1FggoiVpaSUT9g9TdBYJfr/6wOPfqXebAiEA7dMVioDfqQ5t
zH2KySLtEVe2ANWroJLwL8Ts3vUVxH0CIQC7hlWTOe+T8Eg/nvhRyuHE3GFiYYHq
lOftdxQJZmg5KQIgWg+fjq2zBSAzsEaycezJ/dFLWRGRRuOeFVjropsJPTkCIQDt
p9I6LkIJqfid8y4YC1mSFF0g4ClEoAIv918R47hAEA==
-----END RSA PRIVATE KEY-----
        """, false)
        var hashed = data.get_string_from_utf8().md5_buffer()
        return crypto.sign(HashingContext.HASH_MD5, hashed, signer)

```

## Android 版技能修改器

后来,在Android 里面测试了一下算法,是通用的,所以就写了一个Android版的修改器,再也不用开网页修改了。

> (https://github.com/aprz512/sgz-gd-save-editor)
>

bigmojin 发表于 2024-8-24 07:23

大佬:简单的游戏逆向;我:拿小板凳、笔记本:这个什么?

rustzig 发表于 2024-8-23 18:51

相当牛...

amwquhwqas128 发表于 2024-8-23 22:44

技术很强

Feichong1995 发表于 2024-8-23 22:46

真的很牛啊

zdmin 发表于 2024-8-23 23:35

技术很牛

assuller 发表于 2024-8-23 23:40

这个是什么天书

netxboy 发表于 2024-8-24 00:56

有这技术,,,还玩什么游戏。。

skyparis 发表于 2024-8-24 07:37

非常厉害

等到烟火也清凉 发表于 2024-8-24 08:57

体验技能的话 直接改技能文件更好
https://imgsrc.baidu.com/forum/pic/item/377adab44aed2e736191530dc101a18b87d6fa9c.jpg
https://imgsrc.baidu.com/forum/pic/item/f11f3a292df5e0feeb625c201a6034a85edf729d.jpg
页: [1] 2 3
查看完整版本: 记一次简单的游戏逆向与修改