记一次简单的游戏逆向与修改
最近沉迷于一个老游戏,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)
> 大佬:简单的游戏逆向;我:拿小板凳、笔记本:这个什么? 相当牛... 技术很强 真的很牛啊 技术很牛
这个是什么天书 有这技术,,,还玩什么游戏。。 非常厉害 体验技能的话 直接改技能文件更好
https://imgsrc.baidu.com/forum/pic/item/377adab44aed2e736191530dc101a18b87d6fa9c.jpg
https://imgsrc.baidu.com/forum/pic/item/f11f3a292df5e0feeb625c201a6034a85edf729d.jpg