2026腾讯游戏安全竞赛Android初赛 wp

初赛对godot的理解不够,以前学习的主要是UE4相关,godot还是第一次搞,godot也有类似ue4那种dump,决赛才发现.

题目附件:下载压缩包

主要目标

  • 正算法:根据屏幕左上角的随机token生成右上角的flag
  • 逆算法:根据屏幕右上角的flag反推左上角的token
  • 按实现的算法数量和完成度计分

到达绿色方块也可获得flag

Flag获取 [Frida Hook碰撞]

更简单的方法是把属性dump出来直接去触发碰撞,我这样调得出来的效率太低了

补充patch掉校验 主动触发 然后重新编译打包 失败会闪退 估计是得用原来的引擎或版本不对?

这个其实应该放在后面的,我是解完包,逆完算法在开始的,还好搞了,不对不知道,自己逆向的算法搞错了

要想获得flag 无非几种常见思路

  1. 主动调用process
  2. 主动撞箱子,飞上去
  3. 主动触发装箱函数

由于初次解出godot对感觉没有ue4那么有规律,也不知道怎么看字符串,对照方法,只能硬调

当前方案是用 Frida 强制 Trigger2 碰撞,让真 flag 显示在 Label2 UI 上,decrypted/decompiled/trigger.gd 是挂在 Trigger1 和 Trigger2 两个 Area3D 节点上的共用脚本。简化后的_process逻辑:

1
2
3
4
5
6
7
8
9
10
11
func _process(delta):
# ...
var body = get_overlapping_bodies()
if body.size() > 0:
if str(get_path()) == "/root/TownScene/Trigger1":
label.text = "flag{sec2026_PART0_example}"
elif str(get_path()) == "/root/TownScene/Trigger2":
if not flag2Triggered:
flag2Triggered = true
# 读 Label.text 里的Token: xxxxxxxx, 调 GameExtension.Process 算真 flag
label.text = "flag{" + FLAG_PREFIX + flag1 + "} "

Trigger2 是屋顶的绿色方块,是一个可穿过的 Area3D 触发器(应该不是实体),只是 Y 高度超出车能到达的范围,所以 get_overlapping_bodies() 永远返回空数组,Process永远不被调用,flag 永远不显示。

Part1 的 20 分要求让 Trigger2 触发一次,使真 flag 出现在右上角 Label2 UI 上

不改 APK (之前试过改 trigger.gdc 再打包,改出来游戏直接崩,不是签名问题就是字节码格式问题),改用 Frida 在运行期 hook Area3D::get_overlapping_bodies,在 onLeave 里调用 Array::resize(out_array, 1) 强制把返回的空数组 resize 成size=1,trigger.gd 的 body.size() > 0 判断就过了,Trigger2 的分支自然执行,flag 直接写到 Label2,不需要替换字符串也不需要走 UI hack。

get_overlapping_bodies:获取当前这个 Area3D 正在重叠,碰撞到的物体列表

两个难点:

  1. 区分 Trigger1 vs Trigger2。Trigger1 每帧都能触发 (玩家若撞到它),它会写flag1可能覆盖 Trigger2 的flag。必须只给 Trigger2 force,不动 Trigger1。
  2. 找到真正被 GDScript 调用的 Area3D::get_overlapping_bodies 函数地址。libgodot_android.so 没有符号,得自己定位。

由于不太懂godot,一开始找错了函数 (Area2D 不是 Area3D)

第一次用 find_overlapping_bodies.py扫 libgodot_android.so,找 “get_overlapping_bodies”
字符串,再从 _bind_methods 的 ADRP+ADD pair 找到目标函数指针,结果定位到:

1
sub_27AF370  (vaddr 0x27af370)

image-20260410235940769

反编译看起来非常像 get_overlapping_bodies:sub_3957654 构造 Array 检查monitoring flag 遍历链表 sub_395126Cresize 返回。但里面Array::set_typed 传的类名字符串是Node2D:

1
2
3
Node2D = "Node2D";               // Area2D的
sub_3C5FA18(&Node2D, &obj_); // 构造 StringName
sub_3956F20(a2, 24, &obj_, ...); // set_typed(OBJECT, "Node2D")

0x27af370 塞进 Frida hook 后,调用次数一直是 0,但 Array::resize 探针每 2 秒能记到 20+ 次调用,证明 Frida 能 hook libgodot_android.so,就是这个函数从来没被执行过 , 它是 Area2D::get_overlapping_bodies,游戏里没 Area2D 节点,所以根本不跑。find_overlapping_bodies.py 脚本的启发式把 Area2D 和 Area3D 弄反了。

image-20260411000439958

脚本就不贴了,没啥用,思想就是

就是扫 libgodot_android.so 找 godot::Area3D::get_overlapping_bodies的 vaddr 偏移。

在 Godot 的 Area3D::_bind_methods() 里, 会有一段ClassDB::bind_method(D_METHOD(“get_overlapping_bodies”), &Area3D::get_overlapping_bodies);编译后的 ARM64 汇编是:

1
2
3
4
5
6
ADRP Xa, <"get_overlapping_bodies" string page>   加载字符串地址
ADD Xa, Xa, :lo12:<string>
...
ADRP Xb, <&Area3D::get_overlapping_bodies page> 加载函数指针
ADD Xb, Xb, :lo12:<function>
BL <ClassDB::bind_method>

这两对 ADRP+ADD 之间通常只差几条指令.我们扫 libgodot_android.so 的 .rodata 找 “get_overlapping_bodies\0” 字符串,

再扫 .text 里引用这个地址的 ADRP+ADD, 然后在引用点附近找第二对 ADRP+ADD,

目标就是 Area3D::get_overlapping_bodies.

注: Area2D 也有同名方法, 所以会找到 2 个候选, 选 arm64 的第二个大概率是 Area3D.

最后是用 LR反向追踪找到真函数

既然 Array::resize (sub_395126C,这里是被去掉符号了) 的 hook 能生效,就在它的 onEnter里读 this.context.lr(= caller’s next PC = BL resize 指令的下 4 字节)反向定位所有调 resize 的 call site,采样 10 秒:

为什么要 hook 它,Area3D::get_overlapping_bodies 内部会两次调用 Array::resize:

  • 进循环前 resize(a2, 初始估计 count) 预分配
  • 遍历链表收集完 bodies 后 resize(a2, 实际 count) 收尾

所以 trigger.gd 每帧每个 trigger 跑一次 get_overlapping_bodies,就会触发 2 次 Array::resize 调用。hook 这个函数等于间接监视到了 get_overlapping_bodies的内部活动,尽管我们还不知道 get_overlapping_bodies 的地址。

什么意思嘞,可以看效果解释

1
2
3
4
5
6
7
Interceptor.attach(resizeAddr, {
onEnter: function(args) {
const lr = this.context.lr;
const callSiteOff = lr.sub(4).sub(mod.base).toInt32();
callSites.set(callSiteOff, (callSites.get(callSiteOff) || 0) + 1);
}
});

开车 10 秒后,top hits:

image-20260411001412352

this.context.lr LR 寄存器BL 指令的下 4 字节,LR 是 Link Register,ARM64 里叫 X30,专门存函数返回地址,ARM64 的函数调用指令是 BL target,执行时 CPU 做两件事:

1
2
LR = PC + 4    ; 把"下一条指令地址"存到 LR (所以调用方返回时用)
PC = target ; 跳转到被调函数

举个例子,假设 get_overlapping_bodies 里有一条指令:

1
2
0x25fa790:  BL  sub_395126C    ← 调 Array::resize
0x25fa794: MOV X0, X1 ← BL 的"下一条"

当 CPU 执行到 0x25fa790 这条 BL 时:

  • LR = 0x25fa794 (= BL 指令的地址 + 4 字节,因为 ARM64 每条指令固定 4 字节)
  • 然后跳到 sub_395126C 开始执行

此时 Frida 的 Interceptor.attach(sub_395126C) 的 onEnter 会被触发。在 onEnter 里 this.context.lr 就是当时的 LR 值 = 0x25fa794。 想知道谁调了我? 做一个减法:

call_site = lr - 4 = 0x25fa794 - 4 = 0x25fa790

0x25fa790 就是那条 BL sub_395126C 的地址,也就是call site(调用点)。它所在的函数就是调用者。

1392≈ 60 FPS × 2 triggers × 10 秒 ,完全对得上两个 Trigger 每帧各调一次。两个 call site 都在同一函数 sub_25FA6F4 0x25fa6f4 内 (一次用于初始 resize,一次用于最终 resize)。反编译这个函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
int64 Area3D::get_overlapping_bodies(Area3D *a1@X0, Array *a2@X8) {
sub_3957654(a2); // Array::Array()
// ...
Node3D = "Node3D"; // 真正的 Node3D
sub_3C5FA18(&Node3D, &obj__1);
sub_3956F20(a2, 24, &obj__1, &Node3D); // set_typed(OBJECT, "Node3D")
if ((*(_BYTE *)(a1 + 1400) & 1) == 0) // monitoring flag @ +1400
return error();
sub_395126C(a2, *(_DWORD *)(a1 + 1444)); // 初始 resize,call site #1
v5 = *(_QWORD **)(a1 + 1424); // 链表头
// ... 遍历链表收集 bodies ...
return sub_395126C(a2, v6); // 最终 resize,call site #2
}

image-20260411002134146

识别 Trigger2 的 this 指针,知道了函数地址,还得区分 Trigger1 和 Trigger2。

get_overlapping_bodies 这个函数是所有 Area3D 都用的。Trigger1 和 Trigger2 都是 Area3D如果不区分很可能会看不出效果

解决办法是看 trigger.gd 的代码,obj.Process() 这行只在 Trigger2 的分支里出现。Trigger1 分支压根不调 Process

办法借助 GameExtension::Process 的调用时机。trigger.gd 里 obj.Process() 只在 Trigger2 分支里被调:

1
2
3
4
elif str(get_path()) == "/root/TownScene/Trigger2":
if not flag2Triggered:
# ...
var flag1 = obj.Process(xor_enc(str(label1.text).substr(7)))

所以:

  1. 每次 Area3D::get_overlapping_bodies 进入时记下 state.lastArea3D = args[0]
  2. 每次 GameExtension::Process 进入时,当前调用它的就是 Trigger2,而此时 lastArea3D就是 Trigger2 的 this 指针 (因为 Trigger2 的 _process先调 get_overlapping_bodies 再调 Process,中间没别的 Area3D 调用)
  3. 缓存 state.trigger2Ptr = state.lastArea3D,从此只给 Trigger2 force,Trigger1 和其他 Area3D完全不动

(trigger2Ptr == null)对所有 Area3D force,确保 Trigger2 那帧能进Process 分支,把自己的 this 指针”暴露”出来。一旦暴露,立刻切换到只 force Trigger2模式 ,Trigger1 及其他 Area3D 的 get_overlapping_bodies 不再被干扰。

流程时序 (两种可能的场景树顺序都 OK):

  • Trigger1._process 先跑: 被 force, 写假 flag 到 Label2 (1 帧,临时)
  • Trigger2._process 后跑: 被 force, 写真 flag 到 Label2, 调 Process,Process 的 hook 捕获 lastArea3D = Trigger2.this
  • 下一帧: Trigger1._process 不再被 force, body.size() == 0, 不写 Label2,真 flag 保留在 Label2 上

反过来 (Trigger2 先跑、Trigger1 后跑) 也可以,因为一旦 Process 被调、trigger2Ptr被设,后续 Trigger1 不再被 force,Trigger1 分支不执行,真 flag 不被覆盖。

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
const GET_OVERLAPPING_BODIES_OFFSET = 0x25fa6f4;
const ARRAY_RESIZE_OFFSET = 0x395126c;

const PROCESS_OFFSET = 0x4e548;

const FLAG_PREFIX = "sec2026_PART1_";

rpc.exports = {
getState: function() { return JSON.stringify(state); },
};

// Godot Array 内部布局读取

function getArraySize(arrayPtr) {
try {
const arrayPrivate = arrayPtr.readPointer();
if (arrayPrivate.isNull()) return -1;
const dataPtr = arrayPrivate.add(16).readPointer();
if (dataPtr.isNull()) return 0;
return dataPtr.sub(8).readU64().toNumber();
} catch (e) {
return -1;
}
}

// 状态
const state = {
lastArea3D: null, // 最近一次 get_overlapping_bodies 的 this 指针 (string)
trigger2Ptr: null, // Process 第一次被调时确定的 Trigger2 的 this 指针
realFlag: null, // 从 Process 返回值读到的真 flag
forceCount: 0, // 统计 force 了多少次 (debug)
};

// Hook 安装
let arrayResize = null;

function installHooks() {
const godot = Process.findModuleByName("libgodot_android.so");
const sec = Process.findModuleByName("libsec2026.so");
if (!godot || !sec) {
setTimeout(installHooks, 300);
return;
}
console.log(`[+] libgodot_android.so base = ${godot.base}`);
console.log(`[+] libsec2026.so base = ${sec.base}`);

const gobAddr = godot.base.add(GET_OVERLAPPING_BODIES_OFFSET);
const resizeAddr = godot.base.add(ARRAY_RESIZE_OFFSET);
const procAddr = sec.base.add(PROCESS_OFFSET);
console.log(`[+] Area3D::get_overlapping_bodies @ ${gobAddr}`);
console.log(`[+] Array::resize @ ${resizeAddr}`);
console.log(`[+] GameExtension::Process @ ${procAddr}`);

// dump first 16 bytes of the hooked region to verify we're in code
try {
const b = gobAddr.readByteArray(16);
const bv = new Uint8Array(b);
let hex = "";
for (let i = 0; i < 16; i++) hex += bv[i].toString(16).padStart(2, "0") + " ";
console.log(`[diag] bytes @ gob: ${hex}`);
const b2 = resizeAddr.readByteArray(16);
const bv2 = new Uint8Array(b2);
let hex2 = "";
for (let i = 0; i < 16; i++) hex2 += bv2[i].toString(16).padStart(2, "0") + " ";
console.log(`[diag] bytes @ resize: ${hex2}`);
} catch (e) { console.log(`[!] byte dump failed: ${e}`); }

arrayResize = new NativeFunction(resizeAddr, 'int', ['pointer', 'int']);

// ─── 探针: hook Array::resize — 每帧成千上万次调用, 确认 Frida 能拦截 libgodot_android.so ─
let resizeHits = 0;
Interceptor.attach(resizeAddr, {
onEnter: function(args) { resizeHits++; }
});
setTimeout(function() {
console.log(`[probe] Array::resize 被调用 ${resizeHits} 次 (应该 > 0)`);
}, 2000);

// ─── Hook get_overlapping_bodies ─────────────────────────
let gobCallCount = 0;
const seenPtrs = new Set();
Interceptor.attach(gobAddr, {
onEnter: function(args) {
// X0 = Area3D *this, X8 = out Array* (hidden struct return)
this.selfPtr = args[0];
this.outPtr = this.context.x8;
state.lastArea3D = this.selfPtr.toString();
gobCallCount++;
const key = this.selfPtr.toString();
if (!seenPtrs.has(key)) {
seenPtrs.add(key);
console.log(`[gob#${gobCallCount}] NEW Area3D this=${key} outPtr=${this.outPtr}`);
}
},
onLeave: function(retval) {
// 决定是否要 force:
// - 还没发现 Trigger2 (trigger2Ptr == null) → 对所有 Area3D force
// 一次 (确保 Trigger2 这帧能触发)
// - 已经发现 Trigger2 → 只给 Trigger2 这个 this 指针 force
let shouldForce;
if (state.trigger2Ptr === null) {
shouldForce = true;
} else {
shouldForce = (this.selfPtr.toString() === state.trigger2Ptr);
}
if (!shouldForce) return;

// 如果原本就有 overlap (真实碰撞), 不动它
const cur = getArraySize(this.outPtr);
if (cur > 0) return;

try {
arrayResize(this.outPtr, 1);
state.forceCount++;
if (state.forceCount <= 5 || state.forceCount % 60 === 0) {
console.log(` [force #${state.forceCount}] this=${this.selfPtr} size 0→${getArraySize(this.outPtr)}`);
}
} catch (e) {
console.log(`[!] resize failed: ${e}`);
}
}
});
// 每 3 秒打印一次调用计数, 用于诊断 "hook 有没有生效"
setInterval(function() {
console.log(`[diag] get_overlapping_bodies 总调用次数 = ${gobCallCount}, force 次数 = ${state.forceCount}, trigger2Ptr = ${state.trigger2Ptr}`);
}, 3000);
console.log(`[+] get_overlapping_bodies hooked — 对所有 Area3D force 直到找到 Trigger2`);

// ─── Hook GameExtension::Process ─────────────────────────
Interceptor.attach(procAddr, {
onEnter: function(args) {
// Process 只在 Trigger2._process 里被调
// 此时 lastArea3D 就是 Trigger2 的 this 指针
if (state.trigger2Ptr === null && state.lastArea3D !== null) {
state.trigger2Ptr = state.lastArea3D;
console.log(`\n[★] 发现 Trigger2 this ptr = ${state.trigger2Ptr}`);
console.log(`[★] 从此之后只 force Trigger2, Trigger1 和其他 Area3D 恢复正常`);
}
this.retSlot = args[0];
// 读取 input PackedByteArray (用于 log)
try {
const pba = args[2];
const cp = pba.readPointer();
if (!cp.isNull()) {
const sz = cp.sub(8).readU64().toNumber();
if (sz > 0 && sz <= 32) {
let hex = "";
for (let i = 0; i < sz; i++)
hex += cp.add(i).readU8().toString(16).padStart(2, "0");
console.log(` Process input (xor_enc'd token) = ${hex}`);
}
}
} catch (e) {}
},
onLeave: function(retval) {
// 读 return String
try {
const stringPtr = this.retSlot.readPointer();
if (stringPtr.isNull()) return;
const length = stringPtr.sub(8).readU64().toNumber();
if (length <= 0 || length > 64) return;
let text = "";
for (let i = 0; i < length; i++) {
text += String.fromCodePoint(stringPtr.add(i * 4).readU32());
}
state.realFlag = `flag{${FLAG_PREFIX}${text}}`;
console.log(`\n REAL FLAG = ${state.realFlag}\n`);
console.log(`force 总次数: ${state.forceCount}`);
} catch (e) {
console.log(`[!] 读 Process 返回 String 失败: ${e}`);
}
}
});
console.log("");
}

installHooks();

image-20260411003419124

image-20260410224154917

这里的token 和flag可以拿到后面验证

静态 SceneTree 还原[赛后]

建议先看后面章节再回来

上面的 Frida 方案虽然能出 flag,但技术味道偏运行时取巧, hook Area3D::get_overlapping_bodies 强制 resize,本质是让游戏自己去执行那段 GDScript 把 flag 写到 Label2。这种做法放到 UE4 里对标,大概等于用 UEDumper 的live 模式打断点抓内存,而不是经典的SDK dumper,照着源码的数据结构,静态枚举对象和读属性

Godot 4 里有没有 UE4 FUObjectArray+ SDK dumper 的对应物?有:

UE4 Godot 4.5
GUObjectArray (FUObjectArray) ObjectDB::object_slots
UObject::GetName / GetOuter Node::data.name / .parent
SDK dumper 静态读内存 解 .scn文件 + ObjectDB 内存枚举

但有个更好的做法:Godot 的场景是完整序列化在磁盘上的 .scn(二进制 PackedScene)或 .tscn(文本)。节点类、名字、父子关系、所有属性的初始值 全部写死在文件里。APK 解包后,这些文件就在 export-*.scn根本不需要游戏运行就能完整还原 SceneTree

decrypt_godot45.py放在了解包章节

1
2
decrypt_godot45.py   AES-CFB-128 解密每个 .scn / .gdc
scn_parser.py Godot 4.5 binary RSRC parser + PackedScene 还原

调用流程:

1
2
3
4
5
6
7
8
9
export-...-town_scene.scn  (AES 加密)

│ decrypt_godot45.py (AES-CFB-128 with script_encryption_key)

decrypted_scenes/town_scene.scn (RSRC magic 开头的纯 Godot 资源)

│ scn_parser.py

场景文件

scn 文件是加密的

APK 解包拿到的 .scn首 4 字节不是 RSRC,而是随机字节:

用 decrypt_godot45.py(key 是从 libgodot_android.so .data 段扫 Shannon 熵找到的 32 字节值)解出来

脚本也在解包那里

参考源码,RSRC 格式 (core/io/resource_format_binary.cpp)

Godot 4 的二进制资源文件布局固定:

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
┌─────────────────────────────────────┐
│ 4B "RSRC" magic │ header
│ 4B big_endian │
│ 4B use_real64 │
│ 4B engine_ver_major │
│ 4B engine_ver_minor │
│ 4B format_version │
│ U* ustring resource_type │ "PackedScene"
│ 8B importmd_ofs │
│ 4B flags │ UIDS | NAMED_SCENE_IDS | REAL_T_DOUBLE | HAS_SCRIPT_CLASS
│ 8B uid (if UIDS) │
│ U* script_class (if HAS_SCRIPT_CLASS)│
│ 44B reserved[11] = 0 │
├─────────────────────────────────────┤
│ 4B string_table_size │ string table: all names / property keys
│ N * ustring │
├─────────────────────────────────────┤
│ 4B ext_resources_count │ external refs (scripts, textures, subscenes)
│ N * (type, path, uid?) │
├─────────────────────────────────────┤
│ 4B int_resources_count │ index: (path, byte_offset)
│ N * (path, u64_offset) │
├─────────────────────────────────────┤
│ 每个 int_resource 在其 offset 处展开: │
│ U* class_name │
│ 4B property_count │
│ N * (u32 name_idx, Variant value) │
├─────────────────────────────────────┤
│ ... │
└─────────────────────────────────────┘

ustring的编码是:u32 length_including_NUL 后跟 length`个 UTF-8 字节(没有 4 字节对齐填充)。

然后Variant 序列化

这里是坑最多的地方。每个 Variant 是 32 type_tag 后跟类型相关字节。Godot 4 的类型枚举:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
VARIANT_NIL = 1,  VARIANT_BOOL = 2,  VARIANT_INT = 3,  VARIANT_FLOAT = 4,
VARIANT_STRING = 5,
VARIANT_VECTOR2 = 10, VARIANT_RECT2 = 11, VARIANT_VECTOR3 = 12, VARIANT_PLANE = 13,
VARIANT_QUATERNION = 14, VARIANT_AABB = 15, VARIANT_BASIS = 16,
VARIANT_TRANSFORM3D = 17, VARIANT_TRANSFORM2D = 18, VARIANT_COLOR = 20,
VARIANT_NODE_PATH = 22, VARIANT_OBJECT = 24, VARIANT_DICTIONARY = 26,
VARIANT_ARRAY = 30, VARIANT_PACKED_BYTE_ARRAY = 31, VARIANT_PACKED_INT32_ARRAY = 32,
VARIANT_PACKED_FLOAT32_ARRAY = 33, VARIANT_PACKED_STRING_ARRAY = 34,

VARIANT_PACKED_VECTOR3_ARRAY = 35,
VARIANT_PACKED_COLOR_ARRAY = 36,
VARIANT_PACKED_VECTOR2_ARRAY = 37, // 原来是 35
// ..
VARIANT_PACKED_VECTOR4_ARRAY = 53, // 4.3+ 新增

写 parser 时踩的第一个坑:原本照Godot 4的通用写 PACKED_VECTOR2_ARRAY = 3,解 town_scene 时在某个 CSGPolygon3D 的 polygon 数组处就跑偏了 。实际那个 tag 35 在 4.5 里已经是 PACKED_VECTOR3_ARRAY。

Python 实现:

1
2
3
4
5
6
7
8
9
def _get_string(self) -> str:
id_ = self.r.u32()
if id_ & 0x80000000:
length = id_ & 0x7FFFFFFF
data = self.r.raw(length)
if data and data[-1] == 0:
data = data[:-1]
return data.decode('utf-8', errors='replace')
return self.string_map[id_] if id_ < len(self.string_map) else f'<str#{id_}>'

Object 引用

场景里大量字段是引用另一个资源”脚本、mesh、纹理、subscene)。VARIANT_OBJECT的编码:

1
2
3
4
5
6
7
8
9
10
11
case VARIANT_OBJECT: {
uint32_t objtype = f->get_32();
switch (objtype) {
case OBJECT_EMPTY: ... // 0 = null
case OBJECT_EXTERNAL_RESOURCE: ... // 1 = 旧格式 (type+path 字符串)
case OBJECT_INTERNAL_RESOURCE: // 2 = 本文件内某个 int_res
uint32_t index = f->get_32();
case OBJECT_EXTERNAL_RESOURCE_INDEX: // 3 = ext_res[] 数组下标
uint32_t index = f->get_32();
}
}

在 4.5 文件里几乎只见 23,都只是一个 u32 索引,很紧凑。

PackedScene 的扁平化节点数组

最核心的一步。解出来的 PackedScene资源只有一个属性 _bundled(Dictionary),里面是完整的场景定义。字段:

1
2
3
4
5
6
7
8
9
10
11
12
_bundled = {
"names": PackedStringArray, # scene-local string pool
"variants": Array, # scene-local variant pool
"node_count": int,
"nodes": PackedInt32Array, # 节点数据扁平化成 int 数组(核心)
"conn_count": int,
"conns": PackedInt32Array, # 信号连接
"node_paths": Array[NodePath],
"editable_instances": Array[NodePath],
"version": int,
"base_scene": int (可选),
}

nodes数组是整棵树的扁平编码。scene/resources/packed_scene.cpp:SceneState::pack() 的写入顺序:

1
2
3
4
5
6
7
8
9
10
每个节点的连续 int 序列:
parent_idx i32 // 父节点在数组里的索引 (-1 = 根)
owner_idx i32
type_idx i32 // names[type_idx] 是类名;-1/0x3FFFFFFF 表示 instanced scene
name_idx i32 // 低 N 位是 names[] 索引
instance_idx i32 // variants[instance_idx] 是 subscene 实例(若有)
property_count i32
{ name_idx, value_idx } × property_count // 指向 names[] / variants[]
group_count i32
{ name_idx } × group_count

还原算法就一个循环:从头走 nodes[],按这个 schema 解析每个节点。parent_idx 自然形成树。

完整代码

image-20260422223146093

解包

寻找key

没接触过godot,但是应该需要解包,查阅相关资料

DownUnderCTF 2025 - Un1corn - 博客园

查到工具Releases · GDRETools/gdsdecomp

观察结构发现是散包模式资源直接散在assets下,不是打成单个.pck,还有一些so,Godot 导出模板在编译时内置一个 script_encryption_key[32] 全局变量,导出 APK 时编辑器将真实 key 写入该变量位置。key 必然存在于 libgodot_android.so 的 .data段(因为是有显式初始值的可写全局变量)。

根据博客的两种方式定位key寻找失败,似乎是被strip了,但是肯定的是key肯定在libgodot_android里

可以写一个算法专门扫描key,大概思路是

  • 前后各8字节全零
  • Shannon 熵 ≥ 4.0
  • 字节多样性 ≥ 20 种不同值

AES字节的混乱程度为熵

来加快扫描

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
import math
from collections import Counter

LIB = "preliminary/lib/arm64-v8a/libgodot_android.so"

with open(LIB, "rb") as f:
blob = f.read()

# Sections we care about (writable + readable + initialized)
SECTIONS = {
".rodata": (0x3aa5d0, 0x625ac8),
".data.rel.ro": (0x3ea3470, 0x156370),
".data": (0x3ffdee8, 0x7100),
}

def shannon(b):
if not b: return 0.0
c = Counter(b)
n = len(b)
return -sum((v/n) * math.log2(v/n) for v in c.values())

def section_for(off):
for name, (s, sz) in SECTIONS.items():
if s <= off < s + sz:
return name
return "?"

hits = []
for i in range(8, len(blob) - 40):
pre = blob[i-8 : i]
win = blob[i : i+32]
post = blob[i+32 : i+40]
if pre != b"\x00"*8: continue
if post != b"\x00"*8: continue
if win.count(0) > 6: continue
if len(set(win)) < 18: continue
ent = shannon(win)
if ent < 4.0: continue
hits.append((ent, i, win))

hits.sort(reverse=True)
print(f"found {len(hits)} 32B high-entropy islands surrounded by ≥8 bytes of zeros\n")
for ent, off, win in hits[:30]:
sec = section_for(off)
print(f" ent={ent:.2f} file off=0x{off:08x} section={sec}")
print(f" {win.hex().upper()}")

输出

1
2
.data + 0x4f08  (va = 0x400edf0)
CE4DF8753B59A5A39ADE58AC07EF947A3DA39F2AF75E3284D51217C04D49A061

在IDA看

image-20260410134020098

找到key但是用工具解不开,报错Wrong key,猜想可能被魔改AES加密算法?

AES 逆向

问了GPT每个加密的 .gdc/ .gdextension 文件格式,这是Godot常见加密文件头和导出脚本格式(带完整性校验的 AES 加密文件头)

1
2
3
4
5
Offset  Size  含义
0x00 16 校验字段(非传统 MD5,可能是 HMAC)
0x10 8 明文大小 (uint64 LE)
0x18 16 AES IV
0x28 N 密文 (N = 明文大小向上对齐到 16 字节)

.gdextension是Godot 4 的原生扩展描述文件。用来告诉 Godot 应该如何加载一个 GDExtension,包括要加载哪些平台下的动态库、入口符号、兼容性信息等;真正执行的是它指向的本地库文件,比如 .dll / .so / .dylib

.gdc是 GDScript 的编译/加密后的脚本产物。Godot 3.x 里,官方有script encryption key机制,导出时可以把脚本加密,避免以明文形式直接被看到。对应资料里明确提到导出时可对脚本使用 256-bit AES 密钥保护。

追踪调用链:

image-20260410135734987

1
2
3
4
5
6
sub_3804C2C (PCK loader)
→ sub_3801410 (FileAccessEncrypted::open_internal)
→ sub_376EDA0 (mbedtls_aes_init)
→ sub_376EDFC → sub_197C210 (mbedtls_aes_setkey_enc, 256-bit)
→ sub_376EF68 (wrapper)
→ sub_197DE18 (自定义 CFB-128)

关键是识别sub_197C210大量AES的特征

image-20260410140100304

sub_197C210通过 AES key schedule 展开逻辑确认是setkey_enc(正向密钥扩展)

sub_197DE18是自定义的AES 魔改,函数签名如下,与标准 mbedtls CFB-128 的关键差异在于解密分支(mode = 0):

1
sub_197DE18(ctx, mode, length, iv_off_ptr, iv, input, output)

标准 mbedtls CFB-128 解密:

1
2
3
output[i] = iv[n] ^ ct[i];
iv[n] = ct[i];
n = (n + 1) & 0xf;

魔改 CFB-128 解密:

1
2
3
output[i] = iv[n] ^ ct[i] ^ n;     //  额外 XOR 块内偏移 n
iv[n] = ct[i] ^ n; // feedback 也 XOR 了 n
n = (n + 1) & 0xf;

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
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 = [
"preliminary/assets/token.gdc",
"preliminary/assets/label2.gdc",
"preliminary/assets/spedometer.gdc",
"preliminary/assets/Trigger/trigger.gdc",
"preliminary/assets/car_select/car_select.gdc",
"preliminary/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()

.gdextension解密后是完整的 INI 文本:

image-20260410140823678

反编译

Godot 4.5 gdc 文件格式

1
2
3
4
5
Offset  Size  含义
0x00 4 Magic "GDSC"
0x04 4 Bytecode version (uint32 LE, 101 = 0x65 for Godot 4.5)
0x08 4 Uncompressed size (uint32 LE)
0x0C N zstd 压缩帧(标识符用 XOR 0xb6 混淆)

image-20260410140930611

使用GDRE Tools反编译,解密后的 .gdc 是标准格式,GDRE Tools 可以直接处理:

1
2
3
4
./gdre_tools.x86_64 --headless \
--decompile=decrypted/token.gdc \
--bytecode=4.5.0 \
--output=decrypted/decompiled

5 个文件全部成功反编译为 .gd`源码。

image-20260410140704826

可以看到part1

flag生成算法解析

GD分析

flag生成算法为根据屏幕左上角的随机token生成的右上角的flag算法

token.gd

1
2
3
4
5
6
7
8
9
10
11
12
13
const TOKEN_LEN = 8
const CHARS = "0123456789abcdef"

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():
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
const FLAG_PREFIX = "sec2026_PART1_"
var 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

# 当玩家撞到 Trigger2 时触发:
var token_str = str(label1.text).substr(7)
var flag1 = obj.Process(xor_enc(token_str)) # native 处理
label.text = "flag{" + FLAG_PREFIX + flag1 + "}"

输入[a, b, c, d, e, f, g, h]:

1
2
Step 1: for i in 0..6:  result[i] ^= result[i+1]
Step 2: result[7] ^= result[0] (使用 Step 1 之后的 result[0])

变换后:

1
[a^b, b^c, c^d, d^e, e^f, f^g, g^h, h^(a^b)]

xor_enc 逆运算

给定输出 [x0, x1, x2, x3, x4, x5, x6, x7],还原原始 [a, b, c, d, e, f, g, h]

1
2
3
4
5
6
7
8
9
10
def xor_enc_inverse(x):
h = x[7] ^ x[0] # x7 = h ^ x0,所以 h = x7 ^ x0
g = x[6] ^ h
f = x[5] ^ g
e = x[4] ^ f
d = x[3] ^ e
c = x[2] ^ d
b = x[1] ^ c
a = x[0] ^ b
return bytes([a, b, c, d, e, f, g, h])

flag1还要加上Process的处理

Dump 真实so文件

发现libsec2026.so没几个函数,字符串没有交叉引用

nm libsec2026.so ,T extension_init 0x56d50

readelf 0x56d50 在 LOAD 段(perm=R+X,应该是代码)

但 get_bytes(0x56d50) 读出来完全不像 ARM64 指令

应该是被壳或者其它东西保护了

image-20260410151333523

image-20260410151445655

start 是合法的 ARM64 代码,是加壳器的解密 stub,运行时把真正的代码解密到内存中。

image-20260410151601701

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
; 解析 /proc/self/auxv 获取页大小
svc #0 (openat "/proc/self/auxv")
svc #0 (read)
svc #0 (close)

; 调用解压器
BL sub_69984 ; sub_69984(&dword_69A6C, 4129, out_buf, &out_size)

; 创建匿名 memfd
svc #0 (memfd_create)
svc #0 (write)
svc #0 (mmap PROT_READ|PROT_EXEC)
svc #0 (close)

; 跳转到解压后的代码 + 0x10
ADR x0, qword_69860
ADD x14, x14, #0x10
BR x14 ; jump to unpacked code (skipping 16-byte header)

Header 在 0x69A60:

偏移 字段
0x69A60 未压缩大小 0x17A0
0x69A6 压缩大小 0x1021
0x69A68 unknown (= 2)
0x69A6C 压缩数据起点

这样就有两种方法了,静态逆向,动态dump

方法1: 静态逆向

分析剩下的代码发现实现了以下功能

sub_69984(64 字节)+ sub_699C4(24 字节位读取器)+ sub_699DC(gamma 码读取器)

是aPlib变种,其中特征

  • 32-bit 位缓冲 (adds w4, w4, w4 + ldr w4, [x0], #4 )
  • 每符号 1 bit 的 literal/match 标志
  • Gamma 码变长 length 编码(交替读 data bit 和 control bit)
  • cmn w5, #0xD00 长距离优化阈值
  • 终止符 = 距离 = 0xFFFFFFFF (mvn w5, w5; cbz w5)

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

def decode_aplib(src: bytes) -> bytes:
src_pos = [0]
bitbuf = [0x80000000]

def read_bit():
val = bitbuf[0]
new_val = (val << 1) & 0xFFFFFFFF
old_carry = (val >> 31) & 1
if new_val == 0:
dword = int.from_bytes(src[src_pos[0]:src_pos[0] + 4], "little")
src_pos[0] += 4
bitbuf[0] = ((dword << 1) + old_carry) & 0xFFFFFFFF
return (dword >> 31) & 1
bitbuf[0] = new_val
return old_carry

def read_gamma():
val = 1
while True:
val = (val << 1) | read_bit()
ctrl = read_bit()
if ctrl == 1:
return val

def read_lit():
b = src[src_pos[0]]
src_pos[0] += 1
return b

dst = bytearray()
distance_neg = 0xFFFFFFFF

while True:
if read_bit() == 1:
# Literal byte
dst.append(read_lit())
continue

# Match
gamma = read_gamma()
if gamma >= 3:
byte = read_lit()
new_distance_neg = ((gamma - 3) << 8) | byte
distance_neg = (~new_distance_neg) & 0xFFFFFFFF
if distance_neg == 0:
break

w1 = read_bit()
w1 = (w1 << 1) | read_bit()
if w1 == 0:
w1 = read_gamma() + 2

distance = (0x100000000 - distance_neg) & 0xFFFFFFFF
if distance > 0xD00:
w1 += 1

length = w1 + 1
for _ in range(length):
dst.append(dst[len(dst) - distance])

return bytes(dst)

if __name__ == "__main__":
import os
import sys

LIB_PATH = "preliminary/lib/arm64-v8a/libsec2026.so"
BLOB_OFFSET = 0x69A6C # from sub_69984 call in `start`
BLOB_SIZE = 4129 # from header at 0x69A60+4
EXPECTED_SIZE = 6048 # from header at 0x69A60+0

if not os.path.exists(LIB_PATH):
print(f"错误: 找不到 {LIB_PATH}", file=sys.stderr)
print("请在项目根目录 (preliminary 的父目录) 运行此脚本", file=sys.stderr)
sys.exit(1)

with open(LIB_PATH, "rb") as f:
lib_bytes = f.read()

# Also verify the header at 0x69A60
header = lib_bytes[0x69A60:0x69A6C]
usize, csize, flag = struct.unpack("<III", header)
print(f"Stage1 header @ 0x69A60:")
print(f" uncompressed size = 0x{usize:x} = {usize}")
print(f" compressed size = 0x{csize:x} = {csize}")
print(f" unknown field = 0x{flag:x}")
assert usize == EXPECTED_SIZE, f"unexpected usize {usize}"
assert csize == BLOB_SIZE, f"unexpected csize {csize}"

blob = lib_bytes[BLOB_OFFSET:BLOB_OFFSET + csize]
print(f"\n解压 {len(blob)} 字节 aPlib 压缩数据...")
out = decode_aplib(blob)
print(f"解压完成: {len(out)} 字节 (期望 {EXPECTED_SIZE})")
assert len(out) == EXPECTED_SIZE

out_path = "unpacked_stage2.bin"
with open(out_path, "wb") as f:
f.write(out)
print(f"\n已保存到 {out_path}")
print(f"\n前 16 字节 (header): {out[:16].hex()}")
print(f"后接 4 字节 (entry): {out[16:20].hex()} (= {int.from_bytes(out[16:20], 'little'):08x})")

坑点:gamma 码的 control bit 语义,b.cc loop意味着 control=0 继续循环,control=1停止,第一次写反了导致完全解错

把 6048 字节的 stage 2 加载到新 IDA segment 分析

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
#!/usr/bin/env python3
import ida_bytes
import ida_segment
import ida_ua
import ida_funcs
import ida_auto
import idaapi
import idautils
import idc

def decode_aplib(src: bytes) -> bytes:
src_pos = [0]
bitbuf = [0x80000000]

def read_bit():
val = bitbuf[0]
new_val = (val << 1) & 0xFFFFFFFF
old_carry = (val >> 31) & 1
if new_val == 0:
dword = int.from_bytes(src[src_pos[0]:src_pos[0] + 4], "little")
src_pos[0] += 4
bitbuf[0] = ((dword << 1) + old_carry) & 0xFFFFFFFF
return (dword >> 31) & 1
bitbuf[0] = new_val
return old_carry

def read_gamma():
val = 1
while True:
val = (val << 1) | read_bit()
if read_bit() == 1:
return val

def read_lit():
b = src[src_pos[0]]
src_pos[0] += 1
return b

dst = bytearray()
distance_neg = 0xFFFFFFFF
while True:
if read_bit() == 1:
dst.append(read_lit())
continue
gamma = read_gamma()
if gamma >= 3:
byte = read_lit()
new_neg = ((gamma - 3) << 8) | byte
distance_neg = (~new_neg) & 0xFFFFFFFF
if distance_neg == 0:
break
w1 = read_bit()
w1 = (w1 << 1) | read_bit()
if w1 == 0:
w1 = read_gamma() + 2
distance = (0x100000000 - distance_neg) & 0xFFFFFFFF
if distance > 0xD00:
w1 += 1
for _ in range(w1 + 1):
dst.append(dst[len(dst) - distance])
return bytes(dst)


# libsec2026.so 里的 aPlib blob 位置
BLOB_VADDR = 0x69A6C
BLOB_SIZE = 4129

# 在 IDA 里选一个远离现有 segments 的高地址映射 stage 2
SEG_BASE = 0x200000
SEG_SIZE = 0x2000 # 6048 字节 < 0x2000, 足够
ENTRY_VADDR = SEG_BASE + 0x10 # 真正的 entry = offset 0x10 (跳过 16B header)

print(f"[*] 从 libsec2026.so @ {hex(BLOB_VADDR)} 读取 {BLOB_SIZE} 字节 aPlib 压缩数据")
compressed = ida_bytes.get_bytes(BLOB_VADDR, BLOB_SIZE)
if compressed is None or len(compressed) != BLOB_SIZE:
raise RuntimeError(f"无法读取 blob from {hex(BLOB_VADDR)}, 确认当前 session 是 libsec2026.so")

print(f"[*] 解压 aPlib...")
unpacked = decode_aplib(compressed)
print(f"[+] 解压得到 {len(unpacked)} 字节")
assert len(unpacked) == 6048, f"期望 6048 字节, 实际 {len(unpacked)}"

# ─── 创建 segment ───
existing = ida_segment.get_segm_by_name("unpacked")
if existing:
print(f"[!] segment 'unpacked' 已存在, 跳过创建")
else:
print(f"[*] 创建 segment 'unpacked' @ {hex(SEG_BASE)}..{hex(SEG_BASE + SEG_SIZE)}")
ok = idaapi.add_segm(
0, # selector
SEG_BASE, # start_ea
SEG_BASE + SEG_SIZE, # end_ea
"unpacked", # name
"CODE", # class: CODE means executable
)
if not ok:
raise RuntimeError("add_segm 失败")

# 设置 64-bit 模式 + R+X 权限
seg = ida_segment.get_segm_by_name("unpacked")
seg.bitness = 2 # 2 = 64-bit
seg.perm = 5 # 5 = READ | EXEC
ida_segment.update_segm(seg)

# ─── 写入解压后的字节 ───
print(f"[*] 写入 {len(unpacked)} 字节到 {hex(SEG_BASE)}")
for i, b in enumerate(unpacked):
ida_bytes.patch_byte(SEG_BASE + i, b)

# ─── 强制 entry point 分析为代码 ───
print(f"[*] 在 {hex(ENTRY_VADDR)} (stage 2 entry) 创建函数")
# 先 undefine 整段, 避免 IDA 把它当作 data
ida_bytes.del_items(ENTRY_VADDR, ida_bytes.DELIT_SIMPLE, SEG_SIZE - 0x10)
# 让 IDA 从 entry 开始分析
ida_ua.create_insn(ENTRY_VADDR)
ida_funcs.add_func(ENTRY_VADDR)

# ─── 触发自动分析覆盖整个 segment ───
print(f"[*] 触发自动分析 {hex(SEG_BASE)}..{hex(SEG_BASE + SEG_SIZE)}")
ida_auto.plan_and_wait(SEG_BASE, SEG_BASE + SEG_SIZE)

# ─── 打印识别出的函数 ───
funcs = [f for f in idautils.Functions(SEG_BASE, SEG_BASE + SEG_SIZE)]
print(f"\n[+] 自动分析完成! 在 stage 2 里识别出 {len(funcs)} 个函数:")
for fn_ea in funcs[:30]:
fn = ida_funcs.get_func(fn_ea)
name = ida_funcs.get_func_name(fn_ea)
size = fn.end_ea - fn.start_ea
print(f" {hex(fn_ea)} {name:<24} size={hex(size)}")
if len(funcs) > 30:
print(f" ... 还有 {len(funcs) - 30} 个函数未显示")

执行效果

image-20260410175123003

在sub_201150发现

image-20260410175236964

0x2158055=UPX!,是个UPX壳

12 字节 chunk header 格式

偏移 字段
+0 orig_size (uint32) — 解压后大小
+4 comp_size (uint32) — 压缩数据大小
+8 method (uint8) — 2=aPlib, 14=LZMA
+9 filter (uint8) — 0 或 82='R' (ARM64 BL 重定位修正)
+10..12 填充

LZMA 魔改

sub_200394 是 LZMA range decoder,也是魔改:

参数 标准 LZMA 本题变种
kBitModelTotal 2048 (11-bit) 4096 (12-bit)
初始 prob 1024 2048
范围检查 range >> 11 range >> 12
kNumMoveBit 5 5

而且属性字节 (lc, lp, pb) 编码方式也不同(3 个分开的字节而不是打包的 (pb*5+lp)*9+lc):

1
2
3
4
// sub_20024C 里的 LZMA 方法 14 初始化:
pb = byte0 & 7 // 位置比特
lc = byte1 >> 4 // 字面量上下文比特
lp = byte1 & 0xF // 字面量位置比特

Python标准lzma模块无法解码这个变种

BL 重定位过滤器(filter=82 ‘R’),LZMA 解压后还有一层 AArch64 BL/B 指令修正:

1
2
3
4
5
6
for (i = num_insns - 1; i >= 0; i--) {
insn = *(uint32_t*)(dst + 4*i);
if (((insn >> 26) & 0x1F) == 5) {
*(uint32_t*)(dst + 4*i) = (insn & 0xFC000000) | ((insn - i) & 0x3FFFFFF);
}
}

把绝对偏移形式的 BL 转回相对偏移形式

因为 LZMA 变种太复杂,手写 Python 移植太耗时,用 Unicorn 直接模拟执行 sub_20024C:

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
import struct
import os
import sys
from unicorn import Uc, UC_ARCH_ARM64, UC_MODE_ARM, UC_PROT_ALL
from unicorn.arm64_const import (
UC_ARM64_REG_X0, UC_ARM64_REG_X1, UC_ARM64_REG_X2, UC_ARM64_REG_X3,
UC_ARM64_REG_W1, UC_ARM64_REG_W4, UC_ARM64_REG_SP, UC_ARM64_REG_LR,
UC_ARM64_REG_PC,
)

LIBSEC_PATH = "preliminary/lib/arm64-v8a/libsec2026.so"
STAGE2_PATH = "unpacked_stage2.bin"

# Stage 2 代码在 Unicorn 里的映射位置
STAGE2_BASE = 0x200000
STAGE2_SIZE = 0x2000

SUB_20024C_OFFSET = 0x24C # LZMA/aPlib 解压分发器
SUB_200074_OFFSET = 0x074

CHUNK2_FILE_OFFSET_IN_LIB = 0x4b147
CHUNK2_HEADER_SIZE = 12
CHUNK2_ORIG_SIZE = 637936
CHUNK2_COMP_SIZE = 124681
CHUNK2_METHOD = 14
CHUNK2_FILTER = 82


if not os.path.exists(STAGE2_PATH):
sys.exit(1)

if not os.path.exists(LIBSEC_PATH):
print(f"错误: 找不到 {LIBSEC_PATH}", file=sys.stderr)
sys.exit(1)

stage2 = open(STAGE2_PATH, "rb").read()
assert len(stage2) == 6048, f"stage2 size is {len(stage2)}, expected 6048"

lib = open(LIBSEC_PATH, "rb").read()

chunk2_data = lib[CHUNK2_FILE_OFFSET_IN_LIB + CHUNK2_HEADER_SIZE
: CHUNK2_FILE_OFFSET_IN_LIB + CHUNK2_HEADER_SIZE + CHUNK2_COMP_SIZE]
assert len(chunk2_data) == CHUNK2_COMP_SIZE
print(f"[*] chunk 2 压缩数据: {len(chunk2_data)} 字节, 前 8 字节 = {chunk2_data[:8].hex()}")
print(f" (其中前 2 字节 '1a 03' 是 LZMA props: pb=2, lc=3, lp=0)")


print("\n[*] 初始化 Unicorn (ARM64)")
uc = Uc(UC_ARCH_ARM64, UC_MODE_ARM)

# 映射 stage 2 代码
uc.mem_map(STAGE2_BASE, STAGE2_SIZE, UC_PROT_ALL)
uc.mem_write(STAGE2_BASE, stage2)
print(f" stage 2 代码 @ {hex(STAGE2_BASE)}..{hex(STAGE2_BASE + STAGE2_SIZE)}")

# 映射栈
STACK_BASE = 0x400000
STACK_SIZE = 0x100000
uc.mem_map(STACK_BASE, STACK_SIZE, UC_PROT_ALL)
print(f" 栈 @ {hex(STACK_BASE)}..{hex(STACK_BASE + STACK_SIZE)}")

# 映射 chunk 2 压缩输入
INPUT_BASE = 0x500000
INPUT_SIZE = 0x40000
uc.mem_map(INPUT_BASE, INPUT_SIZE, UC_PROT_ALL)
uc.mem_write(INPUT_BASE, chunk2_data)
print(f" 压缩输入 @ {hex(INPUT_BASE)} ({len(chunk2_data)} 字节)")

# 映射解压输出缓冲区 (需要至少 637936 字节, 分配 1 MB)
OUTPUT_BASE = 0x600000
OUTPUT_SIZE = 0x100000
uc.mem_map(OUTPUT_BASE, OUTPUT_SIZE, UC_PROT_ALL)
print(f" 解压输出 @ {hex(OUTPUT_BASE)}..{hex(OUTPUT_BASE + OUTPUT_SIZE)}")

# 映射 scratch 区: 放一个 uint32 size_ptr (输出: 实际解压了多少字节)
SCRATCH_BASE = 0x900000
uc.mem_map(SCRATCH_BASE, 0x10000, UC_PROT_ALL)
# 预先写入期望的 orig_size (sub_20024C 的一些路径会读这个值)
uc.mem_write(SCRATCH_BASE, struct.pack("<I", CHUNK2_ORIG_SIZE))
print(f" size_ptr @ {hex(SCRATCH_BASE)} (预置 = {CHUNK2_ORIG_SIZE})")

print(f"\n[*] 调用 sub_20024C @ {hex(STAGE2_BASE + SUB_20024C_OFFSET)}")
print(f" X0 (input ptr) = {hex(INPUT_BASE)}")
print(f" W1 (comp_size) = {CHUNK2_COMP_SIZE}")
print(f" X2 (output ptr) = {hex(OUTPUT_BASE)}")
print(f" X3 (size ptr) = {hex(SCRATCH_BASE)}")
print(f" W4 (method) = {CHUNK2_METHOD} (LZMA)")

uc.reg_write(UC_ARM64_REG_X0, INPUT_BASE)
uc.reg_write(UC_ARM64_REG_W1, CHUNK2_COMP_SIZE)
uc.reg_write(UC_ARM64_REG_X2, OUTPUT_BASE)
uc.reg_write(UC_ARM64_REG_X3, SCRATCH_BASE)
uc.reg_write(UC_ARM64_REG_W4, CHUNK2_METHOD)
uc.reg_write(UC_ARM64_REG_SP, STACK_BASE + STACK_SIZE - 0x10000)
uc.reg_write(UC_ARM64_REG_LR, 0xDEADBEEF)

try:
uc.emu_start(STAGE2_BASE + SUB_20024C_OFFSET, 0xDEADBEEF, timeout=60_000_000)
print("[+] 模拟执行完成")
except Exception as e:
pc = uc.reg_read(UC_ARM64_REG_PC)
print(f"[!] 模拟异常 @ pc={hex(pc)}: {e}")
sys.exit(1)


raw_output = bytes(uc.mem_read(OUTPUT_BASE, CHUNK2_ORIG_SIZE))
print(f"\n[+] LZMA 解压完成, raw 输出: {len(raw_output)} 字节")
print(f" 前 64 字节: {raw_output[:64].hex()}")

# 应用 BL 重定位 filter
print(f"\n[*] 应用 BL 重定位 filter (filter={CHUNK2_FILTER}=R)")
filtered = bytearray(raw_output)
num_insns = len(filtered) // 4
for i in range(num_insns - 1, -1, -1):
insn = struct.unpack_from("<I", filtered, i * 4)[0]
if ((insn >> 26) & 0x1F) == 5:
new_insn = (insn & 0xFC000000) | ((insn - i) & 0x3FFFFFF)
struct.pack_into("<I", filtered, i * 4, new_insn)
filtered = bytes(filtered)
print(f"[+] Filter 完成, 最终字节: {filtered[:64].hex()}")

# 用 capstone 反汇编前几条指令
try:
import capstone
cs = capstone.Cs(capstone.CS_ARCH_ARM64, capstone.CS_MODE_ARM)
print("\n[*] 前 12 条 ARM64 指令 (filter 后):")
for insn in list(cs.disasm(filtered[:48], 0))[:12]:
print(f" +{insn.address:04x}: {insn.mnemonic} {insn.op_str}")
except ImportError:
print("")

# 保存结果
open("chunk2_raw.bin", "wb").write(raw_output)
open("chunk2_filtered.bin", "wb").write(filtered)
print("\n[+] 已保存:")
print(f" chunk2_raw.bin ({len(raw_output)} 字节, 未应用 filter)")
print(f" chunk2_filtered.bin ({len(filtered)} 字节, 应用 BL filter)")
print("\n[*] 这 637,936 字节会被 solve.md 6.8 里的重建脚本放到")
print(" libsec2026_v3.so 的文件偏移 0x4b070..0xe6c60 (= PT_LOAD[1] 主代码段)")

py_eval 环境下 hook 函数的闭包不能访问外部变量,要用 builtins._emu_state 之类的 hack 传状态

需要在IDApython的环境先安装unicorn

输出

1
2
3
4
5
first 64 of output:
5f2403d5 1f2003d5 405f4e10 0c6e0214
5f2403d5 c0035fd6 5f2403d5 8e5c0214
5f2403d5 600000b4 f00300aa 00021fd6
c0035fd6 5f2403d5 e10300aa 1f2003d5

image-20260410182355459

可以当AArch64 指令解出来解释处理,说明 LZMA 解压成功。但这时还没做 BL 重定位过滤器 (filter=82=’R’),所以里面的 B/BL 指令的偏移还是存储优化后的值,需要再跑一遍 filter 修正

加密区布局与 chunks 位置

加密区在 0x4b070 - 0x69860(125424 字节)

外层 24 字节 header 0x4b070

偏移 含义
+0..4 2f ef 94 51 magic
+4..8 20 20 20 20 填充
+8..16 d0 12 0e 2a 00 00 00 00
+16..20 b0 c0 0e 00 = 0xec0b0 总未压缩大小 = 966832
+20..24 60 6c 0e 00 = 0xe6c60 PT_LOAD[1] 大小

Chunk 列表(从 0x4b088 开始):

# 位置 orig comp method filter 内容
1 +0x18 568 179 2 (aPlib) 0 ELF header + 9 个 phdrs
2 +0xd7 637,936 124,681 14 (LZMA) 82 (‘R’) 主代码段的一部分

只有 2 个 chunks,总计 638504 字节解压输出,远小于966832字节总大小

image-20260410182541656

分析 sub_201450在第一次 sub_201150 之后的 phdr 循环发现:

1
2
3
4
5
6
201578: ldr x20, [x19, #0x10]  ; x20 = phdr.p_vaddr
20157c: ldr x0, [x19, #0x20] ; x0 = phdr.p_filesz
201580: add w20, w20, w0 ; x20 = vaddr + filesz (segment end)
...
2015b8: sub x1, x20, x1 ; x1 = segment_end - orig_size ← 放置地址
2015c4: str x1, [x29, #0xd8] ; var_18 = output destination

**即每个 chunk 的输出放置在 (phdr.p_vaddr + phdr.p_filesz - chunk.orig_size)**,也就是从该 segment 的末尾向前对齐。

对PT_LOAD[1](vaddr=0, filesz=0xe6c60),chunk 2 (637936 bytes) 的放置地址:

1
var_18 = 0 + 0xe6c60 - 637936 = 0xe6c60 - 0x9bbf0 = 0x4b070

所以 chunk 2 的输出必须写到文件偏移 0x4b070,而不是 chunk 1 后面的 568

需要重建解压出来的这块数据,因为没有 ELF header,也没有 PHDRs,IDA 加载不了这些数据

第一次重建的策略是把PACKED的vaddr内容直接复制到 REAL 的同 file offset,这样字符串搜索(用 recovered.find(b”Process”))确实能找到Process在文件里的某个偏移,看起来像成功了。但是把libsec2026_v2.so加载到 IDA发现:

1
get_bytes(0xed5c4, 20) → '\x60\xe2\x0c\x00...'  # 不是 "Process",是指针

IDA 按 REAL PHDRs 的 file-vaddr 映射去读 vaddr 0xed5c4,读到的是文件偏移 0xeb5c4 (因为 PT_LOAD[3] file 0xeb5c0 → vaddr 0xed5c0),但 v2 放到文件偏移 0xeb5c4 的是 .data.rel.ro 的指针内容,不是 .data 的Process字符串。

两份 libsec2026.so 的phdrs 完全不一样,因为 packer 把原 ELF 完全重排了

  • PACKED 的 .data (Process) 字符串: vaddr 0xed5c4 = file 0x705c4
  • REAL 的 .data (Process”) 字符串: vaddr 0xed5c4 = file 0xeb5c4

在v2里把 PACKED 的 vaddr 0xed5c4 内容放到了 REAL file 0xed5c4,但 IDA 加载时按 REAL PHDRs 去读 REAL file 0xeb5c4,读到错的内容

正确的做法按 REAL PHDRs 的 file↔vaddr 映射把 PACKED vaddr 读到的内容放到 REAL file offset,让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
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
import os
import struct
import sys

# 1. aPlib 解码

def decode_aplib(src: bytes) -> bytes:
"""
aPlib 变种解码:
- 32-bit 位缓冲器 (sub_699C4)
- Gamma 码变长 length (sub_699DC, control bit == 1 停止)
- 终止符 = 距离 = 0xFFFFFFFF
"""
src_pos = [0]
bitbuf = [0x80000000]

def read_bit():
val = bitbuf[0]
new_val = (val << 1) & 0xFFFFFFFF
old_carry = (val >> 31) & 1
if new_val == 0:
dword = int.from_bytes(src[src_pos[0]:src_pos[0] + 4], "little")
src_pos[0] += 4
bitbuf[0] = ((dword << 1) + old_carry) & 0xFFFFFFFF
return (dword >> 31) & 1
bitbuf[0] = new_val
return old_carry

def read_gamma():
val = 1
while True:
val = (val << 1) | read_bit()
if read_bit() == 1:
return val

def read_lit():
b = src[src_pos[0]]
src_pos[0] += 1
return b

dst = bytearray()
distance_neg = 0xFFFFFFFF
while True:
if read_bit() == 1:
dst.append(read_lit())
continue
gamma = read_gamma()
if gamma >= 3:
byte = read_lit()
new_neg = ((gamma - 3) << 8) | byte
distance_neg = (~new_neg) & 0xFFFFFFFF
if distance_neg == 0:
break
w1 = read_bit()
w1 = (w1 << 1) | read_bit()
if w1 == 0:
w1 = read_gamma() + 2
distance = (0x100000000 - distance_neg) & 0xFFFFFFFF
if distance > 0xD00:
w1 += 1
for _ in range(w1 + 1):
dst.append(dst[len(dst) - distance])
return bytes(dst)



# 2. Unicorn 模拟 stage 2 的 LZMA 解压器
def unicorn_lzma_decompress(stage2_code: bytes, compressed: bytes, orig_size: int) -> bytes:
"""
加载 stage 2 loader 到 Unicorn, 调 sub_20024C(method=14) 解压 LZMA。

sub_20024C 签名:
(a1=ptr_to_compressed, w1=comp_size, a2=output_buf, a3=size_ptr, w4=method)
"""
try:
from unicorn import Uc, UC_ARCH_ARM64, UC_MODE_ARM, UC_PROT_ALL
from unicorn.arm64_const import (
UC_ARM64_REG_X0, UC_ARM64_REG_W1, UC_ARM64_REG_X2, UC_ARM64_REG_X3,
UC_ARM64_REG_W4, UC_ARM64_REG_SP, UC_ARM64_REG_LR,
)
except ImportError:
print("错误: 需要 unicorn 模块。运行: pip install unicorn", file=sys.stderr)
sys.exit(1)

STAGE2_BASE = 0x200000
STACK_BASE = 0x400000
INPUT_BASE = 0x500000
OUTPUT_BASE = 0x600000
SCRATCH = 0x900000
SUB_20024C_OFFSET = 0x24C

uc = Uc(UC_ARCH_ARM64, UC_MODE_ARM)
uc.mem_map(STAGE2_BASE, 0x2000, UC_PROT_ALL)
uc.mem_write(STAGE2_BASE, stage2_code)
uc.mem_map(STACK_BASE, 0x100000, UC_PROT_ALL)
uc.mem_map(INPUT_BASE, 0x40000, UC_PROT_ALL)
uc.mem_write(INPUT_BASE, compressed)
uc.mem_map(OUTPUT_BASE, 0x100000, UC_PROT_ALL)
uc.mem_map(SCRATCH, 0x1000, UC_PROT_ALL)
uc.mem_write(SCRATCH, struct.pack("<I", orig_size))

uc.reg_write(UC_ARM64_REG_X0, INPUT_BASE)
uc.reg_write(UC_ARM64_REG_W1, len(compressed))
uc.reg_write(UC_ARM64_REG_X2, OUTPUT_BASE)
uc.reg_write(UC_ARM64_REG_X3, SCRATCH)
uc.reg_write(UC_ARM64_REG_W4, 14) # LZMA
uc.reg_write(UC_ARM64_REG_SP, STACK_BASE + 0x90000)
uc.reg_write(UC_ARM64_REG_LR, 0xDEADBEEF)

uc.emu_start(STAGE2_BASE + SUB_20024C_OFFSET, 0xDEADBEEF, timeout=60_000_000)
return bytes(uc.mem_read(OUTPUT_BASE, orig_size))


def apply_bl_filter(data: bytes) -> bytes:
"""
AArch64 BL 重定位修正 filter (filter byte = 82 = 'R'):
对应 sub_200148 里的后处理:
for i in range(len_insns - 1, -1, -1):
if ((insn >> 26) & 0x1F) == 5: # B or BL opcode
insn = (insn & 0xFC000000) | ((insn - i) & 0x3FFFFFF)
"""
filtered = bytearray(data)
num_insns = len(filtered) // 4
for i in range(num_insns - 1, -1, -1):
insn = struct.unpack_from("<I", filtered, i * 4)[0]
if ((insn >> 26) & 0x1F) == 5:
new_insn = (insn & 0xFC000000) | ((insn - i) & 0x3FFFFFF)
struct.pack_into("<I", filtered, i * 4, new_insn)
return bytes(filtered)


# 3. PACKED ELF phdrs 辅助

def parse_elf_phdrs(elf_bytes: bytes):
e_phoff = struct.unpack("<Q", elf_bytes[32:40])[0]
e_phnum = struct.unpack("<H", elf_bytes[56:58])[0]
phdrs = []
for i in range(e_phnum):
po = e_phoff + i * 56
p_type = struct.unpack("<I", elf_bytes[po:po+4])[0]
p_offset = struct.unpack("<Q", elf_bytes[po+8:po+16])[0]
p_vaddr = struct.unpack("<Q", elf_bytes[po+16:po+24])[0]
p_filesz = struct.unpack("<Q", elf_bytes[po+32:po+40])[0]
phdrs.append((p_type, p_offset, p_vaddr, p_filesz))
return phdrs


def read_at_vaddr(elf_bytes: bytes, phdrs, vaddr: int, size: int) -> bytes:
"""按 phdrs 的 vaddr→file 映射从 elf_bytes 里读 size 字节。"""
result = bytearray(size)
for p_type, p_off, p_va, p_fs in phdrs:
if p_type != 1: # PT_LOAD only
continue
if vaddr + size <= p_va or vaddr >= p_va + p_fs:
continue
start_v = max(vaddr, p_va)
end_v = min(vaddr + size, p_va + p_fs)
for v in range(start_v, end_v):
result[v - vaddr] = elf_bytes[p_off + (v - p_va)]
return bytes(result)


# 4. 主流程

def main():
PACKED_PATH = "preliminary/lib/arm64-v8a/libsec2026.so"
OUTPUT_PATH = "libsec2026_v3.so"

# 最外层 aPlib blob — 生成 stage 2 loader
STAGE1_BLOB_VADDR = 0x69A6C
STAGE1_BLOB_SIZE = 4129
STAGE2_EXPECTED = 6048

# 加密代码区布局
ENC_BASE = 0x4b070
CHUNK1_DATA = 0x4b094 # chunk 1 压缩数据位置 (179 B aPlib)
CHUNK1_ORIG = 568
CHUNK1_COMP = 179
CHUNK2_DATA = 0x4b153 # chunk 2 压缩数据位置 (124681 B LZMA)
CHUNK2_ORIG = 637936
CHUNK2_COMP = 124681

TOTAL_SIZE = 966832 # 0xec0b0, 来自 outer header

if not os.path.exists(PACKED_PATH):
print(f"错误: 找不到 {PACKED_PATH}", file=sys.stderr)
print("请在 preliminary/ 的父目录运行此脚本", file=sys.stderr)
sys.exit(1)

print(f"[*] 读取 packed: {PACKED_PATH}")
packed = open(PACKED_PATH, "rb").read()
print(f" 大小: {len(packed)} 字节")

# ─── Step 1: 解压 Stage 2 loader ──────────────────────────────────
print(f"\n[*] Step 1: aPlib 解压 stage 2 loader ({STAGE1_BLOB_SIZE}B → {STAGE2_EXPECTED}B)")
stage1_blob = packed[STAGE1_BLOB_VADDR:STAGE1_BLOB_VADDR + STAGE1_BLOB_SIZE]
stage2_loader = decode_aplib(stage1_blob)
assert len(stage2_loader) == STAGE2_EXPECTED, \
f"stage2 size {len(stage2_loader)} != {STAGE2_EXPECTED}"
print(f"[+] 得到 stage 2 loader: {len(stage2_loader)} 字节")

# ─── Step 2: 解压 Chunk 1 ─────────────────────────────────────────
print(f"\n[*] Step 2: aPlib 解压 chunk 1 ({CHUNK1_COMP}B → {CHUNK1_ORIG}B, ELF header+phdrs)")
chunk1_blob = packed[CHUNK1_DATA:CHUNK1_DATA + CHUNK1_COMP]
chunk1_output = decode_aplib(chunk1_blob)
assert len(chunk1_output) == CHUNK1_ORIG, \
f"chunk1 size {len(chunk1_output)} != {CHUNK1_ORIG}"
assert chunk1_output[:4] == b"\x7fELF", \
f"chunk1 不是 ELF magic: {chunk1_output[:4].hex()}"
print(f"[+] ELF magic ✓, phnum = {struct.unpack('<H', chunk1_output[56:58])[0]}")

# ─── Step 3: 解压 Chunk 2 (LZMA via Unicorn, 然后 BL filter) ──────
print(f"\n[*] Step 3: Unicorn 模拟 LZMA 解压 chunk 2 ({CHUNK2_COMP}B → {CHUNK2_ORIG}B)")
chunk2_comp = packed[CHUNK2_DATA:CHUNK2_DATA + CHUNK2_COMP]
chunk2_raw = unicorn_lzma_decompress(stage2_loader, chunk2_comp, CHUNK2_ORIG)
assert len(chunk2_raw) == CHUNK2_ORIG
print(f"[+] LZMA 解压 ✓, 应用 BL 重定位 filter")
chunk2_filtered = apply_bl_filter(chunk2_raw)

# ─── Step 4: 解析 PACKED phdrs ───────────────────────────────────
print(f"\n[*] Step 4: 解析 PACKED libsec2026.so 的 PHDRs")
packed_phdrs = parse_elf_phdrs(packed)
for (ptype, poff, pva, pfs) in packed_phdrs:
if ptype == 1:
print(f" PT_LOAD file {hex(poff)}..{hex(poff+pfs)} "
f"vaddr {hex(pva)}..{hex(pva+pfs)}")

# ─── Step 5: 拼装 libsec2026_v3.so ────────────────────────────────
print(f"\n[*] Step 5: 拼装 libsec2026_v3.so ({TOTAL_SIZE} 字节)")
recovered = bytearray(TOTAL_SIZE)

recovered[0:CHUNK1_ORIG] = chunk1_output
print(f" [0x000..0x{CHUNK1_ORIG:x}] ELF header + phdrs (from chunk 1)")

recovered[0x238:ENC_BASE] = packed[0x238:ENC_BASE]
print(f" [0x238..0x{ENC_BASE:x}] 中间只读区 (.rodata/.eh_frame, identity copy)")

recovered[ENC_BASE:ENC_BASE + CHUNK2_ORIG] = chunk2_filtered
print(f" [0x{ENC_BASE:x}..0x{ENC_BASE + CHUNK2_ORIG:x}] 主代码段 (from chunk 2)")

# REAL PT_LOAD[2]: file 0xe6c60..0xeb5c0 ← PACKED vaddr 0xe7c60..0xec5c0
pt2_size = 0xeb5c0 - 0xe6c60 # 0x4960
recovered[0xe6c60:0xeb5c0] = read_at_vaddr(packed, packed_phdrs, 0xe7c60, pt2_size)
print(f" [0xe6c60..0xeb5c0] PT_LOAD[2]: .data.rel.ro / .got (from packed vaddr 0xe7c60)")

# REAL PT_LOAD[3]: file 0xeb5c0..0xeb780 ← PACKED vaddr 0xed5c0..0xed780 ★
pt3_size = 0xeb780 - 0xeb5c0 # 0x1c0
recovered[0xeb5c0:0xeb780] = read_at_vaddr(packed, packed_phdrs, 0xed5c0, pt3_size)
print(f" [0xeb5c0..0xeb780] PT_LOAD[3]: .data (含 'Process' 字符串, from packed vaddr 0xed5c0) ★")

with open(OUTPUT_PATH, "wb") as f:
f.write(recovered)
print(f"\n[+] 已写入 {OUTPUT_PATH} ({len(recovered)} 字节)")

# ─── 验证 ──────────────────────────────────────────────────────────
print(f"\n[*] 验证重建结果:")
checks = [
(b"\x7fELF", 0x0, "ELF magic"),
(b"extension_init", 0xd48, "extension_init 字符串 (PT_LOAD[1] identity)"),
(b"GameExtension", 0x19eca, "GameExtension 字符串 (PT_LOAD[1])"),
(b"Process", 0xeb5c4, "Process 字符串 (PT_LOAD[3] file, = vaddr 0xed5c4)"),
]
all_ok = True
for expected, off, desc in checks:
actual = bytes(recovered[off:off+len(expected)])
ok = actual == expected
all_ok = all_ok and ok
mark = "✓" if ok else "✗"
print(f" {mark} {desc}")
print(f" @ file 0x{off:x}: {actual!r}")

if all_ok:
print(f"\n[✓] 全部验证通过! 用 IDA 加载 {OUTPUT_PATH} 进行分析。")
print(f" key 起点 (vaddr 0xed5d2 = file 0xeb5d2): {bytes(recovered[0xeb5d2:0xeb5d2+16]).hex()}")
else:
print(f"\n[✗] 有检查未通过, 请检查脚本。", file=sys.stderr)
sys.exit(1)

image-20260410184313317

image-20260410184355765

方法2 动态dump

image-20260411005928650

7b84a6e000-7b84b5c000

image-20260411010047747

Sofixer修复下

image-20260411010807003

image-20260411010743020

Process分析

定位到主逻辑

image-20260410184652718

逆向发现是chacha20,初始化key

image-20260410185815705

加密主循环:

sub_5B950

image-20260410185919867

Block 生成器 sub_5BCEC

image-20260410185945945

Quarter Round:sub_5BB54

image-20260410190002155

从这看出来是标准的ChaCha20 quarter round(rotations 16, 12, 8, 7)

用 Unicorn 跑真实sub_5BCEC(ctx) 生成一个 keystream block,与 Python 实现对比, 确认算法是 ChaCha20

这里被自己手解的二进制坑了一把,libsec2026_v3.so 0xED5D2 / 0xED5F3位置的字节不是真正的key / nonce。那只是 Unicorn 模拟 LZMA 解压时输出未清零的残余数据,恰好落在以为是 key / nonce 的偏移上。原始加壳的 libsec2026.so 在运行时展开出的数据完全不同。

后面还是使用dump出来的对着看加上frida验证才发现的,建议使用动态dump

关键参数的hook

image-20260411005628836

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
const INIT_OFFSET    = 0x5b818;   // sub_5B818: ChaCha20 state init (ctx, key, nonce, counter)
const PROCESS_OFFSET = 0x4e548; // GameExtension::Process(ret, this, PackedByteArray*)

// libgodot_android.so: force Trigger2 碰撞的偏移
const GOB_OFFSET = 0x25fa6f4; // Area3D::get_overlapping_bodies
const RESIZE_OFFSET = 0x395126c; // Array::resize

function hex(buf) {
let s = "";
for (let i = 0; i < buf.length; i++) s += buf[i].toString(16).padStart(2, "0");
return s;
}

const state = { lastArea3D: null, trigger2Ptr: null };

function install() {
const sec = Process.findModuleByName("libsec2026.so");
const godot = Process.findModuleByName("libgodot_android.so");
if (!sec || !godot) { setTimeout(install, 300); return; }
console.log(`[+] libsec2026.so base = ${sec.base}`);
console.log(`[+] libgodot_android.so base = ${godot.base}`);

const initAddr = sec.base.add(INIT_OFFSET);
const procAddr = sec.base.add(PROCESS_OFFSET);
const gobAddr = godot.base.add(GOB_OFFSET);
const resizeAddr = godot.base.add(RESIZE_OFFSET);
const arrayResize = new NativeFunction(resizeAddr, 'int', ['pointer', 'int']);

// ─── Force Trigger2 collision ────────────────
Interceptor.attach(gobAddr, {
onEnter: function(args) {
this.selfPtr = args[0];
this.outPtr = this.context.x8;
state.lastArea3D = this.selfPtr.toString();
},
onLeave: function(retval) {
const shouldForce = (state.trigger2Ptr === null) ||
(this.selfPtr.toString() === state.trigger2Ptr);
if (!shouldForce) return;
try {
const arrayPrivate = this.outPtr.readPointer();
if (!arrayPrivate.isNull()) {
const dp = arrayPrivate.add(16).readPointer();
const cur = dp.isNull() ? 0 : dp.sub(8).readU64().toNumber();
if (cur > 0) return;
}
arrayResize(this.outPtr, 1);
} catch (e) {}
}
});

// 先看看 0x5b818 处的 4 字节, 确认是不是可执行代码
try {
const bytes = new Uint8Array(initAddr.readByteArray(16));
console.log(`[diag] bytes @ 0x5b818 = ${hex(bytes)}`);
} catch (e) {
console.log(`[!] 读 init bytes 失败: ${e}`);
}

// ─── Hook init: dump key (32B) + nonce (12B) + counter ─────────────
let dumped = false;
Interceptor.attach(initAddr, {
onEnter: function(args) {
if (dumped) return;
dumped = true;
// sub_5B818(ctx@X0, key@X1, nonce@X2, counter@W3)
const keyPtr = args[1];
const noncePtr = args[2];
const counter = args[3].toInt32();
try {
const key = new Uint8Array(keyPtr.readByteArray(32));
const nonce = new Uint8Array(noncePtr.readByteArray(12));
console.log(`\n ChaCha20 init 被调`);
console.log(` key = ${hex(key)}`);
console.log(` nonce = ${hex(nonce)}`);
console.log(` counter = ${counter}`);
} catch (e) {
console.log(`[!] 读 key/nonce 失败: ${e}`);
}
},
onLeave: function(retval) {
if (dumped !== true) return;
// init 结束后, ctx 里前 64 字节是 state[0..16]. state[0..3] = constant
// 但我们没有保存 ctx 指针, 只能在 onEnter 读. 改造:
}
});

// 再 hook 一次, 这次在 onEnter 保 ctx, onLeave dump state
let constDumped = false;
Interceptor.attach(initAddr, {
onEnter: function(args) {
this.ctx = args[0];
},
onLeave: function(retval) {
if (constDumped) return;
constDumped = true;
try {
const state = new Uint8Array(this.ctx.readByteArray(64));
console.log(` state[0..16] = ${hex(state.slice(0, 16))} const`);
console.log(` state[16..48] = ${hex(state.slice(16, 48))} (key)`);
console.log(` state[48..52] = ${hex(state.slice(48, 52))} (counter)`);
console.log(` state[52..64] = ${hex(state.slice(52, 64))} (nonce)`);

// 解码 constant 作 ASCII
let ascii = "";
for (let i = 0; i < 16; i++) {
const b = state[i];
ascii += (b >= 0x20 && b < 0x7f) ? String.fromCharCode(b) : ".";
}
console.log(` constant ASCII = "${ascii}"`);
} catch (e) {
console.log(`[!] 读 state 失败: ${e}`);
}
}
});

// ─── Hook Process: dump 8-byte input + 输出 String ─────────
Interceptor.attach(procAddr, {
onEnter: function(args) {
if (state.trigger2Ptr === null && state.lastArea3D !== null) {
state.trigger2Ptr = state.lastArea3D;
console.log(` Trigger2 this ptr = ${state.trigger2Ptr}`);
}
this.retSlot = args[0];
try {
const pba = args[2];
const cp = pba.readPointer();
if (!cp.isNull()) {
const sz = cp.sub(8).readU64().toNumber();
if (sz > 0 && sz <= 32) {
const bytes = new Uint8Array(sz);
for (let i = 0; i < sz; i++) bytes[i] = cp.add(i).readU8();
console.log(`\n[★] Process input (${sz} B) = ${hex(bytes)}`);
}
}
} catch (e) {}
},
onLeave: function(retval) {
try {
} catch (e) {}
}
});
}
install();

静态也能看到key

image-20260411010743020

unicorn验证下

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

try:
from unicorn import Uc, UC_ARCH_ARM64, UC_MODE_ARM, UC_PROT_ALL, UC_HOOK_CODE
from unicorn.arm64_const import (
UC_ARM64_REG_X0, UC_ARM64_REG_X1, UC_ARM64_REG_X2, UC_ARM64_REG_X3,
UC_ARM64_REG_SP, UC_ARM64_REG_LR, UC_ARM64_REG_PC,
)
except ImportError:
print("错误: 需要 unicorn。运行: pip install unicorn", file=sys.stderr)
sys.exit(1)


LIB_PATH = "libsec2026_v3.so"

# 关键函数 vaddr (重建后的 REAL libsec2026.so 里)
SUB_5BCEC = 0x5BCEC # ChaCha20 block generator
SUB_5B7C4 = 0x5B7C4 # memcpy_chk wrapper
SUB_5C138 = 0x5C138 # store_u32_le
SUB_5C2A0 = 0x5C2A0 # memset clear
PSUB_5C138_GOT = 0xEBF00 # psub_5C138 的 GOT 条目

CHACHA_CONST = b"fxpaod 31-byse k" # 16 字节, 替代 "expand 32-byte k"
KEY = b"Th1s ls n0t a rea1 key!!@sec2026" # 32 字节 ASCII
NONCE = b"012345678901" # 12 字节 ASCII

# Unicorn 内存布局
STACK_BASE = 0x10000000
STACK_SIZE = 0x100000
HEAP_BASE = 0x30000000
HEAP_SIZE = 0x100000


# Hook 函数 (handle libc GOT 调用)

def make_hook():
"""生成一个能被 unicorn 调用的 hook"""
def hook_code(uc, addr, size, user_data):
# sub_5B7C4: memcpy_chk(dst=x0, bufsize=x1, src=x2, size=x3)
# 里面会通过 GOT 调 __memcpy_chk, 那个 GOT 没解析 → 会 BR 到 0 崩
# 直接用 Python 做一次 memcpy 然后跳过整个函数
if addr == SUB_5B7C4:
x0 = uc.reg_read(UC_ARM64_REG_X0)
x2 = uc.reg_read(UC_ARM64_REG_X2)
x3 = uc.reg_read(UC_ARM64_REG_X3)
uc.mem_write(x0, bytes(uc.mem_read(x2, x3)))
uc.reg_write(UC_ARM64_REG_PC, uc.reg_read(UC_ARM64_REG_LR))
return

# sub_5C2A0: memset(dst=x0, 0, size=x1) — 清理临时 state
if addr == SUB_5C2A0:
x0 = uc.reg_read(UC_ARM64_REG_X0)
x1 = uc.reg_read(UC_ARM64_REG_X1)
uc.mem_write(x0, b"\x00" * int(x1))
uc.reg_write(UC_ARM64_REG_PC, uc.reg_read(UC_ARM64_REG_LR))
return

return hook_code


# 1. 跑真实的 ChaCha20 block generator (Unicorn)

def get_real_keystream(key: bytes, nonce: bytes, const16: bytes) -> bytes:
"""加载 libsec2026_v3.so, 调 sub_5BCEC(ctx), 返回 64 字节 keystream."""
assert len(const16) == 16
if not os.path.exists(LIB_PATH):
print(f"错误: 找不到 {LIB_PATH}, 请先跑 rebuild.py", file=sys.stderr)
sys.exit(1)

lib_data = open(LIB_PATH, "rb").read()

uc = Uc(UC_ARCH_ARM64, UC_MODE_ARM)

# 映射一大段空间覆盖整个 libsec2026_v3.so vaddr 范围 (max 0xf4130)
uc.mem_map(0, 0x200000, UC_PROT_ALL)

# 按 PHDRs 把每个 PT_LOAD 段加载到对应 vaddr
e_phoff = struct.unpack("<Q", lib_data[32:40])[0]
e_phnum = struct.unpack("<H", lib_data[56:58])[0]
for i in range(e_phnum):
po = e_phoff + i * 56
p_type = struct.unpack("<I", lib_data[po:po+4])[0]
p_offset = struct.unpack("<Q", lib_data[po+8:po+16])[0]
p_vaddr = struct.unpack("<Q", lib_data[po+16:po+24])[0]
p_filesz = struct.unpack("<Q", lib_data[po+32:po+40])[0]
if p_type == 1 and p_filesz > 0:
uc.mem_write(p_vaddr, lib_data[p_offset:p_offset+p_filesz])

# 栈和堆
uc.mem_map(STACK_BASE, STACK_SIZE, UC_PROT_ALL)
uc.mem_map(HEAP_BASE, HEAP_SIZE, UC_PROT_ALL)
uc.mem_write(PSUB_5C138_GOT, struct.pack("<Q", SUB_5C138))

# 注册 hook
uc.hook_add(UC_HOOK_CODE, make_hook())

ctx = HEAP_BASE
uc.mem_write(ctx, b"\x00" * 136) # 全零初始化
uc.mem_write(ctx, const16) # 常量 (16 B)
uc.mem_write(ctx + 16, key) # key (32 B)
uc.mem_write(ctx + 48, b"\x00" * 4) # counter = 0
uc.mem_write(ctx + 52, nonce) # nonce (12 B)

# ─── 调 sub_5BCEC(ctx) ─────
uc.reg_write(UC_ARM64_REG_X0, ctx)
uc.reg_write(UC_ARM64_REG_SP, STACK_BASE + STACK_SIZE - 0x10000)
uc.reg_write(UC_ARM64_REG_LR, 0xDEADBEEF)

try:
uc.emu_start(SUB_5BCEC, 0xDEADBEEF, timeout=30_000_000)
except Exception as e:
pc = uc.reg_read(UC_ARM64_REG_PC)
print(f"[!] Unicorn 崩了 @ pc={hex(pc)}: {e}", file=sys.stderr)
raise

# 读出 keystream
return bytes(uc.mem_read(ctx + 64, 64))


# 2. Python 参考实现 (零常量和标准常量两个版本)
def rotl32(x: int, n: int) -> int:
x &= 0xFFFFFFFF
return ((x << n) | (x >> (32 - n))) & 0xFFFFFFFF


def chacha20_block(key: bytes, nonce: bytes, counter: int, constants) -> bytes:
"""标准 ChaCha20 20-round block 函数, 常量可配置。"""
state = list(constants) \
+ list(struct.unpack("<8I", key)) \
+ [counter] \
+ list(struct.unpack("<3I", nonce))
w = state[:]

def qr(a, b, c, d):
w[a] = (w[a] + w[b]) & 0xFFFFFFFF
w[d] = rotl32(w[d] ^ w[a], 16)
w[c] = (w[c] + w[d]) & 0xFFFFFFFF
w[b] = rotl32(w[b] ^ w[c], 12)
w[a] = (w[a] + w[b]) & 0xFFFFFFFF
w[d] = rotl32(w[d] ^ w[a], 8)
w[c] = (w[c] + w[d]) & 0xFFFFFFFF
w[b] = rotl32(w[b] ^ w[c], 7)

for _ in range(10): # 10 双轮 = 20 轮
# Column rounds
qr(0, 4, 8, 12); qr(1, 5, 9, 13); qr(2, 6, 10, 14); qr(3, 7, 11, 15)
# Diagonal rounds
qr(0, 5, 10, 15); qr(1, 6, 11, 12); qr(2, 7, 8, 13); qr(3, 4, 9, 14)

# 加回初始状态
out = [(w[i] + state[i]) & 0xFFFFFFFF for i in range(16)]
return struct.pack("<16I", *out)


# 3. 对比验证
def main():
# 1. Unicorn 跑真实 sub_5BCEC, 用真实的常量/key/nonce
print("[1] 用 Unicorn 执行 libsec2026_v3.so 里的 sub_5BCEC(ctx)...")
real = get_real_keystream(KEY, NONCE, CHACHA_CONST)
print(f" Real (Unicorn):")
print(f" {real[:32].hex()}")
print(f" {real[32:].hex()}")

# 2. Python 三个候选常量
variants = {
"zero": [0, 0, 0, 0],
"expand": list(struct.unpack("<4I", b"expand 32-byte k")),
"fxpaod": list(struct.unpack("<4I", CHACHA_CONST)),
}

results = {}
for name, constants in variants.items():
py = chacha20_block(KEY, NONCE, 0, constants=constants)
match = (real == py)
results[name] = match
print()
print(f"[{name}] Python ChaCha20 constants = {name!r}")
print(f" {py[:32].hex()}")
print(f" {py[32:].hex()}")
print(f" MATCH: {match}")



if __name__ == "__main__":
sys.exit(main())

image-20260411004930806

结果验证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
#!/usr/bin/env python3
import struct

CHACHA_CONST = b"fxpaod 31-byse k" # 16 字节
KEY_BYTES = b"Th1s ls n0t a rea1 key!!@sec2026" # 32 字节
NONCE_BYTES = b"012345678901" # 12 字节

assert len(CHACHA_CONST) == 16
assert len(KEY_BYTES) == 32
assert len(NONCE_BYTES) == 12
CHACHA_CONST_WORDS = struct.unpack("<4I", CHACHA_CONST)


def rotl32(x, n):
"""32-bit left rotation."""
x &= 0xFFFFFFFF
return ((x << n) | (x >> (32 - n))) & 0xFFFFFFFF


def quarter_round(state, a, b, c, d):
"""Standard ChaCha20 quarter round."""
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, counter, nonce):
state = list(CHACHA_CONST_WORDS)
state += list(struct.unpack("<8I", key))
state.append(counter)
state += list(struct.unpack("<3I", nonce))

working = state[:]

# 20 rounds = 10 × (column round + diagonal round)
for _ in range(10):
# Column round
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 round
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 initial state back
out = [(working[i] + state[i]) & 0xFFFFFFFF for i in range(16)]
return struct.pack("<16I", *out)


def chacha20_encrypt(key, nonce, plaintext, counter=0):
ciphertext = bytearray(len(plaintext))
pos = 0
block_counter = counter
while pos < len(plaintext):
keystream = chacha20_block(key, block_counter, nonce)
block_len = min(64, len(plaintext) - pos)
for i in range(block_len):
ciphertext[pos + i] = plaintext[pos + i] ^ keystream[i]
pos += block_len
block_counter += 1
return bytes(ciphertext)


# xor_enc
def xor_enc(plain: bytes) -> bytes:
assert len(plain) == 8
r = bytearray(plain)
for i in range(7):
r[i] = r[i] ^ r[i + 1]
r[7] = r[7] ^ r[0] # note: r[0] is NEW (= a^b)
return bytes(r)

def xor_enc_inverse(x: bytes) -> bytes:
assert len(x) == 8
h = x[7] ^ x[0]
g = x[6] ^ h
f = x[5] ^ g
e = x[4] ^ f
d = x[3] ^ e
c = x[2] ^ d
b = x[1] ^ c
a = x[0] ^ b
return bytes([a, b, c, d, e, f, g, h])

FLAG_PREFIX = "sec2026_PART1_"


def token_to_flag(token: str) -> str:
"""

1. token 的 ASCII 字节
2. xor_enc
3. ChaCha20 (constant = "fxpaod 31-byse k")
4. 每字节 %02X 大写 hex 格式化 → 16 字符
5. 拼接成 flag{sec2026_PART1_...}
"""
assert len(token) == 8
token_bytes = token.encode("ascii")
xored = xor_enc(token_bytes)
ciphertext = chacha20_encrypt(KEY_BYTES, NONCE_BYTES, xored)
hex_out = "".join(f"{b:02X}" for b in ciphertext)
return f"flag{{{FLAG_PREFIX}{hex_out}}}"


def flag_to_token(flag: str) -> str:
inner = flag.strip()
assert inner.startswith("flag{") and inner.endswith("}")
inner = inner[5:-1]
assert inner.startswith(FLAG_PREFIX)
hex_part = inner[len(FLAG_PREFIX):]
assert len(hex_part) == 16
ciphertext = bytes.fromhex(hex_part)
xored = chacha20_encrypt(KEY_BYTES, NONCE_BYTES, ciphertext)
token_bytes = xor_enc_inverse(xored)
return token_bytes.decode("ascii")

if __name__ == "__main__":
import sys
if len(sys.argv) != 2:
print("用法:", file=sys.stderr)
print(f" {sys.argv[0]} <8 hex chars> # forward: token → flag", file=sys.stderr)
print(f" {sys.argv[0]} flag{{sec2026_PART1_...}} # reverse: flag → token", file=sys.stderr)
sys.exit(1)
arg = sys.argv[1]
if arg.startswith("flag{"):
print(f"token = {flag_to_token(arg)}")
elif len(arg) == 8:
print(f"flag = {token_to_flag(arg)}")
else:
print("用法:", file=sys.stderr)
print(f" {sys.argv[0]} <8 hex chars> # forward: token → flag", file=sys.stderr)
print(f" {sys.argv[0]} flag{{sec2026_PART1_...}} # reverse: flag → token", file=sys.stderr)
sys.exit(1)

image-20260411004318524

CPP实现

用Flag获取里的实际例子验证

编译:g++ -std=c++17 -O2 -Wall -Wextra flag_solve.cpp -o flag_solve

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
#include <array>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <iostream>
#include <sstream>
#include <stdexcept>
#include <string>
#include <string_view>
#include <vector>

namespace solver {
constexpr std::array<uint8_t, 32> KEY_BYTES = {
// "Th1s ls n0t a rea1 key!!@sec2026"
'T','h','1','s',' ','l','s',' ','n','0','t',' ','a',' ','r','e',
'a','1',' ','k','e','y','!','!','@','s','e','c','2','0','2','6',
};

constexpr std::array<uint8_t, 12> NONCE_BYTES = {
// "012345678901"
'0','1','2','3','4','5','6','7','8','9','0','1',
};

// ChaCha20 常量 "fxpaod 31-byse k"
constexpr std::array<uint8_t, 16> CHACHA_CONST = {
'f','x','p','a','o','d',' ','3','1','-','b','y','s','e',' ','k',
};

static const std::string FLAG_PREFIX = "sec2026_PART1_";

// ChaCha20 with scrambled "fxpaod 31-byse k" constant
static inline uint32_t rotl32(uint32_t x, int n) {
return (x << n) | (x >> (32 - n));
}

// 小端载入 32-bit
static inline uint32_t load_u32_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);
}

// 小端存储 32-bit
static inline void store_u32_le(uint8_t* p, uint32_t v) {
p[0] = (uint8_t)(v);
p[1] = (uint8_t)(v >> 8);
p[2] = (uint8_t)(v >> 16);
p[3] = (uint8_t)(v >> 24);
}

//quarter round
static inline void quarter_round(uint32_t& a, uint32_t& b, uint32_t& c, uint32_t& d) {
a += b; d = rotl32(d ^ a, 16);
c += d; b = rotl32(b ^ c, 12);
a += b; d = rotl32(d ^ a, 8);
c += d; b = rotl32(b ^ c, 7);
}

static void chacha20_block(const uint8_t key[32],
uint32_t counter,
const uint8_t nonce[12],
uint8_t out[64])
{
uint32_t state[16];

// state[0..4] = "fxpaod 31-byse k" as 4 × LE u32 (scrambled constant)
for (int i = 0; i < 4; ++i)
state[i] = load_u32_le(CHACHA_CONST.data() + i * 4);
// state[4..12] = 8 × 32-bit key
for (int i = 0; i < 8; ++i)
state[4 + i] = load_u32_le(key + i * 4);
// state[12] = 32-bit counter
state[12] = counter;
// state[13..16] = 3 × 32-bit nonce
for (int i = 0; i < 3; ++i)
state[13 + i] = load_u32_le(nonce + i * 4);

uint32_t w[16];
std::memcpy(w, state, sizeof(state));

// 20 rounds = 10 × (column round + diagonal round)
for (int r = 0; r < 10; ++r) {
// Column round
quarter_round(w[0], w[4], w[ 8], w[12]);
quarter_round(w[1], w[5], w[ 9], w[13]);
quarter_round(w[2], w[6], w[10], w[14]);
quarter_round(w[3], w[7], w[11], w[15]);
// Diagonal round
quarter_round(w[0], w[5], w[10], w[15]);
quarter_round(w[1], w[6], w[11], w[12]);
quarter_round(w[2], w[7], w[ 8], w[13]);
quarter_round(w[3], w[4], w[ 9], w[14]);
}

// Add initial state back and serialize as little-endian
for (int i = 0; i < 16; ++i)
store_u32_le(out + i * 4, w[i] + state[i]);
}

// ChaCha20 stream 加密
static std::vector<uint8_t> chacha20_encrypt(const uint8_t key[32],
const uint8_t nonce[12],
const std::vector<uint8_t>& in,
uint32_t counter = 0)
{
std::vector<uint8_t> out(in.size());
uint8_t keystream[64];
size_t pos = 0;
while (pos < in.size()) {
chacha20_block(key, counter, nonce, keystream);
size_t block_len = std::min<size_t>(64, in.size() - pos);
for (size_t i = 0; i < block_len; ++i)
out[pos + i] = in[pos + i] ^ keystream[i];
pos += block_len;
++counter;
}
return out;
}

// xor_enc和它的解析逆运算
static std::array<uint8_t, 8> xor_enc(const std::array<uint8_t, 8>& plain) {
std::array<uint8_t, 8> r = plain;
for (int i = 0; i < 7; ++i) {
r[i] = r[i] ^ r[i + 1];
}
r[7] = r[7] ^ r[0]; // r[0] 此时已是 a^b
return r;
}

// 解析逆运算:
static std::array<uint8_t, 8> xor_enc_inverse(const std::array<uint8_t, 8>& x) {
std::array<uint8_t, 8> r;
uint8_t h = x[7] ^ x[0];
uint8_t g = x[6] ^ h;
uint8_t f = x[5] ^ g;
uint8_t e = x[4] ^ f;
uint8_t d = x[3] ^ e;
uint8_t c = x[2] ^ d;
uint8_t b = x[1] ^ c;
uint8_t a = x[0] ^ b;
r[0] = a; r[1] = b; r[2] = c; r[3] = d;
r[4] = e; r[5] = f; r[6] = g; r[7] = h;
return r;
}

// 完整 token ↔ flag 映射
std::string token_to_flag(std::string_view token) {
if (token.size() != 8) {
throw std::invalid_argument("token must be exactly 8 ASCII characters");
}

// Step 1: ASCII → bytes
std::array<uint8_t, 8> token_bytes;
for (size_t i = 0; i < 8; ++i)
token_bytes[i] = static_cast<uint8_t>(token[i]);

// Step 2: xor_enc
auto xored = xor_enc(token_bytes);

// Step 3: ChaCha20
std::vector<uint8_t> xored_vec(xored.begin(), xored.end());
auto ciphertext = chacha20_encrypt(KEY_BYTES.data(), NONCE_BYTES.data(), xored_vec);

// Step 4: hex format
std::string hex_out;
hex_out.reserve(16);
char buf[3];
for (uint8_t b : ciphertext) {
std::snprintf(buf, sizeof(buf), "%02X", b);
hex_out.append(buf, 2);
}

// Step 5: 拼装
return "flag{" + FLAG_PREFIX + hex_out + "}";
}

// ─────── reverse: flag → token ───────
std::string flag_to_token(std::string_view flag) {
const std::string prefix = "flag{" + FLAG_PREFIX;
const std::string suffix = "}";

// 去掉首尾空白
while (!flag.empty() && std::isspace(static_cast<unsigned char>(flag.front()))) flag.remove_prefix(1);
while (!flag.empty() && std::isspace(static_cast<unsigned char>(flag.back()))) flag.remove_suffix(1);

if (flag.substr(0, prefix.size()) != prefix ||
flag.substr(flag.size() - suffix.size()) != suffix) {
throw std::invalid_argument("flag format invalid: expected flag{" + FLAG_PREFIX + "XXXXXXXXXXXXXXXX}");
}
std::string_view hex_part = flag.substr(prefix.size(),
flag.size() - prefix.size() - suffix.size());
if (hex_part.size() != 16) {
throw std::invalid_argument("hex part must be exactly 16 characters");
}

// Step 2: hex → bytes
std::vector<uint8_t> ciphertext(8);
auto hex_val = [](char c) -> int {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
throw std::invalid_argument("invalid hex character");
};
for (size_t i = 0; i < 8; ++i) {
int hi = hex_val(hex_part[i * 2]);
int lo = hex_val(hex_part[i * 2 + 1]);
ciphertext[i] = static_cast<uint8_t>((hi << 4) | lo);
}

// Step 3: ChaCha20 解密
auto xored_vec = chacha20_encrypt(KEY_BYTES.data(), NONCE_BYTES.data(), ciphertext);
std::array<uint8_t, 8> xored;
std::copy(xored_vec.begin(), xored_vec.end(), xored.begin());

// Step 4: xor_enc_inverse
auto token_bytes = xor_enc_inverse(xored);

// Step 5: bytes → ASCII string
std::string token;
token.reserve(8);
for (auto b : token_bytes) {
if (b < 0x20 || b > 0x7e) {
std::ostringstream oss;
oss << "recovered token byte 0x" << std::hex << (int)b
<< " is not printable ASCII (flag is probably malformed)";
throw std::runtime_error(oss.str());
}
token.push_back(static_cast<char>(b));
}
return token;
}

}


int main(int argc, char** argv) {
using namespace solver;
try {
if (argc != 2) {
std::cerr << "用法:\n";
std::cerr << " " << argv[0] << " <8 hex chars> # forward: token → flag\n";
std::cerr << " " << argv[0] << " flag{sec2026_PART1_...} # reverse: flag → token\n";
return 1;
}

std::string arg = argv[1];

if (arg.rfind("flag{", 0) == 0) {
// 看起来像 flag → 逆算法
std::string token = flag_to_token(arg);
std::cout << "token = " << token << "\n";
} else if (arg.size() == 8) {
// 看起来像 token → 正算法
std::string flag = token_to_flag(arg);
std::cout << "flag = " << flag << "\n";
} else {
std::cerr << "用法:\n";
std::cerr << " " << argv[0] << " <8 hex chars> # forward: token → flag\n";
std::cerr << " " << argv[0] << " flag{sec2026_PART1_...} # reverse: flag → token\n";
return 1;
}
} catch (const std::exception& e) {
std::cerr << "错误: " << e.what() << "\n";
return 1;
}
return 0;
}

image-20260411003559696