一、题目信息

给了一个apk和说明文档,让小车和方块碰撞可以弹flag(这车是真难开)

题目要求是拿到flag(20)以及逆向flag生成算法(40+40)

image

运行游戏,通过启动页面判断采用的是Godot引擎

GDScript 是 Godot 开发的编程语言

Godot 可以同时使用 C# 和 GDScript 编程,两者在使用时差别不大,性能差距可以忽略,GDScript 更全能一些,C# 目前还不支持 Android 平台

另外,GDScript 在运行时不会被编译,C# 会被编译。GDScript 在运行时会被 GDScript VM 调用并直接执行

要拿flag得让车碰到绿方块,但它在房顶上,只能开挂拿,黄色方块用于生成测试flag

二、逆向过程

环境:

  • 设备: Google Pixel 4, Android 10, root
  • 工具: IDA Pro, Frida 17.2.11, jadx, gdsdecomp,

apk解包后文件结构如下

1
2
3
4
5
6
7
8
9
10
11
12
preliminary/
├── assets/
│ ├── token.gdc # Token 生成逻辑 (编译后 GDScript)
│ ├── label2.gdc # Flag 显示逻辑
│ ├── Trigger/trigger.gdc # 触发方块逻辑 (核心)
│ ├── ext/sec2026.gdextension # GDExtension 配置
│ └── project.binary # Godot 项目配置
├── lib/arm64-v8a/
│ ├── libsec2026.so # ★ 核心目标: 自定义安全库 (462KB)
│ ├── libgodot_android.so # Godot 引擎运行时 (67MB)
│ └── libc++_shared.so # C++ 标准库
└── classes.dex # Java/Kotlin 层

首先想到的是使用gdsdecomp反编译项目直接拿到GDScript

但是报错

1
2
3
4
5
ERROR: The MD5 sum of the decrypted file does not match the expected value.
at: FileAccessEncrypted::open_and_parse (core\io\file_access_encrypted.cpp:113)
ERROR: Can't open encrypted pack directory (PCK format version 3, engine version 4.5.1).
ERROR: FATAL ERROR: Cannot open encrypted pck! (wrong key?)
ERROR: Can't load project!

从报错信息得到以下信息

  1. PCK 是加密的 — 用了 Godot 内置的 FileAccessEncrypted 加密机制
  2. PCK 格式版本 3,引擎版本 4.5.1
  3. 加密方式是 AES-256-CFB,密钥是编译时嵌入的 script_encryption_key
  4. MD5 校验失败 — 说明提供的 key 不对

意思是现在缺一个key,也就是AES-256-CFB的key

godot是开源项目,应该是魔改了libgodot_android.so,IDA分析发现抹了符号,先去官网godotengine/godot下一份带符号的so和源码

寻找AES解密的key

AI说主要在这两个类里找

image

其实根据前面的报错信息FileAccessEncrypted::open_and_parse (core\io\file_access_encrypted.cpp:113)也可以确定这个方法

core/io/file_access_encrypted.cpp:104看到

image

script_encryption_key 全局引用,在 core/io/file_access_pack.cpp:300看到PCK 解密直接用全局数组script_encryption_key[32]

image

core/core_builders.py:68 看到:

image

这个 key 是编译时从环境变量 SCRIPT_AES256_ENCRYPTION_KEY(64 位 hex 字符串)读取,硬编码进 libgodot_android.so 的全局变量里。

由此确定调用链:file_access_pack.cpp → open_and_parse() → ctx.set_encode_key() → 底层 mbedtls_aes_setkey_enc()。

接下来就是找这几个函数在so里的偏移,对比分析下来信息如下:(bindiff失败了,文件太大,电脑直接卡死)

  • mbedtls_aes_setkey_enc (0x197C210):有明显的AES加密痕迹,sbox等
  • thunk (0x197BD08) — PLT/GOT 跳板
  • AESContext::set_encode_key (0x376EDFC)
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
/*
* hook_aes_key.js — 直接 hook mbedtls_aes_setkey_enc 底层 (sub_197C210)
* 用法: frida -U -f com.tencent.ACE.gamesec2026.preliminary -l hook_aes_key.js
*/
function waitForModule(name, callback) {
var mod = Process.findModuleByName(name);
if (mod) { callback(mod); return; }
var interval = setInterval(function () {
mod = Process.findModuleByName(name);
if (mod) { clearInterval(interval); callback(mod); }
}, 200);
}

