打开游戏是Godot引擎,说来也巧前几个月国赛也写过,但是碰到的这个引擎都是exe,没有接触过apk

解包

首先是用GDRE_tools解包,当时解apk的时候遇到困难了

一直卡在要输入key,没遇到过这个还真被卡蒙了,然后就去查资料

找密钥

法1:

找到can’t open encrypted pack directory.

法2:

gdscript::load_byte_code 字符串

godot 游戏提取aeskey - 吾爱破解 - 52pojie.cn

上面两个正常的思路都没找到

在资源文件夹中看到很多gdc文件

用010打开来分析就发现魔术头是不对的,被加密过了

image-20260523175428916

本题找密钥:

重新分析

1
2
3
4
5
6
7
加载 PCK

判断是否加密

读取 AES Key

解密文件

对关键词encrypt、aes等搜索都没有作用,只有pck这边搜到一些有用的字符串,PCKPacker是生成 PCK,PackedSourcePCK是读取 PCK,相当于 PCK 解包/访问器

image-20260521183831201

这里flags bit0进行特殊处理

image-20260521193817078

进入1410函数,这个是初始化加密包密钥的函数,而且一定是AES-256

image-20260523205703863

而且也可以很容易的找到密钥

CE4DF8753B59A5A39ADE58AC7EF947A3DA39F2AF75E3284D51217C04D49A061

image-20260521195122540

顺着这个函数分析sub_376EF68,从这个函数可以清晰的发现是AES-CTR/CFB 类似的流模式

魔改的部分在这

image-20260523181355394

解密

根据以上的分析写解密脚本把加密的gdc文件解密,gdc解密之后就可以用Godotretool来获得gc源码

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
import os
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes

KEY = bytes.fromhex("CE4DF8753B59A5A39ADE58AC07EF947A3DA39F2AF75E3284D51217C04D49A061")

def aes_enc(key, block):
"""One-block ECB encrypt with AES-256."""
c = Cipher(algorithms.AES(key), modes.ECB())
return c.encryptor().update(block)

def godot45_cfb_decrypt(key, iv, ct):
"""Godot 4.5 modified CFB-128 decrypt."""
iv = bytearray(iv)
pt = bytearray(len(ct))
n = 0
for i in range(len(ct)):
if n == 0:
iv = bytearray(aes_enc(key, bytes(iv)))
c = ct[i]
pt[i] = iv[n] ^ c ^ n
iv[n] = c ^ n
n = (n + 1) & 0xf
return bytes(pt)

def decrypt_file(path):
blob = open(path, "rb").read()
size = int.from_bytes(blob[16:24], "little")
iv = blob[24:40]
ct = blob[40:]
pt = godot45_cfb_decrypt(KEY, iv, ct)
return pt[:size]

if __name__ == "__main__":
files = [
"tengxun/assets/token.gdc",
"tengxun/assets/label2.gdc",
"tengxun/assets/spedometer.gdc",
"tengxun/assets/Trigger/trigger.gdc",
"tengxun/assets/car_select/car_select.gdc",
"tengxun/assets/ext/sec2026.gdextension",
]
os.makedirs("decrypted", exist_ok=True)
for path in files:
pt = decrypt_file(path)

out = "decrypted/" + os.path.basename(path)
with open(out, "wb") as f:
f.write(pt)

print(f"=== {path} size={len(pt)} → {out}")
if path.endswith(".gdextension"):
try:
print(pt.decode("utf-8"))
except UnicodeDecodeError:
print("(non-UTF-8)")
print(pt[:100].hex())
else:
for i in range(0, min(64, len(pt)), 16):
chunk = pt[i:i+16]
print(f" +{i:03x}: {chunk.hex()} {''.join(chr(b) if 32<=b<127 else '.' for b in chunk)}")
print()

这样子就能拿到正确的gdc文件了,扔进去反汇编

image-20260429203609736

image-20260523211452738

得到的源码分析,因为题目要拿到token,可以看到token.gd这个文件里面,token是随机生成的然后绑定在一个 Label 控件上,每次显示到Label上打印到控制台

然后flag部分,可以从trigger中得到一些东西,如果小车碰到Trigger1,显示测试 flag。

碰到Trigger2,才会生成我们需要的flag

  • 获取 Label 文本,去掉 Token 前缀,得到 8 位 16进制的token。
  • 对 token 调用 xor_enc() 做一轮链式异或变换(7 次相邻异或 + 尾首异或),得到 8 字节 PackedByteArray
  • 将结果传给 GameExtension.Process()——这是 native 扩展 libsec2026.so 提供的方法。
  • native 层返回的字符串拼接为最终 flag:flag{sec2026_PART1_<native返回值>}

可以通过修改aok,把Trigger1和Trigger2路径调换一下,重新打包,然后碰到1的时候就会给2的flag来验证后面的flag生成算法对不对

image-20260523212343012

修改apk

apktool

用apktool解包

image-20260507200637118

修改东西

image-20260507202617695

然后再把trigger.gd.remap中的路径修改一下

但是这里不知道为什么我用apktool正常的重打包一直成功不了。然后就用MT来打包了

MT重打包

