2026腾讯游戏安全技术竞赛-安卓客户端安全-初赛 Writeup

前言

拿到题目也是懵逼了,腾子你怎么换引擎了今年,那我复现的往年那么多 UE4 算什么(T_T)

只能硬着头皮做了。

担惊受怕两天也是如愿所偿,进决赛了/(ㄒoㄒ)/~~,希望决赛能好好发挥,佬们轻点打!!

image-20260416123923252

*.gdc 文件解密分析

寻找核心加密代码

上来直接解压看到 lib/ 中有libgodot_android.so ,确定是 Godot 引擎。

同时在 assets/ 中看到有 .gdc 的 GDScript 字节码,查看他的文件头发现不是标准的 GDSC 开头

image-20260411115813911

既然如此,那就直接开始在 libgodot_android.so 里找怎么解密这些 .gdc 文件

网上找了很多,也试了很多常见的字符串搜索,如 open_encrypted 是加密文件打开的方法名、open_encrypted_with_pass 是带密码的加密打开的方法名,但向上对他们进行交叉引用只招到了一些 Godot 方法注册

image-20260411120709058

最后搜索的是 PackedSourcePCK ,这个是 PCK 包读取器的 RTTI 名,定位到

image-20260411123720759

前面的15是指字符串 PackedSourcePCK 的长度,往上找交叉引用,

image-20260411124452658

在这里我们找到了 PackedSourcePCK 类的虚函数表,当进入函数 sub_3804C2C 时,完全可以确定就是这里了

image-20260411124929265

这里明显都是在做 PCK 文件头校验,这里代码太多太乱了,交给 ai,以下两个函数分析来自 GPT5.4 结合 idapro mcp进行的分析

sub_3804C2C 函数分析

sub_3804C2C 中,0x3804F08 处有一个关键条件判断
1
2
3
v36 = (*(__int64 (__fastcall **)(_QWORD *))(*a17 + 360LL))(a17);
if ( (v31 & 1) == 0 )
goto LABEL_71; // 不加密,直接读文件列表

v31 & 11 时,代码不会跳转,而是继续往下执行加密处理路径。这里的 v31 来自 PCK 文件头中的 flags 字段,bit 0 标志”是否加密”。

加密分支的关键代码段 (0x3804F10 ~ 0x3804F78)