waitForModule("libgodot_android.so", function (mod) {
var aes_setkey_enc = mod.base.add(0x197C210);
Interceptor.attach(aes_setkey_enc, {
onEnter: function (args) {
var key_ptr = args[1];
var keybits = args[2].toInt32();
var keyLen = keybits / 8;
console.log("\n[mbedtls_aes_setkey_enc] keybits=" + keybits);
console.log(hexdump(key_ptr, { length: keyLen, header: true, ansi: true }));
}
});
});

key:ce4df8753b59a5a39ade58ac07ef947a3da39f2af75e3284d51217c04d49a061

拿去解密,发现还是不对

没想明白为什么,选择直接去内存dump解密后的gd文件

dump解密后的文件

每个加密的 .gdc/.scn 文件被 Godot 加载时都会经过 FileAccessEncrypted,析构时被 dump 出来。最终 dump 出的 decrypted_0.bin ~ decrypted_N.bin就是解密后的原始 .gdc 文件,可以直接用 gdsdecomp 反编译。

hook 的是 FileAccessEncrypted 的析构函数 ~FileAccessEncrypted,当 Godot 读完一个加密文件后会销毁对象,此时内部的 data 缓冲区里还保存着完整的解密后明文。

file_access_encrypted.h 里的成员声明顺序,在apk的libgodot_android.so里找到对象,分析vtable确认偏移 0x3802984

偏移 类型 字段名 说明
+0 ptr vtable 虚表指针
+308 byte big_endian 大端序标志
+400 Vector data 解密后数据缓冲区
+408 uint64 pos 当前读写位置
1
2
3
4
5
6
7
8
9
10
11
12
13
// Hook ~FileAccessEncrypted 析构函数,在文件关闭前 dump 解密后的数据
waitForModule("libgodot_android.so", function (mod) {
Interceptor.attach(mod.base.add(0x3802984), {
onEnter: function (args) {
var inst = ptr(args[0]);
var data_ptr = inst.add(400).readPointer();
var sizeNum = data_ptr.sub(8).readU32();
var fd = new File(DUMP_DIR + "decrypted_" + count + ".bin", "wb");
fd.write(data_ptr.readByteArray(sizeNum));
fd.close();
}
});
});

GDScript 反编译及分析

使用 gdsdecomp 工具对 dump 出的 .gdc 文件进行反编译:

1
gdre_tools.exe --headless --decompile=token.gdc --bytecode=4.5.1 --output=decompiled/

成功反编译 7 个脚本文件:token.gd, trigger.gd, label2.gd, vehicle.gd, car_select.gd, follow_camera.gd, spedometer.gd

文件 继承 作用 与 flag 的关系
token.gd Label 启动时生成 8 位随机 hex token 直接相关 — token 是 flag 算法的输入
trigger.gd Area3D 方块碰撞检测 + 浮动动画,碰撞时调用 GameExtension.Process() 核心 — flag 生成的调用入口
label2.gd Label 连接 Trigger 的信号,显示 flag 文本 显示层,无计算逻辑

Token 生成逻辑 (token.gd)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
extends Label
const TOKEN_LEN = 8
const CHARS = "0123456789abcdef"
var rng = RandomNumberGenerator.new()

func generate_token(len: int) -> String:
var s = ""
for i in len:
var idx = rng.randi_range(0, CHARS.length() - 1)
s += CHARS[idx]
return s

func _ready() -> void:
rng.randomize()
text = "Token: " + generate_token(TOKEN_LEN)

Token 是 8 位随机小写 hex 字符串。

核心调用链 (trigger.gd)

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
extends Area3D
signal collided_with(name)
const FLAG_PREFIX: = "sec2026_PART1_"
var obj

func _ready() -> void :
obj = GameExtension.new()

func xor_enc(plain: String) -> PackedByteArray:
var out_buf = plain.to_utf8_buffer()
if out_buf.size() < 8:
out_buf.resize(8)
var result = out_buf.slice(0, 8)
for i in range(7):
result[i] = result[i] ^ result[i + 1]
result[7] = result[7] ^ result[0]
return result

