2026腾讯游戏安全竞赛Android初赛 wp 初赛对godot的理解不够,以前学习的主要是UE4相关,godot还是第一次搞,godot也有类似ue4那种dump,决赛才发现.
题目附件:下载压缩包
主要目标
正算法:根据屏幕左上角的随机token生成右上角的flag
逆算法:根据屏幕右上角的flag反推左上角的token
按实现的算法数量和完成度计分
到达绿色方块也可获得flag
Flag获取 [Frida Hook碰撞] 更简单的方法是把属性dump出来直接去触发碰撞,我这样调得出来的效率太低了
补充patch掉校验 主动触发 然后重新编译打包 失败会闪退 估计是得用原来的引擎或版本不对?
这个其实应该放在后面的 ,我是解完包,逆完算法在开始的,还好搞了,不对不知道,自己逆向的算法搞错了
要想获得flag 无非几种常见思路
主动调用process
主动撞箱子,飞上去
主动触发装箱函数
由于初次解出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 正在重叠,碰撞到的物体列表
两个难点:
区分 Trigger1 vs Trigger2 。Trigger1 每帧都能触发 (玩家若撞到它),它会写flag1可能覆盖 Trigger2 的flag。必须只给 Trigger2 force,不动 Trigger1。
找到真正被 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)
反编译看起来非常像 get_overlapping_bodies:sub_3957654 构造 Array 检查monitoring flag 遍历链表 sub_395126Cresize 返回。但里面Array::set_typed 传的类名字符串是Node2D:
1 2 3 Node2D = "Node2D" ; sub_3C5FA18(&Node2D, &obj_); sub_3956F20(a2, 24 , &obj_, ...);
把 0x27af370 塞进 Frida hook 后,调用次数一直是 0 ,但 Array::resize 探针每 2 秒能记到 20+ 次调用,证明 Frida 能 hook libgodot_android.so,就是这个函数从来没被执行过 , 它是 Area2D::get_overlapping_bodies ,游戏里没 Area2D 节点,所以根本不跑。find_overlapping_bodies.py 脚本的启发式把 Area2D 和 Area3D 弄反了。
脚本就不贴了,没啥用,思想就是
就是扫 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:
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); Node3D = "Node3D" ; sub_3C5FA18(&Node3D, &obj__1); sub_3956F20(a2, 24 , &obj__1, &Node3D); if ((*(_BYTE *)(a1 + 1400 ) & 1 ) == 0 ) return error(); sub_395126C(a2, *(_DWORD *)(a1 + 1444 )); v5 = *(_QWORD **)(a1 + 1424 ); return sub_395126C(a2, v6); }
识别 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)))
所以:
每次 Area3D::get_overlapping_bodies 进入时记下 state.lastArea3D = args[0]
每次 GameExtension::Process 进入时,当前调用它的就是 Trigger2,而此时 lastArea3D就是 Trigger2 的 this 指针 (因为 Trigger2 的 _process先调 get_overlapping_bodies 再调 Process,中间没别的 Area3D 调用)
缓存 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); }, }; 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 , trigger2Ptr : null , realFlag : null , forceCount : 0 , }; 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} ` ); 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' ]); let resizeHits = 0 ; Interceptor .attach (resizeAddr, { onEnter : function (args ) { resizeHits++; } }); setTimeout (function ( ) { console .log (`[probe] Array::resize 被调用 ${resizeHits} 次 (应该 > 0)` ); }, 2000 ); let gobCallCount = 0 ; const seenPtrs = new Set (); Interceptor .attach (gobAddr, { onEnter : function (args ) { 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 ) { let shouldForce; if (state.trigger2Ptr === null ) { shouldForce = true ; } else { shouldForce = (this .selfPtr .toString () === state.trigger2Ptr ); } if (!shouldForce) return ; 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} ` ); } } }); 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` ); Interceptor .attach (procAddr, { onEnter : function (args ) { 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 ]; 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 ) { 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 ();
这里的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 , VARIANT_PACKED_VECTOR4_ARRAY = 53 ,
写 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: ... case OBJECT_EXTERNAL_RESOURCE: ... case OBJECT_INTERNAL_RESOURCE: uint32_t index = f->get_32 (); case OBJECT_EXTERNAL_RESOURCE_INDEX: uint32_t index = f->get_32 (); } }
在 4.5 文件里几乎只见 2 和 3,都只是一个 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 自然形成树。
完整代码
解包 寻找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 mathfrom collections import CounterLIB = "preliminary/lib/arm64-v8a/libgodot_android.so" with open (LIB, "rb" ) as f: blob = f.read() 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:.2 f} file off=0x{off:08x} section={sec} " ) print (f" {win.hex ().upper()} " )
输出
1 2 .data + 0x4f08 (va = 0x400edf0) CE4DF8753B59A5A39ADE58AC07EF947A3DA39F2AF75E3284D51217C04D49A061
在IDA看
找到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 密钥保护。
追踪调用链:
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的特征
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; iv[n] = ct[i] ^ 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 osfrom cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modesKEY = 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 文本:
反编译 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 混淆)
使用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`源码。
可以看到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 ] 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 指令
应该是被壳或者其它东西保护了
start 是合法的 ARM64 代码,是加壳器的解密 stub,运行时把真正的代码解密到内存中。
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 structdef 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 : dst.append(read_lit()) continue 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 BLOB_SIZE = 4129 EXPECTED_SIZE = 6048 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() 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 import ida_bytesimport ida_segmentimport ida_uaimport ida_funcsimport ida_autoimport idaapiimport idautilsimport idcdef 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) BLOB_VADDR = 0x69A6C BLOB_SIZE = 4129 SEG_BASE = 0x200000 SEG_SIZE = 0x2000 ENTRY_VADDR = SEG_BASE + 0x10 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)} " 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 , SEG_BASE, SEG_BASE + SEG_SIZE, "unpacked" , "CODE" , ) if not ok: raise RuntimeError("add_segm 失败" ) seg = ida_segment.get_segm_by_name("unpacked" ) seg.bitness = 2 seg.perm = 5 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) print (f"[*] 在 {hex (ENTRY_VADDR)} (stage 2 entry) 创建函数" )ida_bytes.del_items(ENTRY_VADDR, ida_bytes.DELIT_SIMPLE, SEG_SIZE - 0x10 ) ida_ua.create_insn(ENTRY_VADDR) ida_funcs.add_func(ENTRY_VADDR) 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 } 个函数未显示" )
执行效果
在sub_201150发现
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 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 structimport osimport sysfrom unicorn import Uc, UC_ARCH_ARM64, UC_MODE_ARM, UC_PROT_ALLfrom 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" STAGE2_BASE = 0x200000 STAGE2_SIZE = 0x2000 SUB_20024C_OFFSET = 0x24C 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_SIZEprint (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) 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)} " )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)} 字节)" )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_BASE = 0x900000 uc.mem_map(SCRATCH_BASE, 0x10000 , UC_PROT_ALL) 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 ()} " )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 ()} " )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
可以当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字节总大小
分析 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...'
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 osimport structimport sysdef 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) 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 ) 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) 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 : 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) def main (): PACKED_PATH = "preliminary/lib/arm64-v8a/libsec2026.so" OUTPUT_PATH = "libsec2026_v3.so" STAGE1_BLOB_VADDR = 0x69A6C STAGE1_BLOB_SIZE = 4129 STAGE2_EXPECTED = 6048 ENC_BASE = 0x4b070 CHUNK1_DATA = 0x4b094 CHUNK1_ORIG = 568 CHUNK1_COMP = 179 CHUNK2_DATA = 0x4b153 CHUNK2_ORIG = 637936 CHUNK2_COMP = 124681 TOTAL_SIZE = 966832 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)} 字节" ) 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)} 字节" ) 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 ]} " ) 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) 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)} " ) 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)" ) pt2_size = 0xeb5c0 - 0xe6c60 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)" ) pt3_size = 0xeb780 - 0xeb5c0 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 )
方法2 动态dump
7b84a6e000-7b84b5c000
Sofixer修复下
Process分析 定位到主逻辑
逆向发现是chacha20,初始化key
加密主循环:
sub_5B950
Block 生成器 sub_5BCEC
Quarter Round:sub_5BB54
从这看出来是标准的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
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 ; const PROCESS_OFFSET = 0x4e548 ; const GOB_OFFSET = 0x25fa6f4 ; const RESIZE_OFFSET = 0x395126c ; 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' ]); 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) {} } }); try { const bytes = new Uint8Array (initAddr.readByteArray (16 )); console .log (`[diag] bytes @ 0x5b818 = ${hex(bytes)} ` ); } catch (e) { console .log (`[!] 读 init bytes 失败: ${e} ` ); } let dumped = false ; Interceptor .attach (initAddr, { onEnter : function (args ) { if (dumped) return ; dumped = true ; 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 ; } }); 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)` ); 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} ` ); } } }); 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
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 structimport sysimport ostry : 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" SUB_5BCEC = 0x5BCEC SUB_5B7C4 = 0x5B7C4 SUB_5C138 = 0x5C138 SUB_5C2A0 = 0x5C2A0 PSUB_5C138_GOT = 0xEBF00 CHACHA_CONST = b"fxpaod 31-byse k" KEY = b"Th1s ls n0t a rea1 key!!@sec2026" NONCE = b"012345678901" STACK_BASE = 0x10000000 STACK_SIZE = 0x100000 HEAP_BASE = 0x30000000 HEAP_SIZE = 0x100000 def make_hook (): """生成一个能被 unicorn 调用的 hook""" def hook_code (uc, addr, size, user_data ): 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 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 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) uc.mem_map(0 , 0x200000 , UC_PROT_ALL) 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)) uc.hook_add(UC_HOOK_CODE, make_hook()) ctx = HEAP_BASE uc.mem_write(ctx, b"\x00" * 136 ) uc.mem_write(ctx, const16) uc.mem_write(ctx + 16 , key) uc.mem_write(ctx + 48 , b"\x00" * 4 ) uc.mem_write(ctx + 52 , nonce) 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 return bytes (uc.mem_read(ctx + 64 , 64 )) 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 ): qr(0 , 4 , 8 , 12 ); qr(1 , 5 , 9 , 13 ); qr(2 , 6 , 10 , 14 ); qr(3 , 7 , 11 , 15 ) 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) def main (): 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 ()} " ) 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())
结果验证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 import structCHACHA_CONST = b"fxpaod 31-byse k" KEY_BYTES = b"Th1s ls n0t a rea1 key!!@sec2026" NONCE_BYTES = b"012345678901" 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[:] for _ in range (10 ): quarter_round(working, 0 , 4 , 8 , 12 ) quarter_round(working, 1 , 5 , 9 , 13 ) quarter_round(working, 2 , 6 , 10 , 14 ) quarter_round(working, 3 , 7 , 11 , 15 ) quarter_round(working, 0 , 5 , 10 , 15 ) quarter_round(working, 1 , 6 , 11 , 12 ) quarter_round(working, 2 , 7 , 8 , 13 ) quarter_round(working, 3 , 4 , 9 , 14 ) out = [(working[i] + state[i]) & 0xFFFFFFFF for i in range (16 )] return struct.pack("<16I" , *out) def chacha20_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) 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 ] 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 )
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 = { '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 = { '0' ,'1' ,'2' ,'3' ,'4' ,'5' ,'6' ,'7' ,'8' ,'9' ,'0' ,'1' , }; 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_" ;static inline uint32_t rotl32 (uint32_t x, int n) { return (x << n) | (x >> (32 - n)); } 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 ); } 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 ); } 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 ]; for (int i = 0 ; i < 4 ; ++i) state[i] = load_u32_le (CHACHA_CONST.data () + i * 4 ); for (int i = 0 ; i < 8 ; ++i) state[4 + i] = load_u32_le (key + i * 4 ); state[12 ] = counter; 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)); for (int r = 0 ; r < 10 ; ++r) { 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 ]); 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 ]); } for (int i = 0 ; i < 16 ; ++i) store_u32_le (out + i * 4 , w[i] + state[i]); } 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; } 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 ]; 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; } std::string token_to_flag (std::string_view token) { if (token.size () != 8 ) { throw std::invalid_argument ("token must be exactly 8 ASCII characters" ); } std::array<uint8_t , 8> token_bytes; for (size_t i = 0 ; i < 8 ; ++i) token_bytes[i] = static_cast <uint8_t >(token[i]); auto xored = xor_enc (token_bytes); std::vector<uint8_t > xored_vec (xored.begin(), xored.end()) ; auto ciphertext = chacha20_encrypt (KEY_BYTES.data (), NONCE_BYTES.data (), xored_vec); 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 ); } return "flag{" + FLAG_PREFIX + hex_out + "}" ; } 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" ); } 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); } 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 ()); auto token_bytes = xor_enc_inverse (xored); 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 ) { std::string token = flag_to_token (arg); std::cout << "token = " << token << "\n" ; } else if (arg.size () == 8 ) { 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 ; }