-
-
[讨论] 南极企鹅游戏安全2026-Android-初赛
-
发表于: 15小时前 196
-
安卓真好玩。想知道有没有去3环程序的思路。
展示:



了解目标、确定方向:
打开游戏,触发示例方块得到样例flag,目标是触发屋顶的方块,得到真正的flag,常规游玩、车是开不到那地方的。
所以第一个目标:传送车的坐标到触发块的坐标。
解包APK发现是Godot引擎制作的游戏,gdc脚本也被加密了,本身对godot不是很熟悉,不过既然是开源的就下载源码下来看看,同时上网搜索发现:Godot官方是支持用脚本加密的。
懒了: 77bK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6%4N6%4N6Q4x3X3f1#2x3Y4m8G2K9X3W2W2i4K6u0W2j5$3&6Q4x3V1k6@1K9s2u0W2j5h3c8Q4x3X3b7J5x3o6l9J5y4K6M7&6i4K6u0V1x3g2)9J5k6o6q4Q4x3X3g2Z5N6r3#2D9 ,直接照抄思路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_197C210AES key schedule 初始化。
- sub_197DE18“魔改流模式”核心。
解密脚本:
#!/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的获取逻辑:

然后就是处理传送车的问题:反过来利用 Godot 自己的对象系统和场景系统,直接让游戏正常执行这段逻辑。
根据Unity开发经验猜测:Godot肯定也有某种find gameObject方法,找了一下 Godot 恰好给了这样做的条件。只要在 Frida 里拿到引擎内部的这些能力:
- 获取全局 singleton;
- 构造
Variant、StringName、String; - 在主线程上远程调用对象方法;
就可以像脚本层一样直接操纵场景节点。
Frida 远程调用 Godot 内部方法移车
这部分的实现对应脚本是frida_move_trigger_sec2026.js(太长放末尾了)。
核心思路远程调用引擎提供的接口
- 从
libgodot_android.so固定偏移解析出运行时接口。 - 自己构造 Godot
Variant参数。 - 通过
Engine.get_main_loop()拿到SceneTree。 - 继续拿到 root,再用
find_child()找到:carTrigger2Label2
- 读取
Trigger2的全局变换,直接把车和物理 body 移过去。
实在懒得搜坐标手动改,还是原生的办法通用性高一些。
如果只是改可见节点的位置,车不一定真的算“进入触发器”,因为场景里真正参与碰撞的是物理对象。也正因为这样,frida_move_trigger_sec2026.js 不是只改一次 transform 就结束了,而是同时做了两层同步:
- 对节点本身调用:
set_global_transformforce_update_transform
- 对
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打开可以分析的函数不多:

主要是解压子程序,然后通过BR命令跳转到子程序去执行逻辑。
二环主要逻辑:
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(脚本见末尾)下来继续分析:
三环:
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的在游戏中被调用的入口点是
[configuration]
entry_symbol = "extension_init"
而Godot GDExtension的标准API是:
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:
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注册了拓展函数的入口点,注册时可以拿到三环关键函数的地址:
[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下来的逻辑:
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在三环内的真正地址:
[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
结合静态来看,开始套娃:
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 类似的分发函数,按照类似的思路去做:
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脚本其实已经有了雏形,可能以后再继续研究看看有没有更好的办法去自动化吧。
打完决赛再放dump执行流的脚本,之后有时间可能改进一下做成工具,思路还是比较自信的,运行时dump听起来就很酷炫,这里多宣传一个个人写得zygisk工具:766K9s2c8@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
脚本集:
见附件,逆向的加密算法:
用例:
frida -U -f com.tencent.ACE.gamesec2026.preliminary -l frida_move_trigger_sec2026.js
/*
* Focused C port of tools/sec2026_process_chacha_replay.py
*
* Scope:
* 1. GDScript text -> flag{sec2026_PART1_<HEX>}
* 2. flag{sec2026_PART1_<HEX>} -> original random text
*
* Layer split:
* - game layer: GDScript xor_enc() and its inverse
* - type bridge: hex/flag formatting and parsing
* - native layer: runtime-captured ChaCha-like stream xor
*
* Build:
* gcc -O2 -std=c11 -Wall -Wextra -o tools/sec2026_process_chacha_replay.exe tools/sec2026_process_chacha_replay.c
*
* Examples:
* tools\\sec2026_process_chacha_replay.exe --gdscript-text c9854cf7
* tools\\sec2026_process_chacha_replay.exe --flag 201E5E4824337211
* tools\\sec2026_process_chacha_replay.exe --flag flag{sec2026_PART1_201E5E4824337211}
*/
#include <ctype.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define PROCESS_BYTES 8
#define FLAG_HEX_LEN 16
#define FLAG_PREFIX "flag{sec2026_PART1_"
#define FLAG_SUFFIX "}"
static const uint32_t RUNTIME_CONSTANTS[4] = {
0x61707866u,
0x3320646Fu,
0x79622D31u,
0x6B206573u,
};
static const uint32_t RUNTIME_KEY_WORDS[8] = {
0x73316854u,
0x20736C20u,
0x2074306Eu,
0x65722061u,
0x6B203161u,
0x21217965u,
0x63657340u,
0x36323032u,
};
static const uint32_t RUNTIME_COUNTER = 0x00000000u;
static const uint32_t RUNTIME_NONCE_WORDS[3] = {
0x33323130u,
0x37363534u,
0x31303938u,
};
typedef struct Sec2026ProcessCipher {
uint32_t state[16];
size_t cursor;
uint8_t keystream[64];
} Sec2026ProcessCipher;
/* -------------------------------------------------------------------------- */
/* shared helpers */
/* -------------------------------------------------------------------------- */
static uint32_t rotl32(uint32_t value, uint32_t shift) {
return (value << shift) | (value >> (32u - shift));
}
static void bytes_to_hex_lower(const uint8_t *data, size_t length, char *out) {
static const char table[] = "0123456789abcdef";
size_t i;
for (i = 0; i < length; ++i) {
out[i * 2] = table[data[i] >> 4];
out[i * 2 + 1] = table[data[i] & 0x0Fu];
}
out[length * 2] = '\0';
}
static void bytes_to_hex_upper(const uint8_t *data, size_t length, char *out) {
static const char table[] = "0123456789ABCDEF";
size_t i;
for (i = 0; i < length; ++i) {
out[i * 2] = table[data[i] >> 4];
out[i * 2 + 1] = table[data[i] & 0x0Fu];
}
out[length * 2] = '\0';
}
static void bytes_to_ascii_preview(const uint8_t *data, size_t length, char *out) {
size_t i;
for (i = 0; i < length; ++i) {
unsigned char value = data[i];
out[i] = (value >= 0x20 && value <= 0x7E) ? (char)value : '.';
}
out[length] = '\0';
}
static int hex_value(int ch) {
if (ch >= '0' && ch <= '9') {
return ch - '0';
}
if (ch >= 'a' && ch <= 'f') {
return 10 + (ch - 'a');
}
if (ch >= 'A' && ch <= 'F') {
return 10 + (ch - 'A');
}
return -1;
}
static int parse_fixed_hex(const char *text, uint8_t *out, size_t out_len) {
size_t i;
for (i = 0; i < out_len; ++i) {
int hi = hex_value((unsigned char)text[i * 2]);
int lo = hex_value((unsigned char)text[i * 2 + 1]);
if (hi < 0 || lo < 0) {
return 0;
}
out[i] = (uint8_t)((hi << 4) | lo);
}
return text[out_len * 2] == '\0';
}
/* -------------------------------------------------------------------------- */
/* game layer: xor_enc() and inverse */
/* -------------------------------------------------------------------------- */
static void game_layer_text_to_process_input(const char *text, uint8_t out[PROCESS_BYTES]) {
size_t length = strlen(text);
size_t i;
memset(out, 0, PROCESS_BYTES);
if (length > PROCESS_BYTES) {
length = PROCESS_BYTES;
}
memcpy(out, text, length);
for (i = 0; i < PROCESS_BYTES - 1; ++i) {
out[i] ^= out[i + 1];
}
out[PROCESS_BYTES - 1] ^= out[0];
}
static void game_layer_process_input_to_padded_text(const uint8_t in[PROCESS_BYTES], uint8_t out[PROCESS_BYTES]) {
size_t i;
uint8_t accum = 0;
memset(out, 0, PROCESS_BYTES);
for (i = 0; i < PROCESS_BYTES; ++i) {
accum ^= in[i];
}
out[1] = accum;
out[0] = (uint8_t)(in[0] ^ out[1]);
for (i = 1; i < PROCESS_BYTES - 1; ++i) {
out[i + 1] = (uint8_t)(in[i] ^ out[i]);
}
}
/* -------------------------------------------------------------------------- */
/* native layer: runtime-captured stream xor */
/* -------------------------------------------------------------------------- */
static void quarter_round(uint32_t state[16], int a, int b, int c, int d) {
state[a] += state[b];
state[d] ^= state[a];
state[d] = rotl32(state[d], 16);
state[c] += state[d];
state[b] ^= state[c];
state[b] = rotl32(state[b], 12);
state[a] += state[b];
state[d] ^= state[a];
state[d] = rotl32(state[d], 8);
state[c] += state[d];
state[b] ^= state[c];
state[b] = rotl32(state[b], 7);
}
static void block_function(const uint32_t input[16], uint8_t out[64]) {
uint32_t working[16];
int round_index;
int word_index;
memcpy(working, input, sizeof(working));
for (round_index = 0; round_index < 10; ++round_index) {
quarter_round(working, 0, 4, 8, 12);
quarter_round(working, 1, 5, 9, 13);
quarter_round(working, 2, 6, 10, 14);
quarter_round(working, 3, 7, 11, 15);
quarter_round(working, 0, 5, 10, 15);
quarter_round(working, 1, 6, 11, 12);
quarter_round(working, 2, 7, 8, 13);
quarter_round(working, 3, 4, 9, 14);
}
for (word_index = 0; word_index < 16; ++word_index) {
uint32_t word = working[word_index] + input[word_index];
out[word_index * 4] = (uint8_t)(word & 0xFFu);
out[word_index * 4 + 1] = (uint8_t)((word >> 8) & 0xFFu);
out[word_index * 4 + 2] = (uint8_t)((word >> 16) & 0xFFu);
out[word_index * 4 + 3] = (uint8_t)((word >> 24) & 0xFFu);
}
}
static void native_layer_init(Sec2026ProcessCipher *cipher) {
memcpy(cipher->state + 0, RUNTIME_CONSTANTS, sizeof(RUNTIME_CONSTANTS));
memcpy(cipher->state + 4, RUNTIME_KEY_WORDS, sizeof(RUNTIME_KEY_WORDS));
cipher->state[12] = RUNTIME_COUNTER;
memcpy(cipher->state + 13, RUNTIME_NONCE_WORDS, sizeof(RUNTIME_NONCE_WORDS));
cipher->cursor = 64;
memset(cipher->keystream, 0, sizeof(cipher->keystream));
}
static void native_layer_refill(Sec2026ProcessCipher *cipher) {
block_function(cipher->state, cipher->keystream);
cipher->cursor = 0;
cipher->state[12] += 1u;
}
static void native_layer_apply_stream(Sec2026ProcessCipher *cipher, const uint8_t *input, size_t length, uint8_t *output) {
size_t offset = 0;
while (offset < length) {
size_t i;
size_t take;
if (cipher->cursor >= 64) {
native_layer_refill(cipher);
}
take = length - offset;
if (take > 64 - cipher->cursor) {
take = 64 - cipher->cursor;
}
for (i = 0; i < take; ++i) {
output[offset + i] = (uint8_t)(input[offset + i] ^ cipher->keystream[cipher->cursor + i]);
}
cipher->cursor += take;
offset += take;
}
}
static void native_layer_forward_process(const uint8_t process_input[PROCESS_BYTES], uint8_t process_output[PROCESS_BYTES]) {
Sec2026ProcessCipher cipher;
native_layer_init(&cipher);
native_layer_apply_stream(&cipher, process_input, PROCESS_BYTES, process_output);
}
static void native_layer_reverse_process(const uint8_t process_output[PROCESS_BYTES], uint8_t process_input[PROCESS_BYTES]) {
Sec2026ProcessCipher cipher;
native_layer_init(&cipher);
native_layer_apply_stream(&cipher, process_output, PROCESS_BYTES, process_input);
}
/* -------------------------------------------------------------------------- */
/* type bridge: flag formatting and parsing */
/* -------------------------------------------------------------------------- */
static void type_bridge_process_output_to_flag_hex(const uint8_t process_output[PROCESS_BYTES], char out[FLAG_HEX_LEN + 1]) {
bytes_to_hex_upper(process_output, PROCESS_BYTES, out);
}
static void type_bridge_process_output_to_full_flag(const uint8_t process_output[PROCESS_BYTES], char out[64]) {
char hex[FLAG_HEX_LEN + 1];
type_bridge_process_output_to_flag_hex(process_output, hex);
sprintf(out, "%s%s%s", FLAG_PREFIX, hex, FLAG_SUFFIX);
}
static int type_bridge_extract_flag_hex(const char *input, char out[FLAG_HEX_LEN + 1]) {
const char *start = input;
const char *end = input + strlen(input);
const char *body;
const char *prefixless;
size_t length;
size_t i;
while (start < end && isspace((unsigned char)*start)) {
++start;
}
while (end > start && isspace((unsigned char)end[-1])) {
--end;
}
body = start;
if ((size_t)(end - start) >= 6 && strncmp(start, "flag{", 5) == 0 && end[-1] == '}') {
body = start + 5;
end -= 1;
}
prefixless = body;
if ((size_t)(end - body) >= strlen("sec2026_PART1_") &&
strncmp(body, "sec2026_PART1_", strlen("sec2026_PART1_")) == 0) {
prefixless = body + strlen("sec2026_PART1_");
}
length = (size_t)(end - prefixless);
if (length != FLAG_HEX_LEN) {
return 0;
}
for (i = 0; i < FLAG_HEX_LEN; ++i) {
if (hex_value((unsigned char)prefixless[i]) < 0) {
return 0;
}
out[i] = (char)toupper((unsigned char)prefixless[i]);
}
out[FLAG_HEX_LEN] = '\0';
return 1;
}
/* -------------------------------------------------------------------------- */
/* cli */
/* -------------------------------------------------------------------------- */
static void print_usage(const char *program_name) {
fprintf(stderr,
"Usage:\n"
" %s --gdscript-text <text>\n"
" %s --flag <16hex|flag{sec2026_PART1_<16hex>}>\n",
program_name,
program_name);
}
static void print_forward_result(const char *text) {
uint8_t process_input[PROCESS_BYTES];
uint8_t process_output[PROCESS_BYTES];
char process_input_hex[PROCESS_BYTES * 2 + 1];
char process_output_hex[PROCESS_BYTES * 2 + 1];
char process_input_ascii[PROCESS_BYTES + 1];
char full_flag[64];
game_layer_text_to_process_input(text, process_input);
native_layer_forward_process(process_input, process_output);
bytes_to_hex_lower(process_input, PROCESS_BYTES, process_input_hex);
bytes_to_hex_upper(process_output, PROCESS_BYTES, process_output_hex);
bytes_to_ascii_preview(process_input, PROCESS_BYTES, process_input_ascii);
type_bridge_process_output_to_full_flag(process_output, full_flag);
printf("game_layer.input_text = '%s'\n", text);
printf("game_layer.process_input_hex = %s\n", process_input_hex);
printf("game_layer.process_input_ascii = %s\n", process_input_ascii);
printf("native_layer.process_output_hex = %s\n", process_output_hex);
printf("type_bridge.flag = %s\n", full_flag);
}
static void print_reverse_result(const char *flag_text) {
char flag_hex[FLAG_HEX_LEN + 1];
uint8_t process_output[PROCESS_BYTES];
uint8_t process_input[PROCESS_BYTES];
uint8_t padded_text[PROCESS_BYTES];
size_t text_length = PROCESS_BYTES;
char process_input_hex[PROCESS_BYTES * 2 + 1];
char padded_text_hex[PROCESS_BYTES * 2 + 1];
char recovered_text[PROCESS_BYTES + 1];
if (!type_bridge_extract_flag_hex(flag_text, flag_hex)) {
fprintf(stderr, "invalid flag value: %s\n", flag_text);
exit(1);
}
if (!parse_fixed_hex(flag_hex, process_output, PROCESS_BYTES)) {
fprintf(stderr, "failed to parse flag hex: %s\n", flag_hex);
exit(1);
}
native_layer_reverse_process(process_output, process_input);
game_layer_process_input_to_padded_text(process_input, padded_text);
while (text_length > 0 && padded_text[text_length - 1] == 0) {
--text_length;
}
memcpy(recovered_text, padded_text, text_length);
recovered_text[text_length] = '\0';
bytes_to_hex_lower(process_input, PROCESS_BYTES, process_input_hex);
bytes_to_hex_lower(padded_text, PROCESS_BYTES, padded_text_hex);
printf("type_bridge.flag_hex = %s\n", flag_hex);
printf("native_layer.process_input_hex = %s\n", process_input_hex);
printf("game_layer.recovered_padded_hex = %s\n", padded_text_hex);
printf("game_layer.recovered_text = '%s'\n", recovered_text);
}
int main(int argc, char **argv) {
if (argc != 3) {
print_usage(argv[0]);
return 1;
}
if (strcmp(argv[1], "--gdscript-text") == 0) {
print_forward_result(argv[2]);
return 0;
}
if (strcmp(argv[1], "--flag") == 0) {
print_reverse_result(argv[2]);
return 0;
}
print_usage(argv[0]);
return 1;
}