首页
社区
课程
招聘
[原创][讨论] 南极企鹅游戏安全2026-Android-初赛
发表于: 2026-4-14 15:39 1719

[原创][讨论] 南极企鹅游戏安全2026-Android-初赛

2026-4-14 15:39
1719

打开游戏,触发示例方块得到样例flag,目标是触发屋顶的方块,得到真正的flag,常规游玩、车是开不到那地方的。

所以第一个目标:传送车的坐标到触发块的坐标。

解包APK发现是Godot引擎制作的游戏,gdc脚本也被加密了,本身对godot不是很熟悉,不过既然是开源的就下载源码下来看看,同时上网搜索发现:Godot官方是支持用脚本加密的。

懒了: 995K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6%4N6%4N6Q4x3X3f1#2x3Y4m8G2K9X3W2W2i4K6u0W2j5$3&6Q4x3V1k6@1K9s2u0W2j5h3c8Q4x3X3b7J5x3o6l9J5y4K6M7&6i4K6u0V1x3g2)9J5k6o6q4Q4x3X3g2Z5N6r3#2D9 ,直接照抄思路dump密钥(脚本末尾)

得到ce4df8753b59a5a39ade58ac07ef947a3da39f2af75e3284d51217c04d49a061 ,

尝试使用社区工具gdre_tools来解密显然是不行的,下载源码后发现脚本解密逻辑中的AES_MODE和游戏逆向出来的处理方式不太一样,要改掉那部分逻辑又得重新下个godot引擎编译很麻烦,干脆直接复用游戏内的解密逻辑,然后再用gdre_tools读取gdc脚本:

游戏引擎(libgodot_android.so)解密魔改部分:

解密脚本:

同时获取到了flag的获取逻辑:

然后就是处理传送车的问题:反过来利用 Godot 自己的对象系统和场景系统,直接让游戏正常执行这段逻辑。

根据Unity开发经验猜测:Godot肯定也有某种find gameObject方法,找了一下 Godot 恰好给了这样做的条件。只要在 Frida 里拿到引擎内部的这些能力:

就可以像脚本层一样直接操纵场景节点。

这部分的实现对应脚本是frida_move_trigger_sec2026.js(太长放末尾了)。

核心思路远程调用引擎提供的接口

实在懒得搜坐标手动改,还是原生的办法通用性高一些。

如果只是改可见节点的位置,车不一定真的算“进入触发器”,因为场景里真正参与碰撞的是物理对象。也正因为这样,frida_move_trigger_sec2026.js 不是只改一次 transform 就结束了,而是同时做了两层同步:

IDA打开可以分析的函数不多:

主要是解压子程序,然后通过BR命令跳转到子程序去执行逻辑。

二环主要逻辑:

选择在00001764 通过Frida脚本动态dump(脚本见末尾)下来继续分析:

三环:

因为三环入口 0x1c48 会先自修改代码,静态分析已经啃不动了,看静态毫无头绪,Hook要打上还得抓时机控制、不然直接崩。想着应该可以用Stalker去追执行流说不定方便点,但由于页权限切换、还有自修改代码等原因、反正我是一个个线程去试了,没扒下来。一番静态压根找不到Process的链路,转向动态,从native层和游戏层之间的沟通入手:

根据解包的配置知道,libsec的在游戏中被调用的入口点是

而Godot GDExtension的标准API是:

在Godot游戏引擎要调用拓展的时候第一个传入的函数指针p_get_proc_address类似于引擎接口查询函数可以直接查到Godot 的注册接口地址classdb_register_extension_class_method 在注册拓展函数的时候,会交一个GDExtensionClassMethodInfo:

call_func注册了拓展函数的入口点,注册时可以拿到三环关键函数的地址:

对照dump下来的逻辑:

可以发现自修改改了不少,动态分析就省去了那部分分析逻辑,而因为执行函数的时候是通过method_userdata下发分发的,调用时又加上了userdate做了一层偏移,触发flag获取的逻辑后,可以得到Object.Process在三环内的真正地址:

结合静态来看,开始套娃:

此时已经可以根据运行的参数,计算出之后的调度链(大致会执行哪些函数都看一遍、结果发现是套娃),通过binary ninja继续跟,直到套娃:0x4ffd0 类似的分发函数,按照类似的思路去做:

重复这个操作下来两三次:0x4d7a8 -> 0x4ffd0 -> 0x4bd68 -> 0x4c8e4 -> 0x4e198 -> 0x4e548 -> 0x5bf18 -> 0x5b69c -> 0x5b5e0 -> 0x5b950。其中 0x5b818/0x5bcec 的 state dump 直接打出了 ChaCha20-like 常量、key、nonce,正式把整个处理逻辑给扒干净了,非常之搞笑。(脚本见末尾)

正好有运行时反汇编的操作,顺手就可以确定加密逻辑,并把flag推随机值的一起做了,验证之后是没问题的:(见样例源代码)

其实思路和2021还是2022的ACE保护差不多,都是游戏引擎侧对入口(global-meta.data、gdc)这种做加密,然后提高保护程序的分析难度,上混淆vmp、控制流平坦化什么的,这次赛题没有反调试、但是多环嵌套代码、自修改和动态跳转已经对Hook时机、方式有了一定的保护。同时三环的自修改写得也挺有意思,有外部分发、根据参数跳转执行的方式也对静态分析有了不小的挑战,同时动态分析时Frida的inlinehook是没法随意提前打上去拿各种信息的,stalker不知道为什么总是会崩。总之,很有意思!

解题优化思路:如果有动态trace多线程dump执行流的办法,其实这道题就变得非常简单了,用的frida脚本其实已经有了雏形,可能以后再继续研究看看有没有更好的办法去自动化吧。

打完决赛再放dump执行流的脚本,之后有时间可能改进一下做成工具,思路还是比较自信的,运行时dump听起来就很酷炫,这里多宣传一个个人写得zygisk工具:7f4K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6y4j5i4u0U0K9o6N6Q4x3X3c8U0L8$3c8W2i4K6u0r3h3Y4W2Y4K9i4y4C8d9h3&6B7k6h3y4@1i4@1g2r3i4@1u0o6i4K6S2o6i4@1f1$3i4K6R3@1i4K6W2r3i4@1f1#2i4K6R3#2i4@1t1@1i4@1f1^5i4@1t1$3i4@1p5K6i4@1f1%4i4K6W2m8i4K6R3@1i4@1f1#2i4@1p5@1i4@1p5%4i4@1f1@1i4@1u0p5i4@1q4o6i4@1f1#2i4K6S2r3i4@1q4r3i4@1f1@1i4@1u0n7i4@1p5#2i4@1f1%4i4K6W2o6i4K6S2n7i4@1f1%4i4K6W2o6i4K6S2n7i4@1g2r3i4@1u0o6i4K6S2o6i4@1f1#2i4K6R3H3i4K6W2r3i4@1f1&6i4K6R3&6i4@1t1@1i4@1f1^5i4@1q4q4i4@1u0m8i4@1f1#2i4K6W2p5i4K6W2n7i4@1f1#2i4K6R3$3i4K6R3#2i4@1f1#2i4@1p5@1i4@1p5%4i4@1f1@1i4@1u0p5i4@1q4o6i4@1f1%4i4K6W2m8i4K6R3@1i4@1f1$3i4K6R3K6i4@1t1K6i4@1f1$3i4@1t1K6i4K6V1#2i4@1g2r3i4@1u0o6i4K6W2m8M7$3g2U0j5$3!0E0j5i4m8Q4x3V1u0@1M7X3q4U0k6i4u0Q4c8e0g2Q4z5p5k6Q4b7f1k6Q4c8e0c8Q4b7V1u0Q4b7e0g2Q4c8e0g2Q4z5f1y4Q4b7e0S2Q4c8e0S2Q4b7V1k6Q4z5e0m8Q4c8e0S2Q4b7e0q4Q4z5p5y4Q4c8e0k6Q4z5e0N6Q4b7U0k6Q4c8e0k6Q4z5p5u0Q4b7e0k6Q4c8e0k6Q4z5o6S2Q4b7f1q4K6P5i4y4U0j5h3I4D9i4@1f1^5i4@1t1H3i4K6R3K6i4@1f1%4i4K6V1@1i4@1p5^5i4@1f1%4i4K6W2m8i4K6R3@1i4@1f1@1i4@1t1^5i4K6R3H3i4@1f1@1i4@1t1^5i4@1q4m8i4@1f1#2i4@1t1H3i4K6S2r3i4@1f1#2i4@1t1%4i4@1p5#2i4@1f1#2i4K6R3#2i4@1t1%4i4@1f1K6i4K6R3H3i4K6R3J5

见附件,逆向的加密算法:

#!/usr/bin/env python3
from __future__ import annotations

import argparse
import hashlib
import struct
from pathlib import Path


DEFAULT_INPUT = Path("lib/arm64-v8a/libsec2026.so")
DEFAULT_OUTPUT = Path("lib/arm64-v8a/libsec2026.payload.bin")
DEFAULT_MAP_SIZE_OFFSET = 0x69A60
DEFAULT_COMP_SIZE_OFFSET = 0x69A64
DEFAULT_BLOB_OFFSET = 0x69A6C
DEFAULT_ENTRY_OFFSET = 0x10


def parse_int(value: str) -> int:
    return int(value, 0)


def read_u32_le(data: bytes, offset: int) -> int:
    return struct.unpack_from("<I", data, offset)[0]


def to_signed32(value: int) -> int:
    value &= 0xFFFFFFFF
    return value - 0x1_0000_0000 if value & 0x80000000 else value


class Bitstream:
    def __init__(self, data: bytes) -> None:
        self.data = data
        self.offset = 0
        self.word = 0x80000000

    def read_byte(self) -> int:
        if self.offset >= len(self.data):
            raise EOFError("read past compressed blob")
        value = self.data[self.offset]
        self.offset += 1
        return value

    def next_bit(self) -> int:
        carry = 1 if (self.word & 0x80000000) else 0
        self.word = (self.word << 1) & 0xFFFFFFFF
        if self.word == 0:
            if self.offset + 4 > len(self.data):
                raise EOFError("reload past compressed blob")
            reloaded = read_u32_le(self.data, self.offset)
            self.offset += 4
            total = reloaded + reloaded + carry
            carry = 1 if total > 0xFFFFFFFF else 0
            self.word = total & 0xFFFFFFFF
        return carry


def decode_varlen(bitstream: Bitstream) -> int:
    value = 1
    while True:
        value = ((value << 1) + bitstream.next_bit()) & 0xFFFFFFFF
        if bitstream.next_bit():
            return value


def unpack_payload(blob: bytes) -> bytes:
    bitstream = Bitstream(blob)
    out = bytearray()
    last_offset = 0xFFFFFFFF

    while True:
        if bitstream.next_bit():
            out.append(bitstream.read_byte())
            continue

        code = decode_varlen(bitstream)
        if code >= 3:
            offset_low = bitstream.read_byte()
            last_offset = (~(offset_low | ((code - 3) << 8))) & 0xFFFFFFFF
            if last_offset == 0:
                break

        length = bitstream.next_bit()
        length = ((length << 1) + bitstream.next_bit()) & 0xFFFFFFFF
        if length == 0:
            length = (decode_varlen(bitstream) + 2) & 0xFFFFFFFF

        if ((last_offset + 0xD00) >> 32) == 0:
            length = (length + 1) & 0xFFFFFFFF

        src = len(out) + to_signed32(last_offset)
        if src < 0:
            raise ValueError(f"invalid back-reference: src={src} offset={last_offset:#x}")

        copies = length + 1
        for _ in range(copies):
            out.append(out[src])
            src += 1

    if bitstream.offset != len(blob):
        raise ValueError(
            f"decompression ended at 0x{bitstream.offset:x}, expected 0x{len(blob):x}"
        )
    return bytes(out)


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="Offline unpacker for the runtime-compressed payload in libsec2026.so."
    )
    parser.add_argument(
        "input",
        nargs="?",
        type=Path,
        default=DEFAULT_INPUT,
        help=f"input shared object (default: {DEFAULT_INPUT})",
    )
    parser.add_argument(
        "-o",
        "--output",
        type=Path,
        default=DEFAULT_OUTPUT,
        help=f"where to write the unpacked payload (default: {DEFAULT_OUTPUT})",
    )
    parser.add_argument(
        "--map-size-offset",
        type=parse_int,
        default=DEFAULT_MAP_SIZE_OFFSET,
        help=f"offset of the mapped-size dword (default: {DEFAULT_MAP_SIZE_OFFSET:#x})",
    )
    parser.add_argument(
        "--comp-size-offset",
        type=parse_int,
        default=DEFAULT_COMP_SIZE_OFFSET,
        help=f"offset of the compressed-size dword (default: {DEFAULT_COMP_SIZE_OFFSET:#x})",
    )
    parser.add_argument(
        "--blob-offset",
        type=parse_int,
        default=DEFAULT_BLOB_OFFSET,
        help=f"offset of the compressed blob (default: {DEFAULT_BLOB_OFFSET:#x})",
    )
    parser.add_argument(
        "--entry-offset",
        type=parse_int,
        default=DEFAULT_ENTRY_OFFSET,
        help=f"runtime branch target inside the unpacked payload (default: {DEFAULT_ENTRY_OFFSET:#x})",
    )
    parser.add_argument(
        "--pad-to-map-size",
        action="store_true",
        help="pad the output file to the loader's mmap size",
    )
    return parser


def main() -> int:
    args = build_parser().parse_args()

    image = args.input.read_bytes()
    map_size = read_u32_le(image, args.map_size_offset)
    comp_size = read_u32_le(image, args.comp_size_offset)
    blob = image[args.blob_offset : args.blob_offset + comp_size]
    if len(blob) != comp_size:
        raise ValueError("compressed blob extends past end of file")

    payload = unpack_payload(blob)
    if len(payload) > map_size:
        raise ValueError(
            f"payload is larger than mapped size: 0x{len(payload):x} > 0x{map_size:x}"
        )

    written = payload.ljust(map_size, b"\x00") if args.pad_to_map_size else payload
    args.output.parent.mkdir(parents=True, exist_ok=True)
    args.output.write_bytes(written)

    sha256 = hashlib.sha256(payload).hexdigest()
    print(f"input:        {args.input}")
    print(f"output:       {args.output}")
    print(f"map size:     0x{map_size:x}")
    print(f"blob size:    0x{comp_size:x}")
    print(f"payload size: 0x{len(payload):x}")
    print(f"entry offset: 0x{args.entry_offset:x}")
    print(f"entry file:   {args.output}@0x{args.entry_offset:x}")
    print(f"sha256:       {sha256}")
    if args.pad_to_map_size and len(written) != len(payload):
        print(f"padded size:  0x{len(written):x}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
00001450        // 二环装载器:根据 arg1
00001450        // 的自相对头字段计算重定位基址与下一环入口;把
00001450        // rebase + *(u32 *)(arg1 + 0xc) 处的内嵌 UPX
00001450        // 风格容器复制到临时映射并解包;遍历 arg3 +
00001450        // 0x40 开始的 0x38
00001450        // 字节描述符,逐段映射、修补并设置权限;如遇可执行尾段则通过
00001450        // memfd 构造 16 字节 RX 跳板;最后在 0x1778
00001450        // 跳入重定位后的下一环入口。
00001458        int64_t x19
00001458        int64_t var_e0 = x19
0000147c        float128 v15
0000147c        sub_1004(arg1, x19, v15)  // 设置描述符号
00001484        // 计算重定位基址:arg1
00001484        // 头字段是自相对偏移,因此 base = arg1 - *(u32
00001484        // *)arg1。
0000148c        double v9 = float.d(arg1 - zx.q(*arg1))
00001490        int32_t x1 = (*(arg1 + 8)).d
00001494        int64_t var_10 = *arg1
000014a4        // 定位内嵌的下一环压缩块:packed_blob = rebase_base
000014a4        // + *(u32 *)(arg1 + 0xc)。
000014a4        char* x19_2 = v9 i+ zx.q(arg1[3])
000014b4        // 定位重定位后的下一环入口:next_entry =
000014b4        // rebase_base + *(u32 *)(arg1 + 4)。
000014b4        double v10 = float.d(v9 i+ zx.q(arg1[1]))
000014b8        uint64_t x0_6 = zx.q(arg1.d - x19_2.d)
000014dc        // mmap
000014dc        // 申请 RW
000014dc        // 临时映射,用来承接下一环的压缩容器。
000014dc        char* x0_7 = sub_74()
000014f0        // 拷贝缓冲区
000014f0        // 把内嵌压缩块复制到临时映射中。
000014f0        sub_f48(x0_7, x19_2, x0_6)
000014fc        uint64_t var_20 = zx.q(*(x0_7 + 0x18))
00001500        void* var_18 = arg3
0000150c        uint64_t var_30 = zx.q(*(x0_7 + 0x1c)) + 0xc
00001510        void* var_28 = &x0_7[0x18]
0000151c        // 初始化 / 推进 scratch+0x18 处的 UPX
0000151c        // 风格块流解析器。
0000151c        sub_1150(&var_30, &var_20)
00001520        // 执行起始地址
00001520        // 描述符表起点:arg3 + 0x40;每项大小 0x38 字节。
00001520        int64_t* x19_3 = arg3 + 0x40
00001530        // desc_end = desc_table + (*(u16 *)(arg3 + 0x38) *
00001530        // 0x38),准备遍历全部段描述符。
00001530        void* x23_1 = &x19_3[zx.q(*(arg3 + 0x38)) * 7]
00001530        
00001538        if (x19_3 u< x23_1)
0000153c            int32_t x24_1 = 0
00001540            int64_t x21_1 = 0
00001540            
00001750            do  // 修补映射的主逻辑
00001550                // 读取 desc->type_flags;只有 (type_flags &
00001550                // 0x2ffffffff) == 1 的项会按可装载段处理。
00001564                if ((*x19_3 & 0x2ffffffff) == 1)
00001568                    if (x21_1 == 0)
0000156c                        // 首个可装载段会建立统一的
0000156c                        // load_bias:load_bias = rebase_base -
0000156c                        // desc->vaddr。
00001574                        x21_1 = v9 i- x19_3[2]
00001574                    
00001578                    // 计算当前段的逻辑末尾 desc->vaddr +
00001578                    // desc->filesz,并从块流中取出下一个 0xc
00001578                    // 字节块头。
00001580                    int32_t x20_2 = (x19_3[2]).d + (x19_3[4]).d
00001584                    var_30 = 0xc
00001594                    int32_t var_50
00001594                    sub_10f0(&var_30, &var_50, 0xc)
000015a0                    var_28 -= 0xc
000015a8                    int32_t var_4c
000015a8                    var_30 = zx.q(var_4c)
000015ac                    int32_t x0_21 = var_50
000015b0                    uint64_t x1_9 = zx.q(x0_21)
000015b4                    // chunk_len =
000015b4                    // hdr[0];记录本次要回填到当前段的块长度。
000015b4                    var_20 = x1_9
000015c0                    // 计算当前块的落点:dst = load_bias +
000015c0                    // (desc->vaddr + desc->filesz - chunk_len)。
000015c0                    int64_t x1_11 = zx.q(x20_2) - x1_9 + x21_1
000015c0                    
000015dc                    if (x19_3[1] + x19_3[4] u> zx.q(x1) || x24_1 != 0)
000015ec                        int32_t var_64_1
000015ec                        
000015ec                        if ((*(x19_3 + 4) & 1) == 0)
00001600                            // 若 desc->flags.bit0
00001600                            // 未置位,则走匿名 RW
00001600                            // 映射路径(sub_f6c)。
00001604                            sub_f6c(x0_21, x1_11, arg1.d)
00001608                            var_64_1 = 0
000015ec                        else
000015f0                            // 若 desc->flags.bit0 置位,则走
000015f0                            // file-backed /
000015f0                            // 对齐映射路径(sub_1374)。
000015f8                            var_64_1 = sub_1374(x0_21, x1_11, arg1)
000015f8                        
0000160c                        // 保存当前 chunk_len /
0000160c                        // dst,随后推进块流到下一个块。
0000160c                        uint32_t x0_23 = var_20.d
00001620                        sub_1150(&var_30, &var_20)
00001620                        
00001628                        // 只有可执行 / file-backed
00001628                        // 类型的段才会继续进入下面的跳板与最终权限处理逻辑。
0000162c                        if ((*(x19_3 + 4) & 1) != 0)
00001644                            int32_t var_40_1 = 0xd4000001
00001650                            int32_t var_3c_1 = 0xa9417be2
0000165c                            int32_t var_38_1 = 0xa8c207e0
00001668                            int32_t var_34_1 = 0xd61f03c0
00001668                            
0000166c                            // 特殊分支:只处理 masked
0000166c                            // type_flags == 0x100000001
0000166c                            // 的描述符(可执行尾段 /
0000166c                            // 跳板场景)。
0000167c                            if ((*x19_3 & 0x1ffffffff) == 0x100000001)
00001680                                // 计算尾部 slack = desc->memsz -
00001680                                // desc->filesz;若满足页对齐约束,则为尾段构造
00001680                                // RX 跳板。
0000169c                                if ((neg.d(x1_11.d + (x19_3[5]).d - (x19_3[4]).d)
0000169c                                        & not.d(arg1.d)) u> 0xf)
0000169c                                    jump(0x16a0)
0000169c                                
000016dc                                int64_t __saved_fp
000016dc                                // 通过 memfd_create -> write(16-byte
000016dc                                // veneer) -> mmap(PROT_RX)
000016dc                                // 生成临时可执行跳板,结果保存在
000016dc                                // x26。
000016dc                                sub_16e4(x19_3, x1_11, x21_1, 0xffffffffffff, x23_1, x24_1, 
000016dc                                    0xc, arg2, x1, &__saved_fp, v9, v10)
000016e0                                undefined
000016e0                            
0000172c                            // 调用 sub_12c4
0000172c                            // 完成当前段的收尾:设权限、刷
0000172c                            // I-cache,并按需要释放中间映射。
00001740                            sub_12c4(x0_23, x1_11, x19_3, var_64_1, x21_1)
00001740                        
00001744                        x24_1 += 1
000015dc                    else
000015e0                        x24_1 += 1
000015e0                
00001748                // 移动到下一个 0x38
00001748                // 字节描述符,继续装载循环。
00001748                x19_3 = &x19_3[7]
00001750            while (x23_1 u> x19_3)
00001750        
00001764        // 释放之前为压缩容器申请的 scratch 映射。
00001764        sub_1c(x0_7, x0_6)
00001778        // 正式跳入下一环入口:blr next_entry(zx.q(*arg2),
00001778        // *(arg2 + 8), *(arg2 + 0x10))。
00001778        v10(zx.q(*arg2), *(arg2 + 8), *(arg2 + 0x10))
0000179c        return 0

传播安全知识、拓宽行业人脉——看雪讲师团队等你加入!

最后于 2026-4-20 00:14 被心若空境编辑 ,原因: 恢复
上传的附件:
收藏
免费 3
支持
分享
最新回复 (1)
雪    币: 104
活跃值: (8407)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
2
tql
2026-4-15 11:57
0
游客
登录 | 注册 方可回帖
返回