文章总结: 该文档详细记录了2026腾讯游戏安全初赛Android逆向题解,涉及Godot引擎游戏逆向、so文件dump、AES密钥定位、gd脚本解密与重打包,以及通过hook定位加密函数等完整流程,展示了实战逆向分析思路与工具使用技巧。
综合评分: 85
文章分类: 逆向分析,二进制安全,ctf,实战经验,移动安全
2026腾讯游戏安全初赛Android题解
318
318
看雪学苑
2026年9月27日 17:59
上海
在小说阅读器读本章
去阅读
在公众号小说中沉浸阅读
虽然没有进决赛但还是记录一下吧,比起去年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一下:
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搓了一个解密脚本:
#!/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层加密逻辑。
这里有了godotscript脚本,虽然因为有cpp扩展的缘故不能直接放回引擎直接运行修改,但可以修改gd脚本重打包,而且我只是修改,连parsepck索引文件都不用重新生成,只用把trigger.gd改一下再用GDRE编译一下.gdc再套刚那套魔改AES加密回去就行了!
这里其实还想了一种更难的方法,如果能找到godot引擎load -> call 一个gd函数的调用链,然后找到对应地址可以构造出一个任意gd代码注入执行器?太复杂了没尝试。
回到trigger.gd,真的非常好改啊,连文件大小都不用变啊,只用把两个碰撞判断的对象调换一下,1改成2,这样碰到黄色方块就是拿到flag,碰到绿色才是示例flag~
改完重打包签名,得到“开挂版”:
下载链接:重打包apk
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调整后的:
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:
emm实话说拿到加密函数地址以后就全依靠ai了,ai 分析出是chacha20 流密码加密,还有key和nonce的地址(0xED5D2,0xED5F3)
但第一次复现加密结果对不上,就动态去看key、nonce有没有更改:都已经触发flag生成以后还是没有变。
所以认为这个”Th1s ls n0t a rea1 key!!@sec2026″ 是个烟雾弹来着,这确实是key,只是加密算法应该又被魔改了(不知道是不是这里做错了,但确实复现出来的flag跟游戏里显示的对上了当时就觉得应该没问题了,不太懂啊有没有佬可以给笨人解释一下)。
// 标准 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脚本:
"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算法脚本:
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include <stdlib.h>
staticconstuint8_t KEY[32] = "Th1s ls n0t a rea1 key!!@sec2026";
staticconstuint8_t NONCE[12] = "012345678901";
staticconstchar FLAG_PREFIX[] = "sec2026_PART1_";
staticconstuint32_t CONSTANTS[4] = {
0x61707866, 0x3320646f, 0x79622d31, 0x6b206573
};
staticinline uint32_trotl32(uint32_t v, int n){
return (v << n) | (v >> (32 - n));
}
staticvoidquarter_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);
}
staticvoidchacha20_block(constuint8_t key[32], uint32_t counter,
constuint8_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);
}
}
staticvoidchacha20(constuint8_t *data, size_t len, uint8_t *out,
constuint8_t key[32], constuint8_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];
}
}
staticvoidxor_enc(constchar *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];
}
staticvoidxor_dec(uint8_t buf[8]){
buf[7] ^= buf[0];
for (int i = 6; i >= 0; i--)
buf[i] ^= buf[i + 1];
}
staticvoidprint_hex(constchar *label, constuint8_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");
}
intmain(int argc, char *argv[]){
if (argc < 2) {
printf("Usage: %s <plaintext>\n", argv[0]);
return 1;
}
constchar *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;
}
看样子是复现成功了吧:
#
#
看雪ID:318
https://bbs.kanxue.com/user-home-988187.htm
*本文为看雪论坛优秀文章,由 318 原创,转载请注明来自看雪社区
第十届安全开发者峰会【议题征集】-欢迎投稿
往期推荐
AI静态分析,内核模块隐藏 Frida 特征,绕过linker私有结构遍历崩溃链
球分享
球点赞
球在看
点击阅读原文查看更多
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:看雪学苑 318
318《2026腾讯游戏安全初赛Android题解》