func _process(delta):
# ... 方块动画 ...
var body = get_overlapping_bodies()
if body.size() > 0:
if str(get_path()) == "/root/TownScene/Trigger2":
var label = get_node("/root/TownScene/Label2")
var label1 = get_node("/root/TownScene/Label")
var flag1 = obj.Process(xor_enc(str(label1.text).substr(7)))
label.text = "flag{" + FLAG_PREFIX + flag1 + "} "

总结一下就是

1
2
3
const FLAG_PREFIX: = "sec2026_PART1_"
var flag1 = obj.Process(xor_enc(str(label1.text).substr(7)))
label.text = "flag{" + FLAG_PREFIX + flag1 + "} "

现在还差Process()方法,里面应该就是核心算法了。

不过还是先开挂拿flag,trigger.gd说明碰了绿方块才会调用Process()方法

改方块坐标拿flag

找指针

trigger.gd_process() 每帧执行 $MeshInstance3D.position.y = height(浮动动画),这会调用引擎的 Node3D::set_position。所以 hook 这个函数,就能在每帧回调里拿到 MeshInstance3D 子节点的 this 指针。

寻找Node::get_parent()的汇编可以直接拿到parent字段在 Node 对象里的绝对偏移

image

写脚本,主要是把两个方块交换下位置方便触发

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Interceptor.attach(setPosition, {
onEnter: function (args) {
if (done) return;
var thisPtr = args[0];
var name = readStringName(thisPtr.add(0x1E0));
if (name !== "MeshInstance3D") return;
var parentPtr = thisPtr.add(0x148).readPointer();
var parentName = readStringName(parentPtr.add(0x1E0));
if (parentName === "Trigger2") {
var vec = Memory.alloc(12);
vec.writeFloat(-14.1);
vec.add(4).writeFloat(0.5);
vec.add(8).writeFloat(-3.96);
setPositionFn(parentPtr, vec);
console.log("[+] Trigger2 moved to ground");
done = true;
}
}
});

拿到flag

image

libsec2026.so 壳保护分析

lib里有一个自实现的so库libsec2026.so

导出了 2 个函数:

  • extension_init @ 0x56D50 — GDExtension 入口
  • start @ 0x69870 — .init_array 构造函数,SO 加载时自动执行

start里有明显的自解密痕迹:

image

AI分析如下

start 实现了一个运行时解壳器,通过直接 syscall (SVC 指令) 绕过 libc

1
2
3
4
5
6
7
8
9
10
void start() {
int fd = syscall(__NR_openat, 0, "/proc/self/auxv", 0);
read(fd, buf, 0x200);
close(fd);
sub_69984(&dword_69A6C, 4129, out_buf, &out_size);
int memfd = syscall(__NR_memfd_create, "name", 0);
write(memfd, decompressed_data, size);
void *base = mmap(NULL, size, PROT_READ|PROT_EXEC, MAP_PRIVATE, memfd, 0);
((void(*)())(base + 0x10))();
}

方案一: Frida hook syscall

同时 hook memfd_createwritemmap,在壳写入 memfd 时截获解压后的数据。dump下来的elf由于base和数据段缺失很难看,转动调。

方案二: Patch SO + IDA 远程调试

start 函数入口 patch 为死循环 (B .),IDA 附加后手动单步跟踪加载器。

动调

经过1次寄存器跳转来到了

image

sub_7C4AA1A450负责把真正的 extension SO 加载到内存

image

  1. memfd_create → write 16字节 header → mmap(RX) → close
  2. 循环遍历 ELF Program Headers,筛选 PT_LOAD 段,mmap 映射
  3. 写入 ARM64 trampoline 指令 (含 BR X10 跳板)
  4. 跳转到加载完成的 extension 入口(extension_init)

跳转过来时在动态解密一个数组

image

解密结果如下

image

其中Process是gd要调用的方法,现在需要确定Process方法的地址

壳加载器将 extension ELF 的代码段和数据段分别 mmap 到内存,两者相对偏移由 ELF program header 决定,每次运行固定。