1. 提取 APK 文件

  • 打开 MT 管理器,点击左上角的 “三横”菜单 按钮。
  • 在菜单中选择 “安装包提取”
  • 在应用列表中找到你的游戏 com.tencent.ACE.gamesec2026.preliminary,点击它。
  • 在弹出的窗口中,点击 “提取”“APK 路径”,选择一个你方便找到的目录(比如 /sdcard/Download//sdcard/MT2/apks/),将 base.apk 文件复制出来 。

image-20260521154750344

2. 修改并重打包

  • 在 MT 管理器的主界面,导航到你刚刚保存 base.apk 的目录。

  • 点击 这个 base.apk 文件,选择 “查看”

  • 现在你就进入了 APK 的内部。你需要替换的文件 trigger.gdtrigger.gdc 应该在 assets/Trigger/ 文件夹下。

  • 把你准备好的新文件从另一侧窗口 直接拖拽或复制粘贴 进来,覆盖掉原来的文件。MT 管理器会自动帮你完成替换和重打包 。

    image-20260521155112923

3. 签名并安装

  • 替换完所有文件后,退回 到 MT 管理器的文件列表界面(就是你看到 base.apk 图标的地方)。
  • 长按 这个 base.apk 文件,在弹出的菜单中选择 “签名”
  • 选择 “签名 APK”“重新签名”。MT 管理器会自动生成一个新的、已签名的 APK 文件(通常原文件名后会自动加上 _sign)。
  • 现在,这个新的 APK 就可以像普通应用一样被安装到手机上了。

image-20260521155630728

flag实现

打开libsec2026.so这个文件,里面的函数很少,而且看LOAD 段有很明显的壳特征

从start函数开始分析,start函数看起来像一个自解压壳的入口函数,在内存中构造并加载另一段 ELF 代码,然后跳转过去执行,可以直接hook dump出来该部分程序或者继续分析sub_69984这个函数来静态解密

image-20260523214308398

静态

这sub_69984里作为入口分析,可以知道这里面是一个NRV 类位流解压器

ai搓了个脚本提取真实的内容

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
from __future__ import annotations

import argparse
from pathlib import Path

from unicorn import Uc, UcError, UC_ARCH_ARM64, UC_HOOK_CODE, UC_HOOK_INTR, UC_MODE_ARM
from unicorn.arm64_const import (
UC_ARM64_REG_PC,
UC_ARM64_REG_SP,
UC_ARM64_REG_W1,
UC_ARM64_REG_X0,
UC_ARM64_REG_X1,
UC_ARM64_REG_X2,
UC_ARM64_REG_X3,
UC_ARM64_REG_X8,
UC_ARM64_REG_X30,
)


STAGE1_FUNC = 0x69984
STAGE1_HDR = 0x69A60

PAGE_SIZE = 0x1000


def align_up(value: int, align: int = PAGE_SIZE) -> int:
return (value + align - 1) & ~(align - 1)


def extract_stage2(blob: bytes) -> bytes:
input_len = int.from_bytes(blob[STAGE1_HDR + 4 : STAGE1_HDR + 8], "little")
src = blob[STAGE1_HDR + 0xC : STAGE1_HDR + 0xC + input_len]

src_addr = 0x100000
dst_addr = 0x200000
meta_addr = 0x300000
stack_addr = 0x400000
ret_addr = 0x7FFF0000

mu = Uc(UC_ARCH_ARM64, UC_MODE_ARM)
for addr, size in (
(0x69000, 0x2000),
(src_addr, 0x10000),
(dst_addr, 0x20000),
(meta_addr, 0x1000),
(stack_addr, 0x10000),
(ret_addr, 0x1000),
):
mu.mem_map(addr, size)

mu.mem_write(0x69000, blob[0x69000:0x6B000])
mu.mem_write(src_addr, src)
mu.reg_write(UC_ARM64_REG_SP, stack_addr + 0x10000 - 0x100)
mu.reg_write(UC_ARM64_REG_X0, src_addr)
mu.reg_write(UC_ARM64_REG_W1, input_len)
mu.reg_write(UC_ARM64_REG_X2, dst_addr)
mu.reg_write(UC_ARM64_REG_X3, meta_addr)
mu.reg_write(UC_ARM64_REG_X30, ret_addr)

def stop_on_return(emu: Uc, addr: int, _size: int, _data: object) -> None:
if addr == ret_addr:
emu.emu_stop()

mu.hook_add(UC_HOOK_CODE, stop_on_return)
mu.emu_start(STAGE1_FUNC, ret_addr)

out_len = int.from_bytes(mu.mem_read(meta_addr, 4), "little")
return bytes(mu.mem_read(dst_addr, out_len))


def run_stage2_loader(blob: bytes, stage2: bytes, max_instructions: int) -> tuple[bytes, bytes, int]:
st2_base = 0x1000000
file_base = 0x2000000
stack_base = 0x3000000
args_base = 0x3100000
dead_base = 0x3F00000

mu = Uc(UC_ARCH_ARM64, UC_MODE_ARM)
for addr, size in (
(st2_base, 0x4000),
(file_base, 0x100000),
(stack_base, 0x20000),
(args_base, 0x1000),
(dead_base, 0x1000),
):
mu.mem_map(addr, size)

mu.mem_write(st2_base, stage2)
mu.mem_write(file_base, blob)

sp0 = stack_base + 0x18000
mu.mem_write(sp0, b"\x00" * 0x20)
mu.mem_write(args_base, b"\x00" * 0x1000)
mu.reg_write(UC_ARM64_REG_SP, sp0)
mu.reg_write(UC_ARM64_REG_X0, file_base + 0x69860)
mu.reg_write(UC_ARM64_REG_X1, args_base)
mu.reg_write(UC_ARM64_REG_X2, stack_base + 0x1000)
mu.reg_write(UC_ARM64_REG_X30, dead_base)

mapped_ranges = [
(st2_base, st2_base + 0x4000),
(file_base, file_base + 0x100000),
(stack_base, stack_base + 0x20000),
(args_base, args_base + 0x1000),
(dead_base, dead_base + 0x1000),
]
next_alloc = 0x4000000
final_entry = 0

def is_mapped(addr: int, size: int) -> bool:
end = addr + size
for start, stop in mapped_ranges:
if addr >= start and end <= stop:
return True
return False

def add_map(addr: int, size: int) -> None:
mapped_ranges.append((addr, addr + size))

state = {"count": 0, "stop": None}

def hook_code(emu: Uc, addr: int, _size: int, _data: object) -> None:
nonlocal final_entry
state["count"] += 1
if state["count"] > max_instructions:
state["stop"] = f"instruction limit hit at 0x{addr:x}"
emu.emu_stop()
return

if addr == st2_base + 0x1004:
lr = emu.reg_read(UC_ARM64_REG_X30)
# The loader falls back to 4K pages if auxv probing fails.
emu.reg_write(UC_ARM64_REG_X0, 0xFFFFFFFFFFFFF000)
emu.reg_write(UC_ARM64_REG_PC, lr)
return

if not (st2_base <= addr < st2_base + len(stage2)):
final_entry = addr - file_base
state["stop"] = f"left stage2 at 0x{addr:x}"
emu.emu_stop()

def hook_intr(emu: Uc, _intno: int, _data: object) -> None:
nonlocal next_alloc
nr = emu.reg_read(UC_ARM64_REG_X8) & 0xFFFFFFFF
x0 = emu.reg_read(UC_ARM64_REG_X0)
x1 = emu.reg_read(UC_ARM64_REG_X1)

if nr == 0xDE:
size = align_up(x1)
addr = x0 if x0 else align_up(next_alloc)
if not x0:
next_alloc = addr + size + PAGE_SIZE
if not is_mapped(addr, size):
emu.mem_map(addr, size)
add_map(addr, size)
emu.reg_write(UC_ARM64_REG_X0, addr)
return

if nr in (0xD7, 0xE2, 0xE3, 0x23, 0xAD, 0xD6, 0x4E, 0x5D, 0x2E):
emu.reg_write(UC_ARM64_REG_X0, 0)
return

if nr == 0x38:
emu.reg_write(UC_ARM64_REG_X0, 3)
return

if nr == 0x3F:
emu.reg_write(UC_ARM64_REG_X0, 0)
return

if nr == 0x39:
emu.reg_write(UC_ARM64_REG_X0, 0)
return

if nr == 0x40:
emu.reg_write(UC_ARM64_REG_X0, emu.reg_read(UC_ARM64_REG_X2))
return

if nr == 0x117:
emu.reg_write(UC_ARM64_REG_X0, 4)
return

state["stop"] = f"unhandled syscall 0x{nr:x}"
emu.emu_stop()

mu.hook_add(UC_HOOK_CODE, hook_code)
mu.hook_add(UC_HOOK_INTR, hook_intr)

try:
mu.emu_start(st2_base + 0x10, 0)
except UcError as exc:
raise RuntimeError(state["stop"] or str(exc)) from exc

if final_entry == 0:
raise RuntimeError(state["stop"] or "stage2 did not reach final entry")

unpacked = bytes(mu.mem_read(file_base, len(blob)))
runtime_image = bytes(mu.mem_read(file_base, 0x100000))
return unpacked, runtime_image, final_entry


def main() -> None:
parser = argparse.ArgumentParser(description="Unpack libsec2026.so via its own AArch64 loader.")
parser.add_argument("input", nargs="?", default="libsec2026.so", help="Path to the packed .so")
parser.add_argument(
"--out-prefix",
default="libsec2026",
help="Prefix for output files (default: libsec2026)",
)
parser.add_argument(
"--max-instructions",
type=int,
default=40_000_000,
help="Stage2 emulation instruction budget",
)
args = parser.parse_args()

input_path = Path(args.input)
blob = input_path.read_bytes()

stage2 = extract_stage2(blob)
unpacked, runtime_image, final_entry = run_stage2_loader(blob, stage2, args.max_instructions)

stage2_path = input_path.with_name(f"{args.out_prefix}.stage2.bin")
unpacked_path = input_path.with_name(f"{args.out_prefix}.unpacked.so")
runtime_path = input_path.with_name(f"{args.out_prefix}.runtime1m.bin")
stage2_path.write_bytes(stage2)
unpacked_path.write_bytes(unpacked)
runtime_path.write_bytes(runtime_image)

print(f"[+] stage2 -> {stage2_path} ({len(stage2)} bytes)")
print(f"[+] unpacked -> {unpacked_path} ({len(unpacked)} bytes)")
print(f"[+] runtime image -> {runtime_path} ({len(runtime_image)} bytes)")
print(f"[+] final entry offset: 0x{final_entry:x}")


if __name__ == "__main__":
main()

动态

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
var CONFIG = {
targetLib: "libsec2026.so",
rawName: "libsec2026_dump.bin",
relocName: "libsec2026_dump_reloc.bin",
loadDelayMs: 150,
pollIntervalMs: 500,
extInitOffset: 0x56d50,
chachaMagic: "66 78 70 61 6f 64 20 33 31 2d 62 79 73 65 20 6b"
};

var PT_LOAD = 1;
var PT_DYNAMIC = 2;
var DT_NULL = 0;
var DT_RELA = 7;
var DT_RELASZ = 8;
var DT_RELAENT = 9;
var R_AARCH64_RELATIVE = 0x403;

var state = {
dumpDir: null,
dumped: false,
inProgress: false,
retryTimer: null,
retryCount: 0,
maxRetries: 20
};

function log(message) {
console.log(message);
}

function readU64AsNumber(p) {
return parseInt(p.readU64().toString(), 10);
}

function readS64AsNumber(p) {
return parseInt(p.readS64().toString(), 10);
}

function findWritableDir() {
var candidates = [];

try {
var cmdline = new File("/proc/self/cmdline", "r").readLine();
var pkgName = cmdline.split("\0")[0].trim();
if (pkgName.indexOf(".") !== -1) {
candidates.push("/data/data/" + pkgName + "/");
}
} catch (_) {}

candidates.push("/data/local/tmp/");
candidates.push("/sdcard/Download/");
candidates.push("/sdcard/");

for (var i = 0; i < candidates.length; i++) {
try {
var probePath = candidates[i] + ".frida_write_test";
var fp = new File(probePath, "wb");
fp.write(new ArrayBuffer(1));
fp.flush();
fp.close();
log("[*] writable dir: " + candidates[i]);
return candidates[i];
} catch (_) {}
}

log("[!] no writable dir found, fallback to /data/local/tmp/");
return "/data/local/tmp/";
}

function writeFile(path, buf) {
try {
var fp = new File(path, "wb");
fp.write(buf);
fp.flush();
fp.close();
return path;
} catch (e) {
var fallback = "/sdcard/" + path.split("/").pop();
log("[!] write failed: " + path + " -> " + e);
log("[*] fallback write: " + fallback);
var fp2 = new File(fallback, "wb");
fp2.write(buf);
fp2.flush();
fp2.close();
return fallback;
}
}

function isElf64(base) {
try {
return base.readU32() === 0x464c457f && base.add(4).readU8() === 2;
} catch (_) {
return false;
}
}

function parseLoadSegments(base) {
var ePhoff = readU64AsNumber(base.add(32));
var ePhentsize = base.add(54).readU16();
var ePhnum = base.add(56).readU16();
var loads = [];
var maxEnd = 0;

for (var i = 0; i < ePhnum; i++) {
var ph = base.add(ePhoff + i * ePhentsize);
var pType = ph.readU32();
if (pType !== PT_LOAD) {
continue;
}

var seg = {
offset: readU64AsNumber(ph.add(8)),
vaddr: readU64AsNumber(ph.add(16)),
filesz: readU64AsNumber(ph.add(32)),
memsz: readU64AsNumber(ph.add(40)),
flags: ph.add(4).readU32()
};
loads.push(seg);

var end = seg.vaddr + seg.memsz;
if (end > maxEnd) {
maxEnd = end;
}
}

return {
totalSize: maxEnd,
loads: loads
};
}

function parseRelaInfo(base) {
var ePhoff = readU64AsNumber(base.add(32));
var ePhentsize = base.add(54).readU16();
var ePhnum = base.add(56).readU16();

for (var i = 0; i < ePhnum; i++) {
var ph = base.add(ePhoff + i * ePhentsize);
if (ph.readU32() !== PT_DYNAMIC) {
continue;
}

var dynVaddr = readU64AsNumber(ph.add(16));
var dynMemsz = readU64AsNumber(ph.add(40));
var relaAddr = 0;
var relaSize = 0;
var relaEnt = 24;

for (var off = 0; off + 16 <= dynMemsz; off += 16) {
var dTag = readS64AsNumber(base.add(dynVaddr + off));
var dVal = readU64AsNumber(base.add(dynVaddr + off + 8));

if (dTag === DT_NULL) {
break;
}
if (dTag === DT_RELA) {
relaAddr = dVal;
} else if (dTag === DT_RELASZ) {
relaSize = dVal;
} else if (dTag === DT_RELAENT) {
relaEnt = dVal;
}
}

return {
relaAddr: relaAddr,
relaSize: relaSize,
relaEnt: relaEnt
};
}

return null;
}

function collectReadableRanges(base, totalSize) {
var ranges = [];
var seen = {};
var perms = ["r--", "r-x", "rw-", "rwx"];
var end = base.add(totalSize);

perms.forEach(function (perm) {
var found;
try {
found = Process.enumerateRangesSync(perm);
} catch (_) {
found = [];
}

found.forEach(function (range) {
var key = range.base.toString();
if (seen[key]) {
return;
}
seen[key] = true;

var rangeEnd = range.base.add(range.size);
if (rangeEnd.compare(base) <= 0 || range.base.compare(end) >= 0) {
return;
}

ranges.push(range);
});
});

return ranges;
}

function readElfImage(base, totalSize) {
try {
return base.readByteArray(totalSize);
} catch (_) {
log("[*] direct read failed, rebuilding from readable ranges");
}

var out = new ArrayBuffer(totalSize);
var dest = new Uint8Array(out);
var end = base.add(totalSize);
var ranges = collectReadableRanges(base, totalSize);

ranges.forEach(function (range) {
var copyStart = range.base.compare(base) > 0 ? range.base : base;
var rangeEnd = range.base.add(range.size);
var copyEnd = rangeEnd.compare(end) < 0 ? rangeEnd : end;
var copySize = parseInt(copyEnd.sub(copyStart).toString(), 10);
if (copySize <= 0) {
return;
}

try {
var chunk = copyStart.readByteArray(copySize);
if (!chunk) {
return;
}
var offset = parseInt(copyStart.sub(base).toString(), 10);
dest.set(new Uint8Array(chunk), offset);
} catch (e) {
log("[!] range read failed @" + copyStart + " size=0x" + copySize.toString(16) + ": " + e);
}
});

return out;
}

function applyRelativeRelocations(buf, base) {
var rela = parseRelaInfo(base);
if (!rela || rela.relaAddr === 0 || rela.relaSize === 0) {
log("[!] DT_RELA not found, skip reloc dump");
return 0;
}

var view = new DataView(buf);
var count = Math.floor(rela.relaSize / rela.relaEnt);
var applied = 0;

log("[*] applying relocations: DT_RELA @ 0x" + rela.relaAddr.toString(16) + ", entries=" + count);

for (var i = 0; i < count; i++) {
var entry = base.add(rela.relaAddr + i * rela.relaEnt);
var rOffset = readU64AsNumber(entry);
var rInfo = readU64AsNumber(entry.add(8));
var rAddend = readS64AsNumber(entry.add(16));
var rType = rInfo & 0xffffffff;

if (rType !== R_AARCH64_RELATIVE) {
continue;
}
if (rOffset < 0 || rOffset + 8 > buf.byteLength) {
continue;
}

var lo;
var hi;
if (rAddend >= 0) {
lo = rAddend & 0xffffffff;
hi = Math.floor(rAddend / 0x100000000);
} else {
var unsigned = rAddend + 0x10000000000000000;
lo = unsigned & 0xffffffff;
hi = Math.floor(unsigned / 0x100000000) & 0xffffffff;
}

view.setUint32(rOffset, lo, true);
view.setUint32(rOffset + 4, hi, true);
applied++;
}

return applied;
}

function verifyMagic(base, totalSize, loads) {
try {
if (Memory.scanSync(base, Math.min(totalSize, 0x200000), CONFIG.chachaMagic).length > 0) {
return true;
}
} catch (_) {}

for (var i = 0; i < loads.length; i++) {
var seg = loads[i];
var scanSize = Math.min(seg.memsz, seg.filesz, 0x200000);
if (scanSize <= 0) {
continue;
}

try {
if (Memory.scanSync(base.add(seg.vaddr), scanSize, CONFIG.chachaMagic).length > 0) {
return true;
}
} catch (_) {}
}

return false;
}

function logLoads(loads) {
loads.forEach(function (seg, index) {
var flags =
((seg.flags & 4) ? "R" : "-") +
((seg.flags & 2) ? "W" : "-") +
((seg.flags & 1) ? "X" : "-");
log(
" LOAD[" + index + "] vaddr=0x" + seg.vaddr.toString(16) +
" memsz=0x" + seg.memsz.toString(16) +
" filesz=0x" + seg.filesz.toString(16) +
" " + flags
);
});
}

function logExtInit(base) {
try {
var p = base.add(CONFIG.extInitOffset);
var insn0 = p.readU32();
var insn1 = p.add(4).readU32();
log("[*] extension_init @ " + p + ":");
log(" insn[0] = 0x" + ("00000000" + insn0.toString(16)).slice(-8));
log(" insn[1] = 0x" + ("00000000" + insn1.toString(16)).slice(-8));
} catch (e) {
log("[!] failed to inspect extension_init: " + e);
}
}

function extInitLooksReady(base) {
try {
var insn0 = base.add(CONFIG.extInitOffset).readU32();
if (insn0 === 0xd503201f) {
return true;
}

var top10 = insn0 >>> 22;
var top9 = insn0 >>> 23;
var top8 = insn0 >>> 24;

// Common AArch64 function prologue shapes seen here.
if (top10 === 0x2d2 || top9 === 0x1a9 || top8 === 0xd1) {
return true;
}
} catch (_) {}

return false;
}

function clearRetryTimer() {
if (state.retryTimer !== null) {
clearTimeout(state.retryTimer);
state.retryTimer = null;
}
}

function scheduleRetry(reason, delayMs) {
if (state.dumped || state.inProgress) {
return;
}

if (state.retryTimer !== null) {
return;
}

if (state.retryCount >= state.maxRetries) {
log("[!] retry limit reached, giving up");
return;
}

state.retryCount++;
log("[*] retry #" + state.retryCount + " in " + delayMs + " ms: " + reason);
state.retryTimer = setTimeout(function () {
state.retryTimer = null;
tryDumpNow();
}, delayMs);
}

function dumpFromModule(mod) {
if (state.dumped || state.inProgress) {
return false;
}

state.inProgress = true;

var base = mod.base;
log("[*] module base: " + base + " size=0x" + mod.size.toString(16));

if (!isElf64(base)) {
log("[!] module base is not an ELF64 header");
state.inProgress = false;
scheduleRetry("ELF header not ready", 500);
return false;
}

if (!extInitLooksReady(base)) {
log("[*] extension_init still does not look decrypted");
logExtInit(base);
state.inProgress = false;
scheduleRetry("code not decrypted yet", 500);
return false;
}

var elfInfo;
try {
elfInfo = parseLoadSegments(base);
} catch (e) {
log("[!] parseLoadSegments failed: " + e);
elfInfo = { totalSize: mod.size, loads: [] };
}

log("[*] virtual image size: 0x" + elfInfo.totalSize.toString(16) + " (" + elfInfo.totalSize + " bytes)");
logLoads(elfInfo.loads);

if (verifyMagic(base, elfInfo.totalSize, elfInfo.loads)) {
log("[+] modified ChaCha constant found");
} else {
log("[*] modified ChaCha constant not found");
}

logExtInit(base);

var raw = readElfImage(base, elfInfo.totalSize);
var rawPath = writeFile(state.dumpDir + CONFIG.rawName, raw);
log("[+] raw dump: " + rawPath);

try {
var reloc = raw.slice(0);
var applied = applyRelativeRelocations(reloc, base);
var relocPath = writeFile(state.dumpDir + CONFIG.relocName, reloc);
log("[+] reloc dump: " + relocPath + " (applied " + applied + " relative relocs)");
} catch (e) {
log("[!] reloc processing failed: " + e);
}

state.dumped = true;
state.inProgress = false;
clearRetryTimer();

log("");
log("======================================");
log(" DUMP DONE");
log("======================================");
return true;
}

function tryDumpNow() {
var mod = Process.findModuleByName(CONFIG.targetLib);
if (!mod) {
return false;
}

return dumpFromModule(mod);
}

function hookDlopen(name, addr) {
Interceptor.attach(addr, {
onEnter: function (args) {
this.libpath = null;
if (args[0].isNull()) {
return;
}
try {
this.libpath = args[0].readUtf8String();
} catch (_) {}
},
onLeave: function (_) {
if (!this.libpath) {
return;
}
if (this.libpath.indexOf(CONFIG.targetLib) === -1) {
return;
}

log("[*] " + name + "(\"" + this.libpath + "\") returned");
scheduleRetry(name + " returned", CONFIG.loadDelayMs);
setTimeout(function () {
tryDumpNow();
}, CONFIG.loadDelayMs);
}
});

log("[*] hooked " + name + " @ " + addr);
}

function startPolling() {
log("[*] dlopen not found, fallback to polling");
var timer = setInterval(function () {
if (tryDumpNow()) {
clearInterval(timer);
} else if (state.dumped) {
clearInterval(timer);
}
}, CONFIG.pollIntervalMs);
}

function install() {
state.dumpDir = findWritableDir();

log("======================================");
log(" libsec2026.so Memory Dump Tool");
log("======================================");

if (tryDumpNow()) {
log("[*] target already loaded, dumped immediately");
return;
}

var hooked = false;
["android_dlopen_ext", "dlopen"].forEach(function (name) {
var addr = Module.findExportByName(null, name);
if (!addr || hooked) {
return;
}
hookDlopen(name, addr);
hooked = true;
});

if (!hooked) {
startPolling();
}
}

rpc.exports = {
dumpnow: function () {
return tryDumpNow();
},
status: function () {
return {
targetLib: CONFIG.targetLib,
dumpDir: state.dumpDir,
dumped: state.dumped
};
}
};

setImmediate(install);

然后用ai分析了一下加密,是一个魔改ChaCha20算法

0x5B818: ChaCha20 state 初始化
0x5B950: 流加密主循环,按 64 字节 keystream 异或输入
0x5BB54: quarter round
0x5BCEC: block 生成器

其中 0x5BB54 很典型,旋转常数就是标准 ChaCha20 的 16, 12, 8, 7,所以算法骨架没改。

0x5B818 里还能直接看到它把常量装成:”fxpaod 31-byse k”

也就是把标准的 “expand 32-byte k” 改掉了。这是它和标准 ChaCha20 最明显的区别。

从 0x5B818 的 state 布局看,ctx 大致是:

ctx+0x00..0x0f: 16 字节常量 “fxpaod 31-byse k”
ctx+0x10..0x2f: 32 字节 key
ctx+0x30: counter
ctx+0x34..0x3f: 12 字节 nonce
ctx+0x40..0x7f: 当前 64 字节 keystream block
ctx+0x80: 当前 block 已消耗字节数
0x5B950 会在 ctx+0x80 >= 0x40 时调用 0x5BCEC 生成新 block,然后把输入按字节与 ctx+0x40+offset 的 keystream 异或。因为这里输入只有 8 字节,所以实际只会用到第一个 block 的前 8 字节,counter 基本就是从 0 起。

所以大致flag的流程是

token(8 ASCII) -> xor_enc -> ChaCha20 -> 8 字节密文转 16 位大写 hex -> GDScript 外层再拼成 flag{sec2026_PART1_…}

算法实现

用python实现flag生成与逆算法

python .\sec2026_flag_codec.py encode 12345678

python .\sec2026_flag_codec.py decode “flag{sec2026_PART1_791E544870372C47}”

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
from __future__ import annotations

import argparse
import struct
from pathlib import Path


FLAG_PREFIX = "sec2026_PART1_"
CHACHA_CONST = b"fxpaod 31-byse k"

# These are kept as built-in fallback values and validation targets.
KEY_BYTES = b"Th1s ls n0t a rea1 key!!@sec2026"
NONCE_BYTES = b"012345678901"

# Static material recovery from the original packed file.
# In libsec2026.so the encrypted blob sits right after:
# "Process\\0input\\0"
# and corresponds to:
# key[32] + b"\\x00" + nonce[12] + b"\\x00"
ORIG_KEY_OFFSET = 0x705D2
ORIG_KEY_SIZE = 32
ORIG_TAIL_OFFSET = ORIG_KEY_OFFSET + ORIG_KEY_SIZE
ORIG_TAIL_SIZE = 14

# Recovered from static binary analysis of the original file layout.
KEY_XOR_MASK = bytes.fromhex("4e5e4fa7f76ea397")
TAIL_XOR_MASK = bytes.fromhex("4ed4b4d659d0beb584dcbcde51d8")


def rotl32(x: int, n: int) -> int:
x &= 0xFFFFFFFF
return ((x << n) | (x >> (32 - n))) & 0xFFFFFFFF


def quarter_round(state: list[int], a: int, b: int, c: int, d: int) -> None:
state[a] = (state[a] + state[b]) & 0xFFFFFFFF
state[d] = rotl32(state[d] ^ state[a], 16)
state[c] = (state[c] + state[d]) & 0xFFFFFFFF
state[b] = rotl32(state[b] ^ state[c], 12)
state[a] = (state[a] + state[b]) & 0xFFFFFFFF
state[d] = rotl32(state[d] ^ state[a], 8)
state[c] = (state[c] + state[d]) & 0xFFFFFFFF
state[b] = rotl32(state[b] ^ state[c], 7)


def chacha20_block(key: bytes, nonce: bytes, counter: int = 0) -> bytes:
state = list(struct.unpack("<4I", CHACHA_CONST))
state += list(struct.unpack("<8I", key))
state.append(counter & 0xFFFFFFFF)
state += list(struct.unpack("<3I", nonce))

working = state[:]
for _ in range(10):
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)

