首页
课程
问答
CTF
社区
招聘
峰会
发现
排行榜
知识库
工具下载
看雪20年
看雪商城
证书查询
登录
注册
首页
社区
课程
招聘
发现
问答
CTF
排行榜
知识库
工具下载
峰会
看雪商城
证书查询
社区
Android安全
发新帖
3
1
[原创]2026腾讯游戏安全初赛Android wp
发表于: 2026-4-17 10:02
22123
[原创]2026腾讯游戏安全初赛Android wp
318
2026-4-17 10:02
22123
虽然没有进决赛但还是记录一下吧,比起去年UE做的已经很多了 ## 1.dump libsec2026 拿到题是一个godot引擎加godot-cpp扩展的游戏 引擎版本是4.5.1(关系到后面对应哪一版源码,虽然每版改动应该也不大吧)  assets目录下可以看到有.gdc脚本,应该有主要相关逻辑写在trigger.gdc\token.gdc等,但.gdc被加密,不能用GDRE工具直接解。 观察两个so文件:libgodot-android.so; libsec2026.so libsec2026.so套了壳,用脚本dump一下 ```javascript function dump_so(libName) { Java.perform(() => { // // 1. 更可靠地获取模块信息 var libso = Process.getModuleByName(libName); if (!libso) { var modules = Process.enumerateModules(); console.log("[+] 已加载的 SO 文件列表 (" + modules.length + " 个):"); modules.forEach(function(m, i) { console.log(" [" + i + "] " + m.name + " base=" + m.base + " size=" + m.size + " path=" + m.path); }); console.error("[!] 错误:未找到模块 libil2cpp.so"); return; } // 2. 验证基地址和大小是否合理 console.log("[+] 模块基址: " + libso.base); console.log("[+] 模块大小: " + libso.size); if (libso.size <= 0 || libso.size > 0x10000000) { // 大小合理性检查,例如大于256MB则可疑 console.error("[!] 模块大小异常,可能获取失败"); return; } // 3. 安全地设置内存权限并读取 try { // 修改内存权限为可读 // var originProtection = Memory.protect(libso.base, libso.size, 'r--'); // 关键改进:分块读取内存,避免不连续映射区域导致崩溃 var chunkSize = 0x1000; // 每次读取4KB var totalSize = libso.size; var file_path = "/sdcard/download/" + libso.name + "_" + libso.base + "_" + ptr(totalSize) + ".so"; var file_handle = new File(file_path, "wb"); if (file_handle) { for (var offset = 0; offset < totalSize; offset += chunkSize) { var bytesToRead = Math.min(chunkSize, totalSize - offset); try { // 读取内存块 var chunk = libso.base.add(offset).readByteArray(bytesToRead); if (chunk !== null) { file_handle.write(chunk); } else { console.warn("[!] 读取的内存块为空,跳过写入"); } } catch (e) { // 如果某一块读取失败,用0填充并继续 console.warn("[!] 偏移 0x" + offset.toString(16) + " 处读取失败,用0填充"); var zeroBuffer = new ArrayBuffer(bytesToRead); file_handle.write(zeroBuffer); } } file_handle.flush(); file_handle.close(); console.log("[dump] 成功: " + file_path); } } catch (e) { console.error("[!] dump_so 过程中发生异常: " + e.message); } }); } console.log("[+] 脚本加载时间: " + new Date().toLocaleTimeString()); setTimeout( () => { console.log("[+] 回调执行时间: " + new Date().toLocaleTimeString()); console.log("[+] 延迟后开始 dump..."); dump_so("libsec2026.so"); }, 10000); // 延迟500毫秒,可根据实际情况调整 ``` 从so文件的大小和字符串信息可以判断出 libgodot_android.so是godot引擎库,libsec2026.so是godot-cpp扩展部分和native方法实现; ## 2.找到 godot-key 查找godot相关加解密,应该是要找一个32位的AES密钥,加密模式是CFB,发现教程说关键函数是core/io/file_access_pack.cpp: 相关教程都是用字符串定位这个构造函数去拿到script_encryption_key  但是,出题人肯定不能出这么简单,已经把这种字符串信息抹去了 但是,本人虽笨却非常的勤快,决定尝试照着源码硬推,这种字符串没有,总有别的字符串,(且秉持着恩师的源码在一起的函数和文件,汇编也会在附近。。。 一番搜索下定位到了pck_packer.cpp:  file_access_pack.cpp是解包部分逻辑,而pck_packer.cpp是打包部分逻辑,二者一定会有关联:二者都跟FileAccessEncrypted::open_and_parse有关,这就缩小查找范围了,可以直接把pck_packer的地址和源码给ai让它去帮我们找这个构造函数了 后面发现还有一些办法: 1.用魔术头“GDPC”定位try_and_open函数 2.用“res://”的交叉引用定位排查等等(不太推荐,很多地方引用 总之在ai强大的能力和努力下,成功地获得一系列相关函数地址:  也自然可以直接定位到我们要的key,这里出题人非常非常善良啊,key不是动态生成的,直接就给了:  ## 3.拿到gd脚本 但是把key输入GDRE还是解密失败了,于是就再次利用ai分析加解密流程,肯定是中间有地方魔改了:  于是再让ai搓了一个解密脚本: ```python #!/usr/bin/env python3 """Godot 4.x 单文件解密 (修改版 AES-256-CFB)""" import struct, sys, hashlib from Crypto.Cipher import AES KEY = bytes.fromhex("ce4df8753b59a5a39ade58ac07ef947a3da39f2af75e3284d51217c04d49a061") def decrypt_modified_cfb(key: bytes, iv: bytes, ciphertext: bytes) -> bytes: """修改版 CFB: plain[i] = keystream[pos] ^ (cipher[i] ^ pos), pos = i % 16""" aes = AES.new(key, AES.MODE_ECB) iv_block = bytearray(iv) plaintext = bytearray(len(ciphertext)) pos = 0 for i in range(len(ciphertext)): if pos == 0: keystream = bytearray(aes.encrypt(bytes(iv_block))) c = ciphertext[i] modified = c ^ pos plaintext[i] = keystream[pos] ^ modified iv_block[pos] = modified pos = (pos + 1) & 0xF return bytes(plaintext) def decrypt_file(data: bytes, key: bytes) -> bytes: """解密 FileAccessEncrypted 格式: [16B md5][8B length][16B iv][ciphertext]""" md5_exp = data[:16] length = struct.unpack_from('<Q', data, 16)[0] iv = data[24:40] ds = length + (16 - length % 16) if length % 16 else length plain = decrypt_modified_cfb(key, iv, data[40:40 + ds])[:length] if hashlib.md5(plain).digest() != md5_exp: raise ValueError("MD5 校验失败 — 密钥错误或数据损坏") return plain ``` 解密后的.gdc文件头就正常了,放进GDRE工具里就可以反编译了 flag生成逻辑就是先异或一下然后传进native层加密(大概  ## 4.重打包游戏 但拿到flag这一步,其实根本不需要去看native层加密逻辑(所以flag只值20分吗。。 这里有了godotscript脚本,虽然因为有cpp扩展的缘故不能直接放回引擎直接运行修改, 但可以修改gd脚本重打包,而且我只是修改,连parsepck索引文件都不用重新生成,只用把trigger.gd改一下再用GDRE编译一下.gdc再套刚那套魔改AES加密回去就行了! (这里其实还想了一种更难的方法,如果能找到godot引擎load -> call 一个gd函数的调用链,然后找到对应地址可以构造出一个任意gd代码注入执行器?(太复杂了没尝试。。 回到trigger.gd,真的非常好改啊,连文件大小都不用变啊,只用把两个碰撞判断的对象调换一下,1改成2,这样碰到黄色方块就是拿到flag,碰到绿色才是示例flag~ 改完重打包签名,得到“开挂版”: 下载链接: <a href="elink@a6eK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6H3j5h3&6Q4x3X3g2T1j5h3W2V1N6g2)9J5k6h3y4G2L8g2)9J5c8Y4y4Q4x3V1j5I4L8#2c8d9y4@1W2J5h3h3k6A6d9V1V1$3j5#2x3&6g2X3!0w2P5Y4c8%4k6#2)9K6c8Y4m8%4k6q4)9K6c8o6t1J5L8o6f1`.">重打包apk</a>  ## 5.找加密函数地址 接下来就是在libsec2026.so里找加密函数了 从字符信息里已经知道godot-cpp扩展部分也在这个so里,所以再次对应源码分析函数 之前研究过unity,感觉godot挺像,就是gd脚本去调cpp肯定是有个调用链的,比如有一个表存了所有函数地址,然后通过一个接口找到对应地址巴拉巴拉 所以就是要hook这个调用链去拿到函数地址,然后就能看加密逻辑了 大概就是classdb是一个关键的类,最关键的注册方法的函数是classdb_register_extension_class_method(ClassDB::bind_method_godot会调用) 定位godot.cpp简单,然后根据godot.cpp里有引用ClassDB::deinitialize,推到classdb的范围再从函数特征定位到ClassDB::bind_method_godot,再让ai分析这个函数:  就拿到了classdb_register_extension_class_method的偏移,然后就可以hook这个函数拿到调用的加密函数的地址减去base基址获得加密函数的偏移: (我这里hook都写一起了,最后再放完整版吧,这个版本也是几次拿到回显然后让ai调整后的 ```javascript const CLASSDB_REG_METHOD_GLOBAL = 0xEDD10; // GDExtensionClassMethodInfo 结构体布局 (ARM64): // +0x00 name (StringName*) // +0x08 method_userdata (MethodBind*) // +0x10 call_func (bind_call) // +0x18 ptrcall_func (bind_ptrcall) // +0x20 method_flags (u32) // +0x24 has_return_value (u8) // +0x28 return_value_info (ptr) // +0x30 return_value_metadata (i32) // +0x34 argument_count (u32) function readPtrAt(addr) { var lo = addr.readU32() >>> 0; var hi = addr.add(4).readU32() >>> 0; return ptr("0x" + hi.toString(16) + ("00000000" + lo.toString(16)).slice(-8)); } function off(p) { return "0x" + p.sub(base).toString(16); } var base; function hookRegistration() { var globalAddr = base.add(CLASSDB_REG_METHOD_GLOBAL); var funcPtr = globalAddr.readPointer(); console.log("[*] globalAddr = " + globalAddr); console.log("[*] classdb_register_extension_class_method = " + funcPtr); Interceptor.attach(funcPtr, { onEnter: function (args) { var info = args[2]; var methodBind = readPtrAt(info.add(0x08)); var argCount = info.add(0x34).readU32(); var hasReturn = info.add(0x24).readU8(); // MethodBind+0x50 = 实际方法函数指针 (ptrcall path 从这里 LDP) var realFunc = readPtrAt(methodBind.add(0x50)); console.log("[method] impl=" + off(realFunc) + " args=" + argCount + " ret=" + hasReturn + " | MethodBind=" + methodBind); }, onLeave: function (retval) { var keyAddr = base.add(0xED5D2); var nonceAddr = base.add(0xED5F3); console.log("[key] " + hexdump(keyAddr, { length: 32, ansi: false })); console.log("[nonce] " + hexdump(nonceAddr, { length: 12, ansi: false })); } }); } ``` 运行一下拿到这个地址  ## 6.分析复现加密函数 然后这里有个什么混淆跳转吧,反正最后加密逻辑在0x4E548  emmmm实话说拿到加密函数地址以后就全依靠ai了() ai 分析出是chacha20 流密码加密,还有key和nonce的地址(0xED5D2,0xED5F3) 但第一次复现加密结果对不上,就动态去看key、nonce有没有更改:都已经触发flag生成以后还是没有变 所以认为这个"Th1s ls n0t a rea1 key!!@sec2026" 是个烟雾弹来着,这确实是key,只是加密算法应该又被魔改了(不知道是不是这里做错了,但确实复现出来的flag跟游戏里显示的对上了当时就觉得应该没问题了,不太懂啊有没有佬可以给笨人解释一下) ```javascript // 标准 ChaCha20 的 16 个 u32 state 布局是: state[0..3] = "expand 32-byte k" (常量 0x61707865, 0x3320646e, 0x79622d32, 0x6b206574) state[4..11] = key (8 个 u32) state[12] = counter state[13..15]= nonce (3 个 u32) ``` 所以直接把这块内存都打印出来:  发现魔改的地方:  然后让ai复现加解密过程就可以了 hook脚本: ```javascript "use strict"; // classdb_register_extension_class_method 函数指针的存储地址 (IDA中的 qword_EDD10) // 注意: qword_EDD08 是 classdb_register_extension_class5 (不同的函数) // off_EC268 (GOT) → qword_EDD10 → 实际函数地址 const CLASSDB_REG_METHOD_GLOBAL = 0xEDD10; // GDExtensionClassMethodInfo 结构体布局 (ARM64): // +0x00 name (StringName*) // +0x08 method_userdata (MethodBind*) // +0x10 call_func (bind_call) // +0x18 ptrcall_func (bind_ptrcall) // +0x20 method_flags (u32) // +0x24 has_return_value (u8) // +0x28 return_value_info (ptr) // +0x30 return_value_metadata (i32) // +0x34 argument_count (u32) function readPtrAt(addr) { var lo = addr.readU32() >>> 0; var hi = addr.add(4).readU32() >>> 0; return ptr("0x" + hi.toString(16) + ("00000000" + lo.toString(16)).slice(-8)); } function off(p) { return "0x" + p.sub(base).toString(16); } var base; function hookRegistration() { var globalAddr = base.add(CLASSDB_REG_METHOD_GLOBAL); var funcPtr = globalAddr.readPointer(); console.log("[*] globalAddr = " + globalAddr); console.log("[*] classdb_register_extension_class_method = " + funcPtr); Interceptor.attach(funcPtr, { onEnter: function (args) { var info = args[2]; var methodBind = readPtrAt(info.add(0x08)); var argCount = info.add(0x34).readU32(); var hasReturn = info.add(0x24).readU8(); // MethodBind+0x50 = 实际方法函数指针 (ptrcall path 从这里 LDP) var realFunc = readPtrAt(methodBind.add(0x50)); console.log("[method] impl=" + off(realFunc) + " args=" + argCount + " ret=" + hasReturn + " | MethodBind=" + methodBind); }, onLeave: function (retval) { var keyAddr = base.add(0xED5D2); var nonceAddr = base.add(0xED5F3); console.log("[key] " + hexdump(keyAddr, { length: 32, ansi: false })); console.log("[nonce] " + hexdump(nonceAddr, { length: 12, ansi: false })); } }); } function hookChaCha20XOR() { // sub_5B950 = ChaCha20_XOR(state, input, output, size) // 混淆跳板: 实际入口 = 0x5B950 + 0x20 var xorFunc = base.add(0x5B950); console.log("[*] hooking ChaCha20_XOR at " + xorFunc); Interceptor.attach(xorFunc, { onEnter: function (args) { var statePtr = args[0]; var inputPtr = args[1]; var size = args[3].toInt32(); // ChaCha20 state: 16 x uint32 = 64 bytes // [0..3] = "expand 32-byte k" constants // [4..11] = key (32 bytes) // [12] = counter // [13..15] = nonce (12 bytes) console.log("[chacha20] size=" + size); console.log("[chacha20] state (64 bytes):\n" + hexdump(statePtr, { length: 64, ansi: false })); console.log("[chacha20] input:\n" + hexdump(inputPtr, { length: size, ansi: false })); // 解析 state 中的 key 和 nonce var key = statePtr.add(16).readByteArray(32); // state[4..11] var nonce = statePtr.add(52).readByteArray(12); // state[13..15] var counter = statePtr.add(48).readU32(); // state[12] console.log("[chacha20] key:\n" + hexdump(key, { ansi: false })); console.log("[chacha20] nonce:\n" + hexdump(nonce, { ansi: false })); console.log("[chacha20] counter=" + counter); this.outputPtr = args[2]; this.size = size; }, onLeave: function (retval) { if (this.size > 0) { console.log("[chacha20] output:\n" + hexdump(this.outputPtr, { length: this.size, ansi: false })); } } }); } function main() { console.log("=== hook_process.js ==="); base = Process.getModuleByName("libsec2026.so").base; console.log("[*] libsec2026.so base = " + base); hookRegistration(); hookChaCha20XOR(); } setTimeout(main, 1000); ``` 复现flag算法脚本: ```c #include <stdio.h> #include <stdint.h> #include <string.h> #include <stdlib.h> static const uint8_t KEY[32] = "Th1s ls n0t a rea1 key!!@sec2026"; static const uint8_t NONCE[12] = "012345678901"; static const char FLAG_PREFIX[] = "sec2026_PART1_"; static const uint32_t CONSTANTS[4] = { 0x61707866, 0x3320646f, 0x79622d31, 0x6b206573 }; static inline uint32_t rotl32(uint32_t v, int n) { return (v << n) | (v >> (32 - n)); } static void quarter_round(uint32_t s[16], int a, int b, int c, int d) { s[a] += s[b]; s[d] ^= s[a]; s[d] = rotl32(s[d], 16); s[c] += s[d]; s[b] ^= s[c]; s[b] = rotl32(s[b], 12); s[a] += s[b]; s[d] ^= s[a]; s[d] = rotl32(s[d], 8); s[c] += s[d]; s[b] ^= s[c]; s[b] = rotl32(s[b], 7); } static void chacha20_block(const uint8_t key[32], uint32_t counter, const uint8_t nonce[12], uint8_t out[64]) { uint32_t state[16], work[16]; state[0] = CONSTANTS[0]; state[1] = CONSTANTS[1]; state[2] = CONSTANTS[2]; state[3] = CONSTANTS[3]; for (int i = 0; i < 8; i++) memcpy(&state[4 + i], key + i * 4, 4); state[12] = counter; for (int i = 0; i < 3; i++) memcpy(&state[13 + i], nonce + i * 4, 4); memcpy(work, state, sizeof(state)); for (int i = 0; i < 10; i++) { quarter_round(work, 0, 4, 8, 12); quarter_round(work, 1, 5, 9, 13); quarter_round(work, 2, 6, 10, 14); quarter_round(work, 3, 7, 11, 15); quarter_round(work, 0, 5, 10, 15); quarter_round(work, 1, 6, 11, 12); quarter_round(work, 2, 7, 8, 13); quarter_round(work, 3, 4, 9, 14); } for (int i = 0; i < 16; i++) { uint32_t val = work[i] + state[i]; memcpy(out + i * 4, &val, 4); } } static void chacha20(const uint8_t *data, size_t len, uint8_t *out, const uint8_t key[32], const uint8_t nonce[12], uint32_t counter) { uint8_t block[64]; for (size_t off = 0; off < len; off += 64) { chacha20_block(key, counter + (uint32_t)(off / 64), nonce, block); size_t chunk = (len - off < 64) ? (len - off) : 64; for (size_t j = 0; j < chunk; j++) out[off + j] = data[off + j] ^ block[j]; } } static void xor_enc(const char *plain, uint8_t buf[8]) { memset(buf, 0, 8); size_t slen = strlen(plain); if (slen > 8) slen = 8; memcpy(buf, plain, slen); for (int i = 0; i < 7; i++) buf[i] ^= buf[i + 1]; buf[7] ^= buf[0]; } static void xor_dec(uint8_t buf[8]) { buf[7] ^= buf[0]; for (int i = 6; i >= 0; i--) buf[i] ^= buf[i + 1]; } static void print_hex(const char *label, const uint8_t *data, size_t len) { printf("%s", label); for (size_t i = 0; i < len; i++) printf("%02x%s", data[i], i + 1 < len ? " " : ""); printf("\n"); } int main(int argc, char *argv[]) { if (argc < 2) { printf("Usage: %s <plaintext>\n", argv[0]); return 1; } const char *test_input = argv[1]; printf("plaintext: %s\n", test_input); uint8_t xored[8]; xor_enc(test_input, xored); print_hex("xor_enc: ", xored, 8); uint8_t encrypted[8]; chacha20(xored, 8, encrypted, KEY, NONCE, 0); print_hex("ChaCha20: ", encrypted, 8); uint8_t decrypted[8]; chacha20(encrypted, 8, decrypted, KEY, NONCE, 0); print_hex("decrypted: ", decrypted, 8); uint8_t recovered[8]; memcpy(recovered, decrypted, 8); xor_dec(recovered); size_t end = 8; while (end > 0 && recovered[end - 1] == 0) end--; printf("recovered: %.*s\n", (int)end, recovered); return 0; } ``` 看样子是复现成功了。。。吧:   特别感谢ida-mcp,Opus4.6,公司买的token,但也可能就是我用了太多ai所以写的不够详细就没进决赛,唉好难过,本来第一次觉得自己能进决赛了55555555
登录后可查看完整内容
传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!
最后于
2026-4-18 09:10 被318编辑 ,原因:
#逆向分析
收藏
・
3
点赞
・
1
打赏
分享
分享到微信
分享到QQ
分享到微博
赞赏记录
参与人
雪币
留言
时间
trackL
感谢你的积极参与,期待更多精彩内容!
2026-4-17 23:56
查看更多
赞赏
×
1 雪花
5 雪花
10 雪花
20 雪花
50 雪花
80 雪花
100 雪花
150 雪花
200 雪花
支付方式:
微信支付
赞赏留言:
快捷留言
感谢分享~
精品文章~
原创内容~
精彩转帖~
助人为乐~
感谢分享~
最新回复
(
2
)
mb_cuhnoneh
雪 币:
0
能力值:
( LV1,RANK:0 )
在线值:
发帖
0
回帖
3
粉丝
0
关注
私信
mb_cuhnoneh
2
楼
下次哪怕用ai完成的 在提交时也润化成自己的分析过程,估计就过了
2026-4-18 17:17
0
318
雪 币:
220
活跃值:
(291)
能力值:
( LV2,RANK:10 )
在线值:
发帖
1
回帖
6
粉丝
3
关注
私信
318
3
楼
mb_cuhnoneh
下次哪怕用ai完成的 在提交时也润化成自己的分析过程,估计就过了
长记性了这次
2026-4-19 20:26
0
游客
登录
|
注册
方可回帖
回帖
表情
雪币赚取及消费
高级回复
返回
318
1
发帖
6
回帖
10
RANK
关注
私信
他的文章
[原创]2026腾讯游戏安全初赛Android wp
22123
关于我们
联系我们
企业服务
看雪公众号
专注于PC、移动、智能设备安全研究及逆向工程的开发者社区
看原图
赞赏
×
雪币:
+
留言:
快捷留言
为你点赞!
返回
顶部