数据段包含 XOR 解密后的明文字符串 “Th1s ls n0t a rea1 key!!@sec2026”,可作为锚点定位数据段地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function findExt() {
var pattern = "54 68 31 73 20 6C 73 20 6E 30 74 20 61 20 72 65 61 31 20 6B 65 79 21 21 40 73 65 63 32 30 32 36";
var found = null;
Process.enumerateRanges("rw-").forEach(function (r) {
if (found || r.size < 0x100 || r.size > 0x100000) return;
try {
var m = Memory.scanSync(r.base, r.size, pattern);
if (m.length > 0) found = m[0].address;
} catch (e) {}
});
if (!found) { console.log("[-] Not found"); return; }
var dataStrBase = found.sub(0x12);
var codeBase = dataStrBase.sub(0xA25C0);
var processAddr = codeBase.add(0x27A8);
console.log("[+] Process() @ " + processAddr);
}
findExt();

先跑交换位置的脚本,然后通过上面的js计算得到Process的真实地址,再detach用ida附加动调分析

image

找到Process具体实现

image

跟踪调试了一下,发现有MBA和类似于控制流平坦化的混淆,直接trace导出让AI看

ce974246a96ec795be642b5569daff86

trace AI分析

以下全是将trace丢给AI后分析出来的结果

混淆分析

extension 代码使用了多种混淆手法:

  • 地址计算混淆 (恒等变换跳板): 每次间接跳转前都经过混淆 gadget,纯粹干扰静态分析
  • 无标准函数序言: 没有 STP X29,X30 序言,全部用 BR/BLR 间接跳转
  • 算术混淆: XOR 被混淆为 (a|b) ^ -(a&b) + 2*((a|b) & -(a&b)) = a^b;自增被混淆为 n += (n&1) + (~n|0xFE) + 2 = n++

数据段分析

运行时解密后:

1
2
3
4
5
+0x04: "Process"                              ← GDExtension 方法名
+0x12: "Th1s ls n0t a rea1 key!!@sec2026" ← ★ ChaCha20 密钥 (32字节)
+0x33: "012345678901" ← ★ ChaCha20 nonce (12字节)
+0x40: "%02X" ← flag 输出格式
+0xC0: "/dev/urandom"

Process() 算法逆向 — ChaCha20 识别

核心算法函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void process_core(PackedByteArray input) {
pthread_mutex_lock(&global_lock);
if (!global_buf) global_buf = malloc(0x100000);
memset(global_buf, 0, 0x100000);
ctx = malloc(0x88);
memset(ctx, 0, 0x88);
chacha20_init(ctx,
key = "Th1s ls n0t a rea1 key!!@sec2026",
nonce = "012345678901",
mode = 0);
uint8_t *data = get_packed_array_ptr(input);
size_t size = get_packed_array_size(input); // = 8
chacha20_encrypt(ctx, data, global_buf, size);
String result = "";
for (int i = 0; i < size; i++) {
char hex[3];
sprintf(hex, "%02X", global_buf[i]);
result += hex;
}
return result;
}
ChaCha20 密钥调度

函数将 32 字节 key 和 12 字节 nonce 以 4 字节为单位加载到 ctx 结构体,同时将加密常量 "expand 32-byte k" 复制到 ctx 开头——这正是 ChaCha20 标准常量

ChaCha20 初始状态矩阵
1
2
3
4
5
         Column 0      Column 1      Column 2      Column 3
Row 0: 0x61707866 0x3320646F 0x79622D31 0x6B206573 ← 常量
Row 1: 0x73316854 0x20736C20 0x2074306E 0x65722061 ← key[0:16]
Row 2: 0x6B203161 0x21217965 0x63657340 0x36323032 ← key[16:32]
Row 3: 0x00000000 0x33323130 0x37363534 0x31303938 ← counter + nonce
Quarter Round 识别

从 trace 完整还原了 quarter round 函数,12 步全部精确匹配,确认为标准 ChaCha20 Quarter Round。从 trace 中的函数调用模式确认执行了 10 次 double round (即 ChaCha20 的 20 轮)。


完整算法总结

Token → Flag 完整流程
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
输入: Token = "b670d101" (8 字符小写 hex)

Step 1: xor_enc 预处理
"b670d101" → UTF-8 bytes → 链式 XOR → 8 字节

Step 2: ChaCha20 流密码加密
Key: "Th1s ls n0t a rea1 key!!@sec2026" (32 bytes)
Nonce: "012345678901" (12 bytes)
Counter: 0
生成 64 字节 keystream,取前 8 字节与输入 XOR

Step 3: Hex 格式化
加密后 8 字节 → 大写 hex 字符串 (16 字符)

Step 4: 拼接 Flag
"flag{sec2026_PART1_" + hex + "}"

输出: flag{sec2026_PART1_2E1E541D26372219}
Flag → Token 逆向流程

ChaCha20 是流密码,加密和解密操作完全相同(都是 XOR keystream)。逆向只需对 flag hex 做一次相同的 ChaCha20 运算,再逆向 xor_enc 即可还原 Token。


C 实现
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
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include <stdlib.h>

#define ROTL32(v, n) (((v) << (n)) | ((v) >> (32 - (n))))

static void quarter_round(uint32_t *a, uint32_t *b, uint32_t *c, uint32_t *d) {
*a += *b; *d ^= *a; *d = ROTL32(*d, 16);
*c += *d; *b ^= *c; *b = ROTL32(*b, 12);
*a += *b; *d ^= *a; *d = ROTL32(*d, 8);
*c += *d; *b ^= *c; *b = ROTL32(*b, 7);
}

static void chacha20_block(const uint32_t state[16], uint8_t out[64]) {
uint32_t s[16];
memcpy(s, state, 64);
for (int i = 0; i < 10; i++) {
quarter_round(&s[0], &s[4], &s[8], &s[12]);
quarter_round(&s[1], &s[5], &s[9], &s[13]);
quarter_round(&s[2], &s[6], &s[10], &s[14]);
quarter_round(&s[3], &s[7], &s[11], &s[15]);
quarter_round(&s[0], &s[5], &s[10], &s[15]);
quarter_round(&s[1], &s[6], &s[11], &s[12]);
quarter_round(&s[2], &s[7], &s[8], &s[13]);
quarter_round(&s[3], &s[4], &s[9], &s[14]);
}
for (int i = 0; i < 16; i++) s[i] += state[i];
memcpy(out, s, 64);
}

static void chacha20_crypt(const uint8_t key[32], const uint8_t nonce[12],
uint32_t counter, uint8_t *data, size_t len) {
uint32_t state[16];
state[0] = 0x61707866; state[1] = 0x3320646F;
state[2] = 0x79622D31; state[3] = 0x6B206573;
memcpy(&state[4], key, 32);
state[12] = counter;
memcpy(&state[13], nonce, 12);
uint8_t keystream[64];
for (size_t off = 0; off < len; off += 64) {
chacha20_block(state, keystream);
size_t chunk = (len - off < 64) ? len - off : 64;
for (size_t i = 0; i < chunk; i++) data[off + i] ^= keystream[i];
state[12]++;
}
}

static void xor_enc(const char *token, uint8_t out[8]) {
for (int i = 0; i < 8; i++) out[i] = (uint8_t)token[i];
for (int i = 0; i < 7; i++) out[i] ^= out[i + 1];
out[7] ^= out[0];
}

static void xor_dec(const uint8_t enc[8], char out[9]) {
uint8_t t[8];
memcpy(t, enc, 8);
t[7] ^= t[0];
for (int i = 6; i >= 0; i--) t[i] ^= t[i + 1];
for (int i = 0; i < 8; i++) out[i] = (char)t[i];
out[8] = '\0';
}

static const uint8_t KEY[32] = "Th1s ls n0t a rea1 key!!@sec2026";
static const uint8_t NONCE[12] = "012345678901";

static void token_to_flag(const char *token, char *flag_hex) {
uint8_t buf[8];
xor_enc(token, buf);
chacha20_crypt(KEY, NONCE, 0, buf, 8);
for (int i = 0; i < 8; i++) sprintf(flag_hex + i * 2, "%02X", buf[i]);
}

static void flag_to_token(const char *flag_hex, char *token) {
uint8_t buf[8];
for (int i = 0; i < 8; i++)
sscanf(flag_hex + i * 2, "%02X", (unsigned *)&buf[i]);
chacha20_crypt(KEY, NONCE, 0, buf, 8);
xor_dec(buf, token);
}