当进入加密分支后,汇编如下:

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
; --- 0x3804F10: 保存上下文,准备分配密钥缓冲 ---
3804F10 STR X26, [SP, #0x10]
3804F14 STUR XZR, [X29, #-0x28] ; 初始化 FileAccessEncrypted 指针为 NULL
3804F18 BL sub_37DAC48 ; 创建 FileAccessEncrypted 对象

; --- 0x3804F28: 分配 32 字节缓冲区 ---
3804F28 MOV W1, #0x20 ; 0x20 = 32,要求 32 字节
3804F34 BL sub_107CBA4 ; 内存分配/扩容函数

; --- 0x3804F40 ~ 0x3804F74: 逐字节复制密钥! ---
3804F40 ADRP X26, #0x4004000 ; 加载页地址
3804F44 MOV X27, XZR ; 循环计数器 i = 0
3804F48 LDR X26, [X26, #0x488] ; 从 GOT 加载实际地址 → X26 = 0x400EDF0

; 循环开始:
3804F4C LDUR X1, [X8, #-8] ; 读取缓冲区当前大小
3804F50 CMP X1, X27 ; if (i >= size) break
3804F54 B.LE loc_3804F78 ;

3804F58 LDRB W28, [X26, X27] ; ★ W28 = byte_400EDF0[i] ← 读取密钥字节!
3804F5C ADD X0, X25, #8
3804F60 BL sub_107CBA4 ; 确保缓冲区大小足够
3804F64 LDUR X8, [X29, #-0x10]
3804F68 STRB W28, [X8, X27] ; ★ key_buf[i] = 密钥字节
3804F6C ADD X27, X27, #1 ; i++
3804F70 LDUR X8, [X29, #-0x10]
3804F74 CBNZ X8, loc_3804F4C ; 继续循环
关键发现:密钥地址 0x400EDF0

上面的汇编可以清晰看到:

  • ADRP X26, #0x4004000 + LDR X26, [X26, #0x488]:通过 GOT 间接加载,实际读取的是地址 0x4004488 处存储的指针。

  • GOT 表 0x4004488 处存储的值

    1
    0x4004488: F0 ED 00 04 00 00 00 00

    0x000000000400EDF0,确认 X26 最终指向 0x400EDF0

  • LDRB W28, [X26, X27]:从 0x400EDF0 + i 读一个字节。

  • STRB W28, [X8, X27]:写入密钥缓冲区 key_buf[i]

  • 循环 32 次(因为前面分配了 32 字节)。

在 IDA 中直接提取密钥,导出十六进制:

1
2
CE 4D F8 75 3B 59 A5 A3 9A DE 58 AC 07 EF 94 7A
3D A3 9F 2A F7 5E 32 84 D5 12 17 C0 4D 49 A0 61

即密钥十六进制表示:

1
ce4df8753b59a5a39ade58ac07ef947a3da39f2af75e3284d51217c04d49a061
为什么可以确认这是 AES 密钥而不是其他数据

证据链

(1) 上下文:这段代码在 sub_3804C2C(PCK 包打开函数)中,且只在”加密标志为 1”时才执行。

(2) 长度:恰好 32 字节 = 256 bit,这是 AES-256 的标准密钥长度。

(3) 去向:复制完成后,密钥缓冲传给了 sub_3801410(见下一节),而 sub_3801410 会验证密钥长度 必须是 32

1
2
3
4
// sub_3801410 @ 0x3801450:
v16 = *(_QWORD *)(a3 + 8);
if ( !v16 || *(_QWORD *)(v16 - 8) != 32LL )
goto LABEL_41; // 报错返回

(4) 复用:同一个 byte_400EDF0 在另一条路径 sub_3805EDC0x38061B0)中也被使用,用途相同——传给 sub_3801410 做加密文件初始化。

(5) 最终sub_3801410 内部会将这 32 字节传给 AES key schedule 函数 sub_197C210,而 sub_197C210a3 == 256 时设置 *a1 = 14(AES-256 的 14 轮),进一步确认这是 AES-256 密钥。

0x3801410 函数分析

sub_3804C2C0x3804FB0 处:

1
v42 = sub_3801410(v41, &a15, &a18, 0LL, 0LL, &a13);

其中:

  • v41 = FileAccessEncrypted 对象
  • &a15 = 底层文件访问对象指针(用于读取原始加密文件)
  • &a18 = 密钥缓冲区

sub_3801410 就是 FileAccessEncrypted 的初始化/打开函数

反编译 sub_3801410 的关键段

sub_3801410 中,当 a4 == 0(打开模式 = 读取)时,执行以下步骤:

步骤 1:验证密钥长度
1
2
3
4
// @ 0x3801450
v16 = *(_QWORD *)(a3 + 8); // 取密钥缓冲区指针
if ( !v16 || *(_QWORD *)(v16 - 8) != 32LL ) // 检查长度是否为 32
goto LABEL_41; // 不是 32 则报错
步骤 2:(可选)检查 GDEC magic
1
2
3
// @ 0x380170C
if ( *(_BYTE *)(a1 + 417) && // 如果设置了加密标志
(vf_360)(a2) != 1128612935 ) // 读取 4 字节 magic

1128612935 = 0x43454447,字节序转换为 ASCII = "GDEC"

注意:这个 GDEC magic 检查只在某些模式下触发。在本题样本中,加密文件直接以 MD5 开头,没有 GDEC 前缀——也就是说我们分析的文件走的是不带 GDEC magic 的那条路径。

步骤 3:读取文件头字段
1
2
3
4
5
6
7
8
// @ 0x3801724
(vf_408)(a2, &a14, 16); // ★ 读取 16 字节 → MD5 摘要
v42 = (vf_368)(a2); // ★ 读取 8 字节 → 明文长度(uint64 小端)
*(_QWORD *)(a1 + 384) = v42; // 保存到对象的 plain_len 字段

// 紧接着读 IV:
// 读取 16 字节到 a1+336 所指的缓冲区
(vf_408)(v44, *(_QWORD *)(a1 + 336), 16); // ★ 读取 16 字节 → IV
步骤 4:读取密文
1
2
3
4
5
6
// @ 0x38017C0 ~ 0x38017D4
v47 = *(_QWORD *)(a1 + 384); // plain_len
v49 = v47 + 15;
v50 = v49 & 0xFFFFFFFFFFFFFFF0LL; // ★ align_up(plain_len, 16)
// ...
(vf_408)(v53, *(_QWORD *)(a1 + 400), v50); // 读取 v50 字节密文
步骤 5:设置 AES 上下文 & 解密
1
2
3
4
5
sub_376EDA0(&a12);                              // AES 上下文结构体初始化(分配 0x120 字节)
sub_376EDFC(&a12, key, 256); // AES key schedule 设置(256 = AES-256)
// ...
sub_376EF68(&a12, v50, iv, ciphertext, output); // ★ 解密!(调用 sub_197DE18 with a2=0)
sub_376EDD0(&a12); // AES 上下文清理/释放

其中 sub_376EDFC 内部调用了 sub_197C210(key expansion),sub_376EF68 内部调用了 sub_197DE18(核心解密循环),参数 W1 = 0 表示解密模式。

步骤 6:MD5 校验
1
2
3
4
5
6
// @ 0x3801A2C ~ 0x3801A54
sub_3CB453C(&a11, &a12); // 计算解密后明文的 MD5
sub_3CB453C(&a10, &a14); // 包装文件头中的 MD5
v68 = sub_3CADE58(&a11, &a10); // 比较两个 MD5
if ( (v68 & 1) != 0 ) // 不匹配
return 16LL; // 返回错误码 16

还原出的文件头结构

综合以上分析,加密资源文件的格式为:

1
2
3
4
5
偏移    大小    字段            说明
0x00 16 md5[16] 明文的 MD5 摘要
0x10 8 plain_len 明文长度(uint64,小端序)
0x18 16 iv[16] AES-CFB 变种的初始化向量
0x28 N ciphertext 密文(N = align_up(plain_len, 16))

用 token.gdc 验证格式

1
2
3
4
5
6
7
8
9
10
文件: preliminary/assets/token.gdc
大小: 680 字节

头部解析:
md5 = 9590a928a742f1f0536581d451f252b5
plain_len = 0x0000000000000271 = 625
iv = c5b1680b09a2f12db436fcb1ddb69c0f

密文长度 = align_up(625, 16) = 640
文件总长 = 40(头部) + 640(密文) = 680 ✓ 完全吻合

这两个函数分析完,我获取了密文,密钥,iv,以及原文件md5值,这些所有内容,但是当我尝试通过 gdre_tools.gdc 文件进行解密和反编译时,发现始终无法成功。

这里了卡了我很久,我一开始以为是文件格式还有我不会使用工具的原因,后来我在网络上找到一些帖子,才考虑到是不是加密算法被魔改了。

以下对算法的深入分析同样来自 GPT5.4 结合 idapro mcp 给出

为什么”标准 AES-CFB”解不出正确结果

最直觉的假设

到这一步,我们已经知道:

  • 密钥:32 字节(AES-256)
  • IV:16 字节
  • 文件头有 MD5 校验

这样做出来的 MD5 不匹配

深入 0x197DE18:找到真正的逐字节解密逻辑

调用链追踪

sub_3801410 中的解密调用出发:

1
2
3
4
5
sub_376EF68(ctx, length, iv_buf, ciphertext, output)

LDR X0, [X0] ; 取 AES 上下文指针
MOV W1, WZR ; ★ W1 = 0 → 解密模式
BL sub_197DE18 ; 核心处理函数

对比另一个封装函数 sub_376EE9C(加密方向):

1
2
3
sub_376EE9C:
MOV W1, #1 ; ★ W1 = 1 → 加密模式
BL sub_197DE18

所以 sub_197DE18统一的 CFB 处理函数,参数 a2 决定方向:

  • a2 == 0:解密
  • a2 == 1:加密
反编译 sub_197DE18

完整伪代码(IDA F5)如下:

1
2
3
4
5
6
7
8
9
10
__int64 sub_197DE18(
__int64 aes_ctx, // a1: AES 上下文(含 key schedule)
unsigned int mode, // a2: 0=解密, 1=加密
__int64 length, // a3: 数据长度
__int64 *offset_ptr, // a4: 当前块内偏移的指针
__int64 state_buf, // a5: 16字节状态块(初始值 = IV)
char *input, // a6: 输入数据
_BYTE *output, // a7: 输出数据
...
)

函数首先检查 mode > 1 则返回错误,然后检查 *offset_ptr > 15 也返回错误。

解密分支的汇编(mode == 0,即 a2 == 0

0x197DF04 开始的解密循环:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
; 循环入口:检查是否需要刷新 keystream
197DF54 CBNZ X25, loc_197DF2C ; if (offset != 0) 跳过 AES-ECB

; offset == 0,需要重新加密状态块生成 keystream
197DF70 MOV X0, X24 ; aes_ctx
197DF74 MOV X1, X22 ; state_buf (输入)
197DF78 MOV X2, X22 ; state_buf (输出, in-place)
197DF7C BL sub_197CDB8 ; ★ AES_ECB_Encrypt(state_buf) → state_buf

; 逐字节处理:
197DF2C LDRB W8, [X21], #1 ; W8 = *input++ (读取一个密文字节)
197DF30 LDRB W9, [X22, X25] ; W9 = state[offset] (读取 keystream 字节)
197DF34 ADD W10, W25, #1 ; W10 = offset + 1
197DF38 SUB X23, X23, #1 ; length--

197DF3C EOR W8, W8, W25 ; ★★ W8 = ciphertext_byte ^ offset
197DF40 EOR W9, W9, W8 ; ★★ W9 = state[offset] ^ (ciphertext_byte ^ offset) = 明文
197DF44 STRB W9, [X20], #1 ; *output++ = plaintext_byte
197DF48 STRB W8, [X22, X25] ; ★★ state[offset] = ciphertext_byte ^ offset (反馈)

197DF4C AND X25, X10, #0xF ; offset = (offset + 1) & 0xF
197DF50 CBZ X23, loc_197DF84 ; if (length == 0) 跳出
逐指令翻译为伪代码
1
2
3
4
5
6
7
8
9
10
11
12
13
state = bytearray(iv)    # 16 字节状态块,初始值为 IV
offset = 0

for cipher_byte in ciphertext:
if offset == 0:
state[:] = AES_ECB_Encrypt(key, state) # 每处理完 16 字节,刷新 keystream

mixed = cipher_byte ^ offset # ★ 关键:密文字节与块内偏移异或
plain = state[offset] ^ mixed # 用 keystream 解密
state[offset] = mixed # ★ 关键:反馈的是 mixed,不是原始密文

output.append(plain)
offset = (offset + 1) & 0x0F
与标准 AES-CFB 的对比
步骤 标准 AES-CFB128 Godot 变种
读入密文字节 c c c
混合操作 mixed = c ^ offset
生成明文 p = keystream[offset] ^ c p = keystream[offset] ^ mixed
反馈到状态块 state[offset] = c state[offset] = mixed

差异只有一个地方:Godot 在每个字节处理时额外混入了 offset(块内位置 0~15)。

加密分支的反向验证

对比加密分支(mode == 1,在 0x197DEAC 开始的循环):

1
2
3
4
5
197DEAC  LDRB  W8, [X21], #1              ; W8 = *input++  (明文字节)
197DEB8 EOR W9, W9, W8 ; W9 = state[offset] ^ plaintext
197DEC0 STRB W9, [X22, X25] ; state[offset] = state[offset] ^ plaintext
197DEC4 EOR W8, W9, W25 ; W8 = (state[offset] ^ plaintext) ^ offset
197DECC STRB W8, [X20], #1 ; *output++ = 密文字节

加密伪代码:

1
2
3
mixed = state[offset] ^ plain_byte         # 先与 keystream 异或
state[offset] = mixed # 反馈 mixed
cipher_byte = mixed ^ offset # 再与 offset 异或生成密文输出

可以验证:解密是加密的精确逆过程 ✓

确认 sub_197CDB8 是 AES-ECB

sub_197CDB8 的反编译结果中可以看到:

  • 它读取 a1 指向的结构体来获取轮数和 round keys。
  • 使用了 dword_4046118dword_4046918 等查找表——这些是标准 AES 实现中的 T-table(Te0~Te3 的变体)。
  • 使用了 byte_4044F18——这是 AES 的 S-Box。
  • 函数接受 16 字节输入(a2),输出 16 字节(a3),是典型的 AES 单块加密。
  • key schedule 函数 sub_197C210 中,当传入 a3 = 256 时设置 *a1 = 14(14 轮 = AES-256)。

因此 sub_197CDB8 就是 AES-256-ECB 单块加密函数

既然如此,在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
from Crypto.Cipher import AES
import hashlib, struct

KEY_HEX = "ce4df8753b59a5a39ade58ac07ef947a3da39f2af75e3284d51217c04d49a061"

def godot_cfb_xor_decrypt(key: bytes, iv: bytes, ciphertext: bytes) -> bytes:
"""
Godot 变种 CFB 解密。
与标准 CFB 的唯一区别:每个密文字节在参与 keystream 异或和状态反馈前,
先与块内偏移 offset (0~15) 做一次异或。
"""
ecb = AES.new(key, AES.MODE_ECB)
state = bytearray(iv)
out = bytearray()
offset = 0

for enc_byte in ciphertext:
if offset == 0:
# 每处理完 16 字节,用 AES-ECB 加密当前状态块生成新的 keystream
state[:] = ecb.encrypt(bytes(state))

mixed = enc_byte ^ offset # Godot 变种的关键一步
plain = state[offset] ^ mixed # 用 keystream 解密
state[offset] = mixed # 反馈到状态块

out.append(plain)
offset = (offset + 1) & 0x0F

return bytes(out)


def decrypt_file(filepath: str, key: bytes) -> bytes:
data = open(filepath, "rb").read()

digest = data[0:16] # MD5 摘要
plain_len = struct.unpack("<Q", data[16:24])[0] # 明文长度
iv = data[24:40] # IV
ciphertext = data[40:] # 密文

plaintext = godot_cfb_xor_decrypt(key, iv, ciphertext)[:plain_len]

# 验证 MD5
if hashlib.md5(plaintext).digest() != digest:
raise ValueError("MD5 mismatch! 密钥或算法有误")
else:
print("Right!")
return plaintext

key = bytes.fromhex(KEY_HEX)
pt = decrypt_file("assets/token.gdc", key)

由于没有多少个文件,我就直接解密了,不写批量解密代码了

此时查看解密后的 .gdc 文件,可以看到已经将文件头恢复成功了

image-20260411140730854

此时使用 gdre_tools 就可以正常反编译了

image-20260411141412349

flag游戏内验证

*.gd 文件分析

首先是 token.gd

image-20260411141919890

这里是 token 的生成逻辑,可以看到是随机生成的

最关键的代码在 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
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
extends Area3D

signal collided_with(name)
var flag1Triggered: = false
var flag2Triggered: = false
var t: = 0.0
var cnt: = 0

const FLAG_PREFIX: = "sec2026_PART1_"
const ROUNDS: = 8
const TOKEN_BYTES: = 4
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):
t += delta * 2.0
if $MeshInstance3D != null and monitoring:

$MeshInstance3D.rotation.y += delta * 1.0

var height = sin(t) * 0.2
$MeshInstance3D.position.y = height

var pulse = 1.0 + sin(t * 3.0) * 0.1
$MeshInstance3D.scale = Vector3(pulse, pulse, pulse)

var body = get_overlapping_bodies()
if body.size() > 0:
if str(get_path()) == "/root/TownScene/Trigger1":
var label = get_node("/root/TownScene/Label2")
label.text = "flag{sec2026_PART0_example}"

elif 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 + "} "

结合 ai 给出分析如下

这个脚本继承自 Area3D,也就是一个 3D 区域检测节点,常用于碰撞检测、触发器。

如果碰到 Trigger1,直接显示测试 flag

如果碰到 Trigger2 则绑定了 flag 生成逻辑:

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

说明接下来我们该去分析 libsec2026.so

获得flag

因为 Godot 4 引擎的 String 内部以 char32_t (UTF-32LE) 存储, 分配在堆上。而 GDScript 编译后的常量池中, 字符串字面量作为 String 对象存在。

因此可以直接修改堆上的 char32_t 数据:

将 elif 条件中的 “/Trigger2” → “/Trigger1” (使其匹配 Trigger1 节点),同时将 if 条件中的 “/Trigger1” → “/TriggerX” (使其不再匹配任何节点),这样当我们在游戏内碰触 Trigger1 时, 代码走 elif 分支, 执行真实 flag 计算。

如下图,我碰到了下面的黄色方块,但得到了另一个 flag

image-20260411200409717

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
function patchTriggersUTF32() {

var p1 = '54 00 00 00 72 00 00 00 69 00 00 00 67 00 00 00 ' +
'67 00 00 00 65 00 00 00 72 00 00 00 31 00 00 00';
var p2 = '54 00 00 00 72 00 00 00 69 00 00 00 67 00 00 00 ' +
'67 00 00 00 65 00 00 00 72 00 00 00 32 00 00 00';

var addrs1 = []; // "/Trigger1" 的 'T' 位置
var addrs2 = []; // "/Trigger2" 的 'T' 位置

Process.enumerateRanges('rw-').forEach(function(range) {
if (range.size < 256 || range.size > 0x80000000) return;
try {
Memory.scanSync(range.base, range.size, p1).forEach(function(m) {
try {
// 检查前一个 char32 是否为 '/' (0x0000002F)
if (m.address.sub(4).readU32() === 0x2F)
addrs1.push(m.address);
} catch (e) {}
});
Memory.scanSync(range.base, range.size, p2).forEach(function(m) {
try {
if (m.address.sub(4).readU32() === 0x2F)
addrs2.push(m.address);
} catch (e) {}
});
} catch (e) {}
});

console.log(' [UTF-32] "/Trigger1": ' + addrs1.length + ' 处');
console.log(' [UTF-32] "/Trigger2": ' + addrs2.length + ' 处');

if (addrs1.length === 0 && addrs2.length === 0) return false;

// 关键: 先收集再修改, 避免 Trigger2→1 后被误识为原始 Trigger1

// Step 1: 将 elif 中的 "Trigger2" → "Trigger1"
// 使其匹配 Trigger1 节点的实际路径
addrs2.forEach(function(addr) {
// '2' 在 "Trigger2" 的第 8 个字符 (index 7), 偏移 = 7 * 4 = 28
addr.add(28).writeU32(0x31); // '2' (0x32) → '1' (0x31)
console.log(' [+] "/Trigger2" → "/Trigger1" @ ' + addr);
});

// Step 2: 将 if 中的 "Trigger1" → "TriggerX"
// 使其不再匹配任何节点, 跳过示例 flag
addrs1.forEach(function(addr) {
addr.add(28).writeU32(0x58); // '1' (0x31) → 'X' (0x58)
console.log(' [+] "/Trigger1" → "/TriggerX" @ ' + addr);
});

return true;
}

function doPatch() {
console.log('[*] 开始搜索并补丁 GDScript 字符串...');
if (patchTriggersUTF32()) {
console.log('[*] UTF-32LE 补丁成功!');
return true;
}
return false;
}

var patchDone = false;

function runOnce() {
// 1. 补丁
patchDone = doPatch();
if (patchDone) {
console.log('\n[*] ✓ 补丁已生效!');
console.log('[*] 在游戏中碰触 Trigger1 (黄色旋转物体) 即可看到真实 flag\n');
} else {
console.log('\n[!] 补丁失败, 场景可能尚未加载, 将自动重试...\n');
}

return patchDone;
}

// 启动: 延迟 5 秒等待 Godot 引擎 + 场景加载
console.log('[*] 等待游戏加载 (5秒)...');
setTimeout(function() {
if (runOnce()) return;

// 自动重试, 每 3 秒一次, 最多 10 次
var attempt = 0;
var timer = setInterval(function() {
attempt++;
if (attempt > 10) {
clearInterval(timer);
console.log('[!] 达到最大重试次数 (10)。请确认游戏已进入主场景。');
console.log('[!] 可手动调用 rpc.exports.patch() 重试');
return;
}
console.log('[*] 重试 #' + attempt + '...');
if (doPatch()) {
patchDone = true;
clearInterval(timer);
console.log('[*] ✓ 补丁成功! 碰触 Trigger1 查看 flag');
var toks = discoverToken();
toks.forEach(function(tok) {
console.log('[*] Token=' + tok + ' → ' + computeFlag(tok));
});
}
}, 3000);
}, 5000);

运行效果如下图所示

image-20260411201137316

flag生成逻辑分析

libsec2026.so 分析

首先打开看到 start 函数

image-20260412152120648

函数通过 openat + read 读取 /proc/self/auxv,获取 AT_PAGESZ(页大小)

sub_69984 是一个解压函数,将代码段的压缩数据解压,之后通过 memfd_create + write + mmap 将解压后的代码映射到内存,通过 BR X14 跳转到映射的代码中执行。

现在需要想办法得到已经被解压之后的代码,这里通过frida动态dump的方式获取,这里我直接hook dlopen 函数,只要 dlopen 返回,说明进程中已经存在了完整脱壳的 so 文件。

同时已知的一些字符串特征可以用来辅助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
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
var TARGET_LIB  = "libsec2026.so";
var RAW_NAME = "libsec2026_dump.bin";
var RELOC_NAME = "libsec2026_dump_reloc.bin";

// 自动检测可写目录
var DUMP_DIR = (function() {
// 通过 /proc/self/cmdline 获取包名,拼出应用数据目录
var candidates = [];
try {
var cmdline = new File("/proc/self/cmdline", "r").readLine();
// cmdline 以 '\0' 结尾,取第一个字段即包名
var pkgName = cmdline.split("\0")[0].trim();
if (pkgName.indexOf(".") !== -1) {
candidates.push("/data/data/" + pkgName + "/");
}
} catch(e) {}
candidates.push("/data/local/tmp/");
candidates.push("/sdcard/Download/");
candidates.push("/sdcard/");

for (var i = 0; i < candidates.length; i++) {
try {
var testPath = candidates[i] + ".frida_write_test";
var f = new File(testPath, "wb");
f.write(new ArrayBuffer(1));
f.flush();
f.close();
console.log("[*] 可写目录: " + candidates[i]);
return candidates[i];
} catch(e) {}
}
console.log("[!] 警告: 未找到可写目录,默认使用 /data/local/tmp/");
return "/data/local/tmp/";
})();
// 脱壳后的 ELF 虚拟地址范围约 0x0 ~ 0xF4000(999KB),用于筛选候选区域
var MIN_ELF_SIZE = 0x80000; // 最少 512KB 才认为是目标

// 已知特征:修改后的 ChaCha20 常量,用于二次验证
var CHACHA_MAGIC = [0x66, 0x78, 0x70, 0x61, 0x6f, 0x64, 0x20, 0x33,
0x31, 0x2d, 0x62, 0x79, 0x73, 0x65, 0x20, 0x6b];
// "fxpaod 31-byse k"

// ========================== 辅助函数 ============================

/**
* 在内存中搜索已知字节序列
*/
function containsMagic(base, size, pattern) {
try {
var result = Memory.scanSync(base, size, patternToHex(pattern));
return result.length > 0;
} catch (e) {
return false;
}
}

function patternToHex(arr) {
return arr.map(function(b) { return ('0' + b.toString(16)).slice(-2); }).join(' ');
}

/**
* 将 ArrayBuffer 写入文件
*/
function writeFile(path, buf) {
try {
var f = new File(path, "wb");
f.write(buf);
f.flush();
f.close();
} catch(e) {
// 如果目标路径写失败,回退到 /sdcard/
var fallback = "/sdcard/" + path.split("/").pop();
console.log("[!] 写入 " + path + " 失败: " + e);
console.log("[*] 回退写入 " + fallback);
var f2 = new File(fallback, "wb");
f2.write(buf);
f2.flush();
f2.close();
}
}

/**
* 从 ELF header 解析所有 PT_LOAD 段,计算虚拟地址范围
* 返回: { totalSize: number, loads: [{vaddr, memsz, filesz, offset, flags}] }
*/
function parseElfLoads(base) {
// Elf64_Ehdr
var e_phoff = parseInt(base.add(32).readU64().toString());
var e_phentsize = base.add(54).readU16();
var e_phnum = base.add(56).readU16();

var loads = [];
var maxEnd = 0;

for (var i = 0; i < e_phnum; i++) {
var phdr = base.add(e_phoff + i * e_phentsize);
var p_type = phdr.readU32();

if (p_type === 1) { // PT_LOAD
var p_offset = parseInt(phdr.add(8).readU64().toString());
var p_vaddr = parseInt(phdr.add(16).readU64().toString());
var p_filesz = parseInt(phdr.add(32).readU64().toString());
var p_memsz = parseInt(phdr.add(40).readU64().toString());
var p_flags = phdr.add(4).readU32();

loads.push({
vaddr: p_vaddr,
memsz: p_memsz,
filesz: p_filesz,
offset: p_offset,
flags: p_flags
});

var end = p_vaddr + p_memsz;
if (end > maxEnd) maxEnd = end;
}
}

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

/**
* 解析 PT_DYNAMIC 段,获取 DT_RELA / DT_RELASZ
*/
function findRelaDynamic(base) {
var e_phoff = parseInt(base.add(32).readU64().toString());
var e_phentsize = base.add(54).readU16();
var e_phnum = base.add(56).readU16();

for (var i = 0; i < e_phnum; i++) {
var phdr = base.add(e_phoff + i * e_phentsize);
if (phdr.readU32() !== 2) continue; // PT_DYNAMIC

var p_vaddr = parseInt(phdr.add(16).readU64().toString());
var p_memsz = parseInt(phdr.add(40).readU64().toString());

var relaAddr = 0, relaSize = 0, relaEnt = 24;

for (var j = 0; j + 16 <= p_memsz; j += 16) {
var d_tag = parseInt(base.add(p_vaddr + j).readS64().toString());
var d_val = parseInt(base.add(p_vaddr + j + 8).readU64().toString());

if (d_tag === 0) break; // DT_NULL
if (d_tag === 7) relaAddr = d_val; // DT_RELA
if (d_tag === 8) relaSize = d_val; // DT_RELASZ
if (d_tag === 9) relaEnt = d_val; // DT_RELAENT
}

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

return null;
}

// ====================== 核心 dump 逻辑 ==========================

/**
* 扫描进程内存,查找脱壳后的 ELF 镜像
*
* 判断标准:
* 1. 内存区域 ≥ MIN_ELF_SIZE
* 2. 起始地址为 ELF64 magic (7f 45 4c 46 02)
* 3. 不属于原始 libsec2026.so 的模块范围
* 4. 不属于系统库
* 5. (可选)包含 "fxpaod 31-byse k" 特征串
*/
function findUnpackedElf() {
console.log("[*] 扫描内存,查找脱壳后的 ELF 镜像...");

var origMod = Process.findModuleByName(TARGET_LIB);
var origBase = origMod ? origMod.base : ptr(0);
var origEnd = origMod ? origMod.base.add(origMod.size) : ptr(0);

var candidates = [];

// 遍历所有可读区域(ELF header 所在页至少是 r--)
var allRanges = Process.enumerateRangesSync('r--');
for (var ri = 0; ri < allRanges.length; ri++) {
var range = allRanges[ri];
if (range.size < MIN_ELF_SIZE) continue;

// 排除原始 SO 模块
if (origMod &&
range.base.compare(origBase) >= 0 &&
range.base.compare(origEnd) < 0) continue;

// 排除系统路径
if (range.file && range.file.path) {
var p = range.file.path;
if (p.indexOf('/system/') !== -1) continue;
if (p.indexOf('/vendor/') !== -1) continue;
if (p.indexOf('/apex/') !== -1) continue;
if (p.indexOf('/dev/') !== -1) continue;
if (p.indexOf('dalvik') !== -1) continue;
if (p.indexOf('base.apk') !== -1) continue;
if (p.indexOf('libgodot_android') !== -1) continue;
if (p.indexOf('libc++_shared') !== -1) continue;
if (p.indexOf('libc.so') !== -1) continue;
if (p.indexOf('gralloc') !== -1) continue;
}

// 检查 ELF64 magic
try {
var magic = range.base.readU32();
if (magic !== 0x464c457f) continue; // \x7fELF
if (range.base.add(4).readU8() !== 2) continue; // ELFCLASS64
} catch (e) {
continue;
}

var path = (range.file && range.file.path) ? range.file.path : "(anonymous/memfd)";
console.log("[+] 候选 ELF64:");
console.log(" 基址: " + range.base);
console.log(" 区域大小: 0x" + range.size.toString(16));
console.log(" 权限: " + range.protection);
console.log(" 来源: " + path);

candidates.push(range);
}

return candidates;
}

/**
* 逐区域读取,组装完整 ELF 镜像
* 对于不可读的空洞区域自动填零
*/
function readElfImage(base, totalSize) {
// 先尝试直接一次性读取
try {
return base.readByteArray(totalSize);
} catch (e) {
console.log("[*] 单次读取失败,改用分区域拼接...");
}

// 收集 base ~ base+totalSize 范围内所有可读区域(r--, r-x, rw-, rwx 等)
var buf = new ArrayBuffer(totalSize);
var dest = new Uint8Array(buf);
var end = base.add(totalSize);

// 'r--' 在 Frida 中匹配所有 readable 区域(包括 r-x, rw-, rwx)
// 但为确保兼容,分别搜集多种权限
var perms = ['r--', 'r-x', 'rw-', 'rwx'];
var seen = {}; // 避免重复区域

var ranges = Process.enumerateRangesSync('r--');
// 补充其他权限的区域(避免遗漏纯 r-x 段)
var extraPerms = ['r-x', 'rw-'];
for (var pi = 0; pi < extraPerms.length; pi++) {
var extra = Process.enumerateRangesSync(extraPerms[pi]);
for (var ei = 0; ei < extra.length; ei++) {
ranges.push(extra[ei]);
}
}
// 去重(按基址)
var seen = {};
var uniqueRanges = [];
for (var ui = 0; ui < ranges.length; ui++) {
var key = ranges[ui].base.toString();
if (!seen[key]) {
seen[key] = true;
uniqueRanges.push(ranges[ui]);
}
}
ranges = uniqueRanges;

for (var ri = 0; ri < ranges.length; ri++) {
var r = ranges[ri];
var rStart = r.base;
var rEnd = rStart.add(r.size);

// 区域是否与目标范围重叠?
if (rEnd.compare(base) <= 0 || rStart.compare(end) >= 0) continue;

// 计算重叠部分
var copyStart = rStart.compare(base) > 0 ? rStart : base;
var copyEnd = rEnd.compare(end) < 0 ? rEnd : end;
var offsetInBuf = parseInt(copyStart.sub(base).toString());
var copySize = parseInt(copyEnd.sub(copyStart).toString());

if (copySize <= 0) continue;

try {
var rawBytes = copyStart.readByteArray(copySize);
if (rawBytes) {
var chunk = new Uint8Array(rawBytes);
dest.set(chunk, offsetInBuf);
}
} catch (e) {
console.log(" [!] 读取失败 @ " + copyStart + " (size 0x" +
copySize.toString(16) + "): " + e);
}
}

return buf;
}

/**
* 对 dump 出的 ArrayBuffer 应用 R_AARCH64_RELATIVE 重定位
* 重定位后 RTTI / vtable / GOT 区域将填充正确的指针值
*/
function applyRelocations(buf, base) {
var rela = findRelaDynamic(base);
if (!rela || rela.relaAddr === 0 || rela.relaSize === 0) {
console.log("[!] 未找到 RELA 段,跳过重定位");
return;
}

var nEntries = Math.floor(rela.relaSize / rela.relaEnt);
console.log("[*] 应用重定位: DT_RELA @ 0x" + rela.relaAddr.toString(16) +
", " + nEntries + " 条目");

var view = new DataView(buf);
var applied = 0;

for (var i = 0; i < nEntries; i++) {
var entryBase = base.add(rela.relaAddr + i * rela.relaEnt);

var r_offset = parseInt(entryBase.readU64().toString());
var r_info = parseInt(entryBase.add(8).readU64().toString());
var r_addend = parseInt(entryBase.add(16).readS64().toString());

var r_type = r_info & 0xFFFFFFFF;

if (r_type !== 0x403) continue; // 只处理 R_AARCH64_RELATIVE

if (r_offset + 7 >= buf.byteLength) continue;

// *r_offset = base_addr(0) + r_addend
var lo = r_addend & 0xFFFFFFFF;
var hi = 0;
if (r_addend >= 0) {
hi = Math.floor(r_addend / 0x100000000);
} else {
// 负 addend(极少见,但安全处理)
var unsigned = r_addend + 0x10000000000000000;
lo = unsigned & 0xFFFFFFFF;
hi = Math.floor(unsigned / 0x100000000) & 0xFFFFFFFF;
}

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

console.log("[+] 已应用 " + applied + " 条 R_AARCH64_RELATIVE 重定位");
}

// ========================= 主入口 ===============================

function doDump() {
// ========== 策略 1:直接从模块基址 dump ==========
// init_array 执行后,.text 已被原地解密(mprotect RW → 解密 → mprotect RX)
// 模块基址就是解密后的 ELF,无需扫描匿名区域
var origMod = Process.findModuleByName(TARGET_LIB);
if (origMod) {
var base = origMod.base;
console.log("[*] 模块基址: " + base + " (size 0x" + origMod.size.toString(16) + ")");

// 确认是 ELF64
try {
if (base.readU32() !== 0x464c457f || base.add(4).readU8() !== 2) {
console.log("[!] 模块基址不是 ELF64 header,跳过策略 1");
doDumpScan();
return;
}
} catch (e) {
console.log("[!] 无法读取模块基址: " + e);
doDumpScan();
return;
}

// 解析 LOAD 段
var elfInfo;
try {
elfInfo = parseElfLoads(base);
} catch (e) {
console.log("[!] 解析 ELF header 失败: " + e + ", 用模块大小替代");
elfInfo = { totalSize: origMod.size, loads: [] };
}

var totalSize = elfInfo.totalSize;
console.log("[*] ELF 虚拟范围: 0x0 ~ 0x" + totalSize.toString(16) +
" (" + totalSize + " bytes)");

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

// 验证特征
var verified = false;
try {
verified = containsMagic(base, Math.min(origMod.size, totalSize), CHACHA_MAGIC);
} catch(e) {}
// 也搜索后续 LOAD 段
if (!verified) {
for (var si = 0; si < elfInfo.loads.length && !verified; si++) {
var seg = elfInfo.loads[si];
try {
verified = containsMagic(base.add(seg.vaddr),
Math.min(seg.memsz, seg.filesz),
CHACHA_MAGIC);
} catch(e) {}
}
}

if (verified) {
console.log("[+] 特征验证: 找到 \"fxpaod 31-byse k\" ✓");
} else {
console.log("[*] 特征验证: 未找到 ChaCha 特征串(可能在运行时生成)");
}

// 检查 extension_init 区域是否已解密(应该是有效 ARM64 指令)
try {
// extension_init 在 vaddr 0x56D50
var extInit = base.add(0x56D50);
var insn0 = extInit.readU32();
var insn1 = extInit.add(4).readU32();
console.log("[*] extension_init @ " + extInit + ":");
console.log(" insn[0] = 0x" + ("00000000" + insn0.toString(16)).slice(-8));
console.log(" insn[1] = 0x" + ("00000000" + insn1.toString(16)).slice(-8));
// NOP = 0xd503201f, STP/SUB 常见开头
if (insn0 === 0xd503201f || (insn0 >>> 22) === 0x2d2 ||
(insn0 >>> 23) === 0x1a9 || (insn0 >>> 24) === 0xd1) {
console.log(" → 有效 ARM64 指令 ✓ (.text 已解密)");
} else {
console.log(" → 疑似仍为加密数据(但继续 dump)");
}
} catch (e) {
console.log("[*] 无法读取 extension_init 区域: " + e);
}

doDumpFromBase(base, totalSize, elfInfo);
return;
}

// ========== 策略 2:扫描匿名区域 ==========
console.log("[!] 未找到模块 " + TARGET_LIB + ",改用扫描模式...");
doDumpScan();
}

/**
* 从已知基址 dump
*/
function doDumpFromBase(base, totalSize, elfInfo) {
console.log("[*] 读取 ELF 镜像...");
var rawBuf = readElfImage(base, totalSize);

// 写入原始 dump
var rawPath = DUMP_DIR + RAW_NAME;
writeFile(rawPath, rawBuf);
console.log("[+] 原始 dump: " + rawPath + " (" + totalSize + " bytes)");

// 应用重定位
try {
var relocBuf = rawBuf.slice(0);
applyRelocations(relocBuf, base);
var relocPath = DUMP_DIR + RELOC_NAME;
writeFile(relocPath, relocBuf);
console.log("[+] 重定位 dump: " + relocPath);
} catch (e) {
console.log("[!] 重定位处理失败: " + e);
}

printDone();
}

/**
* 扫描模式(fallback)
*/
function doDumpScan() {
var candidates = findUnpackedElf();

// 先以特征串筛选
var verified = [];
for (var ci = 0; ci < candidates.length; ci++) {
var c = candidates[ci];
try {
if (containsMagic(c.base, c.size, CHACHA_MAGIC)) {
verified.push(c);
}
} catch(e) {}
}

if (verified.length > 0) {
candidates = verified;
console.log("[+] 通过特征验证的候选: " + verified.length);
}

if (candidates.length === 0) {
console.log("[-] 未找到任何候选 ELF。");
console.log("[*] dump 所有大型匿名可执行区域作为参考...");
var idx = 0;
var rxRanges = Process.enumerateRangesSync('r-x');
for (var ri = 0; ri < rxRanges.length; ri++) {
var r = rxRanges[ri];
if (r.size >= 0x10000 &&
(!r.file || !r.file.path || r.file.path === "")) {
var outPath = DUMP_DIR + "anon_rx_" + idx + ".bin";
console.log(" dump " + r.base + " size=0x" + r.size.toString(16));
try {
writeFile(outPath, r.base.readByteArray(r.size));
console.log(" → " + outPath);
} catch (e) {
console.log(" 失败: " + e);
}
idx++;
}
}
return;
}

candidates.sort(function (a, b) { return b.size - a.size; });
var target = candidates[0];
var base = target.base;
console.log("\n[*] 选中目标: " + base);

var elfInfo;
try {
elfInfo = parseElfLoads(base);
} catch (e) {
elfInfo = { totalSize: target.size, loads: [] };
}

doDumpFromBase(base, elfInfo.totalSize, elfInfo);
}

function printDone() {
console.log("\n======================================");
console.log(" DUMP 完成!");
console.log("======================================");
}

// ====================== 时机控制 ================================

console.log("╔═══════════════════════════════════════╗");
console.log("║ libsec2026.so 内存 Dump 工具 ║");
console.log("╚═══════════════════════════════════════╝");

// 检查目标是否已加载(attach 模式)
var mod = Process.findModuleByName(TARGET_LIB);
if (mod) {
console.log("[*] " + TARGET_LIB + " 已加载 @ " + mod.base +
" (size 0x" + mod.size.toString(16) + ")");
console.log("[*] init_array 已执行完毕,直接开始 dump...\n");
doDump();
} else {
// spawn 模式:hook dlopen,等待目标 SO 加载
console.log("[*] " + TARGET_LIB + " 尚未加载,hook dlopen 等待...\n");

var dlopenNames = ["android_dlopen_ext", "dlopen"];
var hooked = false;

dlopenNames.forEach(function (name) {
var addr = Module.findExportByName(null, name);
if (!addr || hooked) return;

Interceptor.attach(addr, {
onEnter: function (args) {
if (args[0].isNull()) {
this.libpath = null;
return;
}
try {
this.libpath = args[0].readUtf8String();
} catch (e) {
this.libpath = null;
}
},
onReturn: function (retval) {
if (!this.libpath) return;
if (this.libpath.indexOf("libsec2026") === -1) return;

console.log("[*] " + name + "(\"" + this.libpath + "\") 返回");
console.log("[*] .init_array 已执行 → 脱壳完成\n");

// 延迟一小段时间确保所有初始化完成
setTimeout(doDump, 100);
}
});

console.log("[*] 已 hook " + name + " @ " + addr);
hooked = true;
});

if (!hooked) {
console.log("[!] 未找到 dlopen,尝试轮询检测...");
var pollId = setInterval(function () {
var m = Process.findModuleByName(TARGET_LIB);
if (m) {
clearInterval(pollId);
console.log("[*] 检测到 " + TARGET_LIB + " 已加载\n");
setTimeout(doDump, 200);
}
}, 500);
}
}

dump.so 分析

由于代码太过于混乱,实在不好分析,只好交给ai

障碍:跳板混淆

所有函数调用都通过间接跳板 (trampoline) 实现。每个跳板是一段固定模式的代码:

1
2
3
4
5
6
7
8
9
ADR   X8, loc_XXXX        ; 加载一个地址
MVN W9, W8 ; 计算 ~X8
AND X10, X8, #0x20 ; X8 & 0x20
ORR X9, X9, #~0x20 ; X9 |= 0xFFFFFFFFFFFFFFDF
ADD X10, X8, X10 ; X10 = X8 + (X8 & 0x20)
ADD X9, X10, X9 ; X9 = X10 + X9
ADD X9, X9, #0x21 ; X9 += 0x21
...
BR X9 ; 跳转到计算出的地址

规律:无论中间计算如何,最终跳转目标始终为 ADR 操作数 + 0x20

例如:

  • ADR X8, 0x5B7F8 → 实际跳转到 0x5B818(ChaCha20 init)
  • ADR X8, 0x5BB34 → 实际跳转到 0x5BB54(quarter_round)
  • ADR X8, 0x5B930 → 实际跳转到 0x5B950(encrypt)

应对方法:在逆向时,每次遇到 ADR X8, addr; ... BR 模式,直接去看 addr + 0x20 处的代码。

字符串搜索 — 找到突破口

在 IDA 中 Shift+F12 打开 Strings 窗口搜索:

地址 字符串 意义
0x19ED8 fxpaod 31-byse k ★ 魔改的 ChaCha20 常量
0x19EC8 GameExtension RTTI 类名
0xD48 extension_init GDExtension 初始化函数

疑点 ④"fxpaod 31-byse k" 与标准 ChaCha20 常量 "expand 32-byte k" 高度相似,仅若干字符不同。这是识别算法的关键线索。

常量对比 — 确认 ChaCha20

将两个 16 字节常量按小端序 u32 解析:

1
2
3
4
5
标准: "expand 32-byte k"
0x61707865 0x3320646e 0x79622d32 0x6b206574

魔改: "fxpaod 31-byse k"
0x61707866 0x3320646f 0x79622d31 0x6b206573

差异模式:末尾字节分别为 5→6, e→f, 2→1, 4→3。这不影响算法结构,只影响初始状态。

从常量出发交叉引用

byte_19ED8X 查找交叉引用 → 被 sub_5B818 引用。

1
2
3
4
5
6
; sub_5B818 — ChaCha20 初始化函数
ADR X2, byte_19ED8 ; 常量 "fxpaod 31-byse k"
MOV X1, #-1
MOV W3, #0x10 ; 16 字节
ADR X8, sub_5B7C4 ; → 跳转到 memcpy 型函数
BLR X8 ; 将常量复制到 state[0..3]

然后连续 8 次调用 off_EBEF8(实际为 LDR W0, [X0]——即小端读 u32)将密钥写入 state[4..11]

1
2
3
4
BLR   X23                  ; read_le_u32(key+0)
STR W0, [X19, #0x10] ; state[4] = key_word[0]
... ; 重复 8 次
STP W0, W21, [X19,#0x2C] ; state[11]=key[7], state[12]=counter

接着 3 次读 Nonce 写入 state[13..15]

1
2
3
STR   W0, [X19, #0x34]     ; state[13] = nonce_word[0]
STR W0, [X19, #0x38] ; state[14] = nonce_word[1]
STR W0, [X19, #0x3C] ; state[15] = nonce_word[2]

最后设置 [X19, #0x80] = 0x40(内部缓冲区用完标记,强制首次调用时生成新 keystream block)。

标准 ChaCha20 状态矩阵得到确认

1
2
3
4
state[ 0.. 3] = 常量 ("fxpaod 31-byse k")
state[ 4..11] = 密钥 (32 字节, 8 个 u32 LE)
state[12] = 计数器 (初始 0)
state[13..15] = Nonce (12 字节, 3 个 u32 LE)
quarter_round (sub_5BB54)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
; 参数: X0=&state[a], X1=&state[b], X2=&state[c], X3=&state[d]

; a += b; d ^= a; d = rotl(d, 16)
LDR W8, [X1] ; W8 = b
LDR W9, [X0] ; W9 = a
ADD W8, W9, W8 ; a = a + b
STR W8, [X0]
LDR W9, [X3]
EOR W0, W9, W8 ; d = d ^ a
STR W0, [X3]
BLR X23 ; rotl(W0, 16) ; 调用 rotl32 函数
STR W0, [X3] ; d = rotl(d, 16)

; c += d; b ^= c; b = rotl(b, 12)
...MOV W1, #0xC... ; 旋转量 12

; a += b; d ^= a; d = rotl(d, 8)
...MOV W1, #8... ; 旋转量 8

; c += d; b ^= c; b = rotl(b, 7)
...MOV W1, #7... ; 旋转量 7

旋转量为 16, 12, 8, 7 — 完全标准。

rotl32 (sub_5B648 → 0x5B764)
1
2
3
4
5
6
7
8
9
10
; W0 = value, W1 = shift_amount
NEG W8, W1 ; W8 = -shift = (32 - shift) mod 32
NEG W9, W1, LSL#1 ; W9 = -(shift*2)
EOR W8, W8, #0x20 ; W8 ^= 32
AND W9, W9, #0x40 ; W9 &= 64
ADD W8, W9, W8 ; W8 = right_shift_amount
LSL W9, W0, W1 ; W9 = value << shift
LSR W8, W0, W8 ; W8 = value >> right_shift
ORR W0, W8, W9 ; result = (value >> rs) | (value << shift)
RET

这是一个带混淆的 rotl32 实现,数学上等价于 (x << n) | (x >> (32-n))

column_round / diagonal_round

column_round (sub_5BF6C)

调用 参数偏移 (×4=索引) 实际索引
第 1 次 +0x00, +0x10, +0x20, +0x30 QR(0, 4, 8, 12)
第 2 次 +0x04, +0x14, +0x24, +0x34 QR(1, 5, 9, 13)
第 3 次 +0x08, +0x18, +0x28, +0x38 QR(2, 6, 10, 14)
第 4 次 +0x0C, +0x1C, +0x2C, +0x3C QR(3, 7, 11, 15)

diagonal_round (sub_5BC48)

调用 参数偏移 实际索引
第 1 次 +0x00, +0x14, +0x28, +0x3C QR(0, 5, 10, 15)
第 2 次 +0x04, +0x18, +0x2C, +0x30 QR(1, 6, 11, 12)
第 3 次 +0x08, +0x1C, +0x20, +0x34 QR(2, 7, 8, 13)
第 4 次 +0x0C, +0x10, +0x24, +0x38 QR(3, 4, 9, 14)

完全标准的 ChaCha20 column + diagonal round 索引。

block 函数 (sub_5BCF0)
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
; 1. 复制 state → working (栈上 64 字节)
MOV X0, SP ; dst = working (栈)
MOV W1, #0x40 ; 64 字节
MOV X2, X19 ; src = state
MOV W3, #0x40
BLR sub_5B7C4 ; memcpy(working, state, 64)

; 2. 执行 10 次 double-round
BLR X21 ; column_round ─┐
BLR X22 ; diagonal_round │ × 10 次
; (重复 10 对) ─┘

; 3. NEON 向量加法: output = working + state
LDP Q0, Q1, [X19] ; state[0..7]
LDP Q2, Q3, [SP] ; working[0..7]
ADD V0.4S, V2.4S, V0.4S ; output[0..3] = working[0..3] + state[0..3]
ADD V1.4S, V3.4S, V1.4S ; output[4..7] = working[4..7] + state[4..7]
; ... 对 state[8..15] 同理

; 4. 将 16 个 u32 以小端序写回输出 (off_EBF00 = STR W1,[X0])
STR → [X19+0x40] ; output[0]
STR → [X19+0x44] ; output[1]
... ; 共 16 个

; 5. 递增计数器
LDR W8, [X19, #0x30] ; counter
ADD W8, W8, #1
STR W8, [X19, #0x30]

标准 ChaCha20 block function:copy → 20 rounds → add → write → counter++

encrypt 函数 (sub_5B950)
1
2
3
4
5
6
; 参数: X0=ctx, X1=input, X2=output, X3=length
; 核心 XOR 循环:
LDRB W13, [X12], #1 ; 读 input 字节
LDRB W14, [X8], #1 ; 读 keystream 字节
EOR W13, W14, W13 ; 异或
STRB W13, [X11], #1 ; 写 output 字节

对于 ≥32 字节的数据,使用 NEON 向量化 XOR 加速:

1
2
3
4
5
LDP   Q0, Q1, [X13, #-0x10]  ; 读 32 字节 input
LDP Q2, Q3, [X12, #-0x10] ; 读 32 字节 keystream
EOR V0.16B, V2.16B, V0.16B ; 向量异或
EOR V1.16B, V3.16B, V1.16B
STP Q0, Q1, [X11, #-0x10] ; 写 32 字节 output
Process 函数 (sub_4E548)

这是 GameExtension.Process() 的 native 实现:

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
; 1. 获取互斥锁
ADR X0, unk_ED7C0
BLR [off_EBD68] ; lock

; 2. 分配 ChaCha20 上下文 (0x88 字节)
BLR [off_EBD78] ; alloc_state() → X21

; 3. 初始化 ChaCha20
ADR X1, unk_ED5D2 ; KEY (32 字节)
ADR X2, unk_ED5F3 ; NONCE (12 字节)
MOV W3, WZR ; counter = 0
BLR [off_EBD80] ; chacha20_init(ctx, key, nonce, 0)

; 4. 获取 PackedByteArray 数据指针
MOV X0, X20 ; PackedByteArray 参数
BLR [off_EBD88] ; get_data_ptr → X22 (数据指针)

; 5. 初始化空字符串
ADR X1, byte_11DC6 ; "" (空字符串)
BLR [off_EBCD0] ; String::init(result, "")

; 6. 分配输出缓冲区
BL 0x88C80 ; PackedByteArray::size() → 8
; ... chacha20_encrypt(ctx, input, output, 8) ...

; 7. 循环格式化为十六进制
loop:
LDR X8, [qword_ED7E8] ; 输出缓冲区指针
LDRB W4, [X21, X8] ; 读一个密文字节
; 调用 sprintf(buf, "%02X", byte)
ADR X4, unk_ED600 ; 格式串 "%02X"
BL sub_4C6D4 ; snprintf
BL sub_64DFC ; String::append
ADD X21, X21, #1
B loop

; 8. 返回十六进制字符串

关键确认

  • 密钥地址:0xED5D2
  • Nonce 地址:0xED5F3
  • 格式化:sprintf(buf, "%02X", byte)大写十六进制

密钥为Th1s ls n0t a rea1 key!!@sec2026

Nonce为 012345678901

疑惑的点

到这里其实已经分析完了,但这里有一个疑惑的点,我dump出的so文件在地址 0xED5D2 可以看到这里的值是正常的

image-20260412165244767

而在ida中看到的值却是不正常的

image-20260412165357602

这里卡了我很久,直到我在010中查看的时候才发现密钥是不一样的

同时在 libsec2026.so 中,可以看到这里的值是乱码,猜测是在解压的代码中有覆盖操作

image-20260412165537936

我抱着求证的心态尝试去hook chacha20_init

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

// chacha20_init(ChaCha20State *state, uint8_t *key, uint8_t *nonce, uint32_t counter)
// 位于 libsec2026.so 偏移 0x5B818
// ARM64 ABI: X0=state, X1=key(32B), X2=nonce(12B), W3=counter

var CHACHA20_INIT_OFFSET = 0x5B818;

function hookChacha20Init() {
var mod = Process.findModuleByName("libsec2026.so");
if (!mod) {
console.log("[-] libsec2026.so 未加载");
return;
}

var funcAddr = mod.base.add(CHACHA20_INIT_OFFSET);
console.log("[*] chacha20_init @ " + funcAddr);

Interceptor.attach(funcAddr, {
onEnter: function(args) {
var state = args[0];
var key = args[1];
var nonce = args[2];
var counter = args[3].toInt32();

console.log("\n════════ chacha20_init 被调用 ════════");
console.log(" state = " + state);
console.log(" key = " + key);
console.log(" nonce = " + nonce);
console.log(" counter = " + counter);

// Dump key (32 bytes)
console.log("\n [Key - 32 bytes]");
console.log(hexdump(key, { length: 32, ansi: true }));

try {
var keyBytes = key.readByteArray(32);
var ka = new Uint8Array(keyBytes);
var keyPrintable = true;
var keyStr = "";
for (var i = 0; i < ka.length; i++) {
if (ka[i] < 0x20 || ka[i] >= 0x7f) keyPrintable = false;
keyStr += String.fromCharCode(ka[i]);
}
if (keyPrintable) {
console.log(" Key ASCII: " + keyStr);
}
} catch(e) {}

// Dump nonce (12 bytes)
console.log("\n [Nonce - 12 bytes]");
console.log(hexdump(nonce, { length: 12, ansi: true }));

try {
var nonceBytes = nonce.readByteArray(12);
var na = new Uint8Array(nonceBytes);
var noncePrintable = true;
var nonceStr = "";
for (var i = 0; i < na.length; i++) {
if (na[i] < 0x20 || na[i] >= 0x7f) noncePrintable = false;
nonceStr += String.fromCharCode(na[i]);
}
if (noncePrintable) {
console.log(" Nonce ASCII: " + nonceStr);
}
} catch(e) {}

// 打印调用栈
console.log("\n [Backtrace]");
console.log(" " + Thread.backtrace(this.context, Backtracer.FUZZY)
.map(function(addr) {
var m = Process.findModuleByAddress(addr);
if (m) {
return m.name + "!0x" + addr.sub(m.base).toString(16);
}
return addr.toString();
}).join("\n "));

console.log("══════════════════════════════════════\n");
}
});

console.log("[+] chacha20_init hook 已安装");
}

var mod = Process.findModuleByName("libsec2026.so");
if (mod) {
hookChacha20Init();
} else {
var names = ["android_dlopen_ext", "dlopen"];
var hooked = false;
names.forEach(function(name) {
var addr = Module.findExportByName(null, name);
if (!addr || hooked) return;
Interceptor.attach(addr, {
onEnter: function(args) {
try { this.lib = args[0].readUtf8String(); } catch(e) { this.lib = null; }
},
onReturn: function() {
if (this.lib && this.lib.indexOf("libsec2026") !== -1) {
setTimeout(hookChacha20Init, 200);
}
}
});
hooked = true;
});
console.log("[*] 等待 libsec2026.so 加载...");
}

注意这个脚本需要在先跑起上获得flag的js代码后再 attach 上去,然后操控小车撞上黄色方块即可

image-20260412170655288

果然传入的是这个 key 和 nonce

算法实现

flag生成算法与生成逆算法python实现如下

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
import struct
import sys

# Modified constants: "fxpaod 31-byse k" (instead of "expand 32-byte k")
CONSTANTS = [0x61707866, 0x3320646f, 0x79622d31, 0x6b206573]

# Key: 32 bytes at runtime vaddr 0xed5d2 (overwritten by outer SO packer)
# Original .data had 1a367ed4... but at runtime it's "Th1s ls n0t a rea1 key!!@sec2026"
KEY_BYTES = b"Th1s ls n0t a rea1 key!!@sec2026"

# Nonce: 12 bytes at runtime vaddr 0xed5f3 (overwritten by outer SO packer)
# Original .data had e485e46a... but at runtime it's "012345678901"
NONCE_BYTES = b"012345678901"

def u32(x):
return x & 0xFFFFFFFF

def rotl32(x, n):
return u32((x << n) | (x >> (32 - n)))

def quarter_round(state, a, b, c, d):
"""Standard ChaCha20 quarter round with rotations 16, 12, 8, 7"""
state[a] = u32(state[a] + state[b]); state[d] ^= state[a]; state[d] = rotl32(state[d], 16)
state[c] = u32(state[c] + state[d]); state[b] ^= state[c]; state[b] = rotl32(state[b], 12)
state[a] = u32(state[a] + state[b]); state[d] ^= state[a]; state[d] = rotl32(state[d], 8)
state[c] = u32(state[c] + state[d]); state[b] ^= state[c]; state[b] = rotl32(state[b], 7)

def chacha20_block(state):
"""Generate one 64-byte keystream block"""
working = list(state)
for _ in range(10): # 20 rounds = 10 double-rounds
# Column rounds
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)
# Diagonal rounds
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)
# Add original state
output = [u32(working[i] + state[i]) for i in range(16)]
return output

def chacha20_init(key, nonce, counter=0):
"""Initialize ChaCha20 state"""
state = list(CONSTANTS)
# Key: 8 x u32 LE
state.extend(struct.unpack('<8I', key))
# Counter
state.append(counter)
# Nonce: 3 x u32 LE
state.extend(struct.unpack('<3I', nonce))
return state

def chacha20_encrypt(key, nonce, plaintext, counter=0):
"""Encrypt/decrypt with modified ChaCha20"""
state = chacha20_init(key, nonce, counter)
result = bytearray()
offset = 0

while offset < len(plaintext):
block = chacha20_block(state)
keystream = b''.join(struct.pack('<I', w) for w in block)

# XOR plaintext with keystream
chunk_size = min(64, len(plaintext) - offset)
for i in range(chunk_size):
result.append(plaintext[offset + i] ^ keystream[i])

offset += chunk_size
state[12] = u32(state[12] + 1) # Increment counter

return bytes(result)

def xor_enc(plain: str) -> bytes:
"""GDScript xor_enc: chain XOR on 8-byte UTF-8 buffer (trigger.gd)

for i in range(7): result[i] ^= result[i+1]
result[7] ^= result[0] # uses already-modified result[0]
"""
buf = bytearray(plain.encode('utf-8'))
if len(buf) < 8:
buf.extend(b'\x00' * (8 - len(buf)))
result = bytearray(buf[:8])
for i in range(7):
result[i] ^= result[i + 1]
result[7] ^= result[0]
return bytes(result)

def xor_dec(enc_bytes: bytes) -> str:
"""Inverse of xor_enc: recover original 8-char token from XOR'd bytes"""
result = bytearray(enc_bytes[:8])
# Reverse: undo result[7] ^= result[0], then undo i=6..0
result[7] ^= result[0]
for i in range(6, -1, -1):
result[i] ^= result[i + 1]
return result.decode('utf-8', errors='replace')

def token_to_flag(token: str) -> str:
"""Convert 8-char token to flag (16 hex chars)

Matches game flow: xor_enc(token) -> chacha20_encrypt -> hex_upper
"""
preprocessed = xor_enc(token)
encrypted = chacha20_encrypt(KEY_BYTES, NONCE_BYTES, preprocessed)
return ''.join(f'{b:02X}' for b in encrypted)

def flag_to_token(flag: str) -> str:
"""Convert flag (16 hex chars) back to token"""
flag_bytes = bytes.fromhex(flag)
decrypted = chacha20_encrypt(KEY_BYTES, NONCE_BYTES, flag_bytes)
return xor_dec(decrypted)

# Test
if __name__ == '__main__':
print("=== Modified ChaCha20 Flag Generator ===")
print(f"Constants: {' '.join(f'0x{c:08x}' for c in CONSTANTS)}")
print(f"Key: {KEY_BYTES.hex()}")
print(f"Nonce: {NONCE_BYTES.hex()}")
print()

# Show initial state
state = chacha20_init(KEY_BYTES, NONCE_BYTES, 0)
print("Initial state:")
for i in range(4):
print(f" [{i*4:2d}-{i*4+3:2d}]: {' '.join(f'{state[i*4+j]:08x}' for j in range(4))}")
print()

# Generate one keystream block for verification
block = chacha20_block(state)
print("First keystream block:")
for i in range(4):
print(f" [{i*4:2d}-{i*4+3:2d}]: {' '.join(f'{block[i*4+j]:08x}' for j in range(4))}")

keystream = b''.join(struct.pack('<I', w) for w in block)
print(f"\nKeystream bytes: {keystream[:16].hex()}")
print()

# Test with sample tokens
test_tokens = ["4b37a5c4", "f54b25f4", "testtest", "aabbccdd"]//在这里改输入的token
for token in test_tokens:
enc = xor_enc(token)
flag = token_to_flag(token)
recovered = flag_to_token(flag)
print(f"Token: {token} -> xor_enc: {enc.hex()} -> Flag: {flag} -> Recovered: {recovered}")

print()

# Interactive mode
if len(sys.argv) > 1:
if sys.argv[1] == '-t' and len(sys.argv) > 2:
# Token to flag
token = sys.argv[2]
print(f"Flag: {token_to_flag(token)}")
elif sys.argv[1] == '-f' and len(sys.argv) > 2:
# Flag to token
flag = sys.argv[2]
print(f"Token: {flag_to_token(flag)}")
else:
print(f"Usage: {sys.argv[0]} -t <token> or -f <flag>")

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

#define CHACHA_BLOCK_SIZE 64

static const uint32_t CONSTANTS[4] = {
0x61707866, 0x3320646f, 0x79622d31, 0x6b206573
};

static const uint8_t KEY_BYTES[32] = {
'T','h','1','s',' ','l','s',' ','n','0','t',' ','a',' ','r','e',
'a','1',' ','k','e','y','!','!','@','s','e','c','2','0','2','6'
};

static const uint8_t NONCE_BYTES[12] = {
'0','1','2','3','4','5','6','7','8','9','0','1'
};

static uint32_t rotl32(uint32_t x, int n) {
return (x << n) | (x >> (32 - n));
}

static uint32_t load32_le(const uint8_t *p) {
return ((uint32_t)p[0]) |
((uint32_t)p[1] << 8) |
((uint32_t)p[2] << 16) |
((uint32_t)p[3] << 24);
}

static void store32_le(uint8_t *p, uint32_t v) {
p[0] = (uint8_t)(v & 0xff);
p[1] = (uint8_t)((v >> 8) & 0xff);
p[2] = (uint8_t)((v >> 16) & 0xff);
p[3] = (uint8_t)((v >> 24) & 0xff);
}

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 chacha20_init(uint32_t state[16], const uint8_t key[32], const uint8_t nonce[12], uint32_t counter) {
state[0] = CONSTANTS[0];
state[1] = CONSTANTS[1];
state[2] = CONSTANTS[2];
state[3] = CONSTANTS[3];

for (int i = 0; i < 8; i++) {
state[4 + i] = load32_le(key + i * 4);
}

state[12] = counter;
state[13] = load32_le(nonce + 0);
state[14] = load32_le(nonce + 4);
state[15] = load32_le(nonce + 8);
}

static void chacha20_block(const uint32_t input[16], uint32_t output[16]) {
uint32_t working[16];
memcpy(working, input, sizeof(working));

for (int i = 0; i < 10; i++) {
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 (int i = 0; i < 16; i++) {
output[i] = working[i] + input[i];
}
}

static void chacha20_encrypt(
const uint8_t key[32],
const uint8_t nonce[12],
const uint8_t *plaintext,
size_t plaintext_len,
uint8_t *ciphertext,
uint32_t counter
) {
uint32_t state[16];
chacha20_init(state, key, nonce, counter);

size_t offset = 0;
while (offset < plaintext_len) {
uint32_t block_words[16];
uint8_t keystream[CHACHA_BLOCK_SIZE];

chacha20_block(state, block_words);
for (int i = 0; i < 16; i++) {
store32_le(keystream + i * 4, block_words[i]);
}

size_t chunk_size = (plaintext_len - offset < CHACHA_BLOCK_SIZE)
? (plaintext_len - offset)
: CHACHA_BLOCK_SIZE;

for (size_t i = 0; i < chunk_size; i++) {
ciphertext[offset + i] = plaintext[offset + i] ^ keystream[i];
}

offset += chunk_size;
state[12] += 1;
}
}

static void xor_enc(const char *plain, uint8_t out[8]) {
size_t len = strlen(plain);
memset(out, 0, 8);

if (len > 8) len = 8;
memcpy(out, plain, len);

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 buf[8];
memcpy(buf, enc, 8);

buf[7] ^= buf[0];
for (int i = 6; i >= 0; i--) {
buf[i] ^= buf[i + 1];
}

memcpy(out, buf, 8);
out[8] = '\0';
}

static void bytes_to_hex_upper(const uint8_t *in, size_t len, char *out) {
static const char HEX[] = "0123456789ABCDEF";
for (size_t i = 0; i < len; i++) {
out[i * 2] = HEX[(in[i] >> 4) & 0xF];
out[i * 2 + 1] = HEX[in[i] & 0xF];
}
out[len * 2] = '\0';
}

static int hex_value(char c) {
if ('0' <= c && c <= '9') return c - '0';
if ('a' <= c && c <= 'f') return c - 'a' + 10;
if ('A' <= c && c <= 'F') return c - 'A' + 10;
return -1;
}

static int hex_to_bytes(const char *hex, uint8_t *out, size_t out_len) {
size_t hex_len = strlen(hex);
if (hex_len != out_len * 2) return -1;

for (size_t i = 0; i < out_len; i++) {
int hi = hex_value(hex[i * 2]);
int lo = hex_value(hex[i * 2 + 1]);
if (hi < 0 || lo < 0) return -1;
out[i] = (uint8_t)((hi << 4) | lo);
}
return 0;
}

static void token_to_flag(const char *token, char flag_hex[17]) {
uint8_t preprocessed[8];
uint8_t encrypted[8];

xor_enc(token, preprocessed);
chacha20_encrypt(KEY_BYTES, NONCE_BYTES, preprocessed, 8, encrypted, 0);
bytes_to_hex_upper(encrypted, 8, flag_hex);
}

static int flag_to_token(const char *flag_hex, char token[9]) {
uint8_t flag_bytes[8];
uint8_t decrypted[8];

if (hex_to_bytes(flag_hex, flag_bytes, 8) != 0) {
return -1;
}

chacha20_encrypt(KEY_BYTES, NONCE_BYTES, flag_bytes, 8, decrypted, 0);
xor_dec(decrypted, token);
return 0;
}

int main(int argc, char *argv[]) {
if (argc != 3) {
fprintf(stderr, "Usage: %s -t <token> | -f <flag>\n", argv[0]);
return 1;
}

if (strcmp(argv[1], "-t") == 0) {
char flag[17];
token_to_flag(argv[2], flag);
printf("%s\n", flag);
return 0;
}

if (strcmp(argv[1], "-f") == 0) {
char token[9];
if (flag_to_token(argv[2], token) != 0) {
fprintf(stderr, "Invalid flag hex.\n");
return 1;
}
printf("%s\n", token);
return 0;
}

fprintf(stderr, "Usage: %s -t <token> | -f <flag>\n", argv[0]);
return 1;
}

使用如图所示

image-20260412163034661

总结

学到很多新知识,感谢腾讯给的学习机会,不过考了太多加解密知识,让我有点措手不及,幸好今年初赛题目似乎比往年简单,或许是换了引擎的原因吧。希望能进决赛,继续加油吧!