out = [(working[i] + state[i]) & 0xFFFFFFFF for i in range(16)]
return struct.pack("<16I", *out)


def chacha20_crypt(data: bytes, key: bytes = KEY_BYTES, nonce: bytes = NONCE_BYTES, counter: int = 0) -> bytes:
out = bytearray(len(data))
pos = 0
block_counter = counter
while pos < len(data):
keystream = chacha20_block(key, nonce, block_counter)
block_len = min(64, len(data) - pos)
for i in range(block_len):
out[pos + i] = data[pos + i] ^ keystream[i]
pos += block_len
block_counter += 1
return bytes(out)


def xor_enc(plain: bytes) -> bytes:
if len(plain) != 8:
raise ValueError("token must be exactly 8 bytes")

result = bytearray(plain)
for i in range(7):
result[i] ^= result[i + 1]
result[7] ^= result[0]
return bytes(result)


def xor_enc_inverse(data: bytes) -> bytes:
if len(data) != 8:
raise ValueError("xor_enc input must be exactly 8 bytes")

h = data[7] ^ data[0]
g = data[6] ^ h
f = data[5] ^ g
e = data[4] ^ f
d = data[3] ^ e
c = data[2] ^ d
b = data[1] ^ c
a = data[0] ^ b
return bytes([a, b, c, d, e, f, g, h])


