大神论坛

找回密码
快速注册
查看: 545 | 回复: 0

[Android] 对《三国志-霸王的大陆》安卓版游戏逆向破解与修改

digest

主题

帖子

0

积分

初入江湖

UID
689
积分
0
精华
威望
0 点
违规
大神币
68 枚
注册时间
2023-10-14 10:57
发表于 2024-08-25 16:37
本帖最后由 dhdajun 于 2024-08-25 16:37 编辑

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

游戏文件:

https://tieba.baidu.com/f/good?cid=0&ie=utf-8&kw=霸王的大陆

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

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

直接覆盖

首先,刷新前保存一次存档,刷新后再保存一次存档,用 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 打开:

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

查看 sav 存档

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

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

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

查找 diy_skills 逻辑

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

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

var encrypted = PoolByteArray(Global.hex_decode(skills))
var sign_version = encrypted[encrypted.size() - 1]
var signed_len = encrypted[encrypted.size() - 2]
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。

数据段校验逻辑:

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/

解密脚本如下:

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[encrypted.size() - 1]
var signed_len = encrypted[encrypted.size() - 2]
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 里面解出来的全部是同一个字符串:

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

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

[123, 34, 76, 86, 49, 34, 58, 34, 229, 190, 183, 229, 138, 169, 34, 44, 34, 76, 86, 50, 34, 58, 34, 34, 44, 34, 76, 86, 51, 34, 58, 34, 233, 149, 191, 229, 133, 181, 34, 44, 34, 76, 86, 52, 34, 58, 34, 34, 44, 34, 76, 86, 53, 34, 58, 34, 230, 153, 186, 232, 191, 159, 34, 44, 34, 76, 86, 54, 34, 58, 34, 34, 44, 34, 76, 86, 55, 34, 58, 34, 230, 188, 171, 229, 176, 132, 34, 44, 34, 76, 86, 56, 34, 58, 34, 34, 125, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
[123, 34, 76, 86, 49, 34, 58, 34, 230, 128, 165, 230, 148, 187, 34, 44, 34, 76, 86, 50, 34, 58, 34, 34, 44, 34, 76, 86, 51, 34, 58, 34, 229, 136, 154, 231, 155, 180, 34, 44, 34, 76, 86, 52, 34, 58, 34, 34, 44, 34, 76, 86, 53, 34, 58, 34, 229, 164, 135, 230, 136, 152, 34, 44, 34, 76, 86, 54, 34, 58, 34, 34, 44, 34, 76, 86, 55, 34, 58, 34, 231, 153, 189, 230, 175, 166, 34, 44, 34, 76, 86, 56, 34, 58, 34, 34, 125, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
{ " L V 1 " : " x x x x x x " ,
使用 java 打印数组:
{"LV1":"�攻","LV2":"","LV3":"刚直","LV4":"","LV5":"备战","LV6":"","LV7":"白毦","LV8":""}

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

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

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

替换数据

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

[123, 34, 76, 86, 49, 34, 58, 34]

// 智迟
skill1 = [230, 153, 186, 232, 191, 159]

[34, 44, 34, 76, 86, 50, 34, 58, 34, 34, 44, 34, 76, 86, 51, 34, 58, 34]

// 藤甲
skill2 = [232, 151, 164, 231, 148, 178]

[34, 44, 34, 76, 86, 52, 34, 58, 34, 34, 44, 34, 76, 86, 53, 34, 58, 34]

// 白毦
skill3 = [231, 153, 189, 230, 175, 166]

[34, 44, 34, 76, 86, 52, 34, 58, 34, 34, 44, 34, 76, 86, 53, 34, 58, 34]

// 玄阵
skill4 = [231, 142, 132, 233, 152, 181]

[34, 44, 34, 76, 86, 56, 34, 58, 34, 34, 125, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]

编写加密逻辑:

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 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)

测试签名逻辑是否正确:

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 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)

替换存档数据,查看:

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

技能修正

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

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

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

使用 js 测试一下看看:

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

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))

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

补充

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

完整代码

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[encrypted.size() - 1]
var signed_len = encrypted[encrypted.size() - 2]
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 = [230, 153, 186, 232, 191, 159]
var skill2 = [232, 151, 164, 231, 148, 178]
var skill3 = [231, 153, 189, 230, 175, 166]
var skill4 = [231, 142, 132, 233, 152, 181]

var skills = [123, 34, 76, 86, 49, 34, 58, 34]
skills.append_array(skill1)
skills.append_array([34, 44, 34, 76, 86, 50, 34, 58, 34, 34, 44, 34, 76, 86, 51, 34, 58, 34])
skills.append_array(skill2)
skills.append_array([34, 44, 34, 76, 86, 50, 34, 58, 34, 34, 44, 34, 76, 86, 51, 34, 58, 34])
skills.append_array(skill3)
skills.append_array([34, 44, 34, 76, 86, 50, 34, 58, 34, 34, 44, 34, 76, 86, 51, 34, 58, 34])
skills.append_array(skill4)
skills.append_array([34, 44, 34, 76, 86, 56, 34, 58, 34, 34, 125, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0])
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


注:若转载请注明大神论坛来源(本贴地址)与作者信息。

返回顶部