int main(int argc, char **argv) {
if (argc == 2 && strcmp(argv[1], "test") == 0) {
char hex[17] = {0}, token[9] = {0};
token_to_flag("b670d101", hex);
printf("enc b670d101 -> %s (expect 2E1E541D26372219)\n", hex);
flag_to_token("2E1E541D26372219", token);
printf("dec 2E1E541D26372219 -> %s (expect b670d101)\n", token);
return 0;
}
if (argc < 3) {
printf("Usage:\n");
printf(" %s enc <token> Token(8 hex) -> Flag\n", argv[0]);
printf(" %s dec <flag_hex> Flag(16 hex) -> Token\n", argv[0]);
return 1;
}
if (strcmp(argv[1], "enc") == 0) {
char hex[17] = {0};
token_to_flag(argv[2], hex);
printf("flag{sec2026_PART1_%s}\n", hex);
} else if (strcmp(argv[1], "dec") == 0) {
char token[9] = {0};
flag_to_token(argv[2], token);
printf("Token: %s\n", token);
}
return 0;
}
编译与测试
1
2
3
4
5
6
7
8
9
10
gcc -o solve solve.c -O2
./solve test
# enc b670d101 -> 2E1E541D26372219 (expect 2E1E541D26372219)
# dec 2E1E541D26372219 -> b670d101 (expect b670d101)

./solve enc b670d101
# flag{sec2026_PART1_2E1E541D26372219}

./solve dec 2E1E541D26372219
# Token: b670d101

总结

frida脚本

脚本 用途
dump_decrypted_pck.js Hook FileAccessEncrypted 析构函数 dump 解密后的 PCK 数据
dump_pck_key.js Hook mbedtls_aes_setkey 捕获 PCK 加密密钥
find_extension.js 搜索假 key 字符串自动定位 Process() 地址
teleport_trigger2.js 自动找到 Trigger2 并提供坐标修改功能
hook_process_call.js 批量 hook extension 函数入口定位 Process()
find_process_func.js 搜索 “Process” 字符串定位并 hook Process()
  1. 多层壳保护: libsec2026.so 使用 syscall 级别的壳加载器,解压 → memfd → mmap → 跳转执行
  2. PCK 加密: Godot 资源文件使用 AES-256-CFB 加密,密钥编译时嵌入引擎 SO
  3. 代码混淆: extension 代码使用地址计算混淆、间接跳转、算术混淆等多种手法
  4. 无符号信息: 脱壳后的代码没有任何符号,所有函数通过间接跳转调用
  5. 动态地址: ASLR 导致每次运行地址不同,需要运行时定位

解题流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
APK 解包 → jadx 分析 Java 层 (排除干扰)

识别 Godot 引擎 + GDExtension 机制

Frida hook AES key → 获取 PCK 加密密钥

dump + 反编译 GDScript → 还原调用链 (xor_enc → Process)

IDA 分析 libsec2026.so → 识别壳保护

Frida dump 脱壳 payload → IDA 分析 extension 代码

Frida 传送 Trigger2 + 定位 Process 地址

IDA 动调 Process() → instruction trace

分析 trace → 识别 ChaCha20 (quarter round + 常量 "expand 32-byte k")

提取 Key/Nonce → 实现正向/逆向算法 → 验证通过

算法本质

GameExtension.Process() 的核心算法是 标准 ChaCha20 流密码,使用固定的密钥和 nonce:

  • 算法: ChaCha20 (RFC 7539)
  • 密钥: "Th1s ls n0t a rea1 key!!@sec2026" (32 字节,伪装成假 key 彩蛋)
  • Nonce: "012345678901" (12 字节)
  • Counter: 0
  • 特性: 流密码,加密 = 解密 (XOR keystream)

参考资料

Protect Your Godot Game - Melaton’s Blog

godot 引擎逆向初探 | in1t’s blog

获取源代码 — Godot Engine (4.5) 简体中文文档

IDA Tracing - yring

GDRETools/gdsdecomp: Godot reverse engineering tools

godotengine/godot: Godot Engine – Multi-platform 2D and 3D game engine