首页
社区
课程
招聘
[原创]2026腾讯游戏安全初赛Android wp
发表于: 3天前 1040

[原创]2026腾讯游戏安全初赛Android wp

3天前
1040

虽然没有进决赛但还是记录一下吧,比起去年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('&lt;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~改完重打包签名,得到“开挂版”:下载链接: 重打包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

emmmm实话说拿到加密函数地址以后就全依靠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 &lt;stdio.h&gt;
#include &lt;stdint.h&gt;
#include &lt;string.h&gt;
#include &lt;stdlib.h&gt;

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 &lt;plaintext&gt;\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


[培训]《冰与火的战歌:Windows内核攻防实战》!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。

最后于 2天前 被318编辑 ,原因:
收藏
免费 1
支持
分享
最新回复 (2)
雪    币: 0
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
2
下次哪怕用ai完成的 在提交时也润化成自己的分析过程,估计就过了
1天前
0
雪    币: 20
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
318
3
mb_cuhnoneh 下次哪怕用ai完成的 在提交时也润化成自己的分析过程,估计就过了
长记性了这次
14小时前
0
游客
登录 | 注册 方可回帖
返回