def token_to_flag(token: str) -> str:
if len(token) != 8:
raise ValueError("token must be 8 ASCII characters")

token_bytes = token.encode("ascii")
xored = xor_enc(token_bytes)
ciphertext = chacha20_crypt(xored)
return f"flag{{{FLAG_PREFIX}{ciphertext.hex().upper()}}}"


def flag_to_token(flag: str) -> str:
flag = flag.strip()
prefix = f"flag{{{FLAG_PREFIX}"
if not flag.startswith(prefix) or not flag.endswith("}"):
raise ValueError(f"flag must look like {prefix}<16 hex chars>}}")

hex_part = flag[len(prefix):-1]
if len(hex_part) != 16:
raise ValueError("flag payload must be exactly 16 hex characters")

ciphertext = bytes.fromhex(hex_part)
xored = chacha20_crypt(ciphertext)
token = xor_enc_inverse(xored)
return token.decode("ascii")


def load_material_from_runtime_dump(path: Path) -> tuple[bytes, bytes]:
data = path.read_bytes()
key = data[0xED5D2:0xED5D2 + 32]
nonce = data[0xED5F3:0xED5F3 + 12]
if len(key) != 32 or len(nonce) != 12:
raise ValueError("dump file is too small")
return key, nonce


def load_material_from_original(path: Path) -> tuple[bytes, bytes]:
data = path.read_bytes()

key_ct = data[ORIG_KEY_OFFSET:ORIG_KEY_OFFSET + ORIG_KEY_SIZE]
if len(key_ct) != ORIG_KEY_SIZE:
raise ValueError("original file is too small for key blob")
key = bytes(key_ct[i] ^ KEY_XOR_MASK[i % len(KEY_XOR_MASK)] for i in range(len(key_ct)))

tail_ct = data[ORIG_TAIL_OFFSET:ORIG_TAIL_OFFSET + ORIG_TAIL_SIZE]
if len(tail_ct) != ORIG_TAIL_SIZE:
raise ValueError("original file is too small for nonce blob")
tail = bytes(a ^ b for a, b in zip(tail_ct, TAIL_XOR_MASK))
if tail[0] != 0 or tail[-1] != 0:
raise ValueError("static tail decode failed separator check")
nonce = tail[1:-1]

if len(key) != 32 or len(nonce) != 12:
raise ValueError("decoded material has unexpected length")
return key, nonce


def resolve_material(
source: str,
original_path: Path,
runtime_dump_path: Path,
) -> tuple[bytes, bytes]:
if source == "static":
return load_material_from_original(original_path)
if source == "runtime":
return load_material_from_runtime_dump(runtime_dump_path)
if source == "builtin":
return KEY_BYTES, NONCE_BYTES
raise ValueError(f"unsupported material source: {source}")


def self_test(material_source: str, original_path: Path, runtime_dump_path: Path) -> None:
key, nonce = resolve_material(material_source, original_path, runtime_dump_path)
if key != KEY_BYTES or nonce != NONCE_BYTES:
raise AssertionError("resolved material does not match expected reference bytes")

samples = [
"12345678",
"ABCDEFGH",
"a1b2c3d4",
]
for token in samples:
flag = token_to_flag_with_material(token, key, nonce)
recovered = flag_to_token_with_material(flag, key, nonce)
if recovered != token:
raise AssertionError(f"roundtrip failed: {token} -> {flag} -> {recovered}")


def token_to_flag_with_material(token: str, key: bytes, nonce: bytes) -> str:
if len(token) != 8:
raise ValueError("token must be 8 ASCII characters")

token_bytes = token.encode("ascii")
xored = xor_enc(token_bytes)
ciphertext = chacha20_crypt(xored, key=key, nonce=nonce)
return f"flag{{{FLAG_PREFIX}{ciphertext.hex().upper()}}}"


def flag_to_token_with_material(flag: str, key: bytes, nonce: bytes) -> str:
flag = flag.strip()
prefix = f"flag{{{FLAG_PREFIX}"
if not flag.startswith(prefix) or not flag.endswith("}"):
raise ValueError(f"flag must look like {prefix}<16 hex chars>}}")

hex_part = flag[len(prefix):-1]
if len(hex_part) != 16:
raise ValueError("flag payload must be exactly 16 hex characters")

ciphertext = bytes.fromhex(hex_part)
xored = chacha20_crypt(ciphertext, key=key, nonce=nonce)
token = xor_enc_inverse(xored)
return token.decode("ascii")


def main() -> None:
parser = argparse.ArgumentParser(description="sec2026 PART1 flag codec")
parser.add_argument(
"--material-source",
choices=["static", "runtime", "builtin"],
default="static",
help="where to resolve key/nonce from (default: static)",
)
parser.add_argument(
"--lib-path",
default="libsec2026.so",
help="original packed lib path for static material recovery",
)
parser.add_argument(
"--dump-path",
default="dumps/libsec2026_dump.bin",
help="runtime dump path for runtime material recovery",
)
sub = parser.add_subparsers(dest="cmd", required=True)

p_enc = sub.add_parser("encode", help="convert 8-char token to flag")
p_enc.add_argument("token", help="8 ASCII characters")

p_dec = sub.add_parser("decode", help="recover token from flag")
p_dec.add_argument("flag", help="flag{sec2026_PART1_<16 hex chars>}")

p_dump = sub.add_parser("show-material", help="read key/nonce from runtime dump")
p_dump.add_argument(
"--source",
choices=["static", "runtime", "builtin"],
default=None,
help="override global material source for this command",
)

sub.add_parser("self-test", help="run roundtrip checks")

args = parser.parse_args()
original_path = Path(args.lib_path)
runtime_dump_path = Path(args.dump_path)
material_source = args.material_source

if args.cmd == "encode":
key, nonce = resolve_material(material_source, original_path, runtime_dump_path)
print(token_to_flag_with_material(args.token, key, nonce))
return

if args.cmd == "decode":
key, nonce = resolve_material(material_source, original_path, runtime_dump_path)
print(flag_to_token_with_material(args.flag, key, nonce))
return

if args.cmd == "show-material":
source = args.source or material_source
key, nonce = resolve_material(source, original_path, runtime_dump_path)
print(f"key = {key!r}")
print(f"nonce = {nonce!r}")
print(f"key.hex = {key.hex()}")
print(f"nonce.hex = {nonce.hex()}")
return

if args.cmd == "self-test":
self_test(material_source, original_path, runtime_dump_path)
print("self-test ok")
return


if __name__ == "__main__":
main()

image-20260528210824942

image-20260528211025261