0%

腾讯游戏安全2026-安卓初赛

Balabala:

和2021-2022版本的加固很像,除了没有反调试基本思路是一样的。

展示:

了解目标、确定方向:

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

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

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

懒了: https://www.52pojie.cn/thread-2002779-1-1.html ,直接照抄思路dump密钥(脚本末尾)

得到ce4df8753b59a5a39ade58ac07ef947a3da39f2af75e3284d51217c04d49a061 ,

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

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

  • sub_3801410
    这是外层 FileAccessEncrypted::open_and_parse 。
  • 0x380170c
    检查魔数 1128612935 = 0x43454447 = “GDEC”。
  • 0x38019e4
    外层真正调用解密 wrapper 的位置。
  • sub_376EF68
    是一层很薄的 wrapper,直接转到 sub_197DE18。
  • sub_197C210
    AES key schedule 初始化。
  • sub_197DE18
    “魔改流模式”核心。

解密脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
#!/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())

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

image-20260412090242150

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

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

  • 获取全局 singleton;
  • 构造 VariantStringNameString
  • 在主线程上远程调用对象方法;

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

Frida 远程调用 Godot 内部方法移车

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

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

  1. libgodot_android.so 固定偏移解析出运行时接口。
  2. 自己构造 Godot Variant 参数。
  3. 通过 Engine.get_main_loop() 拿到 SceneTree
  4. 继续拿到 root,再用 find_child() 找到:
    • car
    • Trigger2
    • Label2
  5. 读取 Trigger2 的全局变换,直接把车和物理 body 移过去。

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

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

  1. 对节点本身调用:
    • set_global_transform
    • force_update_transform
  2. PhysicsServer3D 调用:
    • body_set_state(transform)
    • body_set_state(linear_velocity, Vector3.ZERO)
    • body_set_state(angular_velocity, Vector3.ZERO)
    • body_set_state(sleeping, false)

libsec.so分析:

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

image-20260412061724909

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

二环主要逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
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

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

三环:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
00001c48    int64_t sub_1c48()

00001c48 // 三环入口的首次自修改例程:用 8
00001c48 // 字节常量循环改写 0xa25d2 开始的 0x21
00001c48 // 字节,先解锁后续跳板/代码片段。
00001c48 ff4300d1 sub sp, sp, #0x10
00001c4c cac98bd2 mov x10, #0x5e4e
00001c50 e9031f2a mov w9, wzr {_start}
00001c54 eae9b4f2 movk x10, #0xa74f, lsl #0x10
00001c58 1f2003d5 nop
00001c5c // 定位三环入口后要被原地改写的 0x21 字节区域。
00001c5c a84b5050 adr x8, 0xa25d2
00001c60 eadecdf2 movk x10, #0x6ef7, lsl #0x20
00001c64 6af4f2f2 movk x10, #0x97a3, lsl #0x30 {-0x685c910858b0a1b2}
00001c68 ea0700f9 str x10, [sp, #0x8 {var_8}] {-0x685c910858b0a1b2}

00001c6c 2a7d4093 sxtw x10, w9
00001c70 eb230091 add x11, sp, #0x8 {var_8}
00001c74 2b0940b3 bfxil x11, x9, #0, #0x3 {var_8}
00001c78 2d010012 and w13, w9, #0x1
00001c7c 0c696a38 ldrb w12, [x8, x10]
00001c80 6b014039 ldrb w11, [x11]
00001c84 6e010c0a and w14, w11, w12
00001c88 6b010c2a orr w11, w11, w12
00001c8c ec030e4b neg w12, w14
00001c90 ee03292a mvn w14, w9
00001c94 29010d0b add w9, w9, w13
00001c98 ce791f32 orr w14, w14, #0xfffffffe
00001c9c 6d010c0a and w13, w11, w12
00001ca0 29010e0b add w9, w9, w14
00001ca4 6b010c4a eor w11, w11, w12
00001ca8 29090011 add w9, w9, #0x2
00001cac 6b050d0b add w11, w11, w13, lsl #0x1
00001cb0 3f850071 cmp w9, #0x21
00001cb4 0b692a38 strb w11, [x8, x10]
00001cb8 abfdff54 b.lt 0x1c6c

00001cbc ff430091 add sp, sp, #0x10
00001cc0 c0035fd6 ret

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

动态分析:

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

1
2
[configuration]
entry_symbol = "extension_init"

而Godot GDExtension的标准API是:

1
2
3
4
5
GDExtensionBool extension_init(
GDExtensionInterfaceGetProcAddress p_get_proc_address,
GDExtensionClassLibraryPtr p_library,
GDExtensionInitialization *r_initialization
);

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

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef struct GDExtensionClassMethodInfo {
void *name; // StringName*
void *method_userdata; // 用户数据
void *call_func; // 标准调用入口
void *ptrcall_func; // ptrcall 入口
uint32_t method_flags; // 方法标志
uint32_t has_return_value; // 是否有返回值
uint8_t _pad0[0x0c]; // 中间未关心字段
uint32_t argument_count; // 参数个数
uint8_t _pad1[0x10]; // 中间未关心字段
uint32_t default_argument_count;
uint8_t _pad2[0x0c]; // 结构尾部
} GDExtensionClassMethodInfo;

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
[sec2026_proc] direct classdb_register_extension_class_method class= method= call=libsec2026.so!0x63de4 ptrcall=libsec2026.so!0x63e3c lr=libsec2026.so!0x60bf0
[sec2026_proc] registered method #1 source=direct:libsec2026.so!0x60bf0 class=<unnamed_class> method=<unnamed_method_1> call=libsec2026.so!0x63de4 ptrcall=libsec2026.so!0x63e3c
[sec2026_proc] <unnamed_class>.<unnamed_method_1>@direct:libsec2026.so!0x60bf0.call_func@0x7237065de4 runtime bytes: ff 03 01 d1 fd 7b 02 a9 f3 1b 00 f9 fd 83 00 91 08 00 40 f9 f3 03 04 aa e4 03 05 aa 09 0d 40 f9
[sec2026_proc] <unnamed_class>.<unnamed_method_1>@direct:libsec2026.so!0x60bf0.call_func@0x7237065de4 runtime disassembly:
[sec2026_proc] 0x7237065de4 sub sp, sp, #0x40
[sec2026_proc] 0x7237065de8 stp x29, x30, [sp, #0x20]
[sec2026_proc] 0x7237065dec str x19, [sp, #0x30]
[sec2026_proc] 0x7237065df0 add x29, sp, #0x20
[sec2026_proc] 0x7237065df4 ldr x8, [x0]
[sec2026_proc] 0x7237065df8 mov x19, x4
[sec2026_proc] 0x7237065dfc mov x4, x5
[sec2026_proc] 0x7237065e00 ldr x9, [x8, #0x18]
[sec2026_proc] 0x7237065e04 add x8, sp, #8
[sec2026_proc] 0x7237065e08 blr x9
[sec2026_proc] <indirect call via [object Object]>
[sec2026_proc] 0x7237065e0c adrp x8, #0x72370ed000
[sec2026_proc] 0x7237065e10 add x1, sp, #8
[sec2026_proc] 0x7237065e14 mov x0, x19
[sec2026_proc] 0x7237065e18 ldr x8, [x8, #0xf48]
[sec2026_proc] 0x7237065e1c ldr x8, [x8]
[sec2026_proc] 0x7237065e20 blr x8
[sec2026_proc] <indirect call via [object Object]>
[sec2026_proc] 0x7237065e24 add x0, sp, #8
[sec2026_proc] 0x7237065e28 bl #0x7237068dc0
[sec2026_proc] 0x7237065e2c ldp x29, x30, [sp, #0x20]
[sec2026_proc] 0x7237065e30 ldr x19, [sp, #0x30]
[sec2026_proc] 0x7237065e34 add sp, sp, #0x40
[sec2026_proc] 0x7237065e38 ret

对照dump下来的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
00063de4    uint64_t __convention("apple-arm64-objc-fast-arc-0") sub_63de4(int64_t arg1, 
00063de4 void* arg2 @ x19, void* arg3 @ fp, int64_t arg4, int64_t arg5, int64_t arg6, int64_t arg7)

00063de4 // GameExtension.Process 的 call_func 入口。第一个参数 x0
00063de4 // 就是 method_userdata;运行时会先取 *(x0)
00063de4 // 作为分发表,再取该表的 +0x18
00063de4 // 项作为真实处理函数并间接调用,所以后续稳定落到
00063de4 // sub_4d78c 不是静态直连,而是 slot 分发结果。
00063de4 f40300aa mov x20, x0
00063de8 41fbffd0 adrp x1, 0xfffffffffffcd000
00063dec 21001e91 add x1, x1, #0x780 {-0x32880}
00063df0 a0630091 add x0, fp, #0x18
00063df4 e2031f2a mov w2, wzr {_start}
00063df8 52d9fe97 bl sub_1a340
00063dfc 424b8a52 mov w2, #0x525a
00063e00 a1630091 add x1, fp, #0x18
00063e04 e00314aa mov x0, x20
00063e08 a218bd72 movk w2, #0xe8c5, lsl #0x10 {0xe8c5525a}
00063e0c // 运行时这里是关键间接跳转。按实际执行到的代码看,call_func
00063e0c // 先用 x0(method_userdata) 取 *x0 作为分发表,再取 [*x0
00063e0c // + 0x18] 作为真实处理函数;静态里看到的
00063e0c // x21,本质上就是这个 slot 0x18 取出的函数指针。
00063e0c a0023fd6 blr x21
00063e10 f40300aa mov x20, x0
00063e14 a0630091 add x0, fp, #0x18
00063e18 9c3bff97 bl sub_32c88
00063e1c 000200f0 adrp x0, 0xa6000
00063e20 00402c91 add x0, x0, #0xb10 {0xa6b10}
00063e24 148400f8 str x20, [x0], #0x8 {0xa6b10} {0xa6b18}
00063e28 44690094 bl sub_7e338
00063e2c e00313aa mov x0, x19
00063e30 d2ffff17 b 0x63d78

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

1
[21091116AC::com.tencent.ACE.gamesec2026.preliminary ]-> [sec2026_proc] <unnamed_class>.<unnamed_method_1>@direct:libsec2026.so!0x60bf0 first dispatch chain via call_func slot=0x18 table=0x72370e9d90 libsec2026.so!0x4d7a8 -> libsec2026.so!0x4ffd0

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
0004d78c    // call_func
0004d78c // 落下来的第一层调度器。这里先做一次懒初始化,再从全局
0004d78c // slot
0004d78c // 里取出真实处理函数,把参数继续分发到下一层。
0004d78c fd7bbda9 stp fp, lr, [sp, #-0x30]! {__saved_fp} {__saved_lr}
0004d790 f50b00f9 str x21, [sp, #0x10 {var_20}]
0004d794 f44f02a9 stp x20, x19, [sp, #0x20] {__saved_x20} {__saved_x19}
0004d798 fd030091 mov fp, sp {__saved_fp}
0004d79c c8020090 adrp x8, 0xa5000
0004d7a0 08c11991 add x8, x8, #0x670 {0xa5670}
0004d7a4 08fddf08 ldarb w8, [x8] {0xa5670}
0004d7a8 // 第一层稳定调度入口:从这里开始已经不是 Godot
0004d7a8 // 包装层,而是三环内部自己的分发逻辑。
0004d7a8 48020036 tbz w8, #0, 0x4d7f0

0004d7ac a9020090 adrp x9, 0xa1000
0004d7b0 c8020090 adrp x8, 0xa5000
0004d7b4 a3630091 add x3, fp, #0x18 {var_18}
0004d7b8 290d41f9 ldr x9, [x9, #0x218] {0xa1218}
0004d7bc 083543f9 ldr x8, [x8, #0x668] {0xa5668}
0004d7c0 010840f9 ldr x1, [x0, #0x10]
0004d7c4 e2031faa mov x2, xzr {_start}
0004d7c8 290140f9 ldr x9, [x9]
0004d7cc e00308aa mov x0, x8
0004d7d0 // 通过 **0xa1218
0004d7d0 // 把当前参数和上下文继续送入下一层公共分发出口。
0004d7d0 20013fd6 blr x9
0004d7d4 a8634039 ldrb w8, [fp, #0x18 {var_18}]
0004d7d8 1f0100f1 cmp x8, #0
0004d7dc e0079f1a cset w0, ne
0004d7e0 f44f42a9 ldp x20, x19, [sp, #0x20] {__saved_x20} {__saved_x19}
0004d7e4 f50b40f9 ldr x21, [sp, #0x10 {var_20}]
0004d7e8 fd7bc3a8 ldp fp, lr, [sp], #0x30 {__saved_fp} {__saved_lr}
0004d7ec c0035fd6 ret

0004d7f0 c8020090 adrp x8, 0xa5000
0004d7f4 08c11991 add x8, x8, #0x670
0004d7f8 f30300aa mov x19, x0
0004d7fc e00308aa mov x0, x8 {0xa5670}
0004d800 7dc20094 bl sub_7e1f4
0004d804 e803002a mov w8, w0
0004d808 e00313aa mov x0, x19
0004d80c // 检查初始化标志;未初始化时先走一次建表/取句柄流程。
0004d80c 08fdff34 cbz w8, 0x4d7ac

0004d810 a8020090 adrp x8, 0xa1000
0004d814 083141f9 ldr x8, [x8, #0x260] {0xa1260}
0004d818 // 从 **0xa1260
0004d818 // 取出一条运行时函数指针。这里只能静态看到
0004d818 // slot,看不到最终会跳到哪。
0004d818 150140f9 ldr x21, [x8]
0004d81c 4fd4fe97 bl sub_2958
0004d820 f40300aa mov x20, x0
0004d824 e1fbfff0 adrp x1, 0xfffffffffffcc000
0004d828 21683991 add x1, x1, #0xe5a {-0x331a6}
0004d82c a0630091 add x0, fp, #0x18 {var_18}
0004d830 e2031f2a mov w2, wzr {_start}
0004d834 c332ff97 bl sub_1a340
0004d838 22bc9452 mov w2, #0xa5e1
0004d83c a1630091 add x1, fp, #0x18 {var_18}
0004d840 e00314aa mov x0, x20 {0xa2820}
0004d844 4246a072 movk w2, #0x232, lsl #0x10 {0x232a5e1}
0004d848 // 通过 x21
0004d848 // 间接调用初始化函数,说明这一层已经进入 slot
0004d848 // 驱动的动态分发。
0004d848 a0023fd6 blr x21
0004d84c f40300aa mov x20, x0
0004d850 a0630091 add x0, fp, #0x18 {var_18}
0004d854 0d95ff97 bl sub_32c88
0004d858 c0020090 adrp x0, 0xa5000
0004d85c 00a01991 add x0, x0, #0x668 {0xa5668}
0004d860 // 缓存初始化得到的句柄/上下文,后续调用会直接复用,不再重复建表。
0004d860 148400f8 str x20, [x0], #0x8 {0xa5668} {0xa5670}
0004d864 b5c20094 bl sub_7e338
0004d868 e00313aa mov x0, x19
0004d86c d0ffff17 b 0x4d7ac

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

1
2
3
4
5
6
7
8
9
10
11
function maybeInstallProcessDynamicDispatchProbe(label, address) {
const p = ptr(address);
const ownerModule = Process.findModuleByAddress(p);
if (ownerModule === null || ownerModule.name !== MODULE_NAME) {
return;
}

const relativeOffset = ptrToNumber(p) - ptrToNumber(ownerModule.base);
if (!PROCESS_DYNAMIC_DISPATCH_TARGET_PROBE_OFFSETS.has(relativeOffset)) {
return;
}

重复这个操作下来两三次: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脚本其实已经有了雏形,可能以后再继续研究看看有没有更好的办法去自动化吧。

脚本集:

移车的Frida脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
/*
* Usage:
* frida -U -f com.tencent.ACE.gamesec2026.preliminary -l tools/frida_move_trigger_sec2026.js --no-pause
*
* Manual mode:
* Enter the game scene, then run `sec2026.trigger()` in the Frida REPL.
*/

(function () {
'use strict';

const MODULE_NAME = 'libsec2026.so';
const GODOT_MODULE_NAME = 'libgodot_android.so';
const TRACE_PREFIX = '[sec2026_move]';

const OFFSETS_GODOT = {
variantCall: 0x3c10f34,
variantDestroy: 0x3c10f18,
globalGetSingleton: 0x3c13630,
stringNameNewLatin1: 0x3c12d70,
stringNewUtf8: 0x3c123d8,
getVariantFromTypeConstructor: 0x3c11748,
getVariantToTypeConstructor: 0x3c119fc,
};

const VARIANT_TYPES = {
BOOL: 1,
INT: 2,
STRING: 4,
VECTOR3: 9,
OBJECT: 24,
};

const PHYSICS_CLASSES = ['VehicleBody3D', 'RigidBody3D', 'PhysicsBody3D'];

const BODY_STATE_TRANSFORM = 0;
const BODY_STATE_LINEAR_VELOCITY = 1;
const BODY_STATE_ANGULAR_VELOCITY = 2;
const BODY_STATE_SLEEPING = 3;

const CALL_ERROR_NAMES = {
0: 'OK',
1: 'INVALID_METHOD',
2: 'INVALID_ARGUMENT',
3: 'TOO_MANY_ARGUMENTS',
4: 'TOO_FEW_ARGUMENTS',
5: 'INSTANCE_IS_NULL',
6: 'METHOD_NOT_CONST',
};

const MAIN_THREAD_ID = Process.id;
const VARIANT_SIZE = 0x40;
const STRING_SIZE = 0x10;
const STRING_NAME_SIZE = 0x10;
const CALL_ERROR_SIZE = 0x10;
const GODOT_STRING_COW_PTR_OFFSET = 0x00;
const GODOT_STRING_SIZE_BACK_OFFSET = 0x10;
const MAX_LOG_STRING_CHARS = 512;

let api = null;
let apiReady = false;
let attemptCount = 0;
let sawSecModule = false;
let sawGodotModule = false;

const stringNameCache = new Map();
const stringObjectCache = new Map();
const stringVariantCache = new Map();
const boolVariantCache = new Map();
const intVariantCache = new Map();
let zeroVectorVariant = null;

function log(message) {
console.log(`${TRACE_PREFIX} ${message}`);
}

function hex(value) {
return ptr(value).toString();
}

function samePtr(a, b) {
return ptr(a).toString() === ptr(b).toString();
}

function u64ToNumber(value) {
if (typeof value === 'number') {
return value;
}
return parseInt(value.toString(), 10);
}

function getModule(name) {
if (typeof Process.findModuleByName === 'function') {
const found = Process.findModuleByName(name);
if (found !== null) {
return found;
}
}

if (typeof Process.getModuleByName === 'function') {
try {
return Process.getModuleByName(name);
} catch (_) {
}
}

return null;
}

function ensureRuntimeReady() {
const sec = getModule(MODULE_NAME);
const godot = getModule(GODOT_MODULE_NAME);

if (sec !== null && !sawSecModule) {
sawSecModule = true;
log(`module ready: ${MODULE_NAME} base=${sec.base} size=0x${sec.size.toString(16)}`);
}

if (godot !== null && !sawGodotModule) {
sawGodotModule = true;
log(`module ready: ${GODOT_MODULE_NAME} base=${godot.base} size=0x${godot.size.toString(16)}`);
}

if (api === null && sec !== null && godot !== null) {
resolveApiViaOffsets(godot);
}

return {
secLoaded: sec !== null,
godotLoaded: godot !== null,
};
}

function resolveApiViaOffsets(godotModule) {
if (api !== null) {
return true;
}

const base = ptr(godotModule.base);
const resolved = {
globalGetSingleton: new NativeFunction(base.add(OFFSETS_GODOT.globalGetSingleton), 'pointer', ['pointer']),
stringNameNewLatin1: new NativeFunction(base.add(OFFSETS_GODOT.stringNameNewLatin1), 'void', ['pointer', 'pointer', 'uchar']),
stringNewUtf8: new NativeFunction(base.add(OFFSETS_GODOT.stringNewUtf8), 'void', ['pointer', 'pointer']),
variantCall: new NativeFunction(base.add(OFFSETS_GODOT.variantCall), 'void', ['pointer', 'pointer', 'pointer', 'int64', 'pointer', 'pointer']),
variantDestroy: new NativeFunction(base.add(OFFSETS_GODOT.variantDestroy), 'void', ['pointer']),
getVariantFromTypeConstructor: new NativeFunction(base.add(OFFSETS_GODOT.getVariantFromTypeConstructor), 'pointer', ['int']),
getVariantToTypeConstructor: new NativeFunction(base.add(OFFSETS_GODOT.getVariantToTypeConstructor), 'pointer', ['int']),
};

resolved.variantFromBool = new NativeFunction(
ptr(resolved.getVariantFromTypeConstructor(VARIANT_TYPES.BOOL)),
'void',
['pointer', 'pointer']
);
resolved.variantFromInt = new NativeFunction(
ptr(resolved.getVariantFromTypeConstructor(VARIANT_TYPES.INT)),
'void',
['pointer', 'pointer']
);
resolved.variantFromString = new NativeFunction(
ptr(resolved.getVariantFromTypeConstructor(VARIANT_TYPES.STRING)),
'void',
['pointer', 'pointer']
);
resolved.variantFromVector3 = new NativeFunction(
ptr(resolved.getVariantFromTypeConstructor(VARIANT_TYPES.VECTOR3)),
'void',
['pointer', 'pointer']
);
resolved.variantFromObject = new NativeFunction(
ptr(resolved.getVariantFromTypeConstructor(VARIANT_TYPES.OBJECT)),
'void',
['pointer', 'pointer']
);

resolved.variantToBool = new NativeFunction(
ptr(resolved.getVariantToTypeConstructor(VARIANT_TYPES.BOOL)),
'void',
['pointer', 'pointer']
);
resolved.variantToInt = new NativeFunction(
ptr(resolved.getVariantToTypeConstructor(VARIANT_TYPES.INT)),
'void',
['pointer', 'pointer']
);
resolved.variantToString = new NativeFunction(
ptr(resolved.getVariantToTypeConstructor(VARIANT_TYPES.STRING)),
'void',
['pointer', 'pointer']
);
resolved.variantToVector3 = new NativeFunction(
ptr(resolved.getVariantToTypeConstructor(VARIANT_TYPES.VECTOR3)),
'void',
['pointer', 'pointer']
);
resolved.variantToObject = new NativeFunction(
ptr(resolved.getVariantToTypeConstructor(VARIANT_TYPES.OBJECT)),
'void',
['pointer', 'pointer']
);

api = resolved;
apiReady = true;
log(`resolved GDExtension api via offsets (base=${godotModule.base})`);
return true;
}

function allocVariant() {
return Memory.alloc(VARIANT_SIZE);
}

function allocStringObject() {
return Memory.alloc(STRING_SIZE);
}

function allocStringNameObject() {
return Memory.alloc(STRING_NAME_SIZE);
}

function destroyVariant(variantPtr) {
if (api === null) {
return;
}

const p = ptr(variantPtr);
if (p.isNull()) {
return;
}

try {
api.variantDestroy(p);
} catch (_) {
}
}

function getStringName(name) {
if (stringNameCache.has(name)) {
return stringNameCache.get(name);
}

const obj = allocStringNameObject();
api.stringNameNewLatin1(obj, Memory.allocUtf8String(name), 0);
stringNameCache.set(name, obj);
return obj;
}

function getStringObject(text) {
if (stringObjectCache.has(text)) {
return stringObjectCache.get(text);
}

const obj = allocStringObject();
api.stringNewUtf8(obj, Memory.allocUtf8String(text));
stringObjectCache.set(text, obj);
return obj;
}

function makeVariantFromString(text) {
if (stringVariantCache.has(text)) {
return stringVariantCache.get(text);
}

const variant = allocVariant();
api.variantFromString(variant, getStringObject(text));
stringVariantCache.set(text, variant);
return variant;
}

function makeVariantFromBool(value) {
const key = value ? 'true' : 'false';
if (boolVariantCache.has(key)) {
return boolVariantCache.get(key);
}

const storage = Memory.alloc(1);
storage.writeU8(value ? 1 : 0);
const variant = allocVariant();
api.variantFromBool(variant, storage);
boolVariantCache.set(key, variant);
return variant;
}

function makeVariantFromInt(value) {
const key = `${value}`;
if (intVariantCache.has(key)) {
return intVariantCache.get(key);
}

const storage = Memory.alloc(8);
storage.writeS64(value);
const variant = allocVariant();
api.variantFromInt(variant, storage);
intVariantCache.set(key, variant);
return variant;
}

function makeVariantFromVector3(x, y, z) {
const storage = Memory.alloc(12);
storage.writeFloat(x);
storage.add(4).writeFloat(y);
storage.add(8).writeFloat(z);
const variant = allocVariant();
api.variantFromVector3(variant, storage);
return variant;
}

function getZeroVectorVariant() {
if (zeroVectorVariant !== null) {
return zeroVectorVariant;
}

zeroVectorVariant = makeVariantFromVector3(0.0, 0.0, 0.0);
return zeroVectorVariant;
}

function makeVariantFromObject(objectPtr) {
const storage = Memory.alloc(Process.pointerSize);
storage.writePointer(ptr(objectPtr));
const variant = allocVariant();
api.variantFromObject(variant, storage);
return variant;
}

function callMethod(selfVariant, methodName, args) {
const ret = allocVariant();
const error = Memory.alloc(CALL_ERROR_SIZE);
error.writeU32(0);
error.add(4).writeS32(0);
error.add(8).writeS32(0);

let argv = NULL;
const argc = args !== undefined ? args.length : 0;
if (argc > 0) {
argv = Memory.alloc(argc * Process.pointerSize);
for (let i = 0; i < argc; i += 1) {
argv.add(i * Process.pointerSize).writePointer(ptr(args[i]));
}
}

api.variantCall(selfVariant, getStringName(methodName), argv, argc, ret, error);
return {
ret,
error: error.readU32(),
argument: error.add(4).readS32(),
expected: error.add(8).readS32(),
};
}

function logCallError(prefix, result) {
if (result.error === 0) {
return;
}

const name = CALL_ERROR_NAMES[result.error] || `UNKNOWN_${result.error}`;
log(`${prefix} failed: ${name} arg=${result.argument} expected=${result.expected}`);
}

function variantToObject(variantPtr) {
const out = Memory.alloc(Process.pointerSize);
out.writePointer(NULL);
api.variantToObject(out, variantPtr);
return out.readPointer();
}

function variantToBool(variantPtr) {
const out = Memory.alloc(1);
out.writeU8(0);
api.variantToBool(out, variantPtr);
return out.readU8() !== 0;
}

function variantToInt(variantPtr) {
const out = Memory.alloc(8);
out.writeS64(0);
api.variantToInt(out, variantPtr);
return Number(out.readS64());
}

function readUtf32String(dataPtr, maxChars) {
const chars = [];
for (let i = 0; i < maxChars; i += 1) {
const cp = dataPtr.add(i * 4).readU32();
if (cp === 0) {
break;
}

if (cp <= 0xffff) {
chars.push(String.fromCharCode(cp));
} else {
const n = cp - 0x10000;
chars.push(String.fromCharCode(0xd800 + (n >> 10), 0xdc00 + (n & 0x3ff)));
}
}
return chars.join('');
}

function readGodotString(stringObjectPtr) {
const obj = ptr(stringObjectPtr);
if (obj.isNull()) {
return '';
}

const dataPtr = obj.add(GODOT_STRING_COW_PTR_OFFSET).readPointer();
if (dataPtr.isNull()) {
return '';
}

const rawSize = u64ToNumber(dataPtr.sub(GODOT_STRING_SIZE_BACK_OFFSET).readU64());
if (rawSize <= 0 || rawSize > MAX_LOG_STRING_CHARS + 1) {
return '';
}

return readUtf32String(dataPtr, rawSize - 1);
}

function variantToString(variantPtr) {
const out = allocStringObject();
api.variantToString(out, variantPtr);
return readGodotString(out);
}

function variantToVector3(variantPtr) {
const out = Memory.alloc(12);
out.writeFloat(0);
out.add(4).writeFloat(0);
out.add(8).writeFloat(0);
api.variantToVector3(out, variantPtr);
return {
x: out.readFloat(),
y: out.add(4).readFloat(),
z: out.add(8).readFloat(),
};
}

function formatVec3(vec) {
return `${vec.x.toFixed(3)},${vec.y.toFixed(3)},${vec.z.toFixed(3)}`;
}

function callBoolMethod(selfVariant, methodName, args) {
const result = callMethod(selfVariant, methodName, args || []);
if (result.error !== 0) {
logCallError(methodName, result);
destroyVariant(result.ret);
return null;
}

const value = variantToBool(result.ret);
destroyVariant(result.ret);
return value;
}

function findNamedChild(rootVariant, name) {
const result = callMethod(rootVariant, 'find_child', [
makeVariantFromString(name),
makeVariantFromBool(true),
makeVariantFromBool(false),
]);
if (result.error !== 0) {
logCallError(`find_child("${name}")`, result);
destroyVariant(result.ret);
return null;
}

const objectPtr = variantToObject(result.ret);
if (ptr(objectPtr).isNull()) {
destroyVariant(result.ret);
return null;
}

return {
variant: result.ret,
object: objectPtr,
};
}

function tryReadLabelText(rootVariant) {
const label = findNamedChild(rootVariant, 'Label2');
if (label === null) {
return null;
}

try {
const result = callMethod(label.variant, 'get_text', []);
if (result.error !== 0) {
logCallError('Label2.get_text', result);
destroyVariant(result.ret);
return null;
}

const text = variantToString(result.ret);
destroyVariant(result.ret);
return text;
} finally {
destroyVariant(label.variant);
}
}

function matchNodeClass(nodeVariant, classes) {
for (let i = 0; i < classes.length; i += 1) {
const className = classes[i];
const isMatch = callBoolMethod(nodeVariant, 'is_class', [makeVariantFromString(className)]);
if (isMatch === true) {
return className;
}
}

return null;
}

function getChildCount(nodeVariant, label) {
const result = callMethod(nodeVariant, 'get_child_count', []);
if (result.error !== 0) {
logCallError(`${label}.get_child_count`, result);
destroyVariant(result.ret);
return -1;
}

const count = variantToInt(result.ret);
destroyVariant(result.ret);
return count;
}

function getChildNode(nodeVariant, index, label) {
const result = callMethod(nodeVariant, 'get_child', [makeVariantFromInt(index)]);
if (result.error !== 0) {
logCallError(`${label}.get_child(${index})`, result);
destroyVariant(result.ret);
return null;
}

const objectPtr = variantToObject(result.ret);
if (ptr(objectPtr).isNull()) {
destroyVariant(result.ret);
return null;
}

return {
variant: result.ret,
object: objectPtr,
};
}

function findDescendantByClasses(nodeVariant, classes, label, depth, maxDepth) {
if (depth >= maxDepth) {
return null;
}

const childCount = getChildCount(nodeVariant, label);
if (childCount <= 0) {
return null;
}

for (let index = 0; index < childCount; index += 1) {
const child = getChildNode(nodeVariant, index, label);
if (child === null) {
continue;
}

const matchedClass = matchNodeClass(child.variant, classes);
if (matchedClass !== null) {
return {
variant: child.variant,
object: child.object,
matchedClass,
borrowed: false,
};
}

const nested = findDescendantByClasses(child.variant, classes, `${label}/${index}`, depth + 1, maxDepth);
destroyVariant(child.variant);
if (nested !== null) {
return nested;
}
}

return null;
}

function selectPhysicsTarget(carNode) {
const rootClass = matchNodeClass(carNode.variant, PHYSICS_CLASSES);
if (rootClass !== null) {
return {
variant: carNode.variant,
object: carNode.object,
matchedClass: rootClass,
borrowed: true,
};
}

const found = findDescendantByClasses(carNode.variant, PHYSICS_CLASSES, 'car', 0, 8);
if (found !== null) {
return found;
}

return {
variant: carNode.variant,
object: carNode.object,
matchedClass: 'car_root',
borrowed: true,
};
}

function applyTransform(nodeVariant, label, xformVariant) {
const result = callMethod(nodeVariant, 'set_global_transform', [xformVariant]);
if (result.error !== 0) {
logCallError(`${label}.set_global_transform`, result);
}
destroyVariant(result.ret);

const forceResult = callMethod(nodeVariant, 'force_update_transform', []);
if (forceResult.error !== 0) {
logCallError(`${label}.force_update_transform`, forceResult);
}
destroyVariant(forceResult.ret);
}

function tryOverlapsBody(triggerVariant, bodyObjectPtr, label) {
const bodyVariant = makeVariantFromObject(bodyObjectPtr);
try {
const value = callBoolMethod(triggerVariant, 'overlaps_body', [bodyVariant]);
log(`${label} overlaps_body = ${value}`);
return value;
} finally {
destroyVariant(bodyVariant);
}
}

function tryApplyPhysicsTeleport(physicsVariant, xformVariant) {
const physicsServerObj = api.globalGetSingleton(getStringName('PhysicsServer3D'));
if (ptr(physicsServerObj).isNull()) {
log('global_get_singleton("PhysicsServer3D") returned null');
return false;
}

const physicsServerVariant = makeVariantFromObject(physicsServerObj);
let ridResult = null;
try {
ridResult = callMethod(physicsVariant, 'get_rid', []);
if (ridResult.error !== 0) {
logCallError('physics target.get_rid', ridResult);
return false;
}

let result = callMethod(physicsServerVariant, 'body_set_state', [
ridResult.ret,
makeVariantFromInt(BODY_STATE_TRANSFORM),
xformVariant,
]);
if (result.error !== 0) {
logCallError('PhysicsServer3D.body_set_state(transform)', result);
}
destroyVariant(result.ret);

result = callMethod(physicsServerVariant, 'body_set_state', [
ridResult.ret,
makeVariantFromInt(BODY_STATE_LINEAR_VELOCITY),
getZeroVectorVariant(),
]);
if (result.error !== 0) {
logCallError('PhysicsServer3D.body_set_state(linear_velocity)', result);
}
destroyVariant(result.ret);

result = callMethod(physicsServerVariant, 'body_set_state', [
ridResult.ret,
makeVariantFromInt(BODY_STATE_ANGULAR_VELOCITY),
getZeroVectorVariant(),
]);
if (result.error !== 0) {
logCallError('PhysicsServer3D.body_set_state(angular_velocity)', result);
}
destroyVariant(result.ret);

result = callMethod(physicsServerVariant, 'body_set_state', [
ridResult.ret,
makeVariantFromInt(BODY_STATE_SLEEPING),
makeVariantFromBool(false),
]);
if (result.error !== 0) {
logCallError('PhysicsServer3D.body_set_state(sleeping)', result);
}
destroyVariant(result.ret);

return true;
} finally {
destroyVariant(physicsServerVariant);
if (ridResult !== null) {
destroyVariant(ridResult.ret);
}
}
}

function tryMoveCarToTrigger() {
if (api === null) {
return false;
}

const engineObj = api.globalGetSingleton(getStringName('Engine'));
if (ptr(engineObj).isNull()) {
log('global_get_singleton("Engine") returned null');
return false;
}

const engineVariant = makeVariantFromObject(engineObj);
let sceneTreeVariant = null;
let rootVariant = null;
let car = null;
let trigger = null;
let physicsTarget = null;
let xformResult = null;

try {
const mainLoopResult = callMethod(engineVariant, 'get_main_loop', []);
if (mainLoopResult.error !== 0) {
logCallError('Engine.get_main_loop', mainLoopResult);
destroyVariant(mainLoopResult.ret);
return false;
}
sceneTreeVariant = mainLoopResult.ret;

const rootResult = callMethod(sceneTreeVariant, 'get_root', []);
if (rootResult.error !== 0) {
logCallError('SceneTree.get_root', rootResult);
destroyVariant(rootResult.ret);
return false;
}
rootVariant = rootResult.ret;

car = findNamedChild(rootVariant, 'car');
trigger = findNamedChild(rootVariant, 'Trigger2');
if (car === null || trigger === null) {
log(`scene not ready: car=${car !== null} trigger2=${trigger !== null}`);
return false;
}

physicsTarget = selectPhysicsTarget(car);
log(
`found nodes: car=${hex(car.object)} trigger2=${hex(trigger.object)} ` +
`physics=${hex(physicsTarget.object)} class=${physicsTarget.matchedClass}`
);

const beforePosResult = callMethod(physicsTarget.variant, 'get_global_position', []);
if (beforePosResult.error === 0) {
log(`physics target before = ${formatVec3(variantToVector3(beforePosResult.ret))}`);
} else {
logCallError('physics target.get_global_position', beforePosResult);
}
destroyVariant(beforePosResult.ret);

tryOverlapsBody(trigger.variant, physicsTarget.object, 'before move');

const monitorResult = callMethod(trigger.variant, 'set_monitoring', [makeVariantFromBool(true)]);
if (monitorResult.error !== 0) {
logCallError('Trigger2.set_monitoring(true)', monitorResult);
}
destroyVariant(monitorResult.ret);

xformResult = callMethod(trigger.variant, 'get_global_transform', []);
if (xformResult.error !== 0) {
logCallError('Trigger2.get_global_transform', xformResult);
destroyVariant(xformResult.ret);
xformResult = null;
return false;
}

const triggerPosResult = callMethod(trigger.variant, 'get_global_position', []);
if (triggerPosResult.error === 0) {
log(`trigger2 position = ${formatVec3(variantToVector3(triggerPosResult.ret))}`);
} else {
logCallError('Trigger2.get_global_position', triggerPosResult);
}
destroyVariant(triggerPosResult.ret);

applyTransform(car.variant, 'car', xformResult.ret);
if (!samePtr(physicsTarget.object, car.object)) {
applyTransform(physicsTarget.variant, 'physics target', xformResult.ret);
}
tryApplyPhysicsTeleport(physicsTarget.variant, xformResult.ret);

const afterPosResult = callMethod(physicsTarget.variant, 'get_global_position', []);
if (afterPosResult.error === 0) {
log(`physics target after = ${formatVec3(variantToVector3(afterPosResult.ret))}`);
} else {
logCallError('physics target.get_global_position', afterPosResult);
}
destroyVariant(afterPosResult.ret);

tryOverlapsBody(trigger.variant, physicsTarget.object, 'after move');

const overlapping = callBoolMethod(trigger.variant, 'has_overlapping_bodies', []);
const labelText = tryReadLabelText(rootVariant);
log(
`attempt=${attemptCount.toString().padStart(2, '0')} ` +
`overlapping=${overlapping} label=${JSON.stringify(labelText)}`
);

return overlapping === true ||
(typeof labelText === 'string' && labelText.indexOf('flag{sec2026_PART1_') !== -1);
} finally {
destroyVariant(engineVariant);
if (car !== null) {
destroyVariant(car.variant);
}
if (trigger !== null) {
destroyVariant(trigger.variant);
}
if (physicsTarget !== null && physicsTarget.borrowed === false) {
destroyVariant(physicsTarget.variant);
}
if (rootVariant !== null) {
destroyVariant(rootVariant);
}
if (sceneTreeVariant !== null) {
destroyVariant(sceneTreeVariant);
}
if (xformResult !== null) {
destroyVariant(xformResult.ret);
}
}
}

function executeOnMainThread(work) {
if (typeof Process.runOnThread === 'function') {
try {
return Process.runOnThread(MAIN_THREAD_ID, work);
} catch (_) {
}
}

return work();
}

function getStatus() {
const runtime = ensureRuntimeReady();
return {
apiReady,
secLoaded: runtime.secLoaded,
godotLoaded: runtime.godotLoaded,
attemptCount,
};
}

function manualTrigger() {
const runtime = ensureRuntimeReady();
if (!runtime.secLoaded || !runtime.godotLoaded || api === null) {
const status = getStatus();
log(`manual trigger ignored: runtime not ready ${JSON.stringify(status)}`);
return {
ok: false,
reason: 'runtime_not_ready',
status,
};
}

attemptCount += 1;
const currentAttempt = attemptCount;
log(`manual trigger begin attempt=${currentAttempt}`);

let ok = false;
let error = null;
try {
ok = !!executeOnMainThread(() => tryMoveCarToTrigger());
} catch (e) {
error = `${e}`;
log(`manual trigger exception attempt=${currentAttempt}: ${error}`);
}

const status = getStatus();
log(`manual trigger end attempt=${currentAttempt} ok=${ok}`);
return {
ok,
error,
status,
};
}

setImmediate(() => {
log(`waiting for ${MODULE_NAME}`);
log('manual mode enabled; enter the game scene and run `sec2026.trigger()`');
ensureRuntimeReady();
});

globalThis.sec2026 = {
status() {
const status = getStatus();
log(`status ${JSON.stringify(status)}`);
return status;
},
trigger() {
return manualTrigger();
},
};
})();

用例:

1
frida -U -f com.tencent.ACE.gamesec2026.preliminary -l frida_move_trigger_sec2026.js