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

题目与初赛一样,都是godot

题目附件:

1
2
通过网盘分享的文件:2026_end.zip
链接: https://pan.baidu.com/s/1uCq1F9jPE9kby2NKxwrPyg?pwd=cjj2 提取码: cjj2

题目附件:下载压缩包

ps:一些部分其实写的不太详细,后面有机会再补充吧,赛中使用了codex加速解题,wp中给的顺序非实际做题顺序,做题时没有处理反调试,因为10s的kill时间足够frida输出信息了,反调试是最后去了混淆再处理的,因此下面使用frida分析的动态调代码都会触发反调试,但是信息正常输出,在反调试章节给出了最终处理反调试的frida代码,和一些其它现象。

去混淆部分写的特别乱,主要当时写wp的时候还是有点懵,有空再整理

解包去混淆

这一部分其实跟初赛一样,甚至简化了

找key & 解密gdc

首先找脚本加密 key,与初赛相同,Godot 4.5 用 AES-256 CFB 加密 .gdc 文件,key 存在libgodot_android.so 的 .data段中

沿用初赛脚本做熵扫描,在段中找连续 32 字节高熵区域(前后被零填充包围)。

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

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

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

SECTIONS = {
".rodata": (0x3aa660, 0x625b48),
".data.rel.ro": (0x3ea3560, 0x156370),
".data": (0x3ffe008, 0x7108),
}

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()}")

key在0x4002f18

1
CE4DF8753B59A5A39ADE58AC07EF947A3DA39F2AF75E3284D51217C04D49A061

与初赛相同,用初赛的解密脚本报错了,检查了一下发现,GEQ = GDSC ^ {0x00, 0x01, 0x02, 0x03},是标准的CFB-128,没有XOR,微调了下初赛的脚本

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
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, modified=False):
"""CFB-128 decrypt adds XOR-with-n"""
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]
if modified:
pt[i] = iv[n] ^ c ^ n
iv[n] = c ^ n
else:
pt[i] = iv[n] ^ c
iv[n] = c
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 = [
"final/assets/token.gdc",
"final/assets/label2.gdc",
"final/assets/spedometer.gdc",
"final/assets/Trigger/trigger1.gdc",
"final/assets/Trigger/trigger2.gdc",
"final/assets/Trigger/trigger3.gdc",
"final/assets/Trigger/trigger4.gdc",
"final/assets/car_select/car_select.gdc",
"final/assets/vehicles/vehicle.gdc",
"final/assets/vehicles/follow_camera.gdc",
"final/assets/ext/sec2026.gdextension",
]
os.makedirs("decrypted", exist_ok=True)
for path in files:
pt = decrypt_file(path)
# save
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()

运行脚本发现提取出来的

gdre_tools 反编译

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

image-20260418101555308

是类似的乱码,猜测是被混淆了,因为之前在华为杯看到过类似的题可能是改了opcode的映射?

通过对比初赛和决赛 .gdc 文件结构发现

初赛Godot 4.5 格式

  • header: GDSC magic + version(101) + plaintext_size + zstd 压缩数据
  • 解压后是 4 个 dword 计数 (idents, consts, lines, tokens)
  • identifiers: length(4) + 每字符 4 字节 (UTF-32 LE XOR 0xb6b6b6b6)
  • constants: 标准 Variant 序列化
  • lines: 每条 8 字节 (token_idx + line_num)
  • tokens: 每条 4 字节 (低 8 位 type + 高 24 位 data)

决赛

  • 计算每个 .gdc 的 tail 区域剩余字节,拟合不同 token 大小 ,tokens 8字节 + lines 16字节 是 完全拟合(trigger1: 235*8 + 38*16 = 2488
  • token 改为 8 字节:4 字节 (type+data) + 4 字节 (line number)
  • lines 改为 16 字节:4 dword (token_idx, line, col, ?)

用新格式解析,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
import zstandard as zstd
import struct
import sys

TOKEN_NAMES = {
0x8c: "IDENT", # IDENTIFIER (parameterized: data = ident index)
0x8d: "CONST", # LITERAL (parameterized: data = const index)
0xc6: "extends",
0xc7: "func",
0xcd: "signal",
0xd1: "var",
0xd5: "return",
0xd4: "if",
0xa0: "+",
0xa1: "-",
0xa2: "*",
0xa3: "/",
0xa4: "%",
0xa6: "=",
0xa7: "+=",
0xa8: "-=",
0xa9: "*=",
0xaa: "/=",
0xa5: "**",
0xab: "%=",
0xd8: "(",
0xd9: ")",
0xda: ",",
0xdb: ";",
0xdc: ".",
0xdf: ":",
0xe0: "$",
0xe1: "->",
0xb3: "INDENT",
0xbb: "DEDENT",
0xdf: ":",
# Comparison
0x92: "==",
0x93: "!=",
0x94: "<",
0x95: "<=",
0x96: ">",
0x97: ">=",
# Logic
0x9a: "and",
0x9b: "or",
0x9c: "not",
# Constants
0xb7: "self",
0xbe: "true",
0xbf: "false",
# Unknown → mark
}

ENCODE_FLAG_64 = 1

def parse_variant(plain, pos):
header = int.from_bytes(plain[pos:pos+4], "little"); pos += 4
vtype = header & 0xFF
flags = header >> 16
if vtype == 0: return ("NIL",), pos
elif vtype == 1: return ("BOOL", bool(int.from_bytes(plain[pos:pos+4], "little"))), pos+4
elif vtype == 2:
sz = 8 if (flags & 1) else 4
return ("INT", int.from_bytes(plain[pos:pos+sz], "little", signed=True)), pos+sz
elif vtype == 3:
if flags & 1: return ("FLOAT", struct.unpack("<d", plain[pos:pos+8])[0]), pos+8
else: return ("FLOAT", struct.unpack("<f", plain[pos:pos+4])[0]), pos+4
elif vtype == 4 or vtype == 21:
slen = int.from_bytes(plain[pos:pos+4], "little"); pos += 4
sval = plain[pos:pos+slen].decode("utf-8", errors="replace"); pos += (slen + 3) & ~3
return ("STRING" if vtype==4 else "NODE_PATH", sval), pos
return ("?", vtype), pos

def parse_gdc(path):
data = open(path, "rb").read()
size = int.from_bytes(data[8:12], "little")
plain = zstd.ZstdDecompressor().decompress(data[12:], max_output_size=size)
counts = [int.from_bytes(plain[i*4:i*4+4], "little") for i in range(4)]
ident_n, const_n, line_n, token_n = counts
pos = 16
idents = []
for _ in range(ident_n):
n = int.from_bytes(plain[pos:pos+4], "little"); pos += 4
idents.append(''.join(chr(int.from_bytes(bytes(b ^ 0xb6 for b in plain[pos+i*4:pos+i*4+4]), "little")) for i in range(n)))
pos += 4*n
constants = []
for _ in range(const_n):
v, pos = parse_variant(plain, pos)
constants.append(v)
pos += 16 * line_n # skip lines section
tokens = []
for i in range(token_n):
word = int.from_bytes(plain[pos:pos+4], "little")
line = int.from_bytes(plain[pos+4:pos+8], "little")
t = word & 0xff
d = word >> 8
tokens.append((t, d, line))
pos += 8
return idents, constants, tokens

def fmt_const(v):
if v[0] == "NIL": return "null"
if v[0] == "BOOL": return "true" if v[1] else "false"
if v[0] == "INT": return str(v[1])
if v[0] == "FLOAT":
f = v[1]
if f == int(f): return f"{f:.1f}"
return repr(f)
if v[0] == "STRING": return repr(v[1])
if v[0] == "NODE_PATH": return f"^{v[1]!r}"
return str(v)

def decompile(idents, constants, tokens):
"""Render tokens line-by-line."""
by_line = {}
for t, d, line in tokens:
by_line.setdefault(line, []).append((t, d))
out_lines = []
indent = 0
for line in sorted(by_line.keys()):
toks = by_line[line]
parts = []
for t, d in toks:
if t == 0x8c:
parts.append(idents[d] if d < len(idents) else f"IDENT_{d}")
elif t == 0x8d:
parts.append(fmt_const(constants[d]) if d < len(constants) else f"CONST_{d}")
elif t in TOKEN_NAMES:
name = TOKEN_NAMES[t]
if name == "INDENT": indent += 1; continue
elif name == "DEDENT": indent -= 1; continue
parts.append(name)
else:
parts.append(f"<0x{t:02x}>")
if not parts: continue
# Heuristic spacing
text = ""
for i, p in enumerate(parts):
if p in (".", ",", ":", ";", ")", "]"):
text += p
elif p in ("(", "$", "["):
if i > 0 and not text.endswith((".", "(", "$", " ")):
text += " " if text and text[-1].isalnum() or text[-1] == ")" else ""
text += p
else:
if text and not text.endswith((" ", "(", "$", ".", "[")):
text += " "
text += p
out_lines.append(f"{' '*max(0,indent)}{text} # line {line}")
return "\n".join(out_lines)

if __name__ == "__main__":
fname = sys.argv[1] if len(sys.argv) > 1 else "decrypted/trigger4.gdc"
idents, constants, tokens = parse_gdc(fname)
print(f"# === {fname} ===")
print(f"# {len(idents)} idents, {len(constants)} constants, {len(tokens)} tokens")
print()
print(decompile(idents, constants, tokens))

输出trigger1,示例

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
# === decrypted/trigger1.gdc ===
# 43 idents, 21 constants, 235 tokens

extends Area3D # line 1
signal collided_with (name) # line 3
var _f0: = false # line 4
var _f1: = false # line 5
var _tv: = 0.0 # line 6
var _ix: = 0 # line 7
var _gx # line 8
func _m3 (): # line 10
$MeshInstance3D == null: # line 11
$MeshInstance3D.rotation.y += _rv * 1.0 # line 13
var _yp = sin (_tv) * 0.2 # line 14
$MeshInstance3D.position.y = _yp # line 15
var _sc = 1.0 + sin (_tv * 3.0) * 0.1 # line 16
$MeshInstance3D.scale = Vector3 (_sc, _sc, _sc) # line 17
var _rv: = 0.0 # line 19
func _kc (): # line 21
var _bp: = get_overlapping_bodies () # line 22
_bp.size () <0x8e> 1: # line 23
var _np: = str (get_path ()) # line 25
var _t1: = '/root/' + 'TownScene' + '/Trigger1' # line 26
_np != _t1: # line 27
var _lb: = get_node (NodePath ('/root/TownScene/Label2')) # line 29
var _px: = 'flag{' # line 30
var _sx: = 'sec2026' + '_PART0_' + 'example' + '}' # line 31
_lb.text = _px + _sx # line 32
func _ready (): # line 34
connect ('body_entered', Callable (<0xcc>, '_on_body_entered')) # line 35
_gx = GameExtension.new () # line 36
func _on_body_entered (_b): # line 38
<0xba> # line 39
func _process (_d): # line 41
_gx.Tick () # line 42
_rv = _d # line 43
_tv += _d * 2.0 # line 44
_m3 () # line 45
_kc () # line 46

有点乱,然后研究了libgodotengine.so发现,不仅改格式,果然重排了 token 类型 ID。完整 token 表从 libgodot_android.so 提取在 0x3ec2548 找到 100 项的 _ZN17GDScriptTokenizer5Token8get_nameEv字符串指针表。

image-20260418102906710

AI解释了下说

标准 godot ID ID Token 名称
2 0x8c IDENTIFIER
3 0x8d LITERAL
26 0xa6 =
27 0xa7 +=
20 0xa0 +
22 0xa2 *
4 0x8e <
75 0xd8 (
76 0xd9 )
77 0xda ,
79 0xdc .
81 0xdf :
82 0xe0 $
38 0xc6 extends
58 0xc7 func
64 0xcd signal
68 0xd1 var
42 0xb7 while
41 0xb6 for
59 0xc8 in
19 0x9d ^ (XOR)
33 0x9e <<
34 0x9f >>
16 “and” &
17 “or” |
25 “**” %
  • 第一个 token 为 extends,第二个为 IDENTIFIER(父类名)
  • var X := YVAR IDENT COLON EQUAL LITERAL(推出 :=
  • func _name():FUNC IDENT ( ) :(推出 func( )
  • _a[_j] 在 bytecode 中表现为 IDENT(_a) IF IDENT(_j) RETURN,说明 [ ] 也被映射但形式特别
  • 0xa6 在赋值上下文为 =;同位置 0xa7 在累加上下文为 +=(用 _tv += _d * 2.0 验证)
  • <0x8e>_j <0x8e> _n: 上下文为 <
  • NEWLINE 数量 = 总行数(统计 0xdf 出现频次跨所有 .gdc 验证)

依旧搓个脚本

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
import zstandard as zstd
import struct
import sys

STD_TOKEN_NAMES = [
"EMPTY", # 0
"ANNOTATION", # 1
"IDENTIFIER", # 2
"LITERAL", # 3
"<", # 4
"<=", # 5
">", # 6
">=", # 7
"==", # 8
"!=", # 9
"and", # 10
"or", # 11
"not", # 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
"if", # 40
"elif", # 41
"else", # 42
"for", # 43
"while", # 44
"break", # 45
"continue", # 46
"pass", # 47
"return", # 48
"match", # 49
"when", # 50
"as", # 51
"assert", # 52
"await", # 53
"breakpoint", # 54
"class", # 55
"class_name", # 56
"const", # 57
"enum", # 58
"extends", # 59
"func", # 60
"in", # 61
"is", # 62
"namespace", # 63
"preload", # 64
"self", # 65
"signal", # 66
"static", # 67
"super", # 68
"trait", # 69
"var", # 70
"void", # 71
"yield", # 72
"[", # 73
"]", # 74
"{", # 75
"}", # 76
"(", # 77
")", # 78
",", # 79
";", # 80
".", # 81
"..", # 82
"...", # 83
":", # 84
"$", # 85
"->", # 86
"_", # 87
"NEWLINE", # 88
"INDENT", # 89
"DEDENT", # 90
"PI", # 91
"TAU", # 92
"INF", # 93
"NaN", # 94
"VCS_CONFLICT_MARKER", # 95
"`", # 96
"?", # 97
"ERROR", # 98
"EOF", # 99
]

# Build byte → token name map
def build_byte_map():
m = {}
OFFSET = 0x8a
for std_id, name in enumerate(STD_TOKEN_NAMES):
# Custom enum: insert one token at position 40
custom_id = std_id + (1 if std_id >= 40 else 0)
byte = custom_id + OFFSET
m[byte] = (std_id, name)
# The inserted custom token at custom_id 40
m[40 + OFFSET] = (None, "<UNKNOWN_INSERTED>") # byte 0xca
return m

BYTE_MAP = build_byte_map()

# Per-token formatting hints
KEYWORDS = {"extends", "func", "class", "class_name", "var", "const", "enum", "signal",
"static", "if", "elif", "else", "for", "while", "break", "continue", "pass",
"return", "match", "when", "as", "assert", "await", "breakpoint", "in", "is",
"namespace", "preload", "self", "super", "trait", "void", "yield"}
OPS_BIN = {"<", "<=", ">", ">=", "==", "!=", "and", "or", "&&", "||",
"&", "|", "~", "^", "<<", ">>", "+", "-", "*", "**", "/", "%",
"=", "+=", "-=", "*=", "**=", "/=", "%=", "<<=", ">>=", "&=", "|=", "^=",
"is", "in", "as", "->", ".."}
OPENS = {"(", "[", "{", "$"}
CLOSES = {")", "]", "}"}
PRES_PUNCT = {",", ":", ";"}

ENCODE_FLAG_64 = 1

def parse_variant(plain, pos):
header = int.from_bytes(plain[pos:pos+4], "little"); pos += 4
vtype = header & 0xFF
flags = header >> 16
if vtype == 0: return ("NIL",), pos
elif vtype == 1: return ("BOOL", bool(int.from_bytes(plain[pos:pos+4], "little"))), pos+4
elif vtype == 2:
sz = 8 if (flags & 1) else 4
return ("INT", int.from_bytes(plain[pos:pos+sz], "little", signed=True)), pos+sz
elif vtype == 3:
if flags & 1: return ("FLOAT", struct.unpack("<d", plain[pos:pos+8])[0]), pos+8
else: return ("FLOAT", struct.unpack("<f", plain[pos:pos+4])[0]), pos+4
elif vtype == 4 or vtype == 21:
slen = int.from_bytes(plain[pos:pos+4], "little"); pos += 4
sval = plain[pos:pos+slen].decode("utf-8", errors="replace"); pos += (slen + 3) & ~3
return ("STRING" if vtype==4 else "NODE_PATH", sval), pos
return ("?", vtype), pos

def parse_gdc(path):
data = open(path, "rb").read()
size = int.from_bytes(data[8:12], "little")
plain = zstd.ZstdDecompressor().decompress(data[12:], max_output_size=size)
counts = [int.from_bytes(plain[i*4:i*4+4], "little") for i in range(4)]
ident_n, const_n, line_n, token_n = counts
pos = 16
idents = []
for _ in range(ident_n):
n = int.from_bytes(plain[pos:pos+4], "little"); pos += 4
idents.append(''.join(chr(int.from_bytes(bytes(b ^ 0xb6 for b in plain[pos+i*4:pos+i*4+4]), "little")) for i in range(n)))
pos += 4*n
constants = []
for _ in range(const_n):
v, pos = parse_variant(plain, pos)
constants.append(v)
pos += 16 * line_n # skip lines section
tokens = []
for _ in range(token_n):
word = int.from_bytes(plain[pos:pos+4], "little")
line = int.from_bytes(plain[pos+4:pos+8], "little")
t = word & 0xff
d = word >> 8
tokens.append((t, d, line))
pos += 8
return idents, constants, tokens

def fmt_const(v):
if v[0] == "NIL": return "null"
if v[0] == "BOOL": return "true" if v[1] else "false"
if v[0] == "INT": return str(v[1])
if v[0] == "FLOAT":
f = v[1]
if f == int(f): return f"{int(f)}.0"
return repr(f)
if v[0] == "STRING": return repr(v[1])
if v[0] == "NODE_PATH": return f"^{v[1]!r}"
return str(v)

def name_for(t):
if t == 0x8c: return "IDENTIFIER"
if t == 0x8d: return "LITERAL"
if t in BYTE_MAP:
return BYTE_MAP[t][1]
return f"<0x{t:02x}>"

def render_line(toks, idents, constants):
"""Render a sequence of tokens (no NEWLINE) as one line of GDScript."""
parts = []
for t, d in toks:
if t == 0x8c:
parts.append(idents[d] if d < len(idents) else f"IDENT_{d}")
elif t == 0x8d:
parts.append(fmt_const(constants[d]) if d < len(constants) else f"CONST_{d}")
else:
parts.append(name_for(t))
out = ""
for i, p in enumerate(parts):
prev = parts[i-1] if i > 0 else None
# Spacing rules
if p in CLOSES or p in (",", ".", ":", ";"):
out += p
elif p == "(" or p == "[":
# Function call/index after IDENT or `)` or `]` — no space
if prev and (prev not in OPS_BIN and prev not in OPENS and prev not in KEYWORDS - {"if","while","for","return","elif","not","and","or","in","is","as"} and prev not in (",", ":", ";")):
out += p
else:
out += " " + p if out and not out.endswith(" ") else p
elif p == "$" or p == "@":
if out and not out.endswith((" ", "(", "[", "{", ",", ":", ";", "=")):
out += " "
out += p
elif prev == ".":
out += p
elif prev == "$":
out += p
elif prev in OPENS:
out += p
elif prev is None:
out += p
else:
out += " " + p
return out

def decompile(idents, constants, tokens):
# Group by line
by_line = {}
for t, d, line in tokens:
by_line.setdefault(line, []).append((t, d))
out_lines = []
indent = 0
for line in sorted(by_line.keys()):
toks = by_line[line]

clean_toks = []
for t, d in toks:
name = name_for(t)
if name == "INDENT":
indent += 1
elif name == "DEDENT":
indent -= 1
elif name == "NEWLINE":
pass # skip
else:
clean_toks.append((t, d))
if not clean_toks:
continue
text = render_line(clean_toks, idents, constants)
out_lines.append(" " * max(0, indent) + text)
return "\n".join(out_lines)

if __name__ == "__main__":
fname = sys.argv[1] if len(sys.argv) > 1 else "decrypted/trigger4.gdc"
idents, constants, tokens = parse_gdc(fname)
print(f"# {fname}: {len(idents)} idents, {len(constants)} constants, {len(tokens)} tokens")
print()
print(decompile(idents, constants, tokens))

生成的trigger起码能看了

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
# decrypted/trigger1.gdc: 43 idents, 21 constants, 235 tokens

extends Area3D
signal collided_with(name)
var _f0: = false
var _f1: = false
var _tv: = 0.0
var _ix: = 0
var _gx
func _m3():
if $MeshInstance3D == null:
return
$MeshInstance3D.rotation.y += _rv * 1.0
var _yp = sin(_tv) * 0.2
$MeshInstance3D.position.y = _yp
var _sc = 1.0 + sin(_tv * 3.0) * 0.1
$MeshInstance3D.scale = Vector3(_sc, _sc, _sc)
var _rv: = 0.0
func _kc():
var _bp: = get_overlapping_bodies()
if _bp.size() < 1:
return
var _np: = str(get_path())
var _t1: = '/root/' + 'TownScene' + '/Trigger1'
if _np != _t1:
return
var _lb: = get_node(NodePath('/root/TownScene/Label2'))
var _px: = 'flag{'
var _sx: = 'sec2026' + '_PART0_' + 'example' + '}'
_lb.text = _px + _sx
func _ready():
connect('body_entered', Callable(self, '_on_body_entered'))
_gx = GameExtension.new()
func _on_body_entered(_b):
pass
func _process(_d):
_gx.Tick()
_rv = _d
_tv += _d * 2.0
_m3()
_kc()

让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
extends Area3D

signal collided_with(name)

var _f0 := false
var _f1 := false
var _tv := 0.0
var _ix := 0
var _gx

func _m3():
if $MeshInstance3D == null:
return
$MeshInstance3D.rotation.y += _rv * 1.0
var _yp = sin(_tv) * 0.2
$MeshInstance3D.position.y = _yp
var _sc = 1.0 + sin(_tv * 3.0) * 0.1
$MeshInstance3D.scale = Vector3(_sc, _sc, _sc)

var _rv := 0.0

func _kc():
# 碰撞检查 + flag 输出
var _bp := get_overlapping_bodies()
if _bp.size() < 1:
return
var _np := str(get_path())
var _t1 := '/root/' + 'TownScene' + '/Trigger1'
if _np != _t1:
return
var _lb := get_node(NodePath('/root/TownScene/Label2'))
var _px := 'flag{'
var _sx := 'sec2026' + '_PART0_' + 'example' + '}'
_lb.text = _px + _sx

func _ready():
connect('body_entered', Callable(self, '_on_body_entered'))
_gx = GameExtension.new()

func _on_body_entered(_b):
pass

func _process(_d):
_gx.Tick()
_rv = _d
_tv += _d * 2.0
_m3()
_kc()

Tick 分析

这其实是最后做的当时

Trigger里的Tick一开始以为是part3 最后发现是反调试扎堆的地方

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
extends Area3D
signal collided_with(name)
var _f0 := false
var _f1 := false
var _tv := 0.0
var _ix := 0
var _gx
var _rv := 0.0

@onready var _m3 := $MeshInstance3D

func _ready():
_gx = GameExtension.new()

func _process(_d):
_gx.Tick()
_rv = _d
_tv += _d * 2.0
_m3()

反汇编分析

工作原理博客,不久前才稍微复习了ollvm,用上了刚好,其实原理差不多,虽然混淆每次不一样,如果不理解请先了解ollvm对抗

跟踪到0x9AD68 ,这一块结合codex进行分析

image-20260419014433965

0x9AD68 - 0x9ADB0很明显是个序言

在分配栈空间和初始化魔数

1
2
3
4
MOV     X16, #0xF7CF
MOVK X16, #0xE353,LSL#16
MOVK X16, #0x9BA5,LSL#32
MOVK X16, #0x20C4,LSL#48

比如

1
2
3
4
5
6
7
8
9
10
11
12
13
int state = 0xA612CBCD;

while (1) {
switch (state) {
case 0xA612CBCD:
state = 0xC5499183;
break;

case 0xC5499183:
state = 0x7F1F1A90;
break;
}
}

0x9AD94 - 0x9AE10在进行一些常量设置,结合后面分析得出大概是这样的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
; OLLVM 的魔数,12 个 32-bit 常量填到寄存器
W11 = 0x000F4240 = 1000000 (=1M usec/sec, 乘法因子)
W13 = 0xA612CBCD (initial state)
W14 = 0x38 (entry 7 jump_table index)
W15 = 0xD9 (state transform XOR mask)
X16 = 0x20C49BA5E353F7CF (magic: for division by 1000)
W19 = 0x08 (entry 1 index)
W20 = 0x10 (entry 2 index)
W21 = 0xE1323D07 (state for entry 5)
W22 = 0xC5499183 (state for entry 0 slow trap)
W23 = 0x28 (entry 5 index)
W25 = 0x7F1F1A90 (state for entry 4 fast exit)
W26 = 0x1B49A436 (state for entry 2 baseline writer)
W28 = 0x20 (entry 4 index)

ADR X24, off_161A58 ; X24 = jump_table 基址
STUR W13, [X29, #-0x1C] ; W8 = 0xA612CBCD (initial state)

核心的分发逻辑在0x9AE30 - 0x9AE6C

image-20260419014626620

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
loc_9AE30:                       ; 状态机分发头
CMP W8, W13 ; 0xA612CBCD (initial)
CSEL X10, X19, X14, EQ ; X10 = entry_1_idx(8) or default(0x38)

ADD W9, W26, #0x155
CMP W8, W9 ; 0x1B49A58B (entry 2 state)
CSEL X10, X20, X10, EQ ; X10 = entry_2_idx(0x10) or prev

ADD W9, W21, #0x173
CMP W8, W9 ; 0xE1323E7A (entry 5 state)
CSEL X9, X23, X10, EQ ; X9 = entry_5_idx(0x28) or prev

ADD W10, W22, #0x73
CMP W8, W10 ; 0xC54991F6 (entry 0 state)
CSEL X9, XZR, X9, EQ ; X9 = 0 (entry 0 slow trap) or prev

ADD W10, W25, #0x55
CMP W8, W10 ; 0x7F1F1AE5 (entry 4 state)
CSEL X8, X28, X9, EQ ; X8 = entry_4_idx(0x20) or prev

LDR X8, [X24, X8] ; X8 = jump_table[X8]
BR X8 ; 跳到 handler

0x9AE1C - 0x9AE2C进行状态迁移转换

Entry 0 (0x9AE14) : slow-path

1
2
9AE14  ADD X30, X30, #0x30        ; LR += 0x30
9AE18 RET ; 返回到 LR+0x30

把返回地址向后推 0x30 字节,RET 后 CPU 跳到进入 Tick 时 LR 值 + 0x30 的位置。但 Tick 进入时 LR = 0x9AEFC(BLR llabs 的下一条,被保存在寄存器未被改过),推 0x30 , 跳到 0x9AF2C,那是 Tick 自己函数体内部。

LR用来保存函数返回值,跳转到某个寄存器里保存的地址,并把返回地址保存到 LR/X30

image-20260425175953798

回到 0x9AF2C 后代码继续执行 entry 5 的后半段,再次 B 到 dispatcher,第二次分发时 W8 不再匹配任何 valid state,落到默认 entry 7

以下分了几个entry,下面要穿起来理解但理解某个entry可能不太好理解

Entry 1 (0x9AF44) : baseline 初始化判定

1
2
3
4
5
6
7
8
9AF44  STP W21, W26, [SP, #0x1C]  ; [SP+0x1C] = 0xE1323D07, [SP+0x20] = 0x1B49A436
9AF48 LDR W8, [SP, #0x20] ; W8 = 0x1B49A436 (goto entry 2 候选)
9AF4C LDR W9, [SP, #0x1C] ; W9 = 0xE1323D07 (goto entry 5 候选)
9AF50 LDR X10, [SP, #0x10] ; X10 = qword_1834B8 (baseline)
9AF54 CMP X10, #0
9AF58 CSEL W8, W8, W9, EQ ; if baseline == 0, W8 = entry2_candidate
; else , W8 = entry5_candidate
9AF5C B loc_9AE24 ; 状态转换 + 下次分发

首次 Tick:baseline==0 ,选 entry 2 写 baseline

非首次:baseline!=0,选 entry 5 检查 10 秒

Entry 2 (0x9AE88): 初始化 baseline

1
2
3
4
5
6
7
8
9
10
11
12
13
9AE88  STP XZR, XZR, [X29, #-0x18]        ; 清空 timespec struct
9AE8C MOV W0, #1 ; CLOCK_MONOTONIC
9AE90 SUB X1, X29, #0x18 ; &ts
9AE94 MOV W8, #0x71 ; syscall 113 = clock_gettime
9AE98 SVC 0 ; 内联 SVC
9AE9C STUR W21, [X29, #-0x1C] ; 下次状态 = W21 (→ entry 5)
9AEA0 LDP X9, X8, [X29, #-0x18] ; X9=sec, X8=nsec
9AEA4 SMULH X8, X8, X16 ; X8 = (nsec * 0x20C49BA5E353F7CF) >> 64
9AEA8 ASR X10, X8, #7
9AEAC ADD X8, X10, X8, LSR#63 ; X8 = nsec / 1000 (usec)
9AEB0 MADD X8, X9, X11, X8 ; X8 = sec*1M + usec = now_usec
9AEB4 STR X8, [X27, #0x4B8] ; qword_1834B8 = now_usec
9AEB8 B loc_9AE20 ; 下次状态 :entry 5

这是唯一写 qword_1834B8 的地方(在 Tick 内部)。只在首次调用 Tick 时执行

这段是 Tick 反调试的初始化 baseline 时间戳分支(首次运行时建立基准时间)

Entry 5 (0x9AEBC) : 10 秒 diff 判定 核心反调试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
9AEBC  STP XZR, XZR, [X29, #-0x18]        ; 清空 ts
9AEC0 MOV W0, #1 ; CLOCK_MONOTONIC
9AEC4 SUB X1, X29, #0x18
9AEC8 MOV W8, #0x71 ; syscall 113
9AECC SVC 0 ; 内联 clock_gettime
9AED0 LDP X9, X8, [X29, #-0x18]
9AED4 SMULH X8, X8, X16
9AED8 ASR X10, X8, #7
9AEDC ADD X8, X10, X8, LSR#63 ; X8 = nsec / 1000
9AEE0 LDR X10, [X27, #0x4B8] ; X10 = qword_1834B8 (baseline)
9AEE8 MADD X8, X9, X11, X8 ; X8 = now_usec
9AEEC SUB X0, X8, X10 ; X0 = now - baseline
9AEF0 ADRP X8, llabs_ptr@PAGE
9AEF8 BLR X8 ; X0 = llabs(diff) (libc 调用 ← 唯一个)
9AEFC STP W25, W22, [SP, #0x1C] ; [SP+0x1C]=W25, [SP+0x20]=W22
9AF04 LDR W8, [SP, #0x20] ; W8 = W22 = 0xC5499183 (slow 候选)
9AF0C LDR W9, [SP, #0x1C] ; W9 = W25 = 0x7F1F1A90 (fast 候选)
9AF14 MOVK W10, #0x98, LSL#16 ; W10 = 0x00989680 = 10,000,000 (10s usec)
9AF24 CMP X0, X10
9AF3C CSEL W8, W8, W9, GT ; if diff > 10s, W8=slow; else W8=fast
9AF40 B loc_9AE24 ; 状态转换 + 下次分发

常量:

  • 0x00989680 = 10 秒
  • 0xF4240 = 1000000
  • 0x20C49BA5E353F7CF = 除 1000 魔数

Entry 4 (0x9AF60) : 正常 epilogue(fast path 出口)

1
2
3
4
5
6
7
8
9
10
11
9AF60  LDR X8, [SP, #8]                     ; 读 TLS
9AF64 LDR X8, [X8, #0x28] ; 读当前 canary
9AF68 LDUR X9, [X29, #-8] ; 读栈上保存的 canary
9AF6C CMP X8, X9
9AF70 B.NE loc_9AF94 ; canary mismatch → __stack_chk_fail
9AF74 LDP X20, X19, [SP, #0x90] ; 恢复 callee-saved
9AF78..9AF84 恢复 X21..X28
9AF88 LDP X29, X30, [SP, #0x40]
9AF8C ADD SP, SP, #0xA0
9AF90 RET ; 正常返回
9AF94 BL __stack_chk_fail ; stack 破坏检测

标准 epilogue stack canary 验证。fast path 出口是这里(指的是没被调试过)。

Entry 7 (0x9AE70) trap

1
2
3
9AE70  LDR X1, loc_9AE68                      ; X1 = 从 dispatcher 代码字节读取 8 字节
9AE74 BLR X1 ; 跳到那个地址
9AE78 MOV X0, X1 ; 永不执行

loc_9AE68 实际是 dispatcher 最后两条指令的机器码(LDR X8, [X24, X8] + BR X8):

  • 0x9AE68: F8 68 6B 08
  • 0x9AE6C: 00 01 1F D6

合起来读0xD61F0100F8686B08。

这是 ARM64 指令字节被当成数据地址读取,BLR X1 跳到不存在的内存 , MEM_INVALID 导致 SIGSEGV。OLLVM 的骚操作 , 用自己的指令字节做陷阱指针。

正常运行流程:

1
2
sub_9B7D8 线程 → 每 3s → sub_96A00 → sub_9AF98 → 写 qword_1834B8 = now
trigger4.gd::_process 每帧 → Tick → entry 5 读 baseline → diff = 0-3s → fast path

本质是一个反向心跳机制: 反调试线程每 3s 证明还活着,如果证明中断 ,Tick 自毁。

内联 SVC 的反 hook 细节

Tick 里 clock_gettime 不走 libc,而是 inline SVC

1
2
3
4
MOV W0, #1            ; CLOCK_MONOTONIC
SUB X1, X29, #0x18 ; &ts
MOV W8, #0x71 ; syscall 113
SVC 0 ; 直接陷入内核

Frida Interceptor.attach(libc.so, “clock_gettime”) 完全拦不到。要拦这个 SVC 必须用 Stalker 级别指令扫描,开销大。

OLLVM去混淆 [Tick部分]

发现了 OLLVM 标准结构,实际工作只在 entry block 里。dispatcher 纯粹是混淆。

先找 dispatcher 边界 + jump table 基址

工作原理博客,不久前才稍微复习了ollvm,用上了刚好

简单学习ollvm混淆&polyre例题解析 | Matriy’s blog

angr符号执行对抗ollvm - Qmeimei’s Blog | 探索一切,攻破一切

既然是OLLVM CFF ,我们需要找dispatcher,OLLVM CFF 把它变成巨型 switch

1
2
3
4
5
6
7
8
9
10
11
void f() {
int state = INIT_STATE;
while (1) {
switch (state) {
case 0xABCD1234: blockA(); state = 0x5678EF90; break;
case 0x5678EF90: blockB(); state = ...; break;
case ...: blockC(); state = ...; break;
default: return;
}
}
}

每个 basic block 变成一个 entry

  • CFF 的本质就是间接跳转,,必须有 LDR ; BR 这种查表+跳的模式
  • 这模式在普通编译器代码里罕见(普通函数用 B label 直接跳),所以见到就基本是 dispatcher
  • 找到 dispatcher 后,再用 CFFSolver(后面写了个通用的)枚举所有 state 到 entry的链接,就把整张state→代码块对应表挖出来,CFF 就解开了
1
2
3
4
5
6
disasm 整个函数, 找:
LDR X<reg>, [X<base>, X<state>]
BR X<reg>
往上回溯 X<base> = ADR Xn, off_XXXXXX 这就是 jump_table
dispatcher 起点 = state-transform 第一条 (一般是 EOR W8, W8, #K1)
dispatcher 终点 = BR X<reg> 之后

例1:

1
2
3
0x9AE68: LDR X8, [X24, X8]       X24 = off_161A58 = jump_table
0x9AE6C: BR X8 dispatcher 终点
0x9AE30: dispatcher 起点 (CMP W8, W13)

例2:

1
2
3
0x97DD4: LDR X8, [X28, X8]      X28 = off_161970 = jump_table
0x97DD8: BR X8
0x97C38: dispatcher 起点 (state transform 在0x97C24之后开始)

所以可以得到一个通用流程

  1. Capstone 扫整个函数找 BR X<reg>,往前看一条是不是 LDR X<reg>, [Xm, Xn]
  2. 找到后回溯 Xm(base)的赋值,找 ADR / ADRP+ADD
  3. 收集所有 CMP 的 RHS 常量作为候选 state
  4. 用每个 state 跑 dispatcher,记录 state → byte_offset → table[byte_offset] = entry_addr

用 Unicorn-style 模拟 dispatcher,建立 state到 entry 映射,拿博客里的改就行

这里手动找了序言,放进去了,后面有自动化的版本这里只为了验证

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
import idc
import ida_bytes

class CFFSolver:
def __init__(self, dispatcher_start, dispatcher_end, jump_table, prologue_static_regs):
self.start = dispatcher_start
self.end = dispatcher_end
self.table = jump_table
self.static_regs = dict(prologue_static_regs)

def simulate(self, state_w8):
"""跑一遍 dispatcher, 返回最终X8"""
X = {i: self.static_regs.get(i, 0) for i in range(32)}
X[8] = state_w8
X[31] = 0
Z = 0
addr = self.start
while addr < self.end:
raw = ida_bytes.get_dword(addr)

# MOVZ 32-bit: opcode 0x52800000
if (raw & 0x7F800000) == 0x52800000:
Rd = raw & 0x1F
imm = (raw >> 5) & 0xFFFF
sh = ((raw >> 21) & 0x3) * 16
X[Rd] = (imm << sh) & 0xFFFFFFFF

# MOVK 32-bit: opcode 0x72800000
elif (raw & 0x7F800000) == 0x72800000:
Rd = raw & 0x1F
imm = (raw >> 5) & 0xFFFF
sh = ((raw >> 21) & 0x3) * 16
cur = X[Rd] & 0xFFFFFFFF
X[Rd] = ((cur & ~(0xFFFF << sh)) | (imm << sh)) & 0xFFFFFFFF

# ADD imm 32: 0x11000000
elif (raw & 0x7FC00000) == 0x11000000:
Rd = raw & 0x1F
Rn = (raw >> 5) & 0x1F
imm = (raw >> 10) & 0xFFF
shift = ((raw >> 22) & 0x3) * 12
X[Rd] = (X[Rn] + (imm << shift)) & 0xFFFFFFFF

# SUBS W (compare-with-register): 0x6B000000
elif (raw & 0x7FE00000) == 0x6B000000:
Rn = (raw >> 5) & 0x1F
Rm = (raw >> 16) & 0x1F
a = X[Rn] & 0xFFFFFFFF
b = X[Rm] & 0xFFFFFFFF
Z = 1 if a == b else 0

# CSEL (32 或 64-bit): 0x1A800000 / 0x9A800000
elif (raw & 0x7FE00C00) == 0x1A800000:
Rm = (raw >> 16) & 0x1F
cond = (raw >> 12) & 0xF
Rn = (raw >> 5) & 0x1F
Rd = raw & 0x1F
# cond=0: EQ (Z=1), cond=1: NE (Z=0)
take_true = (cond == 0 and Z) or (cond == 1 and not Z)
X[Rd] = X[Rn] if take_true else X[Rm]

# LDR Xt, [Xn, Xm] (register form): 0xF8600800
elif (raw & 0xFFE00C00) == 0xF8600800:
return X[8]

# BR Xn: 0xD61F0000
elif (raw & 0xFFFFFC1F) == 0xD61F0000:
return X[8]

addr += 4
return X[8]

def solve_all(self, candidate_states=None):
"""尝试所有 CMP 常量, 返回 {state: entry_ea}."""
if candidate_states is None:
candidate_states = self._collect_cmp_constants()

mapping = {}
for state in candidate_states:
off = self.simulate(state)
if off is None:
continue
try:
target = ida_bytes.get_qword(self.table + off)
mapping[state] = (off, target)
except Exception:
pass
return mapping

def _collect_cmp_constants(self):
"""扫 dispatcher 找所有 CMP W8 的对象寄存器值."""
consts = []
regs = dict(self.static_regs)
addr = self.start
while addr < self.end:
raw = ida_bytes.get_dword(addr)
# 跟踪 MOVZ/MOVK/ADD 写入寄存器
if (raw & 0x7F800000) == 0x52800000:
Rd = raw & 0x1F
imm = (raw >> 5) & 0xFFFF
sh = ((raw >> 21) & 0x3) * 16
regs[Rd] = (imm << sh) & 0xFFFFFFFF
elif (raw & 0x7F800000) == 0x72800000:
Rd = raw & 0x1F
imm = (raw >> 5) & 0xFFFF
sh = ((raw >> 21) & 0x3) * 16
cur = regs.get(Rd, 0) & 0xFFFFFFFF
regs[Rd] = ((cur & ~(0xFFFF << sh)) | (imm << sh)) & 0xFFFFFFFF
elif (raw & 0x7FC00000) == 0x11000000:
Rd = raw & 0x1F
Rn = (raw >> 5) & 0x1F
imm = (raw >> 10) & 0xFFF
shift = ((raw >> 22) & 0x3) * 12
if Rn in regs:
regs[Rd] = (regs[Rn] + (imm << shift)) & 0xFFFFFFFF
# CMP W8, Wn -> 收集 Wn 的值
elif (raw & 0x7FE00000) == 0x6B000000:
Rn = (raw >> 5) & 0x1F
Rm = (raw >> 16) & 0x1F
if Rn == 8 and Rm in regs:
consts.append(regs[Rm])
addr += 4
return list(set(consts))

@staticmethod
def transform(state, xor_const, add_const):
"""通用 CFF state-transform. 多数 OLLVM 用 (state ^ K1) + K2 形式."""
return ((state ^ xor_const) + add_const) & 0xFFFFFFFF

def print_mapping(self, mapping):
print(f"{'state':>12s} {'offset':>8s} {'idx':>4s} entry")
print("=" * 60)
for state in sorted(mapping.keys()):
off, target = mapping[state]
print(f" 0x{state:08x} 0x{off:04x} {off//8:3d} 0x{target:x}")


if __name__ == "__main__":
sim = CFFSolver(
dispatcher_start=0x97C38,
dispatcher_end=0x97DDC,
jump_table=0x161970,
prologue_static_regs={
19: 0x286b011e, 20: 0xb0, 21: 0x646ee9e9, 22: 0x11f811c5,
23: 0xf5e7a789, 24: 0x8e7fe997, 25: 0x6da9fc77,
26: 0xb5653484, 27: 0xf1a0b5b5, 28: 0x20,
},
)
mapping = sim.solve_all()
sim.print_mapping(mapping)

print("\n冷启 trace 示例:")
state = 0xC56DA9A6
for i in range(15):
if state not in mapping:
print(f" [{i:2d}] state 0x{state:08x} -> 链终止")
break
off, entry = mapping[state]
print(f" [{i:2d}] state 0x{state:08x} -> entry 0x{entry:x}")
break

输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
The initial autoanalysis has been finished.
state offset idx entry
============================================================
0x04ca63de 0x0028 5 0x980f4
0x11f811d7 0x0058 11 0x980ac
0x286b0222 0x0090 18 0x980e4
0x3c397557 0x0068 13 0x97e30
0x4c3224fe 0x0000 0 0x97ec4
0x4ef34683 0x0008 1 0x980dc
0x646ee9f3 0x0030 6 0x97c0c
0x6da9fd89 0x0088 17 0x97f9c
0x72304c75 0x00e0 28 0x97e80
0x8e7fe9a9 0x0080 16 0x97e08
0xa1a142fc 0x00a8 21 0x97f08
0xa39f8f36 0x0070 14 0x97f58
0xb5653498 0x00b0 22 0x98230
0xc0ad2459 0x0098 19 0x98064
0xc2ee921b 0x0010 2 0x980c8
0xc56da9a6 0x00d0 26 0x98040
0xce7ff0b3 0x00c0 24 0x97df4
0xefce0493 0x00d8 27 0x97c20
0xf1a0b5c7 0x00c8 25 0x981dc
0xf5e7a793 0x00b8 23 0x97e38
0xfd6d51f3 0x0020 4 0x980c0

魔数对应如上

其中的prologue_static_regs 怎么填?看 prologue 里的 MOV W19, …; MOVK W19, #…, LSL #16 序列,把每个寄存器最终的值算出来。手动算或用:

1
2
3
4
5
6
7
8
9
10
11
12
# 抠 prologue 里的 W19到W28 终值
import idc, ida_bytes
regs = {}
for ea in range(FUNC_START, DISPATCHER_START, 4):
raw = ida_bytes.get_dword(ea)
if (raw & 0x7F800000) == 0x52800000: # MOVZ
Rd = raw & 0x1F; imm = (raw >> 5) & 0xFFFF; sh = ((raw >> 21) & 3)*16
regs[Rd] = (imm << sh) & 0xFFFFFFFF
elif (raw & 0x7F800000) == 0x72800000: # MOVK
Rd = raw & 0x1F; imm = (raw >> 5) & 0xFFFF; sh = ((raw >> 21) & 3)*16
regs[Rd] = (regs.get(Rd,0) & ~(0xFFFF<<sh) | (imm<<sh)) & 0xFFFFFFFF
print({k:hex(v) for k,v in regs.items()})

trace cold-start 链

知道 state到entry 后,从 prologue 的 INITIAL_STATE 开始 trace,就是之前的流程

1
2
3
4
5
6
7
8
9
10
11
12
state = INITIAL_STATE  # prologue 末尾把 W8 设为这个值
while state in mapping:
entry = mapping[state]
# 看 entry 的 disasm:
# 1. 找 STUR W<n>, [X29, #-0xXX] → next_state_raw = W<n> 的值
# (从 prologue 静态寄存器里查; 或如果是 MOV imm 在 entry 里, 直接读)
# 2. 找终止 B 0x97C24/97C28
# - 0x97C24: 跳过 transform = next_state = transform(next_state_raw)
# - 0x97C28: 直接进 transform (entry 已经把 W8 设好) = next_state = transform(W8)
next_state = (next_state_raw ^ XOR_K) + ADD_K
chain.append((state, entry))
state = next_state

XOR_K 和 ADD_K 在 dispatcher 入口,比如 sub_97B6C 是 EOR W8, W8, #0x8D; ADD W8, W8, #0x8F

遇到 CSEL 的 entry, 它依赖一个全局 byte(如 byte_183518 = 反调试 flag)。Cold start 时这些 byte 全 0,所以总是走false 分支。trace 一遍 cold-path 即可。这个我的博客里也提到怎么处理

Patch dispatcher 短路成线性

每个 entry 末尾的 B 0x97C24/97C28 替换成 B <下一个 entry>。选一边

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import ida_bytes

def encode_b(src, target):
off = (target - src) >> 2
if off < 0: off &= 0x03FFFFFF
return (0x14000000 | (off & 0x03FFFFFF)).to_bytes(4, 'little')

PATCHES = [
(0x97F98, 0x97F9C), # entry_1 末尾 -> entry_2 起点
(0x98018, 0x97E80), # entry_2 末尾 -> entry_3 起点 (skip CSEL)
...
]
for src, tgt in PATCHES:
ida_bytes.patch_bytes(src, encode_b(src, tgt))

prologue 末尾的 B → dispatcher 也要改成 B → 第一个 entry 的 work 入口(跳过 dispatcher 整个 CSEL chain)。

最后重建函数边界:

1
2
3
4
5
6
7
8
9
10
11
import ida_funcs, ida_auto, ida_hexrays
# 删除被 IDA 误判成独立函数的 entry block
for ea in ORPHAN_ENTRY_FUNCS:
f = ida_funcs.get_func(ea)
if f and f.start_ea == ea and ea != FUNC_START:
ida_funcs.del_func(ea)
# 重建主函数
ida_funcs.del_func(FUNC_START)
ida_funcs.add_func(FUNC_START, FUNC_END_NEW) # FUNC_END_NEW 要覆盖所有 entry
ida_auto.auto_wait()
ida_hexrays.mark_cfunc_dirty(FUNC_START)

完整代码:

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
import ida_bytes
import ida_funcs
import ida_auto
import ida_hexrays


# ---------- 函数边界 ----------
FUNC_START = 0x97B6C
FUNC_END = 0x98230


# ---------- patch 列表 ----------
PATCHES = [
(0x97C08, 0x97F58),
(0x97F98, 0x97F9C),
(0x98018, 0x97E80),
(0x97EC0, 0x980AC),
(0x980C4, 0x980E4),
(0x98100, 0x980C8),
(0x980E0, 0x97E38),
(0x97E7C, 0x97EC4),
(0x97F04, 0x97E08),
(0x97E34, 0x97C0C),
(0x97C20, 0x981DC),
]

# 子函数 (IDA 误判成独立函数的 entry block)
ORPHAN_SUB_FUNCS = [0x97DF4, 0x97E38, 0x98040, 0x981DC]

def encode_b(src, target):
"""编码 ARM64 unconditional B 指令 (4 字节, little-endian)."""
off = (target - src) >> 2
if off < -(1 << 25) or off >= (1 << 25):
raise ValueError(f"B 跳转超界: 0x{src:x} -> 0x{target:x}")
if off < 0:
off &= 0x03FFFFFF
val = 0x14000000 | (off & 0x03FFFFFF)
return val.to_bytes(4, 'little')


def patch_b(src, target, label=""):
bytes_new = encode_b(src, target)
ida_bytes.patch_bytes(src, bytes_new)
print(f" patch 0x{src:x}: B 0x{target:x} ; {label}")


def main():
print("=" * 70)
print("Patching sub_97B6C (GameExtension::_bind_methods) — OLLVM CFF defeat")
print("=" * 70)

# Step 1: 应用 11 个 B 跳转 patch
print("\n[1] 短路 dispatcher, 把 entry chain 拼接成线性")
for src, tgt in PATCHES:
patch_b(src, tgt)

# Step 2: 删除 IDA 误判的 sub-functions
print("\n[2] 删除被 IDA 误判成独立函数的 entry block")
for ea in ORPHAN_SUB_FUNCS:
f = ida_funcs.get_func(ea)
if f and f.start_ea == ea and ea != FUNC_START:
ida_funcs.del_func(ea)
print(f" delete sub_{ea:X}")

# Step 3: 重建主函数, 边界扩到所有 entry 之外
print("\n[3] 重建函数边界")
ida_funcs.del_func(FUNC_START)
ok = ida_funcs.add_func(FUNC_START, FUNC_END)
print(f" add_func(0x{FUNC_START:x}, 0x{FUNC_END:x}) -> {ok}")
if not ok:
# 二次清理 有时 IDA 在 add_func 后还残留旧的 sub-function
for ea in range(FUNC_START, FUNC_END, 4):
f = ida_funcs.get_func(ea)
if f and f.start_ea != FUNC_START:
ida_funcs.del_func(f.start_ea)
ok = ida_funcs.add_func(FUNC_START, FUNC_END)
print(f" retry add_func -> {ok}")

# Step 4: auto-analysis + 刷新 Hex-Rays cache
print("\n[4] auto-analysis + Hex-Rays cache flush")
ida_auto.auto_wait()
ida_hexrays.mark_cfunc_dirty(FUNC_START)

print("\n" + "=" * 70)
print("完成sub_97B6C")
print("sub_9F4EC/sub_9CB50/sub_97358/sub_B4010/sub_AEB14/")
print("sub_AEACC/sub_9610C/sub_9B5E8/sub_9A1EC/sub_CC958.")


if __name__ == "__main__":
main()

初始状态分析:

  • prologue 末尾 W8 = 0xC56DA9A6 (MOV+MOVK)
  • state transform = (W8 ^ 0x8D) + 0x8F

用 ollvm_cff_solver 解出的 21 个 (state→entry) 映射:

1
2
3
4
5
6
7
8
9
10
11
12
state 0xc56da9a6 -> entry 0x98040  
state 0xa39f8f36 -> entry 0x97f58
state 0x6da9fd89 -> entry 0x97f9c
state 0x72304c75 -> entry 0x97e80
state 0x11f811d7 -> entry 0x980ac
state 0x286b0222 -> entry 0x980e4
state 0xc2ee921b -> entry 0x980c8
state 0xf5e7a793 -> entry 0x97e38
state 0x4c3224fe -> entry 0x97ec4
state 0x8e7fe9a9 -> entry 0x97e08
state 0x646ee9f3 -> entry 0x97c0c
state 0xf1a0b5c7 -> entry 0x981dc

Cold-start 链:

1
2
3
4
5
6
7
8
9
10
11
12
13
prologue (0xC56DA9A6)
→ 0x98040 [byte_183518==0]
→ 0xA39F8F36 → 0x97F58 [sub_9F4EC, byte_183518=1]
→ 0x6DA9FD89 → 0x97F9C [sub_B4010, allocas, byte_183522==0]
→ 0x72304C75 → 0x97E80 [sub_9CB50, byte_183522=1]
→ 0x11F811D7 → 0x980AC [sub_B4010]
→ 0x286B0222 → 0x980E4 [sub_AEB14]
→ 0xC2EE921B → 0x980C8 [sub_9B5E8(sub_97704, 0)]
→ 0xF5E7A793 → 0x97E38 [sub_9A1EC, sub_CC958×2, byte_183529==0]
→ 0x4C3224FE → 0x97EC4 [sub_97358, byte_183529=1]
→ 0x8E7FE9A9 → 0x97E08 [sub_B4010, sub_AEACC]
→ 0x646EE9F3 → 0x97C0C [sub_9610C(sub_9AD68, 0)]
→ 0xF1A0B5C7 → 0x981DC [epilogue]

11 个 patch:

1
2
3
4
5
6
7
8
9
10
11
0x97C08 -> B 0x97F58   prologue → first entry
0x97F98 -> B 0x97F9C
0x98018 -> B 0x97E80 skip byte_183522 CSEL (cold path)
0x97EC0 -> B 0x980AC
0x980C4 -> B 0x980E4
0x98100 -> B 0x980C8
0x980E0 -> B 0x97E38
0x97E7C -> B 0x97EC4 skip byte_183529 CSEL (cold path)
0x97F04 -> B 0x97E08
0x97E34 -> B 0x97C0C
0x97C20 -> B 0x981DC epilogue, skip dispatcher

执行后 F5 出 伪代码,17 个工作调用可见,揭示了完整的Godot注册逻辑

效果

image-20260419063104581

这是用 OLLVM CFF patch 方法对付 Tick 函数 (sub_9AD68) 的脚本。逻辑跟之前的同款,只是针对 Tick

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
import ida_bytes
import ida_auto
import ida_funcs
import ida_name
import idaapi
import idc

# ---------- Tick layout ----------
TICK_START = 0x9AD68
TICK_END = 0x9AF94

ENTRY_1 = 0x9AF44
ENTRY_2 = 0x9AE88
ENTRY_4 = 0x9AF60
ENTRY_5 = 0x9AEBC

DISPATCHER_ENTRY = 0x9AE30 # first instruction of dispatcher (CMP W8, W13)
DISPATCHER_LDR = 0x9AE68 # LDR X8, [X24, X8]
DISPATCHER_BR = 0x9AE6C # BR X8

ENTRY_1_BRANCH = 0x9AF5C # entry 1 末尾的 "B loc_9AE24"
ENTRY_2_BRANCH = 0x9AEB8 # entry 2 末尾的 "B loc_9AE20"
ENTRY_5_BRANCH = 0x9AF40 # entry 5 末尾的 "B loc_9AE24"


def encode_b(src, target):
"""Encode ARM64 unconditional B instruction (4 bytes, little-endian)."""
off = (target - src) >> 2
if off < -(1 << 25) or off >= (1 << 25):
raise ValueError(f"B out of range: 0x{src:x} -> 0x{target:x}")
if off < 0:
off &= 0x03FFFFFF
val = 0x14000000 | (off & 0x03FFFFFF)
return val.to_bytes(4, 'little')


def patch_b(src, target, label=""):
bytes_new = encode_b(src, target)
ida_bytes.patch_bytes(src, bytes_new)
print(f" patch 0x{src:x} -> B 0x{target:x} {label}")


def patch_raw(addr, hex_bytes, label=""):
b = bytes.fromhex(hex_bytes.replace(" ", ""))
ida_bytes.patch_bytes(addr, b)
print(f" patch 0x{addr:x} = {hex_bytes} {label}")


def main():
print("=" * 60)
print("Patching sub_9AD68 (Tick) to defeat OLLVM control-flow flattening")
print("=" * 60)

# --- Step 1: hop from dispatcher entry directly into entry 1 ---
print("\n[1] Redirect dispatcher -> entry 1")
patch_b(DISPATCHER_ENTRY, ENTRY_1, "dispatcher -> entry 1")

print("\n[2] Kill unreachable dispatcher tail (LDR + BR -> NOP + RET)")
patch_raw(DISPATCHER_LDR, "1f 20 03 d5", "NOP")
patch_raw(DISPATCHER_BR, "c0 03 5f d6", "RET")

# --- Step 3: chain entry handlers directly ---
print("\n[3] Chain handlers: entry1 -> entry5 -> entry4 (skip dispatcher)")
patch_b(ENTRY_1_BRANCH, ENTRY_5, "entry1 -> entry5 (skip baseline check)")
patch_b(ENTRY_2_BRANCH, ENTRY_5, "entry2 -> entry5 (after baseline init)")
patch_b(ENTRY_5_BRANCH, ENTRY_4, "entry5 -> entry4 (skip slow-path branch)")

# --- Step 4: rebuild function so Hex-Rays sees one clean function ---
print("\n[4] Rebuild sub_9AD68 function boundary")
# Remove any existing function(s) in the Tick range
for ea in range(TICK_START, TICK_END, 4):
f = ida_funcs.get_func(ea)
if f and f.start_ea != TICK_START:
# some entry was treated as its own function — delete it
print(f" delete sub function @ 0x{f.start_ea:x}")
ida_funcs.del_func(f.start_ea)

# Delete and recreate main function
ida_funcs.del_func(TICK_START)
ok = ida_funcs.add_func(TICK_START, TICK_END)
print(f" add_func(0x{TICK_START:x}, 0x{TICK_END:x}) -> {ok}")

# Rename it back to sub_9AD68 (IDA may have named it something else)
ida_name.set_name(TICK_START, "sub_9AD68", ida_name.SN_FORCE)
print(f" renamed to sub_9AD68")

# --- Step 5: kick off auto-analysis ---
print("\n[5] Run auto-analysis")
ida_auto.auto_wait()
print(" done")

print("\n" + "=" * 60)
print("Done.sub_9AD68 ")
print("=" * 60)


if __name__ == "__main__":
main()

image-20260419063239219

可以看到清晰的代码

OLLVM去混淆 [反调试部分]

ps:最后观察了一波主要分两种混淆这里 一种是写死的跳转 一种是条件跳转,这里只是分析,其实还是手动patch,让AI分析

sub_9B7D8

这个是个更复杂的OLLVM(OLLVM CFF + 运行时状态机),依赖 mprotect sub_9AD3C 返回值+ byte-compare loop

因此思路是用Unicorn 跑出真实 cold-path + 静态 patch 完全线性化

为什么需要 Unicorn?

前面的OLLVM 的 CFF 是可静态 trace 的

  • 唯一 CSEL 在 baseline==0 上(cold-start = 0,必走 init 分支)
  • bind_methods: CSEL 在 byte_18351X(cold-start = 0,必走首次分支)

区别是 state 来源是静态常量还是运行时数据

1
2
3
4
5
6
7
9bd00: ADR  X8, #0x98564             X8 = sub_98564 地址(OLLVM 用 ADR+BLR 而非 BL)
9bd04: BLR X8 调 sub_98564 (anti-Frida helper, 内部跑 SYS_openat)
9bd08-9bd28: 各种 MOV 准备候选 state 值
9bd2c: CMP W0, #0 拿返回值 W0 跟 0 比
9bd30-9bd48: 把候选 state 存栈 + 加载
9bd4c: CSEL W8, W9, W8, EQ if (W0==0) W8=W9 else W8=W8
9bd50: B 0x9b93c 跳回 dispatcher,下一个 state = 刚才 CSEL 选的

ADR+BLR:正常函数调用常写成 BL 目标地址,但 OLLVM/混淆器可能先用ADR 把目标地址算到寄存器里,再用 BLR 寄存器调用。这样会把一个直接调用伪装成间接调用

怎么看出 next_state 来自运行时:

  • BLR X8 调外部函数(这里是 sub_98564 内部跑 syscall)
  • CMP W0, #0 → W0 = 返回值
  • CSEL W8, W9, W8, EQ → W8(状态寄存器)取 W9 或 W8,取决于刚才的 CMP
  • B 0x9b93c → 跳回 dispatcher,dispatcher 用 W8 路由

syscall 返回值决定下一个 state

再比如

1
2
3
4
5
9bdd0: LDR  W9, [X19, #0xac]       加载候选 state
9bdd4: LDR X10, [X19, #0x58] X10 = stack[0x58] (之前某个 syscall 的返回值)
9bdd8: CMN X10, #1 X10 跟 -1 比 (CMN = 加法比较,等价于 CMP X10, #-1)
9bddc: CSEL W8, W8, W9, EQ if (X10==-1) W8=W8 else W8=W9
9bde0: B 0x9b93c 跳回 dispatcher

这些都是运行时探测,静态没法预测。所以必须 Unicorn 跑,看实际命中哪些 entry。

写个 grep 工具扫整个函数,这种 BLR CMP CSEL B 的三连就是运行时状态转移

用之前的CFFSolver可以初步探测一下

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
import idc
import ida_bytes

class CFFSolver:
def __init__(self, dispatcher_start, dispatcher_end, jump_table,
prologue_static_regs, stack_prepop=None, stack_base_reg=29):
self.start = dispatcher_start
self.end = dispatcher_end
self.table = jump_table
self.static_regs = dict(prologue_static_regs)
self.stack_prepop = dict(stack_prepop or {})
self.stack_base_reg = stack_base_reg

def simulate(self, state_w8):
"""跑一遍 dispatcher, 返回最终 X8 值 (byte_offset into jump table)."""
X = {i: self.static_regs.get(i, 0) for i in range(32)}
X[8] = state_w8
X[31] = 0
Z = 0
addr = self.start
while addr < self.end:
raw = ida_bytes.get_dword(addr)

# MOVZ 32-bit: opcode 0x52800000
if (raw & 0x7F800000) == 0x52800000:
Rd = raw & 0x1F
imm = (raw >> 5) & 0xFFFF
sh = ((raw >> 21) & 0x3) * 16
X[Rd] = (imm << sh) & 0xFFFFFFFF

# MOVK 32-bit: opcode 0x72800000
elif (raw & 0x7F800000) == 0x72800000:
Rd = raw & 0x1F
imm = (raw >> 5) & 0xFFFF
sh = ((raw >> 21) & 0x3) * 16
cur = X[Rd] & 0xFFFFFFFF
X[Rd] = ((cur & ~(0xFFFF << sh)) | (imm << sh)) & 0xFFFFFFFF

# ADD imm 32: 0x11000000
elif (raw & 0x7FC00000) == 0x11000000:
Rd = raw & 0x1F
Rn = (raw >> 5) & 0x1F
imm = (raw >> 10) & 0xFFF
shift = ((raw >> 22) & 0x3) * 12
X[Rd] = (X[Rn] + (imm << shift)) & 0xFFFFFFFF

# SUB imm 32: 0x51000000
elif (raw & 0x7FC00000) == 0x51000000:
Rd = raw & 0x1F
Rn = (raw >> 5) & 0x1F
imm = (raw >> 10) & 0xFFF
shift = ((raw >> 22) & 0x3) * 12
X[Rd] = (X[Rn] - (imm << shift)) & 0xFFFFFFFF

# LDR Wt, [Xn, #imm12*4] (32-bit, unsigned offset): 0xB9400000
elif (raw & 0xFFC00000) == 0xB9400000:
Rt = raw & 0x1F
Rn = (raw >> 5) & 0x1F
imm = ((raw >> 10) & 0xFFF) * 4
key = (Rn, imm)
if key in self.stack_prepop:
X[Rt] = self.stack_prepop[key]
# else: 未知栈槽, 跳过 (X[Rt] 保持原值)

# SUBS W (compare-with-register): 0x6B000000
elif (raw & 0x7FE00000) == 0x6B000000:
Rn = (raw >> 5) & 0x1F
Rm = (raw >> 16) & 0x1F
a = X[Rn] & 0xFFFFFFFF
b = X[Rm] & 0xFFFFFFFF
Z = 1 if a == b else 0

# CSEL (32 或 64-bit): 0x1A800000 / 0x9A800000
elif (raw & 0x7FE00C00) == 0x1A800000:
Rm = (raw >> 16) & 0x1F
cond = (raw >> 12) & 0xF
Rn = (raw >> 5) & 0x1F
Rd = raw & 0x1F
# cond=0: EQ (Z=1), cond=1: NE (Z=0)
take_true = (cond == 0 and Z) or (cond == 1 and not Z)
X[Rd] = X[Rn] if take_true else X[Rm]

# LDR Xt, [Xn, Xm] (register form): 0xF8600800
elif (raw & 0xFFE00C00) == 0xF8600800:
return X[8] # 我们要的就是 byte_offset

# BR Xn: 0xD61F0000
elif (raw & 0xFFFFFC1F) == 0xD61F0000:
return X[8]

addr += 4
return X[8]

def solve_all(self, candidate_states=None):
"""尝试所有 CMP 常量 (从 dispatcher 中扫出来), 返回 {state: entry_ea}."""
if candidate_states is None:
candidate_states = self._collect_cmp_constants()

mapping = {}
for state in candidate_states:
off = self.simulate(state)
if off is None:
continue
try:
target = ida_bytes.get_qword(self.table + off)
mapping[state] = (off, target)
except Exception:
pass
return mapping

def _collect_cmp_constants(self):
"""扫 dispatcher 找所有 CMP W8 的对象寄存器值."""
consts = []
regs = dict(self.static_regs)
addr = self.start
while addr < self.end:
raw = ida_bytes.get_dword(addr)
# 跟踪 MOVZ/MOVK/ADD 写入寄存器
if (raw & 0x7F800000) == 0x52800000:
Rd = raw & 0x1F
imm = (raw >> 5) & 0xFFFF
sh = ((raw >> 21) & 0x3) * 16
regs[Rd] = (imm << sh) & 0xFFFFFFFF
elif (raw & 0x7F800000) == 0x72800000:
Rd = raw & 0x1F
imm = (raw >> 5) & 0xFFFF
sh = ((raw >> 21) & 0x3) * 16
cur = regs.get(Rd, 0) & 0xFFFFFFFF
regs[Rd] = ((cur & ~(0xFFFF << sh)) | (imm << sh)) & 0xFFFFFFFF
elif (raw & 0x7FC00000) == 0x11000000:
Rd = raw & 0x1F
Rn = (raw >> 5) & 0x1F
imm = (raw >> 10) & 0xFFF
shift = ((raw >> 22) & 0x3) * 12
if Rn in regs:
regs[Rd] = (regs[Rn] + (imm << shift)) & 0xFFFFFFFF
# SUB imm 32: 0x51000000
elif (raw & 0x7FC00000) == 0x51000000:
Rd = raw & 0x1F
Rn = (raw >> 5) & 0x1F
imm = (raw >> 10) & 0xFFF
shift = ((raw >> 22) & 0x3) * 12
if Rn in regs:
regs[Rd] = (regs[Rn] - (imm << shift)) & 0xFFFFFFFF
# LDR Wt, [Xn, #imm] (32-bit unsigned offset): 0xB9400000
elif (raw & 0xFFC00000) == 0xB9400000:
Rt = raw & 0x1F
Rn = (raw >> 5) & 0x1F
imm = ((raw >> 10) & 0xFFF) * 4
key = (Rn, imm)
if key in self.stack_prepop:
regs[Rt] = self.stack_prepop[key]
# CMP W8, Wn -> 收集 Wn 的值
elif (raw & 0x7FE00000) == 0x6B000000:
Rn = (raw >> 5) & 0x1F
Rm = (raw >> 16) & 0x1F
if Rn == 8 and Rm in regs:
consts.append(regs[Rm])
addr += 4
return list(set(consts))

@staticmethod
def transform(state, xor_const, add_const):
"""通用 CFF state-transform. 多数 OLLVM 用 (state ^ K1) + K2 形式."""
return ((state ^ xor_const) + add_const) & 0xFFFFFFFF

def print_mapping(self, mapping):
print(f"{'state':>12s} {'offset':>8s} {'idx':>4s} entry")
print("=" * 60)
for state in sorted(mapping.keys()):
off, target = mapping[state]
print(f" 0x{state:08x} 0x{off:04x} {off//8:3d} 0x{target:x}")


# ---------- 使用示例 ----------
if __name__ == "__main__":
print("=== Example 1: sub_97B6C (bind_methods) ===")
sim1 = CFFSolver(
dispatcher_start=0x97C38,
dispatcher_end=0x97DDC,
jump_table=0x161970,
prologue_static_regs={
19: 0x286b011e, 20: 0xb0, 21: 0x646ee9e9, 22: 0x11f811c5,
23: 0xf5e7a789, 24: 0x8e7fe997, 25: 0x6da9fc77,
26: 0xb5653484, 27: 0xf1a0b5b5, 28: 0x20,
},
)
sim1.print_mapping(sim1.solve_all())

print("\n\n=== Example 2: sub_9B7D8 (3-sec watchdog) ===")
sim2 = CFFSolver(
dispatcher_start=0x9B94C,
dispatcher_end=0x9BAA4,
jump_table=0x1616F0,
prologue_static_regs={
11: 0x4E72E7C0, 17: 0xB0252E87, 20: 0x168D9064,
21: 0x91B4749D, 22: 0xFBE35084, 23: 0xF4015F7A,
24: 0xD19A7254, 25: 0xADC11019, 26: 0xA4998B6E,
27: 0xA30A5DA0, 28: 0x9DF9D120,
3: 0xD8, 4: 0x6925412C,
},
stack_prepop={
# prologue 写入: STR W8, [X19, #0xA8] 其中 W8 = W22 - 0xE
(19, 0xA8): (0xFBE35084 - 0xE) & 0xFFFFFFFF, # = 0xFBE35076
},
stack_base_reg=19,
)
sim2.print_mapping(sim2.solve_all())

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
sub_9B7D8
state offset idx entry
============================================================
0x0a129647 0x0040 8 0x9bf0c
0x13daaceb 0x0008 1 0x9bcf8
0x1c7250ed 0x0038 7 0x9c3b4
0x2658dd4d 0x0018 3 0x9c448
0x2a750c6c 0x0080 16 0x9bc6c
0x3fb1beed 0x0030 6 0x9bcb0
0x453f777c 0x0058 11 0x9bef0
0x4e72e73a 0x0090 18 0x9bc80
0x67179144 0x0070 14 0x9c3f8
0x698549b1 0x0020 4 0x9bacc
0x8a757ba0 0x0010 2 0x9babc
0x91b4749d 0x0000 0 0x9bd54
0x9df9d120 0x0048 9 0x9bef0
0xa4998bd0 0x0028 5 0x9bc5c
0xd19a72a6 0x0060 12 0x9b924
0xf4015f7a 0x0088 17 0x9bc10
0xfbe35076 0x0050 10 0x9bde4

这 17 条映射告诉我们 dispatcher 能跳到哪些地方,但不告诉你它实际跳了哪条。要拿真实链必须 Unicorn 跑(CSEL 依赖运行时探测结果)。

其实就是:

比如next_state {0xfbe35076, 0x698549b1},取决于 W0W0 取决于 mprotect / openat 的真实返回值, 内核行为/文件系统状态决定,编译期/静态分析无法预测 ,我们无法预测,之前的Tick 我们是手动分析和选择了一个分支,但是这里全是

用 Unicorn trace 实际执行

让AI搓一个脚本,准备好got表,然后模拟执行

这脚本把 sub_9B7D8 的所有 syscall 替换成永远成功的 stub,让 Unicorn 实际跑一遍。CSEL 拿到 stub 返回的 0 永远走 cold-path(无调试), 记录 PC 命中 entry 的顺序 ,得到真实链

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

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

LIB_PATH = Path("/home/matriy/RE/game/tencent_ctf/2026_end/final/lib/arm64-v8a/libsec2026.so")

CODE_BASE = 0x0
CODE_SIZE = 0x200000
STACK_BASE = 0x400000
STACK_SIZE = 0x100000
TLS_BASE = 0x600000
HEAP_BASE = 0x700000
STUB_BASE = 0x500000
RET_MAGIC = 0x7FFF0000

SUB_9B7D8 = 0x9B7D8

# 16 OLLVM entries (from CFFSolver mapping)
ENTRIES = {
0x9BD54: "alloca×8 + state_setup",
0x9BCF8: "BLR loc_98564 (anti-debug probe)",
0x9BABC: "LDP+STR data shuffle",
0x9BACC: "mutex_unlock + memcpy + LD1 byte_1834B0",
0x9BC5C: "STR WZR + B 9C434",
0x9BCB0: "mprotect(addr, sz, 5)",
0x9C3B4: "LDP + CSEL on slot==0",
0x9BF0C: "W14 vs W21 byte-compare loop",
0x9BEF0: "MOV W8,#1 + STR + state=W11",
0x9BDE4: "mutex_lock + memcpy + LD1 byte_1834B0",
0x9B924: "clear slot + state=0x8A757B5E",
0x9BC6C: "LDR/STR XZR + state=0x2658DDF3",
0x9BC10: "clear qword_183498 + CSEL on exit_ptr",
0x9BC80: "data shuffle to slots",
0x9C3F8: "mutex_unlock + state=0x1C725013",
0x9C448: "EPILOGUE (RET)",
}

JT_9B7D8 = {
0x1616F0: [0x9BD54, 0x9BCF8, 0x9BABC, 0x9C448, 0x9BACC, 0x9BC5C, 0x9BCB0, 0x9C3B4,
0x9BF0C, 0x9BEF0, 0x9BDE4, 0x9BEF0, 0x9B924, 0x9BAA4, 0x9C3F8, 0x9B94C,
0x9BC6C, 0x9BC10, 0x9BC80, 0x9B94C, 0x98FEC, 0x98E10, 0x98FF8, 0x98E10,
0x98E80, 0x9902C, 0x99054, 0x98E28, 0x98E28, 0x98E98, 0x96ACC, 0x96B14],
}

IMPORT_GOT = {
"sleep": 0x18a0b8, "setpriority": 0x18a0c0, "sysconf": 0x18a0c8,
"exit": 0x18a0d0, "mprotect": 0x18a0d8,
"__memset_chk": 0x18a030, "__memcpy_chk": 0x18a070,
"memcpy": 0x18a150, "memset": 0x18a160,
"dl_iterate_phdr": 0x18a048, "strstr": 0x18a050,
"getpid": 0x18a0e8, "fork": 0x18a0f0, "waitpid": 0x18a0f8,
"ptrace": 0x18a038, "usleep": 0x18a0b0, "llabs": 0x18a0a8,
"free": 0x18a100, "malloc": 0x18a060,
"opendir": 0x18a088, "readdir": 0x18a090, "lstat": 0x18a098,
"closedir": 0x18a0a0, "__vsnprintf_chk": 0x18a0e0,
"__system_property_get": 0x18a248,
"pthread_mutex_lock": 0x18a258,
"pthread_mutex_unlock": 0x18a260,
}


class Tracer:
def __init__(self):
self.blob = LIB_PATH.read_bytes()
self.mu = Uc(UC_ARCH_ARM64, UC_MODE_ARM)
self.mu.mem_map(CODE_BASE, CODE_SIZE)
self.mu.mem_map(STACK_BASE, STACK_SIZE)
self.mu.mem_map(TLS_BASE, 0x10000)
self.mu.mem_map(HEAP_BASE, 0x100000)
self.mu.mem_map(STUB_BASE, 0x20000)
self.mu.mem_map(RET_MAGIC & ~0xFFF, 0x1000)

self.mu.mem_write(CODE_BASE, self.blob[:CODE_SIZE])

# Patch jump table
for addr, vals in JT_9B7D8.items():
self.mu.mem_write(addr, b"".join(struct.pack("<Q", v) for v in vals))

# Setup stubs
self.stub_to_name = {}
self.stub_counter = 0
for name, got in IMPORT_GOT.items():
saddr = STUB_BASE + self.stub_counter * 4
self.stub_counter += 1
self.mu.mem_write(saddr, struct.pack("<I", 0xD65F03C0)) # RET
self.mu.mem_write(got, struct.pack("<Q", saddr))
self.stub_to_name[saddr] = name

# Cache fn ptr slots
self.cache_stub = {}
for slot in range(0x1667D8, 0x166FF8 + 8, 8):
saddr = STUB_BASE + self.stub_counter * 4
self.stub_counter += 1
self.mu.mem_write(saddr, struct.pack("<I", 0xD65F03C0))
self.mu.mem_write(slot, struct.pack("<Q", saddr))
self.cache_stub[saddr] = slot

# Stub for sub_96A00, sub_98564, etc. — make them just RET 0
for stub_target in (0x96A00, 0x98564, 0x96970, 0x96B14, 0x96ACC, 0x9CD18,
0x9AD20, 0x9AD3C, 0x9AD58):
# NOP everything in the function and put RET at the start
self.mu.mem_write(stub_target, struct.pack("<I", 0xD65F03C0)) # RET

self.entry_hits = [] # 按访问顺序记录 (entry_addr, csel_state_after)
self.steps = 0
self.max_steps = 100_000

def _code_hook(self, uc, address, size, data):
self.steps += 1
if self.steps > self.max_steps:
uc.emu_stop()
return

if address == RET_MAGIC:
uc.emu_stop()
return

# 命中 entry
if address in ENTRIES:
w8 = uc.reg_read(UC_ARM64_REG_X8) & 0xFFFFFFFF
self.entry_hits.append((address, w8))

# 命中 epilogue RET
if address == 0x9C480: # RET in epilogue
print(f" [hit RET @ 0x9C480]")
uc.emu_stop()
return

# Stub handling
if STUB_BASE <= address < STUB_BASE + 0x20000:
name = self.stub_to_name.get(address, "?")
if name == "?" and address in self.cache_stub:
slot = self.cache_stub[address]
name = f"cache[0x{slot:x}]"
self._handle_stub(uc, name)
return

def _handle_stub(self, uc, name):
if name == "mprotect":
uc.reg_write(UC_ARM64_REG_X0, 0) # 成功
elif name == "__memset_chk" or name == "memset":
dst = uc.reg_read(UC_ARM64_REG_X0)
val = uc.reg_read(UC_ARM64_REG_X1) & 0xFF
sz = uc.reg_read(UC_ARM64_REG_X2)
if sz < 0x10000:
try: uc.mem_write(dst, bytes([val]) * sz)
except: pass
uc.reg_write(UC_ARM64_REG_X0, dst)
elif name == "__memcpy_chk" or name == "memcpy":
dst = uc.reg_read(UC_ARM64_REG_X0)
src = uc.reg_read(UC_ARM64_REG_X1)
sz = uc.reg_read(UC_ARM64_REG_X2)
if sz < 0x10000:
try:
d = uc.mem_read(src, sz)
uc.mem_write(dst, bytes(d))
except: pass
uc.reg_write(UC_ARM64_REG_X0, dst)
elif name == "sysconf":
uc.reg_write(UC_ARM64_REG_X0, 4096)
elif name == "malloc":
uc.reg_write(UC_ARM64_REG_X0, HEAP_BASE + 0x10000)
elif name == "exit":
print(f" [exit() — STOP]")
uc.emu_stop()
return
else:
uc.reg_write(UC_ARM64_REG_X0, 0)

def _intr_hook(self, uc, intno, data):
pc = uc.reg_read(UC_ARM64_REG_PC)
sysno = uc.reg_read(UC_ARM64_REG_X8)
if sysno == 0x71: # clock_gettime
arg1 = uc.reg_read(UC_ARM64_REG_X1)
try: uc.mem_write(arg1, struct.pack("<QQ", 0, 0))
except: pass
uc.reg_write(UC_ARM64_REG_X0, 0)
uc.reg_write(UC_ARM64_REG_PC, pc + 4)

def _mem_invalid(self, uc, access, address, size, value, data):
pc = uc.reg_read(UC_ARM64_REG_PC)
# Try to map the page on demand for any address in low 4GB
if 0 < address < 0xFFFFFFFFFFFF:
page = address & ~0xFFF
try:
uc.mem_map(page, 0x1000)
# zero-fill
uc.mem_write(page, b"\x00" * 0x1000)
return True # retry
except UcError:
pass
print(f" [MEM_INVALID @PC=0x{pc:x} addr=0x{address:x}] STOP")
return False

def run(self):
sp = STACK_BASE + STACK_SIZE - 0x1000
tls = TLS_BASE + 0x100
self.mu.reg_write(UC_ARM64_REG_SP, sp)
self.mu.reg_write(UC_ARM64_REG_TPIDR_EL0, tls)
self.mu.mem_write(tls + 0x28, struct.pack("<Q", 0xCAFEBABEDEADBEEF))
self.mu.reg_write(UC_ARM64_REG_X0, 0)
self.mu.reg_write(UC_ARM64_REG_X30, RET_MAGIC)

self.mu.hook_add(UC_HOOK_CODE, self._code_hook)
self.mu.hook_add(UC_HOOK_MEM_INVALID, self._mem_invalid)
self.mu.hook_add(UC_HOOK_INTR, self._intr_hook)

try:
self.mu.emu_start(SUB_9B7D8, RET_MAGIC, timeout=30_000_000)
return "OK"
except UcError as e:
pc = self.mu.reg_read(UC_ARM64_REG_PC)
return f"ERROR: {e} at PC=0x{pc:x}"

def summarize(self):
print(f"\n=== Entry 命中序列 ({len(self.entry_hits)} hits, {self.steps} steps) ===")
# 去重连续相同
prev = None
chain = []
for ent, w8 in self.entry_hits:
if ent != prev:
chain.append((ent, w8))
prev = ent
for i, (ent, w8) in enumerate(chain):
note = ENTRIES.get(ent, "?")
print(f" [{i:2d}] 0x{ent:x} W8=0x{w8:08x} ; {note}")


def main():
t = Tracer()
res = t.run()
print(f"Result: {res}")
t.summarize()


if __name__ == "__main__":
main()

image-20260419071201888

Phase 1: 0x9BD54 → 0x9BCB0

Phase 2 (loop): 0x9BDE4 → 0x9BF0C → 0x9BC10 → 0x9BACC → 0x9BC80 → 回 0x9BDE4

找每个 entry 的 dispatcher 出口

不是所有 entry 都直接跳回 0x9B93C/934/938。有些经过中转:

  • 0x9BCB0 末尾跳 0x9BDDC (与 0x9BD54 共用 CSEL+B 0x9B93C)
  • 0x9BACC 末尾跳 0x9BEFC (借用 entry 0x9BEF0 中段的 STR + B 0x9B938)
  • 0x9BF0C loop 末尾在 0x9C3B0 (B 0x9B94C, 直接跳 dispatcher 头)
  • 0x9BC80 末尾跳 0x9C3E4 (state 计算后 B 回 dispatcher)

发现多个 entry 共用 dispatcher 尾巴。如果 patch 共用尾巴,会破坏多个 entry。所以必须 patch 每个 entry 自己的汇入点,不要碰共用尾巴。

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
import ida_bytes
import ida_funcs
import ida_auto
import ida_hexrays

FUNC_START = 0x9B7D8
FUNC_END = 0x9C484

PATCHES = [
(0x9B920, 0x9BD54),
(0x9BDE0, 0x9BCB0),
(0x9BCF4, 0x9BDE4),
(0x9BEEC, 0x9BF0C),
(0x9C3B0, 0x9BC10),
(0x9BC58, 0x9BACC),
(0x9BF08, 0x9BC80),
(0x9BCAC, 0x9BDE4),
]

def encode_b(src, target):
off = (target - src) >> 2
if off < -(1 << 25) or off >= (1 << 25):
raise ValueError(f"B 跳转超界: 0x{src:x} -> 0x{target:x}")
if off < 0:
off &= 0x03FFFFFF
val = 0x14000000 | (off & 0x03FFFFFF)
return val.to_bytes(4, 'little')


def patch_b(src, target, label=""):
ida_bytes.patch_bytes(src, encode_b(src, target))
print(f" patch 0x{src:x}: B 0x{target:x} ; {label}")


def main():
print("=" * 70)
print("Patching sub_9B7D8")
print("=" * 70)

print("\n[1] 应用 8 个 B 跳转 patch")
for src, tgt in PATCHES:
patch_b(src, tgt)

print("\n[2] 重建函数边界")
# 删除 IDA 误判的子函数
for ea in range(FUNC_START, FUNC_END + 4, 4):
f = ida_funcs.get_func(ea)
if f and f.start_ea != FUNC_START and ea != FUNC_START:
ida_funcs.del_func(f.start_ea)

ida_funcs.del_func(FUNC_START)
ok = ida_funcs.add_func(FUNC_START, FUNC_END)
print(f" add_func(0x{FUNC_START:x}, 0x{FUNC_END:x}) -> {ok}")
if not ok:
for ea in range(FUNC_START, FUNC_END, 4):
f = ida_funcs.get_func(ea)
if f and f.start_ea != FUNC_START:
ida_funcs.del_func(f.start_ea)
ok = ida_funcs.add_func(FUNC_START, FUNC_END)
print(f" retry add_func -> {ok}")

print("\n[3] auto-analysis + Hex-Rays cache flush")
ida_auto.auto_wait()
ida_hexrays.mark_cfunc_dirty(FUNC_START)

print("\n" + "=" * 70)
print("完成. sub_9B7D8")
print("=" * 70)


if __name__ == "__main__":
main()

效果

完整伪代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
void sub_9B7D8()
{
unsigned __int8 n25; // w25
size_t len; // x0
int8x16_t v2; // q1
__int64 v3; // x8
int *p_n1638929757_1; // x9
__int64 n1638929757_5; // x21
int8x16_t v6; // q1
__int64 v7; // x0
_BYTE *v8; // x10
_BYTE *v9; // x12
__int64 n1316153280; // x11
__int64 n1638929757_9; // x9
int v12; // w14
unsigned __int64 n1638929757_8; // x0
__int64 v14; // x5
__int64 n53; // x6
__int64 v16; // x7
_BYTE *n1638929757_7; // x8
__int64 n510_1; // x22
int v19; // w12
int v20; // w11
int v21; // w10
int n403399634_1; // w9
__int64 *n510; // x8
__int64 v24; // x26
__int64 *n510_2; // x21
_BYTE *n1638929757_4; // x24
int n1638929757; // w10
__int64 n1638929757_1; // x9
int n1638929589; // w10
__int64 v30; // x0
int n2146325262; // w8
bool v32; // zf
int n378376109; // w10
int n1638929757_3; // w10
int n333098001; // w8
__int64 v36; // [xsp-80h] [xbp-390h] BYREF
__int64 v37; // [xsp-70h] [xbp-380h] BYREF
__int64 v38; // [xsp-60h] [xbp-370h] BYREF
__int64 v39; // [xsp-50h] [xbp-360h] BYREF
__int64 v40; // [xsp-40h] [xbp-350h] BYREF
__int64 v41; // [xsp-30h] [xbp-340h] BYREF
int n378376109_1; // [xsp-20h] [xbp-330h] BYREF
int n1638929757_2; // [xsp-10h] [xbp-320h] BYREF
__int64 v44; // [xsp+0h] [xbp-310h] BYREF
unsigned __int64 StatusReg; // [xsp+8h] [xbp-308h]
int8x16_t v46; // [xsp+10h] [xbp-300h]
_WORD *n1638929757_10; // [xsp+28h] [xbp-2E8h]
int8x16_t v48; // [xsp+30h] [xbp-2E0h]
void *addr; // [xsp+40h] [xbp-2D0h]
__int64 *v50; // [xsp+48h] [xbp-2C8h]
void *v51; // [xsp+50h] [xbp-2C0h]
size_t len_1; // [xsp+58h] [xbp-2B8h]
__int64 n378376109_2; // [xsp+60h] [xbp-2B0h]
int *p_n1638929757; // [xsp+68h] [xbp-2A8h]
__int64 *v55; // [xsp+70h] [xbp-2A0h]
__int64 *v56; // [xsp+78h] [xbp-298h]
__int64 *v57; // [xsp+80h] [xbp-290h]
__int64 *v58; // [xsp+88h] [xbp-288h]
int *p_n378376109; // [xsp+90h] [xbp-280h]
__int64 *v60; // [xsp+98h] [xbp-278h]
__int64 *v61; // [xsp+A0h] [xbp-270h]
int v62; // [xsp+A8h] [xbp-268h]
int n1068613139; // [xsp+ACh] [xbp-264h]
int n712314002; // [xsp+B0h] [xbp-260h]
int n1316153280_1; // [xsp+B4h] [xbp-25Ch]
int n403399634; // [xsp+B8h] [xbp-258h]
int v67; // [xsp+BCh] [xbp-254h]
int8x16_t v68; // [xsp+C0h] [xbp-250h] BYREF
char v69; // [xsp+D0h] [xbp-240h]
char v70; // [xsp+D1h] [xbp-23Fh]
char v71; // [xsp+D2h] [xbp-23Eh]
char v72; // [xsp+D3h] [xbp-23Dh]
int8x16_t v73; // [xsp+E0h] [xbp-230h] BYREF
_BYTE n1638929757_6[512]; // [xsp+F0h] [xbp-220h] BYREF
_WORD n1638929757_11[8]; // [xsp+2F0h] [xbp-20h] BYREF
__int64 v76; // [xsp+300h] [xbp-10h]

StatusReg = _ReadStatusReg(TPIDR_EL0);
v76 = *(_QWORD *)(StatusReg + 40);
n25 = 25;
sleep(3u);
setpriority(PRIO_PROCESS, 0, 19);
_memset_chk(n1638929757_11, 0, 16, 16);
n1638929757_11[0] = 2;
len = sysconf(39);
n1638929757_10 = n1638929757_11;
n1316153280_1 = -1850444643;
addr = (void *)(-(__int64)len & (unsigned __int64)&exit);
len_1 = len;
v62 = -68988810;
v46 = (int8x16_t)xmmword_58560;
v48 = (int8x16_t)xmmword_58640;
v51 = &unk_58000;
p_n1638929757 = &n1638929757_2;
p_n378376109 = &n378376109_1;
v58 = &v41;
v57 = &v40;
v61 = &v39;
v60 = &v38;
v56 = &v37;
v55 = &v36;
n1068613139 = 1068613139;
n712314002 = 712314002;
mprotect(addr, len, 5);
n1068613139 = -68988796;
n712314002 = -778407340;
LABEL_2:
n1638929757_5 = *(_QWORD *)p_n1638929757;
pthread_mutex_lock(&mutex_);
_memcpy_chk(&v73, &unk_1682D0, 16, 16);
v6.n128_u8[0] = byte_1834B0;
v6.n128_u8[1] = byte_1834B0;
v6.n128_u8[2] = byte_1834B0;
v6.n128_u8[3] = byte_1834B0;
v6.n128_u8[4] = byte_1834B0;
v6.n128_u8[5] = byte_1834B0;
v6.n128_u8[6] = byte_1834B0;
v6.n128_u8[7] = byte_1834B0;
v6.n128_u8[8] = byte_1834B0;
v6.n128_u8[9] = byte_1834B0;
v6.n128_u8[10] = byte_1834B0;
v6.n128_u8[11] = byte_1834B0;
v6.n128_u8[12] = byte_1834B0;
v6.n128_u8[13] = byte_1834B0;
v6.n128_u8[14] = byte_1834B0;
v6.n128_u8[15] = byte_1834B0;
v73 = veorq_s8(v6, veorq_s8(v73, v46));
v7 = sub_9AD3C(56, -100, &v73, 0, 0);
*(_QWORD *)p_n378376109 = n1638929757_5;
n1316153280 = 1316153280;
n1068613139 = -1644572194;
n712314002 = 168990453;
n1638929757_9 = 2650395102LL;
v51 = (void *)v7;
v50 = &v44;
v12 = -1379856087;
n1638929757_8 = (unsigned __int64)n1638929757_10;
v14 = 2915111209LL;
n53 = 53;
v16 = 3190645414LL;
n378376109_2 = *(_QWORD *)p_n378376109;
n1638929757_7 = n1638929757_6;
n510_1 = 4215553395LL;
while ( 1 )
{
while ( v12 > -403923595 )
{
while ( 2 )
{
switch ( v12 )
{
case -403923594:
*v9 = 0;
n1638929757_7 = v9 + 1;
v12 = -1379856087;
v68.n128_u32[0] = -1379856087;
break;
case -79413901:
*(_BYTE *)n1316153280 = 0;
n403399634 = -403923892;
v67 = -1559601760;
v19 = -1559601760;
if ( n1316153280 + 1 < n1638929757_8 )
v19 = -403923892;
v12 = (v19 ^ 0x35) + 253;
v9 = (_BYTE *)(n1316153280 + 1);
v68.n128_u32[0] = v12;
if ( v12 <= -403923595 )
goto LABEL_14;
continue;
case 403399908:
*(_BYTE *)n1638929757_9 = 0;
n403399634 = -1104322148;
v67 = -1559601760;
v21 = -1559601760;
if ( n1638929757_9 + 1 < n1638929757_8 )
v21 = -1104322148;
v12 = (v21 ^ 0x35) + 253;
v8 = (_BYTE *)(n1638929757_9 + 1);
v68.n128_u32[0] = v12;
break;
default:
goto LABEL_12;
}
break;
}
}
LABEL_14:
if ( v12 == -1559601518 )
break;
if ( v12 == -1379856087 )
{
n403399634 = 403399634;
v67 = -1559601760;
n403399634_1 = -1559601760;
if ( (unsigned __int64)n1638929757_7 < n1638929757_8 )
n403399634_1 = 403399634;
v12 = (n403399634_1 ^ 0x35) + 253;
n1638929757_9 = (__int64)n1638929757_7;
v68.n128_u32[0] = v12;
}
else
{
if ( v12 != -1104321882 )
{
LABEL_12:
loc_9BFCC();
JUMPOUT(0x9BFE0);
}
*v8 = 0;
n403399634 = -79414205;
v67 = -1559601760;
v20 = -1559601760;
if ( (unsigned __int64)(v8 + 1) < n1638929757_8 )
v20 = -79414205;
v12 = (v20 ^ 0x35) + 253;
n1316153280 = (__int64)(v8 + 1);
v68.n128_u32[0] = v12;
}
}
n510 = &v44;
v24 = (int)v51;
n510_2 = 0;
n1638929757_4 = n1638929757_6;
n1638929757 = 1638929757;
n1638929757_1 = 1638929757;
n1638929757_2 = 1638929757;
while ( 1 )
{
while ( n1638929757 > 378376292 )
{
if ( n1638929757 > 1764049196 )
{
if ( n1638929757 == 1764049197 )
{
n510_1 = -1;
n1638929757 = -1339740337;
n1638929757_2 = -1339740337;
}
else
{
if ( n1638929757 != 2146325702 )
goto LABEL_54;
v32 = n510 == 0;
n1638929757_2 = 1764049125;
n378376109 = 378376109;
LABEL_49:
n378376109_1 = n378376109;
n1638929757_3 = n1638929757_2;
if ( !v32 )
n1638929757_3 = n378376109_1;
n1638929757 = (n1638929757_3 ^ 0xD8) + 240;
n1638929757_2 = n1638929757;
}
}
else if ( n1638929757 == 378376293 )
{
LABEL_28:
n510_1 = (__int64)n510;
n1638929757 = -1339740337;
n1638929757_2 = -1339740337;
}
else
{
if ( n1638929757 != 0x61B0155D )
goto LABEL_54;
v30 = sub_9AD20(63, v24, &v68, 1, 1764049196, v14, n53, v16);
n1638929757_2 = 2146325262;
n378376109_1 = -1827919423;
n2146325262 = 2146325262;
if ( v30 == 1 )
n2146325262 = -1827919423;
n1638929757_1 = (__int64)n1638929757_4;
n1638929757 = (n2146325262 ^ 0xD8) + 240;
n510 = n510_2;
n1638929757_2 = n1638929757;
}
}
if ( n1638929757 <= -1339740338 )
{
if ( n1638929757 == -1827919351 )
{
n25 = v68.n128_u8[0];
v32 = v68.n128_u8[0] == 10;
n1638929757_2 = -647145953;
n378376109 = 109951431;
goto LABEL_49;
}
if ( n1638929757 != -1687526908 )
goto LABEL_54;
n510_1 = (__int64)n510_2;
n1638929757 = -1339740337;
n1638929757_2 = -1339740337;
}
else
{
if ( n1638929757 == -647145545 )
goto LABEL_28;
if ( n1638929757 != 109951503 )
{
if ( n1638929757 != -1339740337 )
{
LABEL_54:
loc_9C310();
JUMPOUT(0x9C324);
}
n1068613139 = -201236608;
n712314002 = 333098001;
n333098001 = 333098001;
n25 = 25;
if ( n510_1 <= 0 )
n333098001 = -201236608;
n1316153280_1 = (n333098001 ^ 0xDC) + 30;
qword_183498 = 0;
n1068613139 = 1770342735;
n712314002 = 1161787266;
pthread_mutex_unlock(&mutex_);
_memcpy_chk(&v68, &unk_1682E0, 20, 20);
v2.n128_u8[0] = byte_1834B0;
v2.n128_u8[1] = byte_1834B0;
v2.n128_u8[2] = byte_1834B0;
v2.n128_u8[3] = byte_1834B0;
v2.n128_u8[4] = byte_1834B0;
v2.n128_u8[5] = byte_1834B0;
v2.n128_u8[6] = byte_1834B0;
v2.n128_u8[7] = byte_1834B0;
v2.n128_u8[8] = byte_1834B0;
v2.n128_u8[9] = byte_1834B0;
v2.n128_u8[10] = byte_1834B0;
v2.n128_u8[11] = byte_1834B0;
v2.n128_u8[12] = byte_1834B0;
v2.n128_u8[13] = byte_1834B0;
v2.n128_u8[14] = byte_1834B0;
v2.n128_u8[15] = byte_1834B0;
v68 = veorq_s8(v2, veorq_s8(v68, v48));
v69 ^= byte_1834B0 ^ 0x90;
v70 ^= byte_1834B0 ^ 0x4B;
v71 ^= byte_1834B0 ^ 0x53;
v72 ^= byte_1834B0 ^ 0x80;
sub_96A00(&v68, (unsigned int)dword_1682C8);
sleep(3u);
*(_DWORD *)v61 = 0;
n1316153280_1 = 1316153280;
*v60 = n378376109_2;
v3 = *v60;
n1068613139 = -1972012194;
n712314002 = -68988796;
p_n1638929757_1 = p_n1638929757;
*v56 = v3;
*(_QWORD *)p_n1638929757_1 = v3;
goto LABEL_2;
}
n510_2 = (__int64 *)((char *)n510 + 1);
*(_BYTE *)n1638929757_1 = n25;
n1638929757_4 = (_BYTE *)(n1638929757_1 + 1);
n1638929757_2 = 1638929589;
n378376109_1 = -1687526964;
n1638929589 = 1638929589;
if ( (__int64)n510 >= 510 )
n1638929589 = -1687526964;
n1638929757 = (n1638929589 ^ 0xD8) + 240;
n1638929757_2 = n1638929757;
}
}
}

image-20260419065426153

去混淆分析 [重新分析]

这里自己想写一个批量去混淆的脚本失败了,想想也对,腾讯在用的混淆怎么可能这么轻松给去了,于是用unicorn和手动分析去找patch列表

重新分析下,因为中间被搞懵了,后面的patch列表都是unicorn+手动分析得到的

原理性部分

来自个人博客

简单学习ollvm混淆&polyre例题解析 | Matriy’s blogangr符号执行对抗ollvm - Qmeimei’s Blog | 探索一切,攻破一切

把OLLVM处理过的反调试函数还原为 F5 可读的线性 C 代码。之前解完发现还有几处混淆漏了,上面的代码好像不是非常通用,经过分析发现

OLLVM CFF 主要有两种派发器实现,底层目的相同(隐藏控制流),但具体形态不同,去混淆策略也不同

第一种: jump-table 表驱动(Tick / sub_9B7D8 / sub_9C654 / sub_9AF98 / sub_99418)

1
2
3
4
5
6
7
8
9
10
dispacther:
LDUR W8, [X29, #STATE_SLOT] ; 读 state
CMP W8, W14 ; 与候选常量比较
CSEL X10, X3, X2, EQ ; 命中 → 选偏移 X3=0x10
CMP W8, W9 ; 下一候选
CSEL X9, X4, X10, EQ ; ...
... ; 11 个 CMP-CSEL 串
CSEL X8, X24, X9, EQ ; 最终偏移到 X8
LDR X8, [X20, X8] ; 从 jump table 读 entry 地址
BR X8 ; 间接跳转

每个 entry 末尾:

1
2
STUR    W?,  [X29, #STATE_SLOT]      ; 写新 state
B 状态变换/派发头 ; 回派发器

第二种: chained-conditional 链式条件(sub_9CDC4)

用一堆按大小组织的CMP + 条件跳转来查找当前 state 对应的真实基本块

可以看这篇文章:https://synthesis.to/2021/03/03/flattening_detection.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
dispacther (派发表分散在整个函数):
MOV W9, #STATE_LOW_BOUND
CMP W8, W9
B.LE subtree_low ; 二分查找:左
MOV W9, #STATE_MID
CMP W8, W9
B.GT subtree_high ; 二分查找:右
MOV W9, #STATE_LEAF_1
CMP W8, W9
B.EQ entry_handler_X ; 叶子: 命中
MOV W9, #STATE_LEAF_2
CMP W8, W9
B.EQ entry_handler_Y ; ...
MOV W9, #STATE_LEAF_3
CMP W8, W9
B.NE other_subtree ; 反向叶子: 不命中走 sibling
; fall-through = entry_handler_Z
1
2
3
4
5
6
7
8
9
10
  N 个 state 全部 CMP + B.EQ 串起来 = 线性搜索(O(N) 平均比 N/2 次):
CMP W8, #STATE_001
B.EQ entry_001
CMP W8, #STATE_002
B.EQ entry_002
...
CMP W8, #STATE_032
B.EQ entry_032
32 个 state 平均 16 次比较慢。
用 B.LE / B.GT 先粗分再细分 = 二叉搜索树(O(log N),32 个 state 只要 5 次比较):

之前的CFF slover解不开这种

每个 entry 末尾:

1
2
3
MOV     W8, #NEW_STATE_PRE_MUTATION
STR W8, [X19, #STATE_SLOT]
B state_mutation_then_dispatch ; 回派发器

特征是CMP + B.cond 长链(无 BR Xn),通常配二分搜索。

派发器附近找 BR Xn:

  • 有 BR Xn 是 jump-table
  • 没有 BR Xn,只有大量 B.LE/GT/EQ/NE 是chained

BR Xn 和 B label 的根本区别跳转目标是从寄存器还是指令本身硬编码的,之前讲过

第一种

关于这一种,[原创] 2026腾讯游戏安全技术竞赛-安卓决赛VM分析与还原-Android安全-看雪安全社区|专业技术交流与安全研究论坛这个师傅有更好的处理方法。

我的是线性化 patch思路这个师傅是修 tab/jpt 表(修 jump_table 使索引连续)

之前实现的是ollvm_cff_solver是第一种,派发器是 state → entry_addr的查表函数,但里面混杂了 OLLVM 制造的死代码。直接 F5 看是一坨 CSEL,看不出到底有几个 entry。解法符号执行派发器,给 state 一个具体值,模拟跑派发器到 BR X8,记下最终的 X8(就是jump table 偏移)。然后从 jump table 拿 entry 地址。

派发器里的 CMP W8, W?中的 W? 都是候选 state 值。扫描派发器,把所有进入 W? 寄存器的常量(MOVZ + MOVK 解算后)当作候选。

  1. 找派发器边界:从序言之后第一个 CMP W8, Wn 开始 (dispatcher_start),到 BR X8 之后 (dispatcher_end)。

  2. 找 jump table:派发器里 ADR Xn, off_XXXX 的 off_XXXX。

  3. 抄 prologue 的静态寄存器值:所有 MOV/MOVK 串解算成 32-bit。

  4. 调 solve_all()

第二种

NZCV 是ARM64 的 4 个条件标志位,每条 B.cond 都靠它决定跳不跳

如NZCV = 0x60000000 二进制 = 0110 0000 … N=0, Z=1, C=1, V=0。

思路是用Unicorn 模拟 CPU 执行函数

二叉树遍历,第二种的派发器是二分搜索树,内部节点是 B.LE/GT 把范围切成两半,叶子是B.EQ跳到 entry。

因此可以BFS / DFS 整棵树,遇到 B.EQ 收集叶子 (state_to_entry),遇到B.LE/GT/NE把目标 push 到工作队列继续走。

下面的可能比较难理解,这里不展开了,有空补充,而且比赛的时候只是分析了,实际上还是让AI分析控制流手动patch的

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
def walk_dispatcher(start_ea, end_ea):
state_to_entry = {}; defaults = []
visited = set(); work = [start_ea]
while work:
ea = work.pop()
if ea in visited: continue
visited.add(ea)
cur = ea
for _ in range(100):
raw = ida_bytes.get_dword(cur)
if raw == 0x6B09011F: # CMP W8, W9
val = back_resolve_imm(cur, 9) # 回溯找 W9 常量,当前 CMP W8, W9 往前找,看看 W9 是怎么来的,还原这次比较里的 magic 常量
bcond = decode_bcond(get_dword(cur+4), cur+4)
cond, tgt = bcond
if cond == COND_EQ:
state_to_entry[val] = tgt # 叶子
elif cond == COND_NE:
state_to_entry[val] = cur+8 # fall-through 是 entry
work.append(tgt); break
else: # B.LE/GT/LT/GE
work.append(tgt) # 子树
cur += 8; continue
b_tgt = decode_b(raw, cur)
if b_tgt: # 默认 B
work.append(b_tgt)
defaults.append((cur, b_tgt))
break
cur += 4
return state_to_entry, defaults

目标仍是建立某个 state 值到 对应的真实基本块入口

每遇到一个条件跳转,就把目标地址加入 work,之后继续遍历

1
2
3
CMP + B.EQ #imm : 叶子,收集 state_to_entry[imm] = B.EQ_target,继续往下扫
CMP + B.NE #imm : 向叶子,state_to_entry[imm] = fall-through 地址;B.NE_target 是另一棵树(push) CMP + B.LE/GT : 内部节点,把跳转目标 push 到队列(另一半子树要走)
B label : 默认分支,push 到队列、记到 defaults

state mutation

1
2
3
4
5
LDR     W8, [X19, #STATE_SLOT]
EOR W8, W8, #IMM_XOR ; 常见 0xC3, 0xB3
ADD W8, W8, #IMM_ADD ; 常见 0x71, 0xB9
STR W8, [X19, #STATE_SLOT]
; 派发器主体

每次 entry 末尾都把期望 next state 的 pre-mutation 值写进 STATE_SLOT,然后 B 回 mutation 头,让派发器算出真正的 next state。

dispatcher 入口加 XOR+ADD 加工成真 state,是 OLLVM 编译期插的混淆,dispatcher 的门口固定的一小段代码,详细可以看

image-20260425230329589

求next_state发现有这样的东西,需要把状态还原回去:

1
next_state = ((entry_set_value ^ MUTATION_XOR) + MUTATION_ADD) & 0xFFFFFFFF

生成patch_list [废弃]

尝试写个自动脚本自己去混淆结果,自动找第一种混淆的entries然后填入用unicorn模拟,自动化的得到patch_list,对于第二种需要收找entries

模块 1:auto_extract_entries() :静态找 entries,扫 [func_lo, func_hi) 找 LDR Xt,[Xn,Xm]+BR Xt 配对

模块 2:CFGTracer : Unicorn 动态 trace, 输出转移列表 [(src_entry, branch_pc, target, NZCV, kind), …]

模块 3:gen_patches() :根据traces的输出转 PATCHES

1
2
3
4
5
6
7
8
9
10
11
对每个 src:
if 1 个转移:
出 unconditional B patch:
(branch_pc, encode_b(branch_pc, target))

if 2 个转移 (B.cond + 另一条):
保留 B.cond + 加 fallthrough B

if ≥3 个转移 (multi-fork dispatcher):
不出 patch, 注释列出所有 fork 标 "manual review needed"
让人挑 cold-path

一般很多个转移可能是dispatcher

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510

from __future__ import annotations
import struct, sys
from pathlib import Path
from collections import defaultdict

from unicorn import Uc, UcError, UC_ARCH_ARM64, UC_MODE_ARM, UC_HOOK_CODE, UC_HOOK_MEM_INVALID, UC_HOOK_INTR
from unicorn.arm64_const import *

LIB = Path(__file__).parent / 'final/lib/arm64-v8a/libsec2026.so'

CODE_BASE = 0x0; CODE_SIZE = 0x300000
STACK_BASE = 0x10000000; STACK_SIZE = 0x100000
HEAP_BASE = 0x20000000; HEAP_SIZE = 0x200000
TLS_BASE = 0x30000000; TLS_SIZE = 0x10000
STUB_BASE = 0x500000; STUB_SIZE = 0x40000
FAKE_HEAP = 0x600000; FAKE_SIZE = 0x10000
RET_MAGIC = 0x7FFF0000

# Imports we know about (GOT offsets in libsec2026.so)
IMPORT_GOT = {
'opendir': 0x18a088, 'readdir': 0x18a090, 'closedir': 0x18a0a0,
'lstat': 0x18a098, 'sleep': 0x18a0b8, 'usleep': 0x18a0b0,
'sysconf': 0x18a0c8, 'exit': 0x18a0d0, 'mprotect': 0x18a0d8,
'getpid': 0x18a0e8, 'fork': 0x18a0f0, 'waitpid': 0x18a0f8,
'ptrace': 0x18a038, 'free': 0x18a100, 'malloc': 0x18a060,
'memcpy': 0x18a150, 'memset': 0x18a160,
'__memset_chk': 0x18a030, '__memcpy_chk': 0x18a070,
'strstr': 0x18a050, 'dl_iterate_phdr': 0x18a048,
'__vsnprintf_chk': 0x18a0e0, '__system_property_get': 0x18a248,
'pthread_mutex_lock': 0x18a258, 'pthread_mutex_unlock': 0x18a260,
'llabs': 0x18a0a8, 'setpriority': 0x18a0c0,
}

# Internal helpers in sub_9CDC4 region — stub to RET 0
INTERNAL_STUBS = [0x9AD20, 0x9AD3C, 0x9AD58, 0x9EB5C, 0x96A00, 0x98564, 0x96970, 0x96B14, 0x96ACC, 0x9CD18]

# ============================================================
# Auto-extract entries from jump_table (read libsec2026.so directly)
# ============================================================

def _parse_relative_relocs(raw):
"""解析 ELF R_AARCH64_RELATIVE relocations, 返回 {r_offset: r_addend} dict.
.so 的 jump_table qword 在 raw bytes 里通常是 0, 真实地址要从 RELA r_addend 拿."""
result = {}
e_shoff = struct.unpack('<Q', raw[0x28:0x30])[0]
e_shentsize = struct.unpack('<H', raw[0x3a:0x3c])[0]
e_shnum = struct.unpack('<H', raw[0x3c:0x3e])[0]
for i in range(e_shnum):
sh = raw[e_shoff + i*e_shentsize : e_shoff + (i+1)*e_shentsize]
if struct.unpack('<I', sh[4:8])[0] != 4: continue # SHT_RELA
sh_off = struct.unpack('<Q', sh[0x18:0x20])[0]
sh_size = struct.unpack('<Q', sh[0x20:0x28])[0]
sh_entsize = struct.unpack('<Q', sh[0x38:0x40])[0]
if sh_entsize != 24: continue
for j in range(sh_size // 24):
r_off, r_info, r_add = struct.unpack('<QQq',
raw[sh_off + j*24 : sh_off + (j+1)*24])
if (r_info & 0xFFFFFFFF) == 1027: # R_AARCH64_RELATIVE
result[r_off] = r_add & 0xFFFFFFFFFFFFFFFF
return result


def auto_extract_entries(func_lo, func_hi, max_entries=64, bad_tol=8):
"""从 .so 二进制直接读 jump_table 解 entries (不依赖 IDA).
自动应用 R_AARCH64_RELATIVE relocations.
返回 sorted unique entries (含函数入口)."""
raw = LIB.read_bytes()
relocs = _parse_relative_relocs(raw)
# Decoders
def get_dword(va):
return struct.unpack('<I', raw[va:va+4])[0]
def get_qword(va):
# 优先用 reloc 的 addend (jump table 真实地址)
if va in relocs: return relocs[va]
return struct.unpack('<Q', raw[va:va+8])[0]
def is_ldr_reg_idx(r): return (r & 0xFFE00C00) == 0xF8600800
def is_br(r): return (r & 0xFFFFFC1F) == 0xD61F0000
def is_adrp(r): return (r & 0x9F000000) == 0x90000000
def is_adr(r): return (r & 0x9F000000) == 0x10000000
def is_ret(r): return (r & 0xFFFFFC1F) == 0xD65F03C0

# 1) find dispatcher LDR+BR
cur = func_lo + 4; disp = None
while cur < func_hi:
r = get_dword(cur)
if is_br(r):
br_reg = (r >> 5) & 0x1F
prev = get_dword(cur - 4)
if is_ldr_reg_idx(prev) and (prev & 0x1F) == br_reg:
disp = (cur, cur - 4, (prev >> 5) & 0x1F)
break
cur += 4
if disp is None:
return None
br_va, ldr_va, base_reg = disp

# 2) find ADRP/ADR for base_reg, walking back from BR
cur = br_va - 4; add_off = 0; table = None
for _ in range(300):
if cur < 0: break
r = get_dword(cur)
if (r & 0xFFC003E0) == 0x91000000:
rd = r & 0x1F; rn = (r >> 5) & 0x1F
if rd == base_reg and rn == base_reg:
imm12 = (r >> 10) & 0xFFF
shift = ((r >> 22) & 0x3) * 12
add_off += (imm12 << shift)
elif is_adrp(r) and (r & 0x1F) == base_reg:
imm_lo = (r >> 29) & 0x3
imm_hi = (r >> 5) & 0x7FFFF
imm = ((imm_hi << 2) | imm_lo) << 12
if imm & (1 << 32): imm -= (1 << 33)
table = (cur & ~0xFFF) + imm + add_off
break
elif is_adr(r) and (r & 0x1F) == base_reg:
imm_lo = (r >> 29) & 0x3
imm_hi = (r >> 5) & 0x7FFFF
imm = ((imm_hi << 2) | imm_lo)
if imm & (1 << 20): imm -= (1 << 21)
table = cur + imm + add_off
break
elif is_ret(r): break
cur -= 4
if table is None:
return None

# 3) read table sequentially, collect entries within [func_lo, func_hi)
entries = set([func_lo])
bad = 0
for i in range(max_entries):
v = get_qword(table + i * 8)
if func_lo <= v < func_hi:
entries.add(v); bad = 0
else:
bad += 1
if bad >= bad_tol: break
return {'addr': func_lo, 'end': func_hi, 'entries': sorted(entries),
'dispatcher_br': br_va, 'jump_table': table}


# ============================================================
# Per-function configuration
# ============================================================

FUNCTIONS = {
'sub_9CDC4': {
'addr': 0x9CDC4, 'end': 0x9E700,
'entries': [ # collected from chained_cmp_solver
0x9CF44, 0x9D040, 0x9D0E4, 0x9D144, 0x9D218, 0x9D26C, 0x9D2C0,
0x9D330, 0x9D34C, 0x9D6D0, 0x9DA00, 0x9DA64, 0x9DAD4, 0x9DB70,
0x9DBE0, 0x9DC0C, 0x9DDBC, 0x9DE2C, 0x9DE98, 0x9DF18, 0x9DF44,
0x9E470, 0x9E9C0,
],
'state_slot': 0x6C,
'mutation': (0xC3, 0x71),
},
# ===== jump-table style: entries 在 trace_one 里自动 auto_extract_entries() 解出 =====
'sub_9AD68': {'addr': 0x9AD68, 'end': 0x9AF94}, # Tick (10s self-destruct gate)
'sub_9B7D8': {'addr': 0x9B7D8, 'end': 0x9C484}, # 3-sec anti-debug watchdog
'sub_9C654': {'addr': 0x9C654, 'end': 0x9CDC0}, # ptrace self-tracer
'sub_9AF98': {'addr': 0x9AF98, 'end': 0x9B7D8}, # tick baseline + CRC32
'sub_99418': {'addr': 0x99418, 'end': 0x9A1DC}, # fd scanner
'sub_95CC0': {'addr': 0x95CC0, 'end': 0x96084}, # ptrace HW_BREAK clearer
}

# ============================================================
# Stubs — syscall + libc returns (cold-path defaults)
# ============================================================

# Stub all known imports — return success
STUB_RETURNS = {
'opendir': 0x600000, # fake DIR*
'readdir': 0, # NULL = no more entries (don't iterate)
'closedir': 0,
'sleep': 0, 'nanosleep': 0, 'usleep': 0,
'open': 5, 'openat': 5, 'close': 0,
'read': 0, # 0 = EOF
'pthread_mutex_lock': 0, 'pthread_mutex_unlock': 0,
'strstr': 0, 'strchr': 0, 'strlen': 0,
'malloc': HEAP_BASE + 0x10000, 'free': 0,
'memset': 0, 'memcpy': 0,
'mprotect': 0,
'getpid': 1234, 'gettid': 1234,
'sysconf': 4096,
}

# ============================================================
# Tracer
# ============================================================

class CFGTracer:
def __init__(self, fn_cfg):
self.cfg = fn_cfg
self.entries = sorted(fn_cfg['entries'])
self.transitions = [] # list of (src_entry, target_addr, nzcv_at_branch, branch_kind)
self.steps = 0
self.max_steps = 200_000
self._setup()

def _entry_of(self, pc):
"""Find the entry block containing pc (largest entry <= pc)."""
prev = None
for e in self.entries:
if e > pc: break
prev = e
return prev

def _setup(self):
raw = LIB.read_bytes()
self.mu = Uc(UC_ARCH_ARM64, UC_MODE_ARM)
for base, size in [(CODE_BASE, CODE_SIZE), (STACK_BASE, STACK_SIZE),
(HEAP_BASE, HEAP_SIZE), (TLS_BASE, TLS_SIZE),
(STUB_BASE, STUB_SIZE), (FAKE_HEAP, FAKE_SIZE),
(RET_MAGIC & ~0xFFF, 0x1000)]:
self.mu.mem_map(base, size)
# Load PT_LOAD
e_phoff = struct.unpack('<Q', raw[0x20:0x28])[0]
e_phnum = struct.unpack('<H', raw[0x38:0x3a])[0]
e_phentsize = struct.unpack('<H', raw[0x36:0x38])[0]
for i in range(e_phnum):
ph = raw[e_phoff + i*e_phentsize : e_phoff + (i+1)*e_phentsize]
if struct.unpack('<I', ph[:4])[0] != 1: continue
p_off = struct.unpack('<Q', ph[8:16])[0]
p_va = struct.unpack('<Q', ph[16:24])[0]
p_fsz = struct.unpack('<Q', ph[32:40])[0]
if p_va + p_fsz > CODE_SIZE: continue
self.mu.mem_write(p_va, raw[p_off:p_off+p_fsz])
# Apply relocations (jump tables etc.)
e_shoff = struct.unpack('<Q', raw[0x28:0x30])[0]
e_shentsize = struct.unpack('<H', raw[0x3a:0x3c])[0]
e_shnum = struct.unpack('<H', raw[0x3c:0x3e])[0]
for i in range(e_shnum):
sh = raw[e_shoff + i*e_shentsize : e_shoff + (i+1)*e_shentsize]
if struct.unpack('<I', sh[4:8])[0] != 4: continue
sh_off = struct.unpack('<Q', sh[0x18:0x20])[0]
sh_size = struct.unpack('<Q', sh[0x20:0x28])[0]
sh_entsize = struct.unpack('<Q', sh[0x38:0x40])[0]
if sh_entsize != 24: continue
for j in range(sh_size // 24):
r_off, r_info, r_add = struct.unpack('<QQq',
raw[sh_off + j*24 : sh_off + (j+1)*24])
if (r_info & 0xFFFFFFFF) == 1027:
try: self.mu.mem_write(r_off, struct.pack('<Q', r_add & 0xFFFFFFFFFFFFFFFF))
except: pass
# Set up libc stubs: each stub = 1 RET (0xD65F03C0)
self.stub_to_name = {}
sc = 0
for name, got in IMPORT_GOT.items():
saddr = STUB_BASE + sc * 4
sc += 1
self.mu.mem_write(saddr, struct.pack('<I', 0xD65F03C0))
self.mu.mem_write(got, struct.pack('<Q', saddr))
self.stub_to_name[saddr] = name
# Stub internal helpers (sub_9AD3C etc.) by writing RET at their start
for addr in INTERNAL_STUBS:
self.mu.mem_write(addr, struct.pack('<I', 0xD65F03C0))
# Also clear function pointer cache region (so anything BLR'd via cache returns)
for slot in range(0x1667D8, 0x166FF8 + 8, 8):
saddr = STUB_BASE + sc * 4
sc += 1
self.mu.mem_write(saddr, struct.pack('<I', 0xD65F03C0))
self.mu.mem_write(slot, struct.pack('<Q', saddr))
self.stub_to_name[saddr] = f'cache_{slot:x}'
self.mu.hook_add(UC_HOOK_CODE, self._code_hook)
self.mu.hook_add(UC_HOOK_MEM_INVALID, self._mem_invalid)
self.mu.hook_add(UC_HOOK_INTR, self._intr_hook)

def _code_hook(self, mu, addr, size, ud):
self.steps += 1
if self.steps > self.max_steps:
mu.emu_stop(); return
if addr == RET_MAGIC:
mu.emu_stop(); return
# Stub-call: write specific return value, jump to LR
if STUB_BASE <= addr < STUB_BASE + STUB_SIZE:
name = self.stub_to_name.get(addr, '?')
mu.reg_write(UC_ARM64_REG_X0, STUB_RETURNS.get(name, 0))
mu.reg_write(UC_ARM64_REG_PC, mu.reg_read(UC_ARM64_REG_LR))
return
# Read current instruction; if it's B/B.cond/BR/BL/BLR, record transition
try:
raw = struct.unpack('<I', mu.mem_read(addr, 4))[0]
except:
return
target = None; kind = None
# B label (0x14000000 / 0x10MM_MMMM mask)
if (raw & 0xFC000000) == 0x14000000:
imm26 = raw & 0x03FFFFFF
if imm26 & (1 << 25): imm26 -= (1 << 26)
target = (addr + (imm26 << 2)) & 0xFFFFFFFF
kind = 'B'
# B.cond (0x54000000)
elif (raw & 0xFF000010) == 0x54000000:
imm19 = (raw >> 5) & 0x7FFFF
if imm19 & (1 << 18): imm19 -= (1 << 19)
target = (addr + (imm19 << 2)) & 0xFFFFFFFF
cond = raw & 0xF
kind = f'B.{["EQ","NE","CS","CC","MI","PL","VS","VC","HI","LS","GE","LT","GT","LE","AL","NV"][cond]}'
# BR Xn (0xD61F0000)
elif (raw & 0xFFFFFC1F) == 0xD61F0000:
reg = (raw >> 5) & 0x1F
target = mu.reg_read(UC_ARM64_REG_X0 + reg)
kind = 'BR'
if target is not None:
src = self._entry_of(addr)
nzcv = mu.reg_read(UC_ARM64_REG_NZCV)
self.transitions.append((src, addr, target, nzcv, kind))

def _mem_invalid(self, mu, access, addr, size, value, ud):
# On-demand map any unmapped page
if 0 < addr < 0xFFFFFFFFFFFF:
page = addr & ~0xFFF
try:
mu.mem_map(page, 0x1000)
mu.mem_write(page, b'\x00' * 0x1000)
return True
except: pass
return False

def _intr_hook(self, mu, intno, ud):
# SVC — stub all syscalls to return 0
pc = mu.reg_read(UC_ARM64_REG_PC)
sysno = mu.reg_read(UC_ARM64_REG_X8)
if sysno == 0x71: # clock_gettime → write {0,0}
try: mu.mem_write(mu.reg_read(UC_ARM64_REG_X1), struct.pack('<QQ', 0, 0))
except: pass
mu.reg_write(UC_ARM64_REG_X0, 0)
mu.reg_write(UC_ARM64_REG_PC, pc + 4)

def run(self):
sp = STACK_BASE + STACK_SIZE - 0x1000
tls = TLS_BASE + 0x100
self.mu.reg_write(UC_ARM64_REG_SP, sp)
self.mu.reg_write(UC_ARM64_REG_TPIDR_EL0, tls)
self.mu.mem_write(tls + 0x28, struct.pack('<Q', 0xCAFEBABEDEADBEEF))
self.mu.reg_write(UC_ARM64_REG_X0, 0)
self.mu.reg_write(UC_ARM64_REG_X30, RET_MAGIC)
try:
self.mu.emu_start(self.cfg['addr'], RET_MAGIC, timeout=30_000_000)
return 'OK'
except UcError as e:
return f'ERROR: {e} @ PC=0x{self.mu.reg_read(UC_ARM64_REG_PC):x}'

def _grouped(self):
"""Build {src_entry: [(branch_pc, target_entry, nzcv, kind), ...]} dedup."""
grouped = defaultdict(list)
for src, branch_pc, target, nzcv, kind in self.transitions:
if src is None or target == src: continue
target_entry = self._entry_of(target)
if target_entry == src: continue
# dedup by (branch_pc, target_entry, kind)
existing = [(br, te, k) for (br, te, _, k) in grouped[src]]
if (branch_pc, target_entry, kind) in existing: continue
grouped[src].append((branch_pc, target_entry, nzcv, kind))
return grouped

def report(self):
grouped = self._grouped()
out = ['='*70, '## block transitions (entry → next entry, NZCV)', '='*70]
for src in sorted(grouped):
out.append(f'\nentry 0x{src:x}:')
for br, tgt, nzcv, kind in grouped[src]:
z = (nzcv >> 30) & 1
tgt_s = f'0x{tgt:x}' if tgt is not None else '<external>'
out.append(f' branch @ 0x{br:x} ({kind}) → {tgt_s} NZCV=0x{nzcv:08x} (Z={z})')
out.append(f'\nTotal: {len(self.transitions)} branch events, '
f'{sum(len(v) for v in grouped.values())} unique entry transitions')
return '\n'.join(out)

def gen_patches(self):
"""Generate copy-pastable PATCHES list from observed transitions.
- Single transition per entry → unconditional B patch (no need to change).
- Multiple transitions → keep original B.cond, but verify consistency.
Returns formatted text for the PATCHES Python list.
"""
# Encoders
def enc_b(src, tgt):
off = (tgt - src) >> 2
if off < -(1 << 25) or off >= (1 << 25): return None
return (0x14000000 | (off & 0x03FFFFFF)).to_bytes(4, 'little')
def enc_bcond(src, tgt, cond):
off = (tgt - src) >> 2
if off < -(1 << 18) or off >= (1 << 18): return None
return (0x54000000 | ((off & 0x7FFFF) << 5) | (cond & 0xF)).to_bytes(4, 'little')

COND_NUM = {'EQ':0,'NE':1,'CS':2,'CC':3,'MI':4,'PL':5,'VS':6,'VC':7,
'HI':8,'LS':9,'GE':10,'LT':11,'GT':12,'LE':13}

grouped = self._grouped()
out = ['='*70, '## Auto-generated PATCHES (cold-path linearization)', '='*70, '']
out.append('PATCHES = [')

for src in sorted(grouped):
transitions = grouped[src]
# Filter out external (target=None) transitions
valid = [(br, tgt, nzcv, kind) for br, tgt, nzcv, kind in transitions if tgt is not None]
if not valid:
out.append(f' # entry 0x{src:x}: only external transitions (likely RET path), skip')
continue

if len(valid) == 1:
# Single transition — patch the branch directly
br, tgt, nzcv, kind = valid[0]
if kind == 'B':
# Already unconditional, but maybe target was wrong. Re-encode to current observed target.
enc = enc_b(br, tgt)
if enc:
out.append(f' (0x{br:x}, {enc.hex()!r:14}), # entry 0x{src:x} → 0x{tgt:x} [B unconditional]')
else:
# Was B.cond but only 1 path observed (cold path). Convert to unconditional B.
enc = enc_b(br, tgt)
if enc:
out.append(f' (0x{br:x}, {enc.hex()!r:14}), # entry 0x{src:x} → 0x{tgt:x} [was {kind}, observed only this path]')

elif len(valid) == 2:
# Two paths — keep B.cond + B fallthrough pattern
# Sort by NZCV so EQ/NE pairs land predictably
valid.sort(key=lambda x: x[2])
(br1, tgt1, nzcv1, kind1), (br2, tgt2, nzcv2, kind2) = valid
# If first is B.cond and second is B (or close), use them as-is
cond_kind = next((k for k in (kind1, kind2) if k.startswith('B.')), None)
if cond_kind:
cond_short = cond_kind.split('.')[1]
cond_n = COND_NUM.get(cond_short, 0)
cond_br = br1 if kind1 == cond_kind else br2
cond_tgt = tgt1 if kind1 == cond_kind else tgt2
fall_br = br2 if kind1 == cond_kind else br1
fall_tgt = tgt2 if kind1 == cond_kind else tgt1
enc1 = enc_bcond(cond_br, cond_tgt, cond_n)
enc2 = enc_b(fall_br, fall_tgt)
if enc1 and enc2:
out.append(f' (0x{cond_br:x}, {enc1.hex()!r:14}), # entry 0x{src:x} {cond_kind} → 0x{cond_tgt:x}')
out.append(f' (0x{fall_br:x}, {enc2.hex()!r:14}), # entry 0x{src:x} fall → 0x{fall_tgt:x}')
else:
out.append(f' # entry 0x{src:x}: 2 unconditional Bs?? skip (manual check)')
else:
out.append(f' # entry 0x{src:x}: {len(valid)} transitions (CSEL multi-fork) — manual review needed')
for br, tgt, nzcv, kind in valid:
out.append(f' # {kind} @ 0x{br:x} → 0x{tgt:x} NZCV=0x{nzcv:08x}')

out.append(']')
return '\n'.join(out)


def trace_one(name, dump_dir=None):
cfg = dict(FUNCTIONS[name])
if 'entries' not in cfg:
# 自动从 jump_table 解 entries
ext = auto_extract_entries(cfg['addr'], cfg['end'])
if ext is None:
print(f'[-] {name}: auto-extract failed (no jump-table dispatcher in [0x{cfg["addr"]:x}, 0x{cfg["end"]:x}))')
return
cfg['entries'] = ext['entries']
print(f'\n{"="*70}\nTracing {name} @ 0x{cfg["addr"]:x}..0x{cfg["end"]:x} '
f'(auto-extracted {len(cfg["entries"])} entries from jump_table @ 0x{ext["jump_table"]:x})\n{"="*70}')
else:
print(f'\n{"="*70}\nTracing {name} @ 0x{cfg["addr"]:x}..0x{cfg["end"]:x} '
f'({len(cfg["entries"])} entries — manually configured)\n{"="*70}')
tracer = CFGTracer(cfg)
res = tracer.run()
print(f'Result: {res}')
report = tracer.report()
patches = tracer.gen_patches()
print(report)
print('\n' + patches)
if dump_dir:
out = Path(dump_dir) / f'patches_{name}.txt'
out.write_text(report + '\n\n' + patches + '\n', encoding='utf-8')
print(f'\n[+] dumped to {out}')


def main(arg='sub_9CDC4'):
# New: 'auto:0xLO-0xHI' triggers auto-extract entries from the .so
if arg.startswith('auto:'):
spec = arg[5:]
lo_s, hi_s = spec.split('-')
lo = int(lo_s, 16); hi = int(hi_s, 16)
cfg = auto_extract_entries(lo, hi)
if cfg is None:
print(f'[-] no jump-table dispatcher in [0x{lo:x}, 0x{hi:x})')
return
name = f'sub_{lo:X}'
print(f'[+] auto-extracted: dispatcher BR @ 0x{cfg["dispatcher_br"]:x}, '
f'table @ 0x{cfg["jump_table"]:x}, {len(cfg["entries"])} entries')
FUNCTIONS[name] = cfg
trace_one(name)
return
if arg == 'all':
dump = Path(__file__).parent / 'gen_patches_out'
dump.mkdir(exist_ok=True)
for name in FUNCTIONS:
try:
trace_one(name, dump_dir=dump)
except Exception as e:
print(f'[-] {name}: {e}')
print(f'\n[+] all done — outputs in {dump}/')
return
if arg not in FUNCTIONS:
cand = [k for k in FUNCTIONS if arg in k]
if not cand:
print(f'Unknown function {arg}. Available: {list(FUNCTIONS)} or "all"')
return
arg = cand[0]
trace_one(arg)


if __name__ == '__main__':
main(sys.argv[1] if len(sys.argv) > 1 else 'sub_9CDC4')

以sub_9c654为例,python xxx sub9c654

输出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
PATCHES = [
(0x9c744, '0c000014' ), # entry 0x9c654 → 0x9c774 [B unconditional]
# entry 0x9c774: 5 transitions (CSEL multi-fork) — manual review needed
# BR @ 0x9c7ec → 0x9ca90 NZCV=0x30000000
# BR @ 0x9c7ec → 0x9c944 NZCV=0x80000000
# BR @ 0x9c7ec → 0x9c9cc NZCV=0x80000000
# BR @ 0x9c7ec → 0x9c894 NZCV=0x30000000
# BR @ 0x9c7ec → 0x9cae4 NZCV=0x30000000
(0x9c92c, '87ffff17' ), # entry 0x9c894 → 0x9c748 [B unconditional]
(0x9c9c8, '01000014' ), # entry 0x9c944 → 0x9c9cc [B unconditional]
(0x9ca8c, '2fffff17' ), # entry 0x9c9cc → 0x9c748 [B unconditional]
(0x9caa8, '28ffff17' ), # entry 0x9ca90 → 0x9c748 [B unconditional]
(0x9caf4, '05000014' ), # entry 0x9cae4 → 0x9cb08 [was B.NE, observed only this path]
# entry 0x9cb08: only external transitions (likely RET path), skip
]

manual review needed代表需要人工确认,加上这一条的效果如下

image-20260426230335082

相比下面的手动patch识别其实还有差距,因此废弃了这个方案

sub_9AF98

image-20260419123606515

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
import ida_bytes
import ida_funcs
import ida_auto
import ida_hexrays

FUNC_START = 0x9AF98
FUNC_END = 0x9B398

def encode_b(src, target):
off = (target - src) >> 2
if off < -(1 << 25) or off >= (1 << 25):
raise ValueError(f"B 跳转超界: 0x{src:x} -> 0x{target:x}")
val = 0x14000000 | (off & 0x03FFFFFF)
return val.to_bytes(4, 'little')


def encode_bcond(src, target, cond):
off = (target - src) >> 2
if off < -(1 << 18) or off >= (1 << 18):
raise ValueError(f"B.cond 跳转超界: 0x{src:x} -> 0x{target:x}")
val = 0x54000000 | ((off & 0x7FFFF) << 5) | (cond & 0xF)
return val.to_bytes(4, 'little')


# AArch64 condition codes
COND_EQ = 0
COND_NE = 1
COND_CC = 3 # carry clear / unsigned <
COND_LT = 11 # signed <


PATCHES = [
# A. prologue → outer loop bound check
(0x9B06C, encode_b(0x9B06C, 0x9B23C), "prologue → outer loop check"),

# B. entry 0x9B23C: CMP X14, X10 (counter, size) — 直接条件分支
(0x9B260, encode_bcond(0x9B260, 0x9B2DC, COND_CC), "outer: i<size → load byte"),
(0x9B264, encode_b(0x9B264, 0x9B35C), "outer: i>=size → epilogue (RET ~CRC)"),

# D. entry 0x9B200 bit-loop bound (CMP W10, #8)
(0x9B224, encode_bcond(0x9B224, 0x9B294, COND_LT), "bit-loop: j<8 → bit body"),
(0x9B228, encode_b(0x9B228, 0x9B33C), "bit-loop: j>=8 → byte tail"),

# E. entry 0x9B294 (CRC: TST W28, #1)
(0x9B2A8, encode_bcond(0x9B2A8, 0x9B278, COND_EQ), "CRC: bit==0 → shift only"),
(0x9B2AC, encode_b(0x9B2AC, 0x9B2B0), "CRC: bit==1 → XOR poly + shift"),

# F+G. shared CRC update tail (used by both 0x9B278 and 0x9B2B0)
(0x9B2D8, encode_b(0x9B2D8, 0x9B30C), "CRC update done → bit-loop tail"),

# H. entry 0x9B30C bit-loop tail (also reused by 0x9B2DC byte-load tail)
(0x9B338, encode_b(0x9B338, 0x9B200), "bit-loop tail → loop back to bit-bound check"),

# I. entry 0x9B33C byte-loop tail (TST X10, #0xFFF)
(0x9B354, encode_bcond(0x9B354, 0x9B070, COND_EQ), "byte tail: every 4KB → baseline refresh"),
(0x9B358, encode_b(0x9B358, 0x9B1D4), "byte tail: else → just increment counter"),

# J. entry 0x9B070 baseline-refresh tail
(0x9B148, encode_b(0x9B148, 0x9B1D4), "baseline refresh → increment counter"),

# K. entry 0x9B1D4 (counter++) tail
(0x9B1FC, encode_b(0x9B1FC, 0x9B23C), "counter++ → outer loop check"),
]

def main():
print("=" * 70)
print("Patching sub_9AF98 (反调试 1 helper — CRC32 + Tick baseline) — OLLVM CFF defeat")
print("=" * 70)

print(f"\n[1] 应用 {len(PATCHES)} 个 patch")
for src, b, note in PATCHES:
ida_bytes.patch_bytes(src, b)
print(f" patch 0x{src:x}: {b.hex()} ; {note}")

print("\n[2] 重建函数边界")
for ea in range(FUNC_START, FUNC_END + 4, 4):
f = ida_funcs.get_func(ea)
if f and f.start_ea != FUNC_START and ea != FUNC_START:
ida_funcs.del_func(f.start_ea)
ida_funcs.del_func(FUNC_START)
ok = ida_funcs.add_func(FUNC_START, FUNC_END)
print(f" add_func(0x{FUNC_START:x}, 0x{FUNC_END:x}) -> {ok}")
if not ok:
for ea in range(FUNC_START, FUNC_END, 4):
f = ida_funcs.get_func(ea)
if f and f.start_ea != FUNC_START:
ida_funcs.del_func(f.start_ea)
ok = ida_funcs.add_func(FUNC_START, FUNC_END)
print(f" retry add_func -> {ok}")

print("\n[3] auto-analysis + Hex-Rays cache flush")
ida_auto.auto_wait()
ida_hexrays.mark_cfunc_dirty(FUNC_START)


if __name__ == "__main__":
main()

sub_9C654

image-20260419090532638

image-20260419123645635

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
import ida_bytes
import ida_funcs
import ida_auto
import ida_hexrays


FUNC_START = 0x9C654
FUNC_END = 0x9CB4C

TST_W27_0x7F = 0x72001B7F # at 0x9C928 originally
CMN_X0_1 = 0xB100041F # at 0x9CA7C originally


def encode_b(src, target):
"""ARM64 unconditional B."""
off = (target - src) >> 2
if off < -(1 << 25) or off >= (1 << 25):
raise ValueError(f"B 跳转超界: 0x{src:x} -> 0x{target:x}")
val = 0x14000000 | (off & 0x03FFFFFF)
return val.to_bytes(4, 'little')


def encode_bcond(src, target, cond):
"""ARM64 B.cond — cond: EQ=0, NE=1, ..., AL=14."""
off = (target - src) >> 2
if off < -(1 << 18) or off >= (1 << 18):
raise ValueError(f"B.cond 跳转超界: 0x{src:x} -> 0x{target:x}")
val = 0x54000000 | ((off & 0x7FFFF) << 5) | (cond & 0xF)
return val.to_bytes(4, 'little')


def encode_word(w):
return (w & 0xFFFFFFFF).to_bytes(4, 'little')


def patch(src, bytes_, label=""):
ida_bytes.patch_bytes(src, bytes_)
print(f" patch 0x{src:x}: {bytes_.hex()} ; {label}")


PATCHES = [
# A. prologue → cold path
(0x9C744, encode_b(0x9C744, 0x9CA90), "prologue B 0x9C774 → B 0x9CA90 (skip dispatcher)"),

# F. entry 0x9C748 (post-waitpid mutation): convert state-mutate to direct cond branch
(0x9C758, encode_bcond(0x9C758, 0x9C808, 0), "0x9C748: B.EQ 0x9C808 (WIFSTOPPED → CONT)"),
(0x9C75C, encode_b(0x9C75C, 0x9C894), "0x9C748: B 0x9C894 (else → waitpid loop)"),

# G. entry 0x9C808 (PTRACE_CONT) tail
(0x9C890, encode_b(0x9C890, 0x9C894), "0x9C808: B 0x9C764 → B 0x9C894 (waitpid)"),

# E. entry 0x9C894 (waitpid) tail: TST + cond branch
(0x9C924, encode_word(TST_W27_0x7F), "0x9C894: insert TST W27, #0x7F"),
(0x9C928, encode_bcond(0x9C928, 0x9CAE4, 0), "0x9C894: B.EQ 0x9CAE4 (no signal → exit(0))"),
(0x9C92C, encode_b(0x9C92C, 0x9C748), "0x9C894: B 0x9C748 (signal → mutation entry)"),

# H. entry 0x9C930 (NOP→W23) tail
(0x9C940, encode_b(0x9C940, 0x9C894), "0x9C930: B 0x9C764 → B 0x9C894"),

# D. entry 0x9C9CC (PTRACE_CONT) own post-CMN block (in dead static-reg-reset region)
(0x9CA10, encode_word(CMN_X0_1), "0x9C9CC: CMN X0, #1 (own CMN slot)"),
(0x9CA14, encode_bcond(0x9CA14, 0x9CB08, 0), "0x9C9CC: B.EQ 0x9CB08 (CONT failed → exit(1))"),
(0x9CA18, encode_b(0x9CA18, 0x9C894), "0x9C9CC: B 0x9C894 (CONT ok → waitpid)"),

# C. entry 0x9C944 (ATTACH) shared post-CMN at 0x9CA7C
(0x9CA88, encode_bcond(0x9CA88, 0x9CB08, 0), "0x9C944: B.EQ 0x9CB08 (ATTACH failed → exit(1))"),
(0x9CA8C, encode_b(0x9CA8C, 0x9C9CC), "0x9C944: B 0x9C9CC (ATTACH ok → CONT)"),

# B. entry 0x9CA90 (fork branch selector)
(0x9CAA8, encode_bcond(0x9CAA8, 0x9C944, 1), "0x9CA90: B.NE 0x9C944 (parent → ATTACH); fall→0x9CAAC RET (child)"),
]


def main():
print("=" * 70)
print("Patching sub_9C654 (反调试 4 — fork+ptrace self-tracer) — OLLVM CFF defeat")
print("=" * 70)

print(f"\n[1] 应用 {len(PATCHES)} 个 patch")
for src, bytes_, note in PATCHES:
patch(src, bytes_, note)

print("\n[2] 重建函数边界")
for ea in range(FUNC_START, FUNC_END + 4, 4):
f = ida_funcs.get_func(ea)
if f and f.start_ea != FUNC_START and ea != FUNC_START:
ida_funcs.del_func(f.start_ea)
ida_funcs.del_func(FUNC_START)
ok = ida_funcs.add_func(FUNC_START, FUNC_END)
print(f" add_func(0x{FUNC_START:x}, 0x{FUNC_END:x}) -> {ok}")
if not ok:
for ea in range(FUNC_START, FUNC_END, 4):
f = ida_funcs.get_func(ea)
if f and f.start_ea != FUNC_START:
ida_funcs.del_func(f.start_ea)
ok = ida_funcs.add_func(FUNC_START, FUNC_END)
print(f" retry add_func -> {ok}")

print("\n[3] auto-analysis + Hex-Rays cache flush")
ida_auto.auto_wait()
ida_hexrays.mark_cfunc_dirty(FUNC_START)


if __name__ == "__main__":
main()

sub_9CDC4

image-20260419090509889

image-20260419123721283

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
import ida_bytes
import ida_funcs
import ida_auto
import ida_hexrays

FUNC_START = 0x9CDC4
FUNC_END = 0x9E700

def encode_b(src, target):
off = (target - src) >> 2
if off < -(1 << 25) or off >= (1 << 25):
raise ValueError(f"B 跳转超界: 0x{src:x} -> 0x{target:x}")
val = 0x14000000 | (off & 0x03FFFFFF)
return val.to_bytes(4, 'little')


def encode_bcond(src, target, cond):
off = (target - src) >> 2
if off < -(1 << 18) or off >= (1 << 18):
raise ValueError(f"B.cond 跳转超界: 0x{src:x} -> 0x{target:x}")
val = 0x54000000 | ((off & 0x7FFFF) << 5) | (cond & 0xF)
return val.to_bytes(4, 'little')


COND_EQ = 0
COND_NE = 1


PATCHES = [
# 初始化: 跳过 dispatcher 直奔初始 entry
(0x9CEF0, encode_b(0x9CEF0, 0x9D6D0), "prologue → 0x9D6D0 (init state selector)"),
(0x9CF40, encode_b(0x9CF40, 0x9D6D0), "0x9CF38 inline → 0x9D6D0"),

# entry 0x9D6D0: CSEL 条件 (LDR W10 = byte_183540; CMP W10, #0; CSEL W8, W9, W8, NE)
(0x9D6F8, encode_bcond(0x9D6F8, 0x9DB70, COND_EQ), "0x9D6D0: byte_183540==0 → 0x9DB70"),
(0x9D6FC, encode_b(0x9D6FC, 0x9D040), "0x9D6D0: byte_183540!=0 → 0x9D040 (opendir)"),

# 简单 entry tail (单一 next state)
(0x9D1B0, encode_b(0x9D1B0, 0x9DF44), "0x9D144 → 0x9DF44"),
(0x9D29C, encode_b(0x9D29C, 0x9DE98), "0x9D26C → 0x9DE98"),
(0x9D32C, encode_b(0x9D32C, 0x9DAD4), "0x9D2C0 → 0x9DAD4"),
(0x9D338, encode_b(0x9D338, 0x9DA00), "0x9D330 → 0x9DA00 (短路过 0x9CF44 reset)"),
(0x9DAD0, encode_b(0x9DAD0, 0x9E470), "0x9DAD4 → 0x9E470"),
(0x9DB6C, encode_b(0x9DB6C, 0x9D330), "0x9DB70 → 0x9D330 (→ 0x9DA00)"),
(0x9DBDC, encode_b(0x9DBDC, 0x9D040), "0x9DBE0 → 0x9D040 (opendir)"),
(0x9DDFC, encode_b(0x9DDFC, 0x9DF18), "0x9DDBC → 0x9DF18"),
(0x9DE28, encode_b(0x9DE28, 0x9D26C), "0x9DE2C → 0x9D26C"),
(0x9DF04, encode_b(0x9DF04, 0x9D218), "0x9DEF? → 0x9D218"),
]


def main():
print("=" * 70)
print("Patching sub_9CDC4 (反调试 5a — task scanner) — chained-CMP CFF defeat")
print("=" * 70)

print(f"\n[1] 应用 {len(PATCHES)} 个 patch")
for src, b, note in PATCHES:
ida_bytes.patch_bytes(src, b)
print(f" patch 0x{src:x}: {b.hex()} ; {note}")

print("\n[2] 重建函数边界")
f = ida_funcs.get_func(FUNC_START)
if f:
ida_funcs.del_func(f.start_ea)
ok = ida_funcs.add_func(FUNC_START, FUNC_END)
print(f" add_func -> {ok}")

print("\n[3] auto-analysis + Hex-Rays cache flush")
ida_auto.auto_wait()
ida_hexrays.mark_cfunc_dirty(FUNC_START)

if __name__ == "__main__":
main()

sub_99418

image-20260419123822898

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
import ida_bytes
import ida_funcs
import ida_auto
import ida_hexrays


FUNC_START = 0x99418
FUNC_END = 0x9A1F0


def encode_b(src, target):
off = (target - src) >> 2
val = 0x14000000 | (off & 0x03FFFFFF)
return val.to_bytes(4, 'little')


def encode_bcond(src, target, cond):
off = (target - src) >> 2
val = 0x54000000 | ((off & 0x7FFFF) << 5) | (cond & 0xF)
return val.to_bytes(4, 'little')


COND_EQ = 0
COND_NE = 1


PATCHES = [
# A. prologue → 初始 entry 0x99EBC (skip dispatcher)
(0x99510, encode_b(0x99510, 0x99EBC), 'prologue → 0x99EBC (initial entry)'),

# B. entry 0x99EBC: CSEL based on byte_183596 (var_14)
# NE (first_run==1) → 0x99D74 (opendir directly)
# EQ (first_run==0) → 0x99CD0 (XOR-decrypt path)
(0x99ED8, encode_bcond(0x99ED8, 0x99D74, COND_NE), '0x99EBC: var_14!=0 → 0x99D74 (opendir)'),
(0x99EDC, encode_b(0x99EDC, 0x99CD0), '0x99EBC: var_14==0 → 0x99CD0 (decrypt path first)'),

# C. entry 0x99EEC (readdir loop): CSEL based on readdir return (X0)
# EQ (X0==NULL, end of dir) → 0x99EE0 → 0x9A198 closedir
# NE (X0!=NULL, got entry) → 0x99F94 (process entry)
(0x99F8C, encode_bcond(0x99F8C, 0x99EE0, COND_EQ), '0x99EEC: readdir==NULL → cleanup'),
(0x99F90, encode_b(0x99F90, 0x99F94), '0x99EEC: readdir!=NULL → process'),

# D. entry 0x99EE0 (single state): → 0x9A198 (closedir)
(0x99EE8, encode_b(0x99EE8, 0x9A198), '0x99EE0 → 0x9A198 (closedir)'),
]


def main():
print("=" * 70)
print("Patching sub_99418 (反调试 5b — fd scanner) — jump-table CFF defeat")
print("=" * 70)

print(f"\n[1] 应用 {len(PATCHES)} 个 patch")
for src, b, note in PATCHES:
ida_bytes.patch_bytes(src, b)
print(f" patch 0x{src:x}: {b.hex()} ; {note}")

print("\n[2] 重建函数边界 (扩展到 0x9A1F0 包含所有 entry)")
for ea in range(FUNC_START, FUNC_END, 4):
f = ida_funcs.get_func(ea)
if f and f.start_ea != FUNC_START:
ida_funcs.del_func(f.start_ea)
f = ida_funcs.get_func(FUNC_START)
if f:
ida_funcs.del_func(f.start_ea)
ok = ida_funcs.add_func(FUNC_START, FUNC_END)
print(f" add_func -> {ok}")

print("\n[3] auto-analysis + Hex-Rays cache flush")
ida_auto.auto_wait()
ida_hexrays.mark_cfunc_dirty(FUNC_START)


if __name__ == "__main__":
main()

字符串解密

这里应该有个xor解密

image-20260426193707857

1
2
3
src = bytes([0x6f, 0xaa, 0x78, 0xc9, 0x3a, 0x62, 0xf6, 0x4a]))
src: 6faa78c93a62f64a
key: 3fd817aa5f11854a decrypted: b'Process\x00'

image-20260426194726112

肯定还有其它字符串被混淆了,查看这个混淆的特性

要做这件事得解决 3 个子问题:
1. 怎么找到所有解密函数(不止一个,可能 3 个 10 个 39 个不知道)
2. 怎么知道每个解密函数用的是哪种 XOR 算法(有简单 XOR 和加 index XOR 两种变体)
3. 怎么知道每次调用传的参数是什么(src 地址在哪、key 是多少、len 是几)

想法一:看形状不看字节,可以去看序言的形状对比一下正常业务代码还是有区别的

XOR decoder 干的事是这样:

1
2
3
4
5
6
7
8
for i 从 0 到 len:                                                                    
读一个字节:a = cipher[i]
异或:b = a ^ key[i%8]
写一个字节: output[i] = b
翻译成 ARM64 汇编一定是这 3 条核心指令:
LDRB W?, [X?, X?] 读 1 个字节
EOR W?, W?, W? 异或
STRB W?, [X?, X?] 写 1 个字节

寄存器编号、地址表达式、指令顺序细节可以变,但这 3 个动作必须出现,而且必须按 LDRB → EOR → STRB 这个顺序出现。 只要 LDRB → EOR → STRB 这 3 个都出现,就不管编译器怎么调,照样识别

想法二:分辨两种 XOR 变体 。 数 EOR 个数,有一种混淆多了一个xor i,但是忘记在哪看见的…,直接看汇编里 EOR 出现几次

想法三:解析每次调用的参数,写小型 CPU 模拟器

我们要在调到 BLR 时知道 X0/X1/X2/W3 是什么。这就要模拟 CPU 的寄存器。
难点

  • key 用 4 条 MOVK 拼出来,得累积
  • ADR 是 PC 相对寻址,得算地址
  • 寄存器在前面可能被设置过别的值,得记住最后一次赋值,可以写一个小 ARM64 模拟器
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
class RegTracker:                                                                      
def __init__(self):
self.regs = {} # 寄存器表: {寄存器号: 当前值}

def step(self, ea, raw):
"""处理 1 条指令, 更新寄存器表."""
if 是 MOVZ Xd, #imm, LSL #sh:
# MOVZ 是 "清零再设值": 把 #imm 左移 sh 位放进 Xd
self.regs[Xd] = imm << sh
elif 是 MOVK Xd, #imm, LSL #sh:
# MOVK 是 "保留其他位, 只覆盖某 16 位"
# 比如原来 X2 = 0xAA17_D83F, MOVK X2, #0x115F, LSL #32
# 结果 X2 = 0x0000_115F_AA17_D83F (只改 32-47 位)
旧值 = self.regs[Xd]
self.regs[Xd] = (旧值 & 不变的位) | (imm << sh)

elif 是 ADR Xd, label:
# ADR 是 "PC + 立即数"
# ADR X0, 0x183510 实际上是 X0 = 当前PC + offset
self.regs[Xd] = ea + 偏移

elif 是 ADRP / ADD imm:
# 类似处理...

然后正向走调用前的所有指令:

def resolve_args(call_ea):
# 1. 找当前基本块的开头
# 从 call_ea 往回走, 遇到 B/RET 就停下 (那是上个块结束)
block_start = 找到当前块的开头(call_ea)
# 2. 从块开头一直跑到 BLR 之前
tracker = RegTracker()
cur = block_start
while cur < call_ea:
指令 = 读 4 字节 at cur
tracker.step(cur, 指令) # 更新寄存器
cur += 4

# 3. 跑完后, tracker.regs 里就是 BLR 时刻的寄存器值
return tracker.regs

跑出来:是

1
2
3
4
5
6
{
0: 0x183510, # X0 = dst
1: 0x58438, # X1 = src
2: 0x4A85115FAA17D83F, # X2 = key
3: 8, # W3 = len
}

为什么是正向走不是反向找

一开始写的是反向找,从 BLR 往回扫,找最近一次设置 X0/X1/X2 的 MOV 指令。但是

  • MOVK 是 4 条拼起来的,反向找只能拿到最后一段,前 3 段拼不上
  • 寄存器可能被多次重写,反向找容易选错
  • 跨基本块时不知道该跟哪条 B 倒回去 ,正向走就没这些问题,从块开头按顺序跑,任何时刻寄存器都是当时的真实值,跟 CPU 一样
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
import ida_bytes
import ida_funcs
import ida_segment
import idautils
import idc
import struct


# ---------- ARM64 指令 helpers ----------

def is_movz_x(r): return (r & 0xFF800000) == 0xD2800000 # MOVZ Xd, #imm{, lsl #sh}
def is_movk_x(r): return (r & 0xFF800000) == 0xF2800000 # MOVK Xd, #imm{, lsl #sh}
def is_movz_w(r): return (r & 0x7F800000) == 0x52800000 # MOVZ Wd
def is_movk_w(r): return (r & 0x7F800000) == 0x72800000 # MOVK Wd
def is_adr(r): return (r & 0x9F000000) == 0x10000000 # ADR Xd, label
def is_adrp(r): return (r & 0x9F000000) == 0x90000000 # ADRP Xd, label
def is_add_imm(r): return (r & 0xFFC003E0) == 0x91000000 # ADD Xd, Xn, #imm12
def is_ldrb(r): return (r & 0xFFE00C00) == 0x38600800 # LDRB Wt, [Xn, Xm]
# 也匹配 LDRB Wt, [Xn, #imm], 简化处理
def is_ldrb_imm(r): return (r & 0xFFC00000) == 0x39400000 # LDRB Wt, [Xn, #imm12]
def is_strb_imm(r): return (r & 0xFFC00000) == 0x39000000 # STRB Wt, [Xn, #imm12]
def is_strb_reg(r): return (r & 0xFFE00C00) == 0x38200800 # STRB Wt, [Xn, Xm]
def is_eor_w(r): return (r & 0x7F200000) == 0x4A000000 # EOR Wd, Wn, Wm (shifted register)
def is_bl(r): return (r & 0xFC000000) == 0x94000000
def is_blr(r): return (r & 0xFFFFFC1F) == 0xD63F0000
def is_b(r): return (r & 0xFC000000) == 0x14000000
def is_ret(r): return (r & 0xFFFFFC1F) == 0xD65F03C0


def reg_of_eor(r):
"""EOR Wd, Wn, Wm —— 返回 (Wd, Wn, Wm)."""
return r & 0x1F, (r >> 5) & 0x1F, (r >> 16) & 0x1F


# ---------- Phase 1: 语义识别 decoder ----------

def is_xor_decoder(func_ea, max_insn=60):
f = ida_funcs.get_func(func_ea)
if not f:
return False, False, None
n_insn = (f.end_ea - f.start_ea) // 4
if n_insn > max_insn:
return False, False, None

# 第一遍: 排除有 BL/BLR 的
cur = func_ea
while cur < f.end_ea:
r = ida_bytes.get_dword(cur)
if is_bl(r) or is_blr(r):
return False, False, None
cur += 4

# 第二遍: 找 EOR + 看前后 LDRB / STRB
has_ldrb = False
eor_ea = None
eor_regs = None # (Wd, Wn, Wm)
has_strb = False
cur = func_ea
while cur < f.end_ea:
r = ida_bytes.get_dword(cur)
if is_ldrb(r) or is_ldrb_imm(r):
has_ldrb = True
if is_eor_w(r) and has_ldrb:
eor_ea = cur
eor_regs = reg_of_eor(r)
if (is_strb_imm(r) or is_strb_reg(r)) and eor_ea is not None:
has_strb = True
break
cur += 4

if not (has_ldrb and eor_ea and has_strb):
return False, False, None

# 第三遍: 判断 WITH_INDEX 变体
n_eor = 0
cur = func_ea
while cur < f.end_ea:
r = ida_bytes.get_dword(cur)
if is_eor_w(r):
n_eor += 1
cur += 4

return True, n_eor > 1, eor_ea


def find_all_decoders(seg):
"""扫 .text 找所有 XOR decoder 函数."""
decoders = {} # func_ea -> with_index
for fn_ea in idautils.Functions(seg.start_ea, seg.end_ea):
is_dec, with_idx, _ = is_xor_decoder(fn_ea)
if is_dec:
decoders[fn_ea] = with_idx
return decoders


# ---------- Phase 2: 找 caller (BL + ADR+BLR) ----------
def find_callsites(decoders, seg):
"""扫 .text 找所有调用 decoder 的位置 (BL 和 ADR+BLR 两种).
返回 [(call_ea, decoder_ea), ...]."""
sites = []
cur = seg.start_ea
while cur < seg.end_ea:
r = ida_bytes.get_dword(cur)
# BL imm26
if is_bl(r):
imm26 = r & 0x03FFFFFF
if imm26 & (1 << 25): imm26 -= (1 << 26)
tgt = cur + (imm26 << 2)
if tgt in decoders:
sites.append((cur, tgt))
# ADR Xn, decoder; BLR Xn
elif is_adr(r):
imm_lo = (r >> 29) & 0x3
imm_hi = (r >> 5) & 0x7FFFF
imm = (imm_hi << 2) | imm_lo
if imm & (1 << 20): imm -= (1 << 21)
tgt = cur + imm
if tgt in decoders:
rd = r & 0x1F
nxt = ida_bytes.get_dword(cur + 4)
if is_blr(nxt) and ((nxt >> 5) & 0x1F) == rd:
sites.append((cur + 4, tgt)) # call_ea = BLR 位置
cur += 4
return sites


# ---------- Phase 3: 跨块寄存器追踪 ----------

class RegTracker:
"""ARM64 mini-emulator: 模拟 MOVZ/MOVK/ADR/ADRP/ADD imm 序列, 跨基本块.
遇到 BL/BLR/RET/未知指令/重新定义就丢弃寄存器(被 clobber)."""

def __init__(self):
self.regs = {} # reg_id -> 64-bit value

def step(self, ea, raw):
"""处理单条指令, 更新寄存器状态."""
if is_movz_x(raw):
sh = ((raw >> 21) & 0x3) * 16
imm = (raw >> 5) & 0xFFFF
rd = raw & 0x1F
self.regs[rd] = (imm << sh) & 0xFFFFFFFFFFFFFFFF
elif is_movk_x(raw):
sh = ((raw >> 21) & 0x3) * 16
imm = (raw >> 5) & 0xFFFF
rd = raw & 0x1F
cur_v = self.regs.get(rd, 0)
self.regs[rd] = ((cur_v & ~(0xFFFF << sh)) | ((imm << sh) & 0xFFFFFFFFFFFFFFFF)) & 0xFFFFFFFFFFFFFFFF
elif is_movz_w(raw):
sh = ((raw >> 21) & 0x3) * 16
imm = (raw >> 5) & 0xFFFF
rd = raw & 0x1F
self.regs[rd] = (imm << sh) & 0xFFFFFFFF
elif is_movk_w(raw):
sh = ((raw >> 21) & 0x3) * 16
imm = (raw >> 5) & 0xFFFF
rd = raw & 0x1F
cur_v = self.regs.get(rd, 0)
self.regs[rd] = ((cur_v & ~(0xFFFF << sh)) | ((imm << sh) & 0xFFFFFFFF)) & 0xFFFFFFFF
elif is_adr(raw):
rd = raw & 0x1F
imm_lo = (raw >> 29) & 0x3
imm_hi = (raw >> 5) & 0x7FFFF
imm = (imm_hi << 2) | imm_lo
if imm & (1 << 20): imm -= (1 << 21)
self.regs[rd] = ea + imm
elif is_adrp(raw):
rd = raw & 0x1F
imm_lo = (raw >> 29) & 0x3
imm_hi = (raw >> 5) & 0x7FFFF
imm = ((imm_hi << 2) | imm_lo) << 12
if imm & (1 << 32): imm -= (1 << 33)
self.regs[rd] = (ea & ~0xFFF) + imm
elif is_add_imm(raw):
rd = raw & 0x1F
rn = (raw >> 5) & 0x1F
imm12 = (raw >> 10) & 0xFFF
shift = ((raw >> 22) & 0x3) * 12
if rn in self.regs:
self.regs[rd] = (self.regs[rn] + (imm12 << shift)) & 0xFFFFFFFFFFFFFFFF


def resolve_args(call_ea, want=(0, 1, 2, 3), max_insn=80):
"""从 call_ea 反向走, 找最近赋值给 X0/X1/X2/W3 的指令.
跨基本块: 如果当前块走完还没拿齐, 找跳进当前块的 B 指令的来源, 继续往前找.
"""
# 简化版: 单纯线性向前走 max_insn 条
# 用前向扫描更准: 找出当前 entry 的开头 (向前走到 RET 或函数边界), 然后正向跑 RegTracker
f = ida_funcs.get_func(call_ea)
if not f:
return {}
# 找 entry 起点: 从 call_ea 向前走到最近的 B / RET / 函数开头
entry_start = call_ea - 4
while entry_start > f.start_ea:
prev_raw = ida_bytes.get_dword(entry_start - 4)
if is_b(prev_raw) or is_ret(prev_raw) or is_blr(prev_raw) or is_bl(prev_raw):
break
entry_start -= 4
if entry_start < f.start_ea:
entry_start = f.start_ea
# 限幅
if call_ea - entry_start > max_insn * 4:
entry_start = call_ea - max_insn * 4

# 正向跑 RegTracker
tr = RegTracker()
cur = entry_start
while cur < call_ea:
raw = ida_bytes.get_dword(cur)
tr.step(cur, raw)
cur += 4
return tr.regs


# ---------- Phase 4: 解密 + 输出 ----------

def xor_decrypt(src_ea, key64, n, with_index=False):
raw = ida_bytes.get_bytes(src_ea, n)
if raw is None: return None
kb = struct.pack('<Q', key64 & 0xFFFFFFFFFFFFFFFF)
out = bytearray(n)
for i in range(n):
v = raw[i] ^ kb[i & 7]
if with_index:
v ^= (i & 0xFF)
out[i] = v & 0xFF
return bytes(out)


def decode_bytes_to_text(b):
try:
s = b.decode('utf-8')
# 截断 \0 后面的内容
z = s.find('\x00')
if z >= 0: s = s[:z]
# 简单可读性检查
if all(0x20 <= ord(c) < 0x7F or c in '\t\n\r' for c in s):
return s, True
return repr(b), False
except UnicodeDecodeError:
return '(bin) ' + b.hex(), False


# ---------- 主流程 ----------

def main():
seg = ida_segment.get_segm_by_name('.text')
if not seg:
print('[-] .text not found'); return
print(f'[*] scanning .text @ 0x{seg.start_ea:x}..0x{seg.end_ea:x}')

# Phase 1
decoders = find_all_decoders(seg)
if not decoders:
print('[-] no XOR decoder found'); return
n_plain = sum(1 for v in decoders.values() if not v)
n_idx = sum(1 for v in decoders.values() if v)
print(f'[+] found {len(decoders)} XOR decoder funcs ({n_plain} PLAIN, {n_idx} WITH_INDEX)')
for ea, wi in sorted(decoders.items()):
print(f' sub_{ea:X} {"WITH_INDEX" if wi else "PLAIN"}')

# Phase 2
sites = find_callsites(decoders, seg)
print(f'\n[+] found {len(sites)} callsites')

# Phase 3 + 4
print(f'\n{"="*100}')
print(f' decoded strings:')
print(f'{"="*100}')
n_ok = 0
n_skip = 0
for call_ea, dec_ea in sorted(sites):
regs = resolve_args(call_ea)
x0 = regs.get(0); x1 = regs.get(1); x2 = regs.get(2); w3 = regs.get(3)
if x1 is None or x2 is None or w3 is None or w3 == 0 or w3 > 1024:
n_skip += 1
print(f' call@0x{call_ea:x} via sub_{dec_ea:X} '
f'[skip: regs incomplete X0={x0} X1={x1} X2={x2} W3={w3}]')
continue
with_idx = decoders[dec_ea]
pt = xor_decrypt(x1, x2, w3, with_idx)
if pt is None:
n_skip += 1; continue
text, readable = decode_bytes_to_text(pt)
tag = 'IDX' if with_idx else 'PLN'
ok = '' if readable else '?'
print(f' {ok} call@0x{call_ea:x} [{tag}] sub_{dec_ea:X} '
f'src=0x{x1:x} key=0x{x2:016x} len={w3:>3}{text!r}')
if readable and x0 is not None:
idc.set_cmt(x0, f'XOR-decrypted: {text!r}', 1)
n_ok += 1

print(f'\n[+] decoded {n_ok}, skipped {n_skip}')


if __name__ == '__main__':
main()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
====================================================================================================
decoded strings:
====================================================================================================
? call@0x96014 [IDX] sub_972CC src=0x58000 key=0xf016e334d7d65666 len= 8 → '(bin) 66f7dff4f0ed1037'
call@0x96564 [PLN] sub_9ECB8 src=0x63b0b key=0x760468dab5493fb3 len= 23 → 'oOoo0Oo0o0Ooo0Oo0false'
call@0x965bc [PLN] sub_9697C src=0x63b01 key=0xdb0ab1367a07456a len= 10 → '0Ooo0Oo0.'
call@0x97138 [PLN] sub_9EA1C src=0x63b42 key=0xcd1f64f4ab960940 len= 5 → 'Node'
call@0x97eac [PLN] sub_9CB50 src=0x58660 key=0x7b88a8a1801fe656 len= 6 → 'input'
call@0x97ef0 [PLN] sub_97358 src=0x58666 key=0x97a36ef7a74f5e4e len= 5 → 'Tick'
? call@0x97f84 [PLN] sub_9F4EC src=0x58000 key=0x4a85115faa17d83f len= 8 → '(bin) 3f781c8a9f1a858a'
call@0x982c0 [IDX] sub_9EAD0 src=0x63b22 key=0xfe9de903ae1f4cc1 len= 11 → 'RefCounted'
call@0x983b0 [IDX] sub_9ED3C src=0x63af7 key=0x4540baf0c9ce3c00 len= 10 → 'oOoo0Oo0.'
call@0x99cfc [IDX] sub_967BC src=0x63ab7 key=0x3962018e8121661a len= 14 → '/proc/self/fd'
call@0x99e38 [IDX] sub_9EB80 src=0x63aa6 key=0xa22a7cd9390da2bd len= 17 → '/proc/self/fd/%s'
call@0x9a04c via sub_97048 [skip: regs incomplete X0=None X1=None X2=None W3=None]
? call@0x9abec [PLN] sub_9625C src=0x58000 key=0x88893b8dad0a5e18 len= 8 → '(bin) 18fe018d4d308948'
call@0x9ac30 [IDX] sub_966F8 src=0x63b84 key=0xcd786cdcb26db64b len= 6 → 'count'
call@0x9cc88 [PLN] sub_978E0 src=0x63acf key=0xe969efb41441f237 len= 7 → 'Object'
call@0x9d170 via sub_9A9A8 [skip: regs incomplete X0=None X1=None X2=None W3=None]
call@0x9d2ec via sub_9A224 [skip: regs incomplete X0=None X1=None X2=None W3=None]
call@0x9da90 via sub_96848 [skip: regs incomplete X0=None X1=None X2=None W3=None]
call@0x9db9c via sub_97AE0 [skip: regs incomplete X0=None X1=None X2=None W3=None]
call@0x9dec4 via sub_984D8 [skip: regs incomplete X0=None X1=None X2=None W3=None]
call@0xa0b9c [PLN] sub_A64A8 src=0x63d61 key=0x6b164603686ad5e3 len= 18 → 'oO0ooOo0oOoo0Oo0.'
call@0xa166c [PLN] sub_A1470 src=0x63be2 key=0x204c2bebdd0825b2 len= 5 → 'Node'
call@0xa4e7c via sub_A1698 [skip: regs incomplete X0=None X1=None X2=None W3=None]
call@0xa5b38 [PLN] sub_A2B88 src=0x63d48 key=0x51ca10ca168211f1 len= 25 → 'oOoo0Oo0o0Ooo0Oo0nullptr'
call@0xa9564 [IDX] sub_A81F4 src=0x63d83 key=0x61615d7d9efc7730 len= 5 → '%02x'
call@0xa9fa0 via sub_A8870 [skip: regs incomplete X0=None X1=None X2=None W3=None]
call@0xaa060 via sub_AAD58 [skip: regs incomplete X0=None X1=None X2=None W3=None]
call@0xaa1f8 via sub_A7818 [skip: regs incomplete X0=None X1=None X2=None W3=None]
call@0xaa370 via sub_A87EC [skip: regs incomplete X0=None X1=None X2=None W3=None]
call@0xaa418 via sub_A8764 [skip: regs incomplete X0=None X1=None X2=None W3=None]
call@0xaa8c8 via sub_A9664 [skip: regs incomplete X0=None X1=None X2=None W3=None]

[+] decoded 18, skipped 13

也可以看到反调试的一些字符

反调试分析及对抗

使用frida会出现花屏10s退出等现象,这里主要使用静态分析方法,可以使用一些trace方法比如stalker等,以下包括分析及对抗方法

[其它] 花屏分析及解决

游戏画面出现马赛克类似,看起来像 shader 损坏,仅在 Frida 实际 hook 时出现,但是起一个空的frida又是正常使用的,一开始以为是inline svc搞了什么东西,后面分析了一圈实在找不到了搜了一下godot机制

可能是如下的问题(我的设备是小米11pro android11,如有相同现象可以跟我讲,初赛也没碰到)

  1. Godot 4 渲染管线对每帧 timing 敏感
  2. Interceptor.attach 每次 hook 调用增加几微秒
  3. 当某条热路径被 hook,累积延迟让 render 线程错过 vsync window
  4. GPU 在 submit#(N+1) 时还在用 submit#N 的 uniform 数据
  5. vertex 数据和 fragment shader uniform 不同步,看起来像shader 损坏

导致后面几个part去发生碰撞验证token和flag同时出现效果图的时候看不出来

image-20260418110308382

解决方法

对frida去调godot会产生这种现象,难以解决,可替代的方法是直接编译一个二进制去执行,用NDK编译的外部二进制注入调试

Frida agent 外部二进制
注入方式 frida-server ptrace注入 frida-agent.so 进游戏进程 完全不注入,独立进程
代码运行位置 游戏地址空间内 自己的进程,自己的页表
函数调用劫持 在每个被 hook 函数入口写 trampoline → 走 V8/QuickJS 解释 JS callback 不动游戏的指令字节,游戏函数完全原速运行
与渲染线程关系 共享同进程 任何线程的卡顿都会推迟下一次 vsync 跨进程 调度器视角是两个独立任务

二进制调试示例代码:

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
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/types.h>
#include <dirent.h>
#include <errno.h>

#define PKG_NAME "com.tencent.ACE.gamesec2026.final"

// Field offsets within objects (from static RE of libgodot_android.so)
#define AREA3D_MONITORING_OFFSET 0x578 // 1400
#define AREA3D_MONITORABLE_OFFSET 0x579 // 1401
#define COLLSHAPE_DISABLED_OFFSET 0x4A1 // 1185
#define NODE3D_VISIBLE_OFFSET 0x1F1 // 497

// vtable offsets in libgodot_android.so (constant addresses relative to libgodot base)
#define AREA3D_VTABLE_OFFSET 0x3F43A88ULL
#define COLLSHAPE_VTABLE_OFFSET 0x3F44DD0ULL
#define MESH_VTABLE_OFFSET 0x3F3D108ULL

// Scan limits
#define MAX_REGIONS 512
#define MAX_CANDIDATES 256
#define CHUNK_SIZE (4 * 1024 * 1024)

typedef struct {
uint64_t start;
uint64_t end;
} mem_region_t;

static pid_t find_game_pid(void) {
DIR *proc = opendir("/proc");
if (!proc) return 0;
struct dirent *e;
pid_t found = 0;
while ((e = readdir(proc))) {
if (e->d_type != DT_DIR) continue;
for (const char *p = e->d_name; *p; p++) if (*p < '0' || *p > '9') goto next;
{
char path[64];
snprintf(path, sizeof(path), "/proc/%s/cmdline", e->d_name);
FILE *f = fopen(path, "r");
if (!f) continue;
char cmd[256] = {0};
fread(cmd, 1, sizeof(cmd) - 1, f);
fclose(f);
if (strstr(cmd, PKG_NAME)) {
found = atoi(e->d_name);
break;
}
}
next: ;
}
closedir(proc);
return found;
}

static int read_maps(pid_t pid, uint64_t *godot_base, mem_region_t *regions, int *n_regions) {
char path[64];
snprintf(path, sizeof(path), "/proc/%d/maps", pid);
FILE *f = fopen(path, "r");
if (!f) { perror("open maps"); return -1; }

*godot_base = 0;
*n_regions = 0;
char line[1024];
while (fgets(line, sizeof(line), f)) {
uint64_t start, end;
char perms[8], dev[32], path_buf[512] = {0};
unsigned long offset, inode;
int n = sscanf(line, "%lx-%lx %7s %lx %31s %lu %511[^\n]",
&start, &end, perms, &offset, dev, &inode, path_buf);
if (n < 6) continue;

// Base = lowest mapping of libgodot (first PT_LOAD, p_vaddr=0, p_offset=0).
// Do NOT compute from executable segment — its p_vaddr ≠ p_offset (16KB align gap).
if (strstr(path_buf, "libgodot_android.so") && offset == 0 && !*godot_base) {
*godot_base = start;
}

// Collect RW regions for heap scan (skip stack/dev/mmap'd files)
if (perms[0] == 'r' && perms[1] == 'w' && perms[3] == 'p') {
if (strstr(path_buf, "[stack]")) continue;
if (strstr(path_buf, "[vvar]")) continue;
if (strstr(path_buf, "/dev/")) continue;
if (end - start > 512ULL * 1024 * 1024) continue; // skip huge regions
if (*n_regions >= MAX_REGIONS) break;
regions[*n_regions].start = start;
regions[*n_regions].end = end;
(*n_regions)++;
}
}
fclose(f);
return 0;
}

static int scan_for_vptr(int mem_fd, mem_region_t *regions, int n_regions,
uint64_t vptr, uint64_t *out, int max_out) {
uint8_t *buf = malloc(CHUNK_SIZE);
if (!buf) return 0;
int count = 0;

for (int r = 0; r < n_regions && count < max_out; r++) {
uint64_t size = regions[r].end - regions[r].start;
for (uint64_t off = 0; off < size && count < max_out; off += CHUNK_SIZE) {
uint64_t chunk = (size - off > CHUNK_SIZE) ? CHUNK_SIZE : (size - off);
if (pread64(mem_fd, buf, chunk, regions[r].start + off) != (ssize_t)chunk) continue;
// 8-byte aligned scan
for (uint64_t i = 0; i + 8 <= chunk; i += 8) {
if (*(uint64_t *)(buf + i) == vptr) {
out[count++] = regions[r].start + off + i;
if (count >= max_out) break;
}
}
}
}
free(buf);
return count;
}

int main(int argc, char **argv) {
int dry_run = (argc > 1 && strcmp(argv[1], "--dry-run") == 0);
if (dry_run) printf("[DRY-RUN MODE — no memory writes]\n");

pid_t pid = find_game_pid();
if (!pid) { fprintf(stderr, "game not running\n"); return 1; }
printf("PID: %d\n", pid);

uint64_t godot_base;
mem_region_t regions[MAX_REGIONS];
int n_regions;
if (read_maps(pid, &godot_base, regions, &n_regions) < 0) return 1;
if (!godot_base) { fprintf(stderr, "libgodot not loaded yet\n"); return 1; }

printf("libgodot base: 0x%lx\n", godot_base);
printf("Heap regions: %d\n", n_regions);

uint64_t area3d_vptr = godot_base + AREA3D_VTABLE_OFFSET;
uint64_t collshape_vptr = godot_base + COLLSHAPE_VTABLE_OFFSET;
uint64_t mesh_vptr = godot_base + MESH_VTABLE_OFFSET;
printf("Area3D vptr: 0x%lx\n", area3d_vptr);
printf("CollShape3D vptr: 0x%lx\n", collshape_vptr);
printf("Mesh3D vptr: 0x%lx\n", mesh_vptr);

char mem_path[64];
snprintf(mem_path, sizeof(mem_path), "/proc/%d/mem", pid);
int mem_fd = open(mem_path, dry_run ? O_RDONLY : O_RDWR);
if (mem_fd < 0) { perror("open mem"); return 1; }

// Scan for Area3D instances
uint64_t area3d_objs[MAX_CANDIDATES];
int n_area = scan_for_vptr(mem_fd, regions, n_regions, area3d_vptr,
area3d_objs, MAX_CANDIDATES);
printf("\nFound %d Area3D objects\n", n_area);

int trigger4_count = 0;
for (int i = 0; i < n_area; i++) {
uint8_t bytes[2];
if (pread64(mem_fd, bytes, 2, area3d_objs[i] + AREA3D_MONITORING_OFFSET) != 2) continue;
if (bytes[0] == 0 && bytes[1] == 0) {
printf(" Trigger4 candidate @ 0x%lx (monitoring=0, monitorable=0)\n", area3d_objs[i]);
if (!dry_run) {
uint8_t enable[2] = {1, 1};
if (pwrite64(mem_fd, enable, 2, area3d_objs[i] + AREA3D_MONITORING_OFFSET) == 2) {
printf(" ✓ patched: monitoring=1, monitorable=1\n");
trigger4_count++;
} else {
perror(" pwrite monitoring");
}
} else {
trigger4_count++;
}
}
}

// Scan for CollisionShape3D with disabled=1
uint64_t coll_objs[MAX_CANDIDATES];
int n_coll = scan_for_vptr(mem_fd, regions, n_regions, collshape_vptr,
coll_objs, MAX_CANDIDATES);
printf("\nFound %d CollisionShape3D objects\n", n_coll);
int coll_patched = 0;
for (int i = 0; i < n_coll; i++) {
uint8_t b;
if (pread64(mem_fd, &b, 1, coll_objs[i] + COLLSHAPE_DISABLED_OFFSET) != 1) continue;
if (b == 1) {
printf(" disabled CollisionShape3D @ 0x%lx\n", coll_objs[i]);
if (!dry_run) {
uint8_t enable = 0;
if (pwrite64(mem_fd, &enable, 1, coll_objs[i] + COLLSHAPE_DISABLED_OFFSET) == 1) {
printf(" ✓ patched: disabled=0\n");
coll_patched++;
}
} else coll_patched++;
}
}

// Scan for MeshInstance3D with visible=0
uint64_t mesh_objs[MAX_CANDIDATES];
int n_mesh = scan_for_vptr(mem_fd, regions, n_regions, mesh_vptr,
mesh_objs, MAX_CANDIDATES);
printf("\nFound %d MeshInstance3D objects\n", n_mesh);
int mesh_patched = 0;
for (int i = 0; i < n_mesh; i++) {
uint8_t b;
if (pread64(mem_fd, &b, 1, mesh_objs[i] + NODE3D_VISIBLE_OFFSET) != 1) continue;
if (b == 0) {
printf(" invisible MeshInstance3D @ 0x%lx\n", mesh_objs[i]);
if (!dry_run) {
uint8_t enable = 1;
if (pwrite64(mem_fd, &enable, 1, mesh_objs[i] + NODE3D_VISIBLE_OFFSET) == 1) {
printf(" ✓ patched: visible=1\n");
mesh_patched++;
}
} else mesh_patched++;
}
}

close(mem_fd);

printf("\n=== SUMMARY ===\n");
printf("Trigger4 (Area3D) patched: %d\n", trigger4_count);
printf("CollisionShape3D enabled: %d\n", coll_patched);
printf("MeshInstance3D made visible: %d\n", mesh_patched);
printf("\n%s — drive into Trigger4, flag should display normally.\n",
dry_run ? "DRY-RUN done" : "DONE");
return 0;
}

image-20260418125947046

反调1:Tick 10s自毁

分析

这是唯一写 qword_1834B8 的地方(在 Tick 内部)。只在首次调用 Tick 时执行

下图为Tick分析章节去混淆后的代码,可以清晰地看到反调试

image-20260419063239219

Entry 5 (0x9AEBC) : 10 秒 diff 判定 核心反调试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
9AEBC  STP XZR, XZR, [X29, #-0x18]       
9AEC0 MOV W0, #1
9AEC4 SUB X1, X29, #0x18
9AEC8 MOV W8, #0x71 ; syscall 113
9AECC SVC 0 ; 内联 clock_gettime
9AED0 LDP X9, X8, [X29, #-0x18]
9AED4 SMULH X8, X8, X16
9AED8 ASR X10, X8, #7
9AEDC ADD X8, X10, X8, LSR#63 ; X8 = nsec / 1000
9AEE0 LDR X10, [X27, #0x4B8] ; X10 = qword_1834B8 (baseline)
9AEE8 MADD X8, X9, X11, X8 ; X8 = now_usec
9AEEC SUB X0, X8, X10 ; X0 = now - baseline
9AEF0 ADRP X8, llabs_ptr@PAGE
9AEF8 BLR X8
9AEFC STP W25, W22, [SP, #0x1C] ; [SP+0x1C]=W25, [SP+0x20]=W22
9AF04 LDR W8, [SP, #0x20] ; W8 = W22 = 0xC5499183 (slow 候选)
9AF0C LDR W9, [SP, #0x1C] ; W9 = W25 = 0x7F1F1A90 (fast 候选)
9AF14 MOVK W10, #0x98, LSL#16 ; W10 = 0x00989680 = 10,000,000 (10s)
9AF24 CMP X0, X10
9AF3C CSEL W8, W8, W9, GT ; if diff > 10s, W8=slow; else W8=fast
9AF40 B loc_9AE24 ; 状态转换 + 分发

qword_1834B8在第一次 Tick 调用时被初始化为当前时间。

fast path 与 slow path 的差异

1
2
fast path  (≤10s):  CSEL W8 = W25 → W25+0x55 → entry 4 → epilogue RET
slow path (>10s): CSEL W8 = W22 → W22+0x73 → entry 0 → ADD LR+0x30 → 二次 dispatch garbage → trap

slow path 不是显式 abort(),而是故意构造的 garbage W8 值让二次 dispatch 落到没初始化的 entry 导致跳到 0xD61F0100F8686B08 (BR + invalid) → SIGSEGV。

正常 60fps 游戏每帧应在 16ms 内 tick 一次。两次 tick 间 10 秒 ,两次 tick 间 10 秒 就是大概率有人在干预。

对抗1

最简单的就是pacth,0x9AE14改成普通 RET (c0 03 5f d6),k可以在二进制patch,但是打包不方便,可以使用运行时frida_patch逻辑每 3 秒把 baseline 改成当前时间,diff 永远 = 0 < 10s 永远 fast path,不会被kill(这里还包含了其它模块的代码)

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
console.log("[bypass] loading");


const ptrace = Module.findExportByName("libc.so", "ptrace");
if (ptrace) {
Interceptor.attach(ptrace, {
onLeave(r) { r.replace(0); }
});
}

const clock_gettime_addr = Module.findExportByName("libc.so", "clock_gettime");
const cgt = new NativeFunction(clock_gettime_addr, 'int', ['int', 'pointer']);
const ts = Memory.alloc(16);

function currentMicros() {
cgt(1, ts); // CLOCK_MONOTONIC
const sec = ts.readU64().toNumber();
const nsec = ts.add(8).readU64().toNumber();
return sec * 1000000 + Math.floor(nsec / 1000);
}

// === [3] pthread_create 拦截 3 条反调试线程 ================================
const noopStub = new NativeCallback(function() { return NULL; }, 'pointer', ['pointer']);

const ANTIDEBUG_OFFSETS = [
0x9C654, // fork+ptrace 互控线程
0x9CDC4, // /proc/self/fd + /proc/self/task 扫描线程
0x9B7D8, // mprotect + dl_iterate_phdr + PTRACE_SETREGSET 混合线程
];

const pthread_create = Module.findExportByName("libc.so", "pthread_create");
if (pthread_create) {
Interceptor.attach(pthread_create, {
onEnter(args) {
// libsec2026 可能还在加载中(.init_array 执行时),但代码已 map
const base = Module.findBaseAddress("libsec2026.so");
if (!base) return;
const start = args[2];
for (const off of ANTIDEBUG_OFFSETS) {
if (start.equals(base.add(off))) {
args[2] = noopStub; // 替换 start_routine 为 no-op
break;
}
}
}
});
}

// === [4] dlopen hook 触发 install ==========================================
// libsec2026.so 加载完成后,装 Tick 定时器刷新
let installed = false;
['android_dlopen_ext', 'dlopen'].forEach(name => {
const fn = Module.findExportByName(null, name);
if (!fn) return;
Interceptor.attach(fn, {
onEnter(args) { this.path = args[0] ? args[0].readCString() : null; },
onLeave(retval) {
if (this.path && this.path.indexOf("libsec2026") >= 0 && !installed) {
installed = true;
setTimeout(install, 1500);
}
}
});
});

// === [5] Tick 10s 定时器破解========================================
function install() {
const secBase = Module.findBaseAddress("libsec2026.so");
const qword_1834B8 = secBase.add(0x1834B8); // Tick 的 baseline 时间戳全局
console.log(`[bypass] secBase=${secBase}`);

let count = 0;
function refreshBaseline() {
try {
qword_1834B8.writeU64(currentMicros());
count++;
if (count <= 3 || count % 20 === 0) {
console.log(`[bypass refresh #${count}]`);
}
} catch (e) {
console.log(`[bypass err] ${e}`);
}
}

setInterval(refreshBaseline, 3000); // 每 3s 刷一次(远小于 10s 阈值)
setTimeout(refreshBaseline, 4000); // 让 Tick entry 2 先自己初始化 baseline

console.log("[bypass] ready, refreshing every 3s");
}

console.log("[bypass] hooks installed, waiting for libsec2026");

image-20260419074343110

对抗2

当然还有其他方案,比如不用frida,直接编写纯C去调试

patch baseline 入口 ,0x9AE9C 加 LDR X12, [X27, #0x4B8] + 0x9AEB4 CBNZ X12 检查 ,保留 baseline 逻辑,只让 trap 不触发

Frida hook clock_gettime,拦截 __NR_clock_gettime 系统调用,需 Stalker,且开销大。

反调试主循环分析

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
sleep(3);
setpriority(PRIO_PROCESS, 0, 19); // 自降优先级
addr = -PAGESIZE & &exit;
mprotect(addr, len, PROT_READ|PROT_EXEC); // 反调试 2

LABEL_LOOP:
pthread_mutex_lock(&mutex_);

// === 反调试 3: inline-syscall procfs 扫描 (合并原 2b+2c) ===
decoded_path = unk_1682D0 ^ xmmword_58560; // = "/proc/self/maps\0"
fd = sub_9AD3C(56, AT_FDCWD, decoded_path, O_RDONLY, 0); // syscall 56 = openat
while (1) {
sub_9AD20(63, fd, &byte_buf, 1, ...); // syscall 63 = read 1 byte
accumulate(byte_buf);
if (matches("frida"|"gum"|"linjector")) trap;
if (read returned 0) break;
}

qword_183498 = 0; // 临时变量清零, 不是 heartbeat
pthread_mutex_unlock(&mutex_);

// === 反调试 4: libgodot_android.so 白名单 + ELF phdr 校验 ===
decoded_name = unk_1682E0 ^ xmmword_58640; // = "libgodot_android.so\0"
sub_96A00(decoded_name, dword_1682C8);
// 内部: dl_iterate_phdr(sub_9EFB4, decoded_name)
// sub_9EFB4 callback:
// if (strstr(info->dlpi_name, "libgodot_android.so")) {
// for (i = 0; i < info->dlpi_phnum; i++) {
// phdr = &info->dlpi_phdr[i]; // sizeof = 0x38
// check phdr->p_type / p_offset / etc;
// }
// }

sleep(3);
goto LABEL_LOOP;

image-20260419065426153

反调2:sub_9B7D8 exit函数页保护检测

分析

sub_9B7D8 里的 mprotect(addr, len, 5)调用0x9BCC4

函数图如上图所示

原理

1
2
3
addr = -PAGESIZE & (uint64_t)&exit;     // 把 libc 的 exit 地址按页对齐
len = sysconf(_SC_PAGESIZE);
ret = mprotect(addr, len, PROT_READ | PROT_EXEC); // 强制设页只读+执行

如果 Frida 等工具用 inline hook 修改了 exit(修改字节码需要先把页设为 PROT_WRITE),那这页可能:

  • 被设为 RWX:mprotect 可能成功也可能失败
  • 被改了字节但页保护已恢复:mprotect 不会探测到
  • 但如果 hook 的页处于已写入但未恢复状态:mprotect 可能返回非 0

对抗1

最简单的方案就是不要去inline hook exit(),一个个函数去hook 对应详细的反调试去分析

对抗2

hook mprotect 强制返回 0,Interceptor.replace(mprotect, () => 0),这里为了演示我们主动去hook exit

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
console.log("[bypass] loading");


const ptrace = Module.findExportByName("libc.so", "ptrace");
if (ptrace) {
Interceptor.attach(ptrace, {
onLeave(r) { r.replace(0); }
});
}

const clock_gettime_addr = Module.findExportByName("libc.so", "clock_gettime");
const cgt = new NativeFunction(clock_gettime_addr, 'int', ['int', 'pointer']);
const ts = Memory.alloc(16);

function currentMicros() {
cgt(1, ts); // CLOCK_MONOTONIC
const sec = ts.readU64().toNumber(); // 注意:Frida UInt64 没有 mul/div,必须 toNumber()
const nsec = ts.add(8).readU64().toNumber();
return sec * 1000000 + Math.floor(nsec / 1000);
}

// === [3] pthread_create 拦截 3 条反调试线程 ================================
const noopStub = new NativeCallback(function() { return NULL; }, 'pointer', ['pointer']);

const ANTIDEBUG_OFFSETS = [
0x9C654, // fork+ptrace 互控线程
0x9CDC4, // /proc/self/fd + /proc/self/task 扫描线程
0x9B7D8, // mprotect + dl_iterate_phdr + PTRACE_SETREGSET 混合线程
];

const pthread_create = Module.findExportByName("libc.so", "pthread_create");
if (pthread_create) {
Interceptor.attach(pthread_create, {
onEnter(args) {
// libsec2026 可能还在加载中(.init_array 执行时),但代码已 map
const base = Module.findBaseAddress("libsec2026.so");
if (!base) return;
const start = args[2];
for (const off of ANTIDEBUG_OFFSETS) {
if (start.equals(base.add(off))) {
args[2] = noopStub; // 替换 start_routine 为 no-op
break;
}
}
}
});
}

// === [4] dlopen hook 触发 install ==========================================
// libsec2026.so 加载完成后,装 Tick 定时器刷新
let installed = false;
['android_dlopen_ext', 'dlopen'].forEach(name => {
const fn = Module.findExportByName(null, name);
if (!fn) return;
Interceptor.attach(fn, {
onEnter(args) { this.path = args[0] ? args[0].readCString() : null; },
onLeave(retval) {
if (this.path && this.path.indexOf("libsec2026") >= 0 && !installed) {
installed = true;
setTimeout(install, 1500);
}
}
});
});

// === [5] Tick 10s 定时器破解========================================
function install() {
const secBase = Module.findBaseAddress("libsec2026.so");
const qword_1834B8 = secBase.add(0x1834B8); // Tick 的 baseline 时间戳全局
console.log(`[bypass] secBase=${secBase}`);

let count = 0;
function refreshBaseline() {
try {
qword_1834B8.writeU64(currentMicros());
count++;
if (count <= 3 || count % 20 === 0) {
console.log(`[bypass refresh #${count}]`);
}
} catch (e) {
console.log(`[bypass err] ${e}`);
}
}

setInterval(refreshBaseline, 3000); // 每 3s 刷一次(远小于 10s 阈值)
setTimeout(refreshBaseline, 4000); // 让 Tick entry 2 先自己初始化 baseline

console.log("[bypass] ready, refreshing every 3s");
}

console.log("[bypass] hooks installed, waiting for libsec2026");

image-20260419081450307

方案就是

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
console.log("[bypass] loading");


const ptrace = Module.findExportByName("libc.so", "ptrace");
if (ptrace) {
Interceptor.attach(ptrace, {
onLeave(r) { r.replace(0); }
});
}

const clock_gettime_addr = Module.findExportByName("libc.so", "clock_gettime");
const cgt = new NativeFunction(clock_gettime_addr, 'int', ['int', 'pointer']);
const ts = Memory.alloc(16);

function currentMicros() {
cgt(1, ts); // CLOCK_MONOTONIC
const sec = ts.readU64().toNumber();
const nsec = ts.add(8).readU64().toNumber();
return sec * 1000000 + Math.floor(nsec / 1000);
}

// === [3] pthread_create 拦截 3 条反调试线程 ================================
const noopStub = new NativeCallback(function() { return NULL; }, 'pointer', ['pointer']);

const ANTIDEBUG_OFFSETS = [
0x9C654, // fork+ptrace 互控线程
0x9CDC4, // /proc/self/fd + /proc/self/task 扫描线程
0x9B7D8, // mprotect + dl_iterate_phdr + PTRACE_SETREGSET 混合线程
];

const pthread_create = Module.findExportByName("libc.so", "pthread_create");
if (pthread_create) {
Interceptor.attach(pthread_create, {
onEnter(args) {
// libsec2026 可能还在加载中(.init_array 执行时),但代码已 map
const base = Module.findBaseAddress("libsec2026.so");
if (!base) return;
const start = args[2];
for (const off of ANTIDEBUG_OFFSETS) {
if (start.equals(base.add(off))) {
args[2] = noopStub; // 替换 start_routine 为 no-op
break;
}
}
}
});
}

// === [4] dlopen hook 触发 install ==========================================
// libsec2026.so 加载完成后,装 Tick 定时器刷新
let installed = false;
['android_dlopen_ext', 'dlopen'].forEach(name => {
const fn = Module.findExportByName(null, name);
if (!fn) return;
Interceptor.attach(fn, {
onEnter(args) { this.path = args[0] ? args[0].readCString() : null; },
onLeave(retval) {
if (this.path && this.path.indexOf("libsec2026") >= 0 && !installed) {
installed = true;
setTimeout(install, 1500);
}
}
});
});

// === [5] Tick 10s 定时器破解========================================
function install() {
const secBase = Module.findBaseAddress("libsec2026.so");
const qword_1834B8 = secBase.add(0x1834B8); // Tick 的 baseline 时间戳全局
console.log(`[bypass] secBase=${secBase}`);

let count = 0;
function refreshBaseline() {
try {
qword_1834B8.writeU64(currentMicros());
count++;
if (count <= 3 || count % 20 === 0) {
console.log(`[bypass refresh #${count}]`);
}
} catch (e) {
console.log(`[bypass err] ${e}`);
}
}

setInterval(refreshBaseline, 3000); // 每 3s 刷一次(远小于 10s 阈值)
setTimeout(refreshBaseline, 4000); // 让 Tick entry 2 先自己初始化 baseline

console.log("[bypass] ready, refreshing every 3s");
}

console.log("[bypass] hooks installed, waiting for libsec2026");

对抗3

提前在 exit 上 GOT hook,走 PLT 而不是 inline,mprotect 也不会发现

反调3:inline-syscall procfs 扫描 (/proc/self/maps)

分析

这里直接看汇编,看反编译出来的代码有误导性,之前搞错了以为是在做什么签名什么的

1
2
3
4
5
6
7
8
9
ub_9AD3C 实际只有 7 条指令
sub_9AD3C:
MOV X8, X0 ; X8 = syscall number
MOV X0, X1 ; 参数依次往前挪
MOV X1, X2
MOV X2, X3
MOV X3, X4
SVC 0 ; 系统调用
RET ; 返回 X0
1
2
3
4
5
6
7
8
9
sub_9AD20 同样 7 条指令, 唯一区别是 X3 强制清零
sub_9AD20:
MOV X8, X0
MOV X0, X1
MOV X1, X2
MOV X2, X3
MOV X3, XZR ; 第 4 个参数强制为 0 (适配 read 等只用 3 参数的 syscall)
SVC 0
RET

这俩是通用 syscall(),把调用解码:

调用 syscall 号 解释
sub_9AD3C(56, -100, &v73, 0, 0) __NR_openat = 56 openat(AT_FDCWD = -100, &v73, O_RDONLY)
sub_9AD20(63, fd, &byte, 1, …) __NR_read = 63 read(fd, &byte, 1)

这是 inline syscall 的 procfs 扫描 ,用 SVC 直接系统调用,绕开 libc 的 openat,read防 Frida Interceptor.attach('openat'))。

watchdog 把 &v73 当 path 传给 openat,但 &v73 是栈上 16 字节缓冲区,由三层 XOR 解密:

1
v73 = unk_1682D0 ^ xmmword_58560 ^ broadcast(byte_1834B0)

image-20260419084037561

image-20260419084054223

/proc/self/maps,异或出来是这个,那其实逻辑就是

1
2
3
4
5
6
7
8
9
10
// 1. XOR 解密路径
fd = openat(AT_FDCWD, "/proc/self/maps", O_RDONLY);

// 2. 逐字节读, 累积成 line, 匹配关键字
while (read(fd, &byte, 1) > 0) {
accumulate(byte);
if (line_matches("frida" | "gum" | "linjector" | ...))
trap_state();
}
close(fd);

对抗1

block sub_9B7D8 整体不启动

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
'use strict';

const TARGET = 'libsec2026.so';
const SUB_9B7D8 = 0x9B7D8;
const QWORD_1834B8 = 0x1834B8; // Tick baseline 全局

// === [1] block sub_9B7D8 watchdog (反调试 2) ===
const noopStub = new NativeCallback(function () { return NULL; }, 'pointer', ['pointer']);

const pthread_create = Module.findExportByName('libc.so', 'pthread_create');
if (pthread_create) {
Interceptor.attach(pthread_create, {
onEnter(args) {
const base = Module.findBaseAddress(TARGET);
if (!base) return;
if (args[2].equals(base.add(SUB_9B7D8))) {
args[2] = noopStub;
console.log('[block-watchdog] blocked sub_9B7D8');
}
}
});
}

// === [2] Tick 10s baseline 刷新 (反调试 1) ===
const cgtPtr = Module.findExportByName('libc.so', 'clock_gettime');
const cgt = new NativeFunction(cgtPtr, 'int', ['int', 'pointer']);
const ts = Memory.alloc(16);

function currentMicros() {
cgt(1, ts); // CLOCK_MONOTONIC
const sec = ts.readU64().toNumber();
const nsec = ts.add(8).readU64().toNumber();
return sec * 1000000 + Math.floor(nsec / 1000);
}

let installed = false;
function installTickRefresh() {
if (installed) return;
const base = Module.findBaseAddress(TARGET);
if (!base) return;
installed = true;

const baselinePtr = base.add(QWORD_1834B8);
console.log('[tick-refresh] baseline @ ' + baselinePtr);

let count = 0;
function refresh() {
try {
baselinePtr.writeU64(currentMicros());
count++;
if (count <= 3 || count % 20 === 0) {
console.log(`[tick-refresh] #${count}`);
}
} catch (e) {
console.log(`[tick-refresh] err: ${e}`);
}
}

setInterval(refresh, 3000); // 每 3s 刷一次 (远小于 10s 阈值)
setTimeout(refresh, 4000); // 让 Tick entry 2 先自己 init baseline
console.log('[tick-refresh] started, refreshing every 3s');
}

// 触发安装: dlopen libsec2026 后启动定时器
['android_dlopen_ext', 'dlopen'].forEach(name => {
const fn = Module.findExportByName(null, name);
if (!fn) return;
Interceptor.attach(fn, {
onEnter(args) { this.path = args[0] ? args[0].readCString() : null; },
onLeave() {
if (this.path && this.path.indexOf(TARGET) >= 0 && !installed) {
setTimeout(installTickRefresh, 1500);
}
}
});
});

// 兜底
const pollTimer = setInterval(() => {
if (Module.findBaseAddress(TARGET)) {
installTickRefresh();
if (installed) clearInterval(pollTimer);
}
}, 100);

console.log('[block-watchdog + tick-refresh] hooks installed, waiting for libsec2026');

对抗2

拦截 SVC 指令本身,Stalker 复杂方案

反调4:sub_9C654 (fork + ptrace self-tracer) 和硬件断点

分析

sub_9C654 (fork + ptrace self-tracer)

.init_array在库加载时会启动以下函数

image-20260418122717538

其中

image-20260419123645635

子进程的cmdline仍然是 com.tencent.ACE.gamesec2026.final(fork 后没 exec),所以 ps -A 能看到两个同名进程。

而Linux 一个 task 同一时刻只能有一个tracer。子进程占着 ,外部 Frida工具 attach 都返回 EPERM。

检测

1
2
$ cat /proc/$GAME_PID/status | grep TracerPid
TracerPid: 31475 # 非 0 即被自跟踪

进程主动 attach 父进程。一旦 attach 成功,Linux 内核把这个父子关系记录到 /proc/31419/status 的 TracerPid 字段里

这也是为什么frida前期调试只能-f启动,不能-n去attach,因为-f时候新建进程,此时 .init_array 还没跑),以下包括两种绕过手段

child 用 exit() 走 _exit_group 终结整个进程组

image-20260426215309466

这里还有个硬件断点,1026 = 0x402 = NT_ARM_HW_BREAK,是硬件断点寄存器组(ARM64 的 DBGBVRn_EL1/DBGBCRn_EL1。

把调试器设的硬件断点全擦了,hbreak 失效。

对抗1

Frida:spawn 模式下 hook pthread_create,让 start_routine == sub_9C654 的调用直接 return(不创建子进程) ,Frida 在游戏跑 sub_99094 之前已经注入。我们 hook libc 的 pthread_create,或者直接使用-f

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const pthread_create = Module.findExportByName("libc.so", "pthread_create");
const noopStub = new NativeCallback(() => NULL, "pointer", ["pointer"]);

Interceptor.attach(pthread_create, {
onEnter(args) {
const secBase = Module.findBaseAddress("libsec2026.so");
const start_routine = args[2]; // 第 3 个参数 = 函数指针
// 如果它要起的线程是 3 个反调试函数之一
for (const offset of [0x9C654, 0x9CDC4, 0x9B7D8]) {
if (start_routine.equals(secBase.add(offset))) {
args[2] = noopStub; // 把它改成空函数
break;
}
}
}
});

对抗2

Method D:kill -9 $TRACER_PID,杀完 child 后,父进程不会因为失去 tracer 而自杀(它对自己的 tracer 是单向检测)

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
// 读 /proc/$pid/status 拿 TracerPid 字段
static void kill_self_tracer(pid_t pid) {
char path[64];
snprintf(path, sizeof(path), "/proc/%d/status", pid);

FILE *f = fopen(path, "r");
if (!f) return;

char line[256];
pid_t tracer = 0;
while (fgets(line, sizeof(line), f)) {
if (sscanf(line, "TracerPid: %d", &tracer) == 1) break;
}
fclose(f);
// 0 表示没trace
if (tracer > 0 && tracer != getpid()) {
printf("Killing self-tracer child PID %d (game PID %d)\n", tracer, pid);
kill(tracer, SIGKILL);
usleep(300 * 1000); // 等 300ms 让内核清理 ptrace 关系
}
}

int main() {
pid_t game_pid = find_game_pid(); // 找游戏进程
kill_self_tracer(game_pid); // 杀掉占着 ptrace 槽的 child
ptrace(PTRACE_ATTACH, game_pid, ...); // 现在能 attach 游戏本身
}

或者

1
TRACER=$(awk '/^TracerPid:/{print $2}' /proc/$GAME_PID/status); [ "$TRACER" != "0" ] && kill -9 $TRACER 

直接命令行kill

反调5:sub_9CDC4 /proc/self/task 扫描

分析

单独 pthread 线程(由 sub_99094 创建),扫描/proc/self/task

image-20260419125608297

image-20260419123721283

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
sub_97AE0 另一种 XOR 解密函数 
dst[i] = src[i] ^ i ^ key[i & 7] // XOR with index

sub_9CDC4 init (0x9DB78..0x9DB9C):
sub_97AE0(
dst = qword_183530, // .bss 目标指针 (运行时填)
src = unk_58540, // 16 字节 XOR'd 源
key = 0x83B3BBD45AD4B5D4, // 8 字节 key (inline)
len = 0x10 // 16 字节
);

unk_58540 原始字节: fb c4 a4 36 b3 91 c6 e1 b0 da f1 25 b9 c5 d6 8c

key bytes (LE): d4 b5 d4 5a d4 bb b3 83

逐字节解密 src[i] ^ i ^ key[i & 7]是/proc/self/task

后面继续分析下去还有混淆

Helper 公式
sub_97AE0 / sub_9A9A8 / sub_96848 dst[i] = src[i] ^ i ^ key[i & 7]
sub_9A224 / sub_984D8 dst[i] = src[i] ^ key[i & 7](无 ^i)
调用位置 helper 源/key/长度 解密结果
0x9DB9C sub_97AE0 unk_58540 / 0x83B3BBD45AD4B5D4 / 16 "/proc/self/task"
0x9D2EC sub_9A224 unk_63A7A / 0xA372088BBE5BB9E2 / 26 "/proc/self/task/%s/status"
0x9D170 sub_9A9A8 unk_63AA0 / 0xB5476C604E8EF907 / 6 gmain
0x9DA90 sub_96848 unk_63A94 / 0x05C11B12D3E63D53 / 12 gum-js-loop
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
// === init (一次性) ===
qword_183530 = "/proc/self/task"; // sub_97AE0 解密
qword_183550 = "/proc/self/task/%s/status"; // sub_9A224 解密
qword_183570 = "gum-js-loop"; // sub_96848 解密
qword_183580 = "gmain"; // sub_9A9A8 解密
unk_183544 = ".."; // sub_984D8 解密

// === 主循环 ===
while (1) {
sleep(N);
sub_99418(); // readlinkat(AT_FDCWD, ?, buf, 256)
// 读某个 symlink (path 在调用方构造)
nanosleep(timespec, NULL); // 通过 sub_9EB5C inline svc 101

DIR *d = opendir("/proc/self/task"); // libc opendir
while ((entry = readdir(d))) { // libc readdir
if (strcmp(entry->d_name, "..") == 0) // 跳过 . 和 ..
continue;

// 用 inline syscall 56 (openat) 打开 status (绕开 libc hook)
snprintf(path, "/proc/self/task/%s/status", entry->d_name);
fd = sub_9AD3C(56, AT_FDCWD, path, O_RDONLY|O_CLOEXEC);

// 用 inline syscall 63 (read) 1 byte 1 byte 读
char line[N];
while (sub_9AD20(63, fd, &byte, 1) > 0) {
line[i++] = byte;
// 累积到换行符, 检查 "Name:\t<NAME>" 格式
if (matches_blacklist(line, "gmain") ||
matches_blacklist(line, "gum-js-loop")) {
trap(); // ← 见下方 trap 机制
}
}
close(fd);
}
closedir(d);
}

还有个trap机制

不调 abort/exit/raise,而是把黑名单字符串本身当指针解引用 , SIGSEGV

1
2
3
4
5
6
7
8
9
; 0x9DF5C: 匹配 "gmain" 后:
ADRP X8, unk_183580 ; X8 = page of "gmain" buffer
LDR X8, [X8, #PAGEOFF] ; X8 = "gmain" 前 8 字节当 u64 = 0x6E69616D67
LDRB W9, [X8], #1 ; 用这个字面值当指针 SIGSEGV (mapping error)

; 0x9E490: 匹配 "gum-js-loop" 后:
ADRP X8, qword_183570 ; X8 = page of "gum-js-loop"
LDR X8, [X8, #PAGEOFF] ; X8 = "gum-js-l" 前 8 字节当 u64 = 0x6c2d736a2d6d7567
LDRB W9, [X8], #1 ; SIGSEGV

对抗1

block pthread_create 创建 sub_9CDC4

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
'use strict';

const TARGET = 'libsec2026.so';
const SUB_9CDC4 = 0x9CDC4; // 要 block 的 detector 线程
const QWORD_1834B8 = 0x1834B8; // Tick baseline 全局

// === [1] block sub_9CDC4 pthread ============================================
const noopStub = new NativeCallback(function () { return NULL; }, 'pointer', ['pointer']);

const pthread_create = Module.findExportByName('libc.so', 'pthread_create');
if (!pthread_create) {
console.log('[block-9cdc4] ERROR: libc pthread_create 找不到');
} else {
Interceptor.attach(pthread_create, {
onEnter(args) {
const base = Module.findBaseAddress(TARGET);
if (!base) return;
const start = args[2];
if (start.equals(base.add(SUB_9CDC4))) {
console.log('[block-9cdc4] ★ blocked sub_9CDC4 — task/fd scanner thread 不启动');
args[2] = noopStub; // 替换 start_routine 为立即 return 的 stub
}
}
});
console.log('[block-9cdc4] hook pthread_create 已装');
}

// === [2] Tick baseline 刷新 (反调试 1, 否则 10s 内被 Tick 杀) ================
const cgtPtr = Module.findExportByName('libc.so', 'clock_gettime');
const cgt = new NativeFunction(cgtPtr, 'int', ['int', 'pointer']);
const ts = Memory.alloc(16);

function micros() {
cgt(1, ts); // CLOCK_MONOTONIC
return ts.readU64().toNumber() * 1000000 + Math.floor(ts.add(8).readU64().toNumber() / 1000);
}

let installed = false;
function installTickRefresh() {
if (installed) return;
const base = Module.findBaseAddress(TARGET);
if (!base) return;
installed = true;

const baselinePtr = base.add(QWORD_1834B8);
console.log('[block-9cdc4] Tick baseline @ ' + baselinePtr);

let count = 0;
setInterval(() => {
try {
baselinePtr.writeU64(micros());
count++;
if (count <= 3 || count % 30 === 0) console.log('[block-9cdc4] tick refresh #' + count);
} catch (e) {}
}, 3000);
}

// 触发 install (libsec2026 加载完后)
['android_dlopen_ext', 'dlopen'].forEach(name => {
const fn = Module.findExportByName(null, name);
if (!fn) return;
Interceptor.attach(fn, {
onEnter(args) { this.path = args[0] ? args[0].readCString() : null; },
onLeave() {
if (this.path && this.path.indexOf(TARGET) >= 0 && !installed) {
setTimeout(installTickRefresh, 1500);
}
}
});
});

// 兜底
const pollTimer = setInterval(() => {
if (Module.findBaseAddress(TARGET)) {
installTickRefresh();
if (installed) clearInterval(pollTimer);
}
}, 100);

console.log('[block-9cdc4] ready, waiting for libsec2026');

对抗2

hook opendir 对/proc/self/task返 NULL

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
'use strict';

const TARGET = 'libsec2026.so';
const QWORD_1834B8 = 0x1834B8;

// 黑名单路径 (sub_9CDC4 和 sub_99418 实测会调的)
const BLOCKED_PATHS = [
'/proc/self/task',
'/proc/self/fd',
];

// === [1] hook opendir ======================================================
const opendir = Module.findExportByName('libc.so', 'opendir');
if (!opendir) {
console.log('[hook-opendir] ERROR: libc opendir 找不到');
} else {
let blockCount = 0;
Interceptor.attach(opendir, {
onEnter(args) {
this.path = args[0].isNull() ? '' : args[0].readCString();
this.shouldBlock = BLOCKED_PATHS.some(p => this.path === p || this.path.indexOf(p + '/') === 0);
},
onLeave(retval) {
if (this.shouldBlock) {
blockCount++;
if (blockCount <= 5 || blockCount % 20 === 0) {
console.log(`[hook-opendir] ★ blocked #${blockCount}: opendir("${this.path}") → NULL`);
}
retval.replace(NULL);
}
}
});
console.log('[hook-opendir] hook 已装, 黑名单: ' + JSON.stringify(BLOCKED_PATHS));
}

// === [2] Tick baseline 刷新 (反调试 1) =====================================
const cgtPtr = Module.findExportByName('libc.so', 'clock_gettime');
const cgt = new NativeFunction(cgtPtr, 'int', ['int', 'pointer']);
const ts = Memory.alloc(16);

function micros() {
cgt(1, ts);
return ts.readU64().toNumber() * 1000000 + Math.floor(ts.add(8).readU64().toNumber() / 1000);
}

let installed = false;
function installTickRefresh() {
if (installed) return;
const base = Module.findBaseAddress(TARGET);
if (!base) return;
installed = true;

const baselinePtr = base.add(QWORD_1834B8);
console.log('[hook-opendir] Tick baseline @ ' + baselinePtr);

let count = 0;
setInterval(() => {
try {
baselinePtr.writeU64(micros());
count++;
if (count <= 3 || count % 30 === 0) console.log('[hook-opendir] tick refresh #' + count);
} catch (e) {}
}, 3000);
}

['android_dlopen_ext', 'dlopen'].forEach(name => {
const fn = Module.findExportByName(null, name);
if (!fn) return;
Interceptor.attach(fn, {
onEnter(args) { this.path = args[0] ? args[0].readCString() : null; },
onLeave() {
if (this.path && this.path.indexOf(TARGET) >= 0 && !installed) {
setTimeout(installTickRefresh, 1500);
}
}
});
});

const pollTimer = setInterval(() => {
if (Module.findBaseAddress(TARGET)) {
installTickRefresh();
if (installed) clearInterval(pollTimer);
}
}, 100);

console.log('[hook-opendir] ready, waiting for libsec2026');

其它

  1. 改 Frida 线程名
  2. hook sub_9AD3C (inline openat) 返 -1
  3. patch 两个 trap 点 (0x9DF70, 0x9E49C) 为 NOP

反调6:sub_99418 /proc/self/fd扫描器

分析

/proc/self/fd 扫描 linjector 特征

被 sub_9CDC4 主循环每轮调用一次。这个其实跟反调试5是同一套线程

image-20260419123822898

3 个新解密字符串(5 个解码器中第 4-6 号):

调用 helper key len 解密结果
0x99CF8 sub_967BC (^i ^ key) 0x3962018E8121661A 14 "/proc/self/fd"
0x99E34 sub_9EB80 (^i ^ key) 0xA22A7CD9390DA2BD 17 "/proc/self/fd/%s"
0x9A048 sub_97048 (^i ^ key) 0xEF26CEB5F1E432CD 10 "linjector" Frida injector

sub_99418 完整流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
qword_183588 = "/proc/self/fd";
unk_1835A0 = "/proc/self/fd/%s";
qword_1835B8 = "linjector";

DIR *d = opendir("/proc/self/fd"); // libc opendir @ 0x99D80
while ((entry = readdir(d))) { // libc readdir @ 0x99EF4
snprintf(path, "/proc/self/fd/%s", entry->d_name); // sub_9C4FC

struct stat st;
lstat(path, &st); // libc lstat @ 0x9A100
if ((st.st_mode & 0xF000) == 0xA000) { // S_IFLNK 检查
char target[256];
readlinkat(AT_FDCWD, path, target, 256); // inline svc 78 @ 0x99530
if (strstr(target, "linjector")) // Frida 注入器特征
trap(); // 同样字符串当指针 LDR → SIGSEGV
}
}
closedir(d); // @ 0x9A19C

Frida agent 注入时,Frida-server 创建 /data/local/tmp/re.frida.server/linjector-helper-32之类的辅助进程并保持 fd 打开。注入完成后,目标进程的 /proc/self/fd/<N> 会有 symlink 指向这个 path ,strstr linjector就抓到了。

对抗方案和反调试5一致,同一套线程,最简单的方法就是把这块调用给block掉

反调7:sub_96A00 libgodot_android.so 完整性校验

分析

1
2
3
4
5
6
7
; sub_96A00 trap state @ 0x96BB8:
0x96BB8: MOV X0, XZR ; exit code = 0
0x96BBC: MOV W8, #0x5E ; syscall 94 = __NR_exit_group
0x96BC0: SVC 0 ; inline syscall, 杀整个进程组
0x96BC4: MOV X0, XZR ;
0x96BC8: MOV W8, #0x5E
0x96BCC: SVC 0

判断逻辑在 sub_9AF98,后面还有一大片混淆

后面去了混淆如下

image-20260419123606515

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
sub_96A00(target_name = "libgodot_android.so", count) {
data_buf = {...};

// 第一步: dl_iterate_phdr 收集 libgodot 的 program headers
dl_iterate_phdr(sub_9EFB4, &data_buf);

// 第二步: OLLVM CFF dispatcher 在 0x96A78..0x96B80
int verify_result = sub_9AF98(data_buf); // 验证器 (本身也是 OLLVM CFF)

// 第三步: 根据验证结果, dispatcher 选 normal exit 或 trap state
if (verify_result_indicates_anomaly) {
// 0x96BB8 trap state:
asm volatile (
"mov x0, xzr\n"
"mov w8, #0x5E\n" // __NR_exit_group (94)
"svc 0\n" // inline svc 杀进程
);
}
return; // 验证通过
}
1
2
3
4
5
6
7
8
9
10
int sub_9EFB4(struct dl_phdr_info *info, ..., void *data) {
if (strstr(info->dlpi_name, "libgodot_android.so")) {
for (i = 0; i < info->dlpi_phnum; i++) {
phdr = &info->dlpi_phdr[i];
sub_CC860/CC958/97020(data, phdr); // 存 phdr 到 data 结构
}
return 1; // 停止 iteration
}
return 0;
}

此外,sub_9AF98 不只有CRC,它还是 Tick baseline 的更新者

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
; sub_9AF98 在 0x9B088..0x9B110 包含完整的 clock_gettime + baseline 写入:
STP XZR, XZR, [X29, #var_18] ; ts = {0,0}
MOV W0, #1 ; CLOCK_MONOTONIC
MOV W8, #0x71 ; syscall 113 = clock_gettime
SVC 0 ; inline (绕 libc)

; 计算 micros = sec * 1000000 + nsec/1000:
SMULH X8, X8, magic_0x20C49BA5E353F7CF ; nsec * (1/1000 在 fixed point)
ASR X10, X8, #7 ; → nsec / 1000
MOV W10, #0xF4240 ; = 1000000
MADD X8, sec, W10, nsec_div_1000 ; X8 = sec*1M + nsec/1000

;WRITE qword_1834B8 (= Tick baseline):
ADRP X9, qword_1834B8@PAGE
STR X8, [X9, qword_1834B8@PAGEOFF]

也就是说反调试的设计逻辑如下:

  • Tick 单独存在没意义(baseline 永远不更新, 第一次就过 10s)
  • sub_9AF98 是唯一合法 heartbeat
  • block watchdog 间接触发 Tick ,这就是为啥之前的脚本必须必须 setInterval(refreshBaseline, 3000) 手动喂

sub_9AF98是CRC其中

image-20260419104624529

0xEDB88320 = bit_reverse(0x04C11DB7) 标准 CRC32实现逻辑。

加上其他 CRC32 特征:

  • entry 0x9B2DC: LDRB W9, [X8, X9] - byte-by-byte load (CRC 输入)
  • entry 0x9B33C: TST X10, #0xFFF - page mask 检查 (验证 phdr 地址对齐)
  • entry 0x9B35C: MVN W0, W8 - return ~crc (CRC 标准 final XOR with 0xFFFFFFFF)
  • EOR ...LSR#1 模式 (位移 CRC 算法核心)

设计巧思

sub_9AF98 同时做 heartbeat + verify, 形成 :

  • libgodot 没改 , CRC 对, 顺便喂了 Tick baseline ,一切正常
  • libgodot 改了 (LD_PRELOAD / patch), CRC 错, exit_group
  • block watchdog , sub_9AF98 不调 , 既没 verify 也没 heartbeat ,Tick 10s 内 trap
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int sub_9AF98(void *data, size_t length) {
usleep(10);
clock_gettime(MONO, &ts);
qword_1834B8 = ts.tv_sec*1e6 + ts.tv_nsec/1000; // heartbeat

uint32_t crc = 0xFFFFFFFF;
for (size_t i = 0; i < length; i++) { // outer = byte_count
uint8_t b = ((uint8_t*)data)[i];
crc ^= b;
for (int j = 0; j < 8; j++) { // inner = 8 bits
crc = (crc >> 1) ^ ((crc & 1) ? 0xEDB88320 : 0);
}
}
return ~crc;
}

双重防御:

  1. 改 libgodot 立即被 CRC 抓
  2. 阻止 verify 间接触发 Tick

对抗

直接 block sub_9B7D8 watchdog,sub_96A00 不被调,整个流程不跑

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
'use strict';

const TARGET = 'libsec2026.so';

// 三个反调试线程的入口地址
const ANTIDEBUG_THREAD_OFFSETS = {
0x9B7D8: 'watchdog (反调试 2)',
0x9C654: 'self-tracer (反调试 4)',
0x9CDC4: '/proc/self/task scanner (反调试 5)',
};

const QWORD_1834B8 = 0x1834B8;

function log(msg) { console.log('[bypass-all] ' + msg); }

// === [1] block 三条反调试 pthread ==========================================
const noopStub = new NativeCallback(function () { return NULL; }, 'pointer', ['pointer']);
const pthread_create = Module.findExportByName('libc.so', 'pthread_create');
if (pthread_create) {
Interceptor.attach(pthread_create, {
onEnter(args) {
const base = Module.findBaseAddress(TARGET);
if (!base) return;
const start = args[2];
for (const [off, name] of Object.entries(ANTIDEBUG_THREAD_OFFSETS)) {
if (start.equals(base.add(parseInt(off)))) {
log(`blocked ${name} @ offset 0x${parseInt(off).toString(16)}`);
args[2] = noopStub;
break;
}
}
}
});
log('pthread_create hook installed');
}

// === [2] 双保险: ptrace → 0 (防止 sub_9C654 的 child 分支实际 trace) ========
const ptrace = Module.findExportByName('libc.so', 'ptrace');
if (ptrace) {
Interceptor.attach(ptrace, {
onLeave(r) { r.replace(0); }
});
log('ptrace → 0');
}

// === [3] 双保险: opendir 过滤 "/proc/self/task" =============================
// 万一 sub_9CDC4 线程 block 失败, opendir 也挡一层
const opendir = Module.findExportByName('libc.so', 'opendir');
if (opendir) {
Interceptor.attach(opendir, {
onEnter(args) {
this.path = args[0].isNull() ? '' : args[0].readCString();
},
onLeave(retval) {
if (this.path === '/proc/self/task' || this.path.indexOf('/proc/self/task') === 0) {
log('blocked opendir("' + this.path + '")');
retval.replace(NULL);
}
}
});
log('opendir filter installed');
}

// === [4] Tick baseline 刷新 (反调试 1) =======================================
const cgtPtr = Module.findExportByName('libc.so', 'clock_gettime');
const cgt = new NativeFunction(cgtPtr, 'int', ['int', 'pointer']);
const ts = Memory.alloc(16);

function currentMicros() {
cgt(1, ts);
const sec = ts.readU64().toNumber();
const nsec = ts.add(8).readU64().toNumber();
return sec * 1000000 + Math.floor(nsec / 1000);
}

let installed = false;
function installTickRefresh() {
if (installed) return;
const base = Module.findBaseAddress(TARGET);
if (!base) return;
installed = true;

const baselinePtr = base.add(QWORD_1834B8);
log('Tick baseline @ ' + baselinePtr);

let count = 0;
function refresh() {
try {
baselinePtr.writeU64(currentMicros());
count++;
if (count <= 3 || count % 20 === 0) log('tick refresh #' + count);
} catch (e) { log('tick refresh err: ' + e); }
}

setInterval(refresh, 3000);
setTimeout(refresh, 4000);
log('Tick baseline refreshing every 3s');
}

// === [5] 触发安装 (libsec2026 加载完成后) ===================================
['android_dlopen_ext', 'dlopen'].forEach(name => {
const fn = Module.findExportByName(null, name);
if (!fn) return;
Interceptor.attach(fn, {
onEnter(args) { this.path = args[0] ? args[0].readCString() : null; },
onLeave() {
if (this.path && this.path.indexOf(TARGET) >= 0 && !installed) {
setTimeout(installTickRefresh, 1500);
}
}
});
});

const pollTimer = setInterval(() => {
if (Module.findBaseAddress(TARGET)) {
installTickRefresh();
if (installed) clearInterval(pollTimer);
}
}, 100);

反调8:sub_10B9E4C(libgodot 内存自校验)

分析

sub_10BAB00 = Main::iteration (Godot 标准, 但作者在第一行插入了 sub_10B9E4C() 调用)

image-20260419110818715

在libgodot_android.so中,被 Godot 标准 JNI 函数 Java_org_godotengine_godot_GodotLib_step 间接调用(每帧)。

image-20260419053251901

特点:

  • 不依赖 dl_iterate_phdr,直接用 process_vm_readv读 /proc/self/mem

  • hash 是某种滚动求和

    1
    2
    3
    4
    n102400 = min(remaining, 0x19000);
    for (i = 0; i < n102400; i++) {
    v2 = (v2 + buf[i]) % 0xFFFFFF;
    }
  • 结果 mod 0xFFFFFF 跟 dword_400A050比

  • dword_400A050 是硬编码期望值

只在第一次调用 v27(s_7) 时执行(初始化qword_4011848)。之后 qword_4011848 非 0,跳过校验,不通过则会自毁

image-20260419053431440

借 Godot 标准退出流程, logcat 看起来像用户主动关游戏

并且经过测试,貌似只有part3的时候会触发,查看伪代码返现这里的dword_400A054值是0xA9A7C针对的就是part3阶段的入口绝对地址。

image-20260419145326516

对抗

不要用Memory.patchCode 改 libgodot 字节

不然可以让校验函数立刻 return success,不计算

1
2
Interceptor.replace(godotBase.add(0x10B9E4C),
new NativeCallback(() => 0, 'int', []));

或者hook process_vm_readv,从干净副本读 ,启动时 dump libsec2026 .text 完整副本到自己 buffer,sub_10B9E4C 调 process_vm_readv 时把请求重定向到副本

下面这份代码是hookpart3时用的发现被kill使用了对应的反反调试,监控到触发了反调试,这里使用的是让校验函数立刻 return success这种方法,成功解决了这个反调试

image-20260419144045853

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
'use strict';

const TARGET = 'libsec2026.so';
const GODOT = 'libgodot_android.so';
const BLOCKED_OFFSETS = new Set([0x9c654, 0x9cdc4]); // NOT blocking 0x9b7d8

let secBase = null;
let godotBase = null;
let installed = false;

function log(msg) {
console.log('[p3-ult] ' + msg);
}

// ==== Exception handler (catch any crashes cleanly) =======================
Process.setExceptionHandler(function (details) {
log('EXCEPTION type=' + details.type + ' addr=' + details.address);
try {
const m = Process.findModuleByAddress(details.context.pc);
if (m) log(' PC in ' + m.name + '+0x' + details.context.pc.sub(m.base).toString(16));
} catch (e) {}
return false;
});

// ==== ptrace → 0 (cheap neutralize via retval.replace, no code patch) =====
const ptrace = Module.findExportByName('libc.so', 'ptrace');
if (ptrace) {
Interceptor.attach(ptrace, { onLeave(r) { r.replace(0); } });
}

// ==== X8 thunk generator (codex technique) =================================
function makeX8Thunk(target) {
const code = Memory.alloc(Process.pageSize);
Memory.patchCode(code, 64, function (buf) {
const w = new Arm64Writer(buf, { pc: code });
w.putStpRegRegRegOffset('x29', 'x30', 'sp', -16, 'pre-adjust');
w.putMovRegReg('x29', 'sp');
w.putMovRegReg('x8', 'x1');
w.putLdrRegAddress('x16', target);
w.putBlrReg('x16');
w.putLdpRegRegRegOffset('x29', 'x30', 'sp', 16, 'post-adjust');
w.putRet();
w.flush();
});
return new NativeFunction(code, 'void', ['pointer', 'pointer']);
}

function tryReadAscii(p, maxLen) {
try {
const s = ptr(p).readUtf8String(maxLen || 128);
if (s && /^[\x20-\x7e]+$/.test(s)) return s;
} catch (e) {}
return null;
}

function dumpInterestingAscii(base, size) {
const hits = [];
try {
for (let off = 0; off < size; off += Process.pointerSize) {
try {
const p = base.add(off).readPointer();
const s = tryReadAscii(p, 128);
if (s) hits.push(`${off.toString(16)}:${s}`);
} catch (e) {}
}
} catch (e) {}
return hits;
}

// ==== pthread_create block (only 9C654 / 9CDC4) ============================
const pthreadCreate = Module.findExportByName('libc.so', 'pthread_create');
if (pthreadCreate) {
Interceptor.attach(pthreadCreate, {
onEnter(args) {
this.block = false;
const mod = Process.findModuleByAddress(args[2]);
if (!mod || mod.name !== TARGET) return;
const off = ptr(args[2]).sub(mod.base).toUInt32();
if (BLOCKED_OFFSETS.has(off)) {
this.block = true;
this.threadPtr = args[0];
try { Memory.writePointer(this.threadPtr, NULL); } catch (e) {}
log('pthread block +0x' + off.toString(16));
} else if (off === 0x9b7d8) {
log('pthread ALLOW sub_9B7D8');
}
},
onLeave(retval) {
if (this.block) retval.replace(0);
}
});
}

// ==== Build sanitized /proc/self/maps ======================================
function preserveReplace(text, needle, replacement) {
const rep = replacement.length >= needle.length
? replacement.slice(0, needle.length)
: replacement + '_'.repeat(needle.length - replacement.length);
return text.split(needle).join(rep);
}

function buildSanitizedMaps() {
const openFn = new NativeFunction(Module.findExportByName('libc.so', 'open'), 'int', ['pointer', 'int']);
const readFn = new NativeFunction(Module.findExportByName('libc.so', 'read'), 'int', ['int', 'pointer', 'int']);
const closeFn = new NativeFunction(Module.findExportByName('libc.so', 'close'), 'int', ['int']);
const path = Memory.allocUtf8String('/proc/self/maps');
const buf = Memory.alloc(0x1000);
const fd = openFn(path, 0);
if (fd < 0) throw new Error('open(maps) failed');
let chunks = [];
while (true) {
const n = readFn(fd, buf, 0x1000);
if (n <= 0) break;
chunks.push(Memory.readUtf8String(buf, n));
if (chunks.join('').length > 2 * 1024 * 1024) break;
}
closeFn(fd);
let text = chunks.join('');
text = preserveReplace(text, 'frida-agent-64.so', 'libandroidfw.so_');
text = preserveReplace(text, 'vendor_socket_hook_prop', 'vendor_socket_safe_prop');
text = preserveReplace(text, 'hook', 'safe');
text = preserveReplace(text, 'frida', 'media');
text = preserveReplace(text, 'gum-js-loop', 'pool-android');
text = preserveReplace(text, 'gmain', 'jmain');
text = preserveReplace(text, 'linjector', 'loopclassd');
return text;
}

// ==== install libsec2026 hooks =============================================
function installSec() {
if (installed) return;
secBase = Module.findBaseAddress(TARGET);
godotBase = Module.findBaseAddress(GODOT);
if (!secBase || !godotBase) return;
installed = true;
log('sec=' + secBase + ' godot=' + godotBase);

// === 1. Suppress 3 death paths ===
Interceptor.replace(secBase.add(0x9a1dc), new NativeCallback(function () {
log('suppressed sub_9A1DC exit_group');
return 0;
}, 'int', []));

Interceptor.replace(secBase.add(0x96bb8), new NativeCallback(function () {
log('suppressed sub_96BB8 exit_group');
return 0;
}, 'int', []));

let logged9eb5c = false;
Interceptor.replace(secBase.add(0x9eb5c), new NativeCallback(function (nr, a0, a1) {
if (!logged9eb5c) {
logged9eb5c = true;
log('patched sub_9EB5C syscall wrapper nr=' + nr);
}
return 0;
}, 'int64', ['int64', 'pointer', 'pointer']));

// === 2. Sanitize /proc/self/maps ===
let mapsStr;
try {
mapsStr = buildSanitizedMaps();
} catch (e) {
log('build sanitized maps failed: ' + e);
mapsStr = '';
}
const mapsPtr = Memory.allocUtf8String(mapsStr);
log('sanitized maps len=' + mapsStr.length);

let mapsFd = -1;
let mapsOff = 0;

// SYS4 (sub_9AD3C) — 4-arg inline syscall
Interceptor.attach(secBase.add(0x9ad3c), {
onEnter(args) {
this.isMaps = false;
if (args[0].toInt32() !== 56) return; // openat
try {
if (Memory.readCString(args[2]) === '/proc/self/maps') this.isMaps = true;
} catch (e) {}
},
onLeave(retval) {
if (this.isMaps) {
mapsFd = retval.toInt32();
mapsOff = 0;
log('tracking /proc/self/maps fd=' + mapsFd);
}
}
});

// SYS3 (sub_9AD20) — 3-arg inline syscall
Interceptor.attach(secBase.add(0x9ad20), {
onEnter(args) {
if (args[0].toInt32() !== 63) return; // read
if (args[1].toInt32() !== mapsFd) return;
const buf = args[2];
const count = args[3].toInt32();
let outCount = 0;
for (let i = 0; i < count; i++) {
if (mapsOff + i >= mapsStr.length) break;
const ch = Memory.readU8(mapsPtr.add(mapsOff + i));
if (ch === 0) break;
Memory.writeU8(buf.add(i), ch);
outCount++;
}
mapsOff += outCount;
this.context.x0 = ptr(outCount);
this.context.pc = this.context.lr;
}
});

// === 3. Refresh qword_1834B8 baseline (Tick 10s timer bypass) ===
const clock_gettime_addr = Module.findExportByName('libc.so', 'clock_gettime');
const cgt = new NativeFunction(clock_gettime_addr, 'int', ['int', 'pointer']);
const ts = Memory.alloc(16);
function currentMicros() {
cgt(1, ts);
const sec = ts.readU64().toNumber();
const nsec = ts.add(8).readU64().toNumber();
return sec * 1000000 + Math.floor(nsec / 1000);
}
const q1834b8 = secBase.add(0x1834B8);
setInterval(() => {
try { q1834b8.writeU64(currentMicros()); } catch (e) {}
}, 3000);

// === 4. Observe collided_with emission ===
const getClassThunk = makeX8Thunk(secBase.add(0x100ABC));
const getNameThunk = makeX8Thunk(secBase.add(0xE6274));
const getPathThunk = makeX8Thunk(secBase.add(0xE7750));
const toStringThunk = makeX8Thunk(secBase.add(0x1015D4));
const getParentFn = new NativeFunction(secBase.add(0xE6CF0), 'pointer', ['pointer']);

function dumpObjectInfo(obj, tag) {
const wrapper = Memory.alloc(0x30);
Memory.writeByteArray(wrapper, new Uint8Array(0x30));
wrapper.add(0x10).writePointer(obj);
try {
const classBuf = Memory.alloc(0x100);
Memory.writeByteArray(classBuf, new Uint8Array(0x100));
getClassThunk(wrapper, classBuf);
const classHits = dumpInterestingAscii(classBuf, 0x80);
if (classHits.length > 0) log(' ' + tag + ' class: ' + classHits.join(' | '));
} catch (e) {}
try {
const nameBuf = Memory.alloc(0x100);
Memory.writeByteArray(nameBuf, new Uint8Array(0x100));
getNameThunk(wrapper, nameBuf);
const nameHits = dumpInterestingAscii(nameBuf, 0x80);
if (nameHits.length > 0) log(' ' + tag + ' name: ' + nameHits.join(' | '));
} catch (e) {}
try {
const pathBuf = Memory.alloc(0x100);
Memory.writeByteArray(pathBuf, new Uint8Array(0x100));
getPathThunk(wrapper, pathBuf);
const strBuf = Memory.alloc(0x100);
Memory.writeByteArray(strBuf, new Uint8Array(0x100));
toStringThunk(pathBuf, strBuf);
const strHits = dumpInterestingAscii(strBuf, 0x80);
if (strHits.length > 0) log(' ' + tag + ' path: ' + strHits.join(' | '));
} catch (e) {}
try {
const parent = getParentFn(wrapper);
if (!parent.isNull()) {
const pwrap = Memory.alloc(0x30);
Memory.writeByteArray(pwrap, new Uint8Array(0x30));
pwrap.add(0x10).writePointer(parent);
const nBuf = Memory.alloc(0x100);
Memory.writeByteArray(nBuf, new Uint8Array(0x100));
getNameThunk(pwrap, nBuf);
const nHits = dumpInterestingAscii(nBuf, 0x80);
if (nHits.length > 0) log(' ' + tag + ' parent: ' + nHits.join(' | '));
}
} catch (e) {}
}

const collidedEmitter = godotBase.add(0x25F6718);
let collidedCount = 0;
Interceptor.attach(collidedEmitter, {
onEnter(args) {
collidedCount++;
log('★★★ collided_with EMIT #' + collidedCount + ' a1=' + args[0] + ' a2=' + args[1]);
dumpObjectInfo(args[0], 'emitter');
// args[2] should be the arg array — dump it
if (!args[2].isNull()) {
try {
for (let i = 0; i < 4; i++) {
const p = args[2].add(i * Process.pointerSize).readPointer();
const s = tryReadAscii(p, 256);
if (s) log(' arg[' + i + ']=' + s);
}
} catch (e) {}
}
}
});

// Generic emit (libgodot+0x3C58F78) filtered by caller
const genericEmit = godotBase.add(0x3C58F78);
Interceptor.attach(genericEmit, {
onEnter(args) {
const ret = this.returnAddress;
if (!ret.equals(godotBase.add(0x25F6B9C))) return;
log('★ generic emit from collided path obj=' + args[0] + ' signame=' + args[1] + ' argc=' + args[3].toInt32());
if (!args[2].isNull()) {
try {
for (let i = 0; i < 6; i++) {
const p = args[2].add(i * Process.pointerSize).readPointer();
const s = tryReadAscii(p, 256);
if (s) log(' argv[' + i + ']=' + s);
}
} catch (e) {}
}
}
});

log('all hooks ready, waiting for collided_with emission');
}

// ==== hook dlopen for libsec2026 ==========================================
for (const name of ['android_dlopen_ext', 'dlopen']) {
const addr = Module.findExportByName(null, name);
if (!addr) continue;
Interceptor.attach(addr, {
onEnter(args) { this.path = args[0].isNull() ? '' : Memory.readCString(args[0]); },
onLeave() {
if (this.path.indexOf(TARGET) !== -1) {
log('dlopen ' + this.path);
setTimeout(installSec, 100);
}
}
});
}
setInterval(installSec, 500);

log('script loaded');

// ============================================================================
// 反调试 6 中和 — pre-init qword_4011848 + noop sub_10B9E4C
// + PART3 区 hook
// ============================================================================
(function () {
let p3Installed = false;
function tryInstall() {
if (p3Installed) return;
const sec = Module.findBaseAddress('libsec2026.so');
const godot = Module.findBaseAddress('libgodot_android.so');
if (!sec || !godot) return;
p3Installed = true;

// [STEP 1] pre-init qword_4011848 = libsec_base
try {
const Q4011848 = godot.add(0x4011848);
const cur = Q4011848.readU64();
if (cur.toString() === '0') {
Q4011848.writeU64(sec); // 直接用 libsec base
log('[A/D6] pre-init qword_4011848 = ' + sec);
} else {
log('[A/D6] qword_4011848 already set: 0x' + cur.toString(16));
}
} catch (e) {
log('[A/D6] pre-init failed: ' + e.message);
}

// [STEP 2] noop sub_10B9E4C — 校验函数立刻 return
try {
Interceptor.replace(godot.add(0x10B9E4C),
new NativeCallback(() => 0, 'int', []));
log('[A/D6] sub_10B9E4C neutralized (noop)');
} catch (e) {
log('[A/D6] noop failed: ' + e.message);
}

// [STEP 3] hook PART3 区
log('[P3] adding PART3-area hooks');

Interceptor.attach(sec.add(0xA9A7C), {
onEnter(args) {
log('[P3] >>> sub_A9A7C ENTER X0=' + args[0]);
try {
const ib = args[0].readByteArray(32);
log('[P3] input: ' + Array.from(new Uint8Array(ib)).map(b=>b.toString(16).padStart(2,'0')).join(' '));
} catch(e) {}
},
onLeave(r) {
try {
const bc = sec.add(0x1836C0).readByteArray(32);
log('[P3] bc: ' + Array.from(new Uint8Array(bc)).map(b=>b.toString(16).padStart(2,'0')).join(' '));
const out = sec.add(0x1836E0).readByteArray(64);
log('[P3] out: ' + Array.from(new Uint8Array(out)).map(b=>b.toString(16).padStart(2,'0')).join(' '));
} catch(e) {}
}
});

Interceptor.attach(sec.add(0xA6BEC), {
onEnter(args) {
log('[P3] >>> VMEntry flag=' + args[3].toInt32());
this.outPtr = args[4];
},
onLeave(r) {
try {
if (this.outPtr && !this.outPtr.isNull()) {
const ob = this.outPtr.readByteArray(32);
log('[P3] output: ' + Array.from(new Uint8Array(ob)).map(b=>b.toString(16).padStart(2,'0')).join(' '));
}
} catch(e) {}
}
});

Interceptor.attach(sec.add(0xAA758), {
onEnter(args) {
log('[P3] >>> sub_AA758 flag=' + args[3].toInt32());
this.outPtr = args[4];
},
onLeave(r) {
try {
if (this.outPtr && !this.outPtr.isNull()) {
const ob = this.outPtr.readByteArray(32);
log('[P3] output: ' + Array.from(new Uint8Array(ob)).map(b=>b.toString(16).padStart(2,'0')).join(' '));
}
} catch(e) {}
}
});

log('[P3] all 3 PART3 hooks installed');
}
setInterval(tryInstall, 500);
})();

[其它] 内联SVC syscall反 hook 加固

sub_9AD3C是个 7 指令通用 SVC wrapper:

image-20260419052936475

image-20260419052953966

类似的还有sub_9CB30。libsec2026 内部所有敏感 syscall(ptrace, mprotect, openat, prctl等)都走这条,不经 libc。

  • Interceptor.attach(Module.findExportByName(“libc.so”, “ptrace”), …) 拦不到
  • 同理 mprotect、openat、fork ,全拦不到
  • 想拦只能用 Frida Stalker 在指令级监控 SVC 0 指令,或者其它比如ebpf

反调试代码汇总

使用方式(不起part3)

1
frida -U -f com.tencent.ACE.gamesec2026.final -l frida_bypass_complete.js -o /tmp/bypass.log

可以加part3的trace不崩

image-20260419145725579

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
'use strict';

const TARGET = 'libsec2026.so';
const GODOT = 'libgodot_android.so';
const BLOCKED_OFFSETS = new Set([0x9c654, 0x9cdc4]); // NOT blocking 0x9b7d8

let secBase = null;
let godotBase = null;
let installed = false;

function log(msg) {
console.log('[p3-ult] ' + msg);
}

// ==== Exception handler (catch any crashes cleanly) =======================
Process.setExceptionHandler(function (details) {
log('EXCEPTION type=' + details.type + ' addr=' + details.address);
try {
const m = Process.findModuleByAddress(details.context.pc);
if (m) log(' PC in ' + m.name + '+0x' + details.context.pc.sub(m.base).toString(16));
} catch (e) {}
return false;
});

// ==== ptrace → 0 (cheap neutralize via retval.replace, no code patch) =====
const ptrace = Module.findExportByName('libc.so', 'ptrace');
if (ptrace) {
Interceptor.attach(ptrace, { onLeave(r) { r.replace(0); } });
}

// ==== X8 thunk generator (codex technique) =================================
function makeX8Thunk(target) {
const code = Memory.alloc(Process.pageSize);
Memory.patchCode(code, 64, function (buf) {
const w = new Arm64Writer(buf, { pc: code });
w.putStpRegRegRegOffset('x29', 'x30', 'sp', -16, 'pre-adjust');
w.putMovRegReg('x29', 'sp');
w.putMovRegReg('x8', 'x1');
w.putLdrRegAddress('x16', target);
w.putBlrReg('x16');
w.putLdpRegRegRegOffset('x29', 'x30', 'sp', 16, 'post-adjust');
w.putRet();
w.flush();
});
return new NativeFunction(code, 'void', ['pointer', 'pointer']);
}

function tryReadAscii(p, maxLen) {
try {
const s = ptr(p).readUtf8String(maxLen || 128);
if (s && /^[\x20-\x7e]+$/.test(s)) return s;
} catch (e) {}
return null;
}

function dumpInterestingAscii(base, size) {
const hits = [];
try {
for (let off = 0; off < size; off += Process.pointerSize) {
try {
const p = base.add(off).readPointer();
const s = tryReadAscii(p, 128);
if (s) hits.push(`${off.toString(16)}:${s}`);
} catch (e) {}
}
} catch (e) {}
return hits;
}

// ==== pthread_create block (only 9C654 / 9CDC4) ============================
const pthreadCreate = Module.findExportByName('libc.so', 'pthread_create');
if (pthreadCreate) {
Interceptor.attach(pthreadCreate, {
onEnter(args) {
this.block = false;
const mod = Process.findModuleByAddress(args[2]);
if (!mod || mod.name !== TARGET) return;
const off = ptr(args[2]).sub(mod.base).toUInt32();
if (BLOCKED_OFFSETS.has(off)) {
this.block = true;
this.threadPtr = args[0];
try { Memory.writePointer(this.threadPtr, NULL); } catch (e) {}
log('pthread block +0x' + off.toString(16));
} else if (off === 0x9b7d8) {
log('pthread ALLOW sub_9B7D8');
}
},
onLeave(retval) {
if (this.block) retval.replace(0);
}
});
}

// ==== Build sanitized /proc/self/maps ======================================
function preserveReplace(text, needle, replacement) {
const rep = replacement.length >= needle.length
? replacement.slice(0, needle.length)
: replacement + '_'.repeat(needle.length - replacement.length);
return text.split(needle).join(rep);
}

function buildSanitizedMaps() {
const openFn = new NativeFunction(Module.findExportByName('libc.so', 'open'), 'int', ['pointer', 'int']);
const readFn = new NativeFunction(Module.findExportByName('libc.so', 'read'), 'int', ['int', 'pointer', 'int']);
const closeFn = new NativeFunction(Module.findExportByName('libc.so', 'close'), 'int', ['int']);
const path = Memory.allocUtf8String('/proc/self/maps');
const buf = Memory.alloc(0x1000);
const fd = openFn(path, 0);
if (fd < 0) throw new Error('open(maps) failed');
let chunks = [];
while (true) {
const n = readFn(fd, buf, 0x1000);
if (n <= 0) break;
chunks.push(Memory.readUtf8String(buf, n));
if (chunks.join('').length > 2 * 1024 * 1024) break;
}
closeFn(fd);
let text = chunks.join('');
text = preserveReplace(text, 'frida-agent-64.so', 'libandroidfw.so_');
text = preserveReplace(text, 'vendor_socket_hook_prop', 'vendor_socket_safe_prop');
text = preserveReplace(text, 'hook', 'safe');
text = preserveReplace(text, 'frida', 'media');
text = preserveReplace(text, 'gum-js-loop', 'pool-android');
text = preserveReplace(text, 'gmain', 'jmain');
text = preserveReplace(text, 'linjector', 'loopclassd');
return text;
}

// ==== install libsec2026 hooks =============================================
function installSec() {
if (installed) return;
secBase = Module.findBaseAddress(TARGET);
godotBase = Module.findBaseAddress(GODOT);
if (!secBase || !godotBase) return;
installed = true;
log('sec=' + secBase + ' godot=' + godotBase);

// === 1. Suppress 3 death paths ===
Interceptor.replace(secBase.add(0x9a1dc), new NativeCallback(function () {
log('suppressed sub_9A1DC exit_group');
return 0;
}, 'int', []));

Interceptor.replace(secBase.add(0x96bb8), new NativeCallback(function () {
log('suppressed sub_96BB8 exit_group');
return 0;
}, 'int', []));

let logged9eb5c = false;
Interceptor.replace(secBase.add(0x9eb5c), new NativeCallback(function (nr, a0, a1) {
if (!logged9eb5c) {
logged9eb5c = true;
log('patched sub_9EB5C syscall wrapper nr=' + nr);
}
return 0;
}, 'int64', ['int64', 'pointer', 'pointer']));

// === 2. Sanitize /proc/self/maps ===
let mapsStr;
try {
mapsStr = buildSanitizedMaps();
} catch (e) {
log('build sanitized maps failed: ' + e);
mapsStr = '';
}
const mapsPtr = Memory.allocUtf8String(mapsStr);
log('sanitized maps len=' + mapsStr.length);

let mapsFd = -1;
let mapsOff = 0;

// SYS4 (sub_9AD3C) — 4-arg inline syscall
Interceptor.attach(secBase.add(0x9ad3c), {
onEnter(args) {
this.isMaps = false;
if (args[0].toInt32() !== 56) return; // openat
try {
if (Memory.readCString(args[2]) === '/proc/self/maps') this.isMaps = true;
} catch (e) {}
},
onLeave(retval) {
if (this.isMaps) {
mapsFd = retval.toInt32();
mapsOff = 0;
log('tracking /proc/self/maps fd=' + mapsFd);
}
}
});

// SYS3 (sub_9AD20) — 3-arg inline syscall
Interceptor.attach(secBase.add(0x9ad20), {
onEnter(args) {
if (args[0].toInt32() !== 63) return; // read
if (args[1].toInt32() !== mapsFd) return;
const buf = args[2];
const count = args[3].toInt32();
let outCount = 0;
for (let i = 0; i < count; i++) {
if (mapsOff + i >= mapsStr.length) break;
const ch = Memory.readU8(mapsPtr.add(mapsOff + i));
if (ch === 0) break;
Memory.writeU8(buf.add(i), ch);
outCount++;
}
mapsOff += outCount;
this.context.x0 = ptr(outCount);
this.context.pc = this.context.lr;
}
});

// === 3. Refresh qword_1834B8 baseline (Tick 10s timer bypass) ===
const clock_gettime_addr = Module.findExportByName('libc.so', 'clock_gettime');
const cgt = new NativeFunction(clock_gettime_addr, 'int', ['int', 'pointer']);
const ts = Memory.alloc(16);
function currentMicros() {
cgt(1, ts);
const sec = ts.readU64().toNumber();
const nsec = ts.add(8).readU64().toNumber();
return sec * 1000000 + Math.floor(nsec / 1000);
}
const q1834b8 = secBase.add(0x1834B8);
setInterval(() => {
try { q1834b8.writeU64(currentMicros()); } catch (e) {}
}, 3000);

// === 4. Observe collided_with emission ===
const getClassThunk = makeX8Thunk(secBase.add(0x100ABC));
const getNameThunk = makeX8Thunk(secBase.add(0xE6274));
const getPathThunk = makeX8Thunk(secBase.add(0xE7750));
const toStringThunk = makeX8Thunk(secBase.add(0x1015D4));
const getParentFn = new NativeFunction(secBase.add(0xE6CF0), 'pointer', ['pointer']);

function dumpObjectInfo(obj, tag) {
const wrapper = Memory.alloc(0x30);
Memory.writeByteArray(wrapper, new Uint8Array(0x30));
wrapper.add(0x10).writePointer(obj);
try {
const classBuf = Memory.alloc(0x100);
Memory.writeByteArray(classBuf, new Uint8Array(0x100));
getClassThunk(wrapper, classBuf);
const classHits = dumpInterestingAscii(classBuf, 0x80);
if (classHits.length > 0) log(' ' + tag + ' class: ' + classHits.join(' | '));
} catch (e) {}
try {
const nameBuf = Memory.alloc(0x100);
Memory.writeByteArray(nameBuf, new Uint8Array(0x100));
getNameThunk(wrapper, nameBuf);
const nameHits = dumpInterestingAscii(nameBuf, 0x80);
if (nameHits.length > 0) log(' ' + tag + ' name: ' + nameHits.join(' | '));
} catch (e) {}
try {
const pathBuf = Memory.alloc(0x100);
Memory.writeByteArray(pathBuf, new Uint8Array(0x100));
getPathThunk(wrapper, pathBuf);
const strBuf = Memory.alloc(0x100);
Memory.writeByteArray(strBuf, new Uint8Array(0x100));
toStringThunk(pathBuf, strBuf);
const strHits = dumpInterestingAscii(strBuf, 0x80);
if (strHits.length > 0) log(' ' + tag + ' path: ' + strHits.join(' | '));
} catch (e) {}
try {
const parent = getParentFn(wrapper);
if (!parent.isNull()) {
const pwrap = Memory.alloc(0x30);
Memory.writeByteArray(pwrap, new Uint8Array(0x30));
pwrap.add(0x10).writePointer(parent);
const nBuf = Memory.alloc(0x100);
Memory.writeByteArray(nBuf, new Uint8Array(0x100));
getNameThunk(pwrap, nBuf);
const nHits = dumpInterestingAscii(nBuf, 0x80);
if (nHits.length > 0) log(' ' + tag + ' parent: ' + nHits.join(' | '));
}
} catch (e) {}
}

const collidedEmitter = godotBase.add(0x25F6718);
let collidedCount = 0;
Interceptor.attach(collidedEmitter, {
onEnter(args) {
collidedCount++;
log('★★★ collided_with EMIT #' + collidedCount + ' a1=' + args[0] + ' a2=' + args[1]);
dumpObjectInfo(args[0], 'emitter');
// args[2] should be the arg array — dump it
if (!args[2].isNull()) {
try {
for (let i = 0; i < 4; i++) {
const p = args[2].add(i * Process.pointerSize).readPointer();
const s = tryReadAscii(p, 256);
if (s) log(' arg[' + i + ']=' + s);
}
} catch (e) {}
}
}
});

// Generic emit (libgodot+0x3C58F78) filtered by caller
const genericEmit = godotBase.add(0x3C58F78);
Interceptor.attach(genericEmit, {
onEnter(args) {
const ret = this.returnAddress;
if (!ret.equals(godotBase.add(0x25F6B9C))) return;
log('★ generic emit from collided path obj=' + args[0] + ' signame=' + args[1] + ' argc=' + args[3].toInt32());
if (!args[2].isNull()) {
try {
for (let i = 0; i < 6; i++) {
const p = args[2].add(i * Process.pointerSize).readPointer();
const s = tryReadAscii(p, 256);
if (s) log(' argv[' + i + ']=' + s);
}
} catch (e) {}
}
}
});

log('all hooks ready, waiting for collided_with emission');
}

// ==== hook dlopen for libsec2026 ==========================================
for (const name of ['android_dlopen_ext', 'dlopen']) {
const addr = Module.findExportByName(null, name);
if (!addr) continue;
Interceptor.attach(addr, {
onEnter(args) { this.path = args[0].isNull() ? '' : Memory.readCString(args[0]); },
onLeave() {
if (this.path.indexOf(TARGET) !== -1) {
log('dlopen ' + this.path);
setTimeout(installSec, 100);
}
}
});
}
setInterval(installSec, 500);

log('script loaded');

// ============================================================================
// 反调试 6 中和 — pre-init qword_4011848 + noop sub_10B9E4C
// + PART3 区 hook
// ============================================================================
(function () {
let p3Installed = false;
function tryInstall() {
if (p3Installed) return;
const sec = Module.findBaseAddress('libsec2026.so');
const godot = Module.findBaseAddress('libgodot_android.so');
if (!sec || !godot) return;
p3Installed = true;

// [STEP 1] pre-init qword_4011848 = libsec_base
try {
const Q4011848 = godot.add(0x4011848);
const cur = Q4011848.readU64();
if (cur.toString() === '0') {
Q4011848.writeU64(sec); // 直接用 libsec base
log('[A/D6] pre-init qword_4011848 = ' + sec);
} else {
log('[A/D6] qword_4011848 already set: 0x' + cur.toString(16));
}
} catch (e) {
log('[A/D6] pre-init failed: ' + e.message);
}

// [STEP 2] noop sub_10B9E4C — 校验函数立刻 return
try {
Interceptor.replace(godot.add(0x10B9E4C),
new NativeCallback(() => 0, 'int', []));
log('[A/D6] sub_10B9E4C neutralized (noop)');
} catch (e) {
log('[A/D6] noop failed: ' + e.message);
}

// [STEP 3] hook PART3 区
log('[P3] adding PART3-area hooks');

Interceptor.attach(sec.add(0xA9A7C), {
onEnter(args) {
log('[P3] >>> sub_A9A7C ENTER X0=' + args[0]);
try {
const ib = args[0].readByteArray(32);
log('[P3] input: ' + Array.from(new Uint8Array(ib)).map(b=>b.toString(16).padStart(2,'0')).join(' '));
} catch(e) {}
},
onLeave(r) {
try {
const bc = sec.add(0x1836C0).readByteArray(32);
log('[P3] bc: ' + Array.from(new Uint8Array(bc)).map(b=>b.toString(16).padStart(2,'0')).join(' '));
const out = sec.add(0x1836E0).readByteArray(64);
log('[P3] out: ' + Array.from(new Uint8Array(out)).map(b=>b.toString(16).padStart(2,'0')).join(' '));
} catch(e) {}
}
});

Interceptor.attach(sec.add(0xA6BEC), {
onEnter(args) {
log('[P3] >>> VMEntry flag=' + args[3].toInt32());
this.outPtr = args[4];
},
onLeave(r) {
try {
if (this.outPtr && !this.outPtr.isNull()) {
const ob = this.outPtr.readByteArray(32);
log('[P3] output: ' + Array.from(new Uint8Array(ob)).map(b=>b.toString(16).padStart(2,'0')).join(' '));
}
} catch(e) {}
}
});

Interceptor.attach(sec.add(0xAA758), {
onEnter(args) {
log('[P3] >>> sub_AA758 flag=' + args[3].toInt32());
this.outPtr = args[4];
},
onLeave(r) {
try {
if (this.outPtr && !this.outPtr.isNull()) {
const ob = this.outPtr.readByteArray(32);
log('[P3] output: ' + Array.from(new Uint8Array(ob)).map(b=>b.toString(16).padStart(2,'0')).join(' '));
}
} catch(e) {}
}
});

log('[P3] all 3 PART3 hooks installed');
}
setInterval(tryInstall, 500);
})();

解密场景文件

PackedScene 内部是 Godot 自己的二进制格式:

  • nodes 数组:节点表(每个节点有 name、parent_idx、type、property 列表)
  • variants 数组:所有节点 property 引用的实际值(Transform3D、StringName、Resource 引用等)

尝试在网上找一些开源工具直接解,但是没找到,解析后能直接拿到每个 Node3D 子类的 transform.origin。

阅读godot源码

godot/core/io/resource_format_binary.cpp at master · godotengine/godot

头格式,编码布局

godot/scene/resources/packed_scene.cpp at master · godotengine/godot

RSRC 头解析

文件起始固定 RSRC magic 后是定长头:

1
2
3
4
5
6
7
8
9
10
big_endian (1 byte)
use_real64 (1 byte)
ver_major (4)
ver_minor (4)
ver_format (4)
type (unicode_string,先读 u32 长度再读字节)
importmd_ofs (8)
flags (4)
uid (8)
+ 11 个 RESERVED_FIELDS 占位 (44 bytes)

flags 里有几个位特别要注意,否则后面尺寸算错崩盘:

  • FORMAT_FLAG_NAMED_SCENE_IDS = 1
  • FORMAT_FLAG_UIDS = 2
  • FORMAT_FLAG_REAL_T_IS_DOUBLE = 4 — 决定 Vector3和Transform3D 是 float 还是 double
  • FORMAT_FLAG_HAS_SCRIPT_CLASS = 8

Variant 类型 ID 常量,parse_variant() 用一个枚举 ID 分发,需要的几个:

1
2
3
4
5
6
VARIANT_NODE_PATH         = 22
VARIANT_TRANSFORM3D = 17
VARIANT_DICTIONARY = 26
VARIANT_ARRAY = 30
VARIANT_PACKED_INT32_ARRAY = 32
VARIANT_STRING_NAME = 44

按上面 schema 全部解析完,对每个 Node3D 子类节点:

1
2
3
4
5
6
node = nodes_table[i]
node_name = names[node["name_index"] & NAME_MASK]
for prop_name_idx, prop_value_idx in node["properties"]:
if names[prop_name_idx] == "transform":
transform = variants[prop_value_idx] # 已经是 Transform3D 对象
origin = transform.origin # Vector3

跑一遍 town_scene 就能把 4 个 Trigger 和 InstancePos 的世界坐标拉出来

需要先解密出来场景文件

1
python3 ./decrypt_godot45.py  ./final/assets/.godot/exported/133200997/export-7a91fd739b7abc563d418228362712ea-town_scene.scn  ./town_scene.dec.scn

然后

1
python3 parse_packed_scene.py town_scene.dec.scn

image-20260418163122175

完整代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
#!/usr/bin/env python3
from __future__ import annotations

import argparse
import struct
from dataclasses import dataclass
from pathlib import Path
from typing import Any


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_RID = 23
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_INT64 = 40
VARIANT_DOUBLE = 41
VARIANT_STRING_NAME = 44
VARIANT_VECTOR2I = 45
VARIANT_RECT2I = 46
VARIANT_VECTOR3I = 47
VARIANT_PACKED_INT64_ARRAY = 48
VARIANT_PACKED_FLOAT64_ARRAY = 49
VARIANT_VECTOR4 = 50
VARIANT_VECTOR4I = 51
VARIANT_PROJECTION = 52
VARIANT_PACKED_VECTOR4_ARRAY = 53

OBJECT_EMPTY = 0
OBJECT_EXTERNAL_RESOURCE = 1
OBJECT_INTERNAL_RESOURCE = 2
OBJECT_EXTERNAL_RESOURCE_INDEX = 3

NAME_INDEX_BITS = 18
NAME_MASK = (1 << NAME_INDEX_BITS) - 1


@dataclass
class ExtResource:
typ: str
path: str
uid: int


@dataclass
class IntResource:
path: str
offset: int


class Reader:
def __init__(self, data: bytes):
self.data = data
self.off = 0
self.real_is_double = False
self.ver_format = 0
self.string_map: list[str] = []
self.ext_resources: list[ExtResource] = []
self.int_resources: list[IntResource] = []

def seek(self, off: int) -> None:
self.off = off

def u8(self) -> int:
v = self.data[self.off]
self.off += 1
return v

def u16(self) -> int:
v = struct.unpack_from("<H", self.data, self.off)[0]
self.off += 2
return v

def u32(self) -> int:
v = struct.unpack_from("<I", self.data, self.off)[0]
self.off += 4
return v

def u64(self) -> int:
v = struct.unpack_from("<Q", self.data, self.off)[0]
self.off += 8
return v

def i32(self) -> int:
v = struct.unpack_from("<i", self.data, self.off)[0]
self.off += 4
return v

def i64(self) -> int:
v = struct.unpack_from("<q", self.data, self.off)[0]
self.off += 8
return v

def f32(self) -> float:
v = struct.unpack_from("<f", self.data, self.off)[0]
self.off += 4
return v

def f64(self) -> float:
v = struct.unpack_from("<d", self.data, self.off)[0]
self.off += 8
return v

def raw(self, n: int) -> bytes:
b = self.data[self.off:self.off + n]
self.off += n
return b

def get_unicode_string(self) -> str:
n = self.u32()
if n == 0:
return ""
b = self.raw(n)
return b.rstrip(b"\x00").decode("utf-8", errors="replace")

def get_string(self) -> str:
idx = self.u32()
if idx & 0x80000000:
n = idx & 0x7FFFFFFF
if n == 0:
return ""
return self.raw(n).decode("utf-8", errors="replace")
return self.string_map[idx]

def advance_padding(self, n: int) -> None:
extra = 4 - (n % 4)
if extra < 4:
self.off += extra

def parse_variant(self) -> Any:
vt = self.u32()
plausible_next = {
VARIANT_NIL,
VARIANT_FLOAT,
VARIANT_STRING,
VARIANT_OBJECT,
VARIANT_DICTIONARY,
VARIANT_ARRAY,
VARIANT_PACKED_INT32_ARRAY,
VARIANT_PACKED_STRING_ARRAY,
VARIANT_TRANSFORM3D,
VARIANT_NODE_PATH,
VARIANT_STRING_NAME,
10,
40,
41,
}
if vt == VARIANT_NIL:
return None
if vt == VARIANT_BOOL:
return bool(self.u32())
if vt == VARIANT_INT:
return self.i32()
if vt == VARIANT_INT64:
# Custom build: tag 40 behaves like a 32-bit bool in scene data.
return bool(self.u32())
if vt == VARIANT_FLOAT:
# This sample appears to reuse tag 4 for Vector2 in several UI properties.
x = self.f32() if not self.real_is_double else self.f64()
if self.off + 8 <= len(self.data):
nxt4 = struct.unpack_from("<I", self.data, self.off)[0]
nxt8 = struct.unpack_from("<I", self.data, self.off + 4)[0]
if nxt4 not in plausible_next and nxt8 in plausible_next:
y = self.f32() if not self.real_is_double else self.f64()
return {"Vector2": [x, y]}
return x
if vt == VARIANT_DOUBLE:
# Custom build: tag 41 behaves like a 32-bit int in scene data.
return self.i32()
if vt == VARIANT_STRING:
return self.get_unicode_string()
if vt == VARIANT_STRING_NAME:
return {"StringName": self.get_unicode_string()}
if vt == VARIANT_VECTOR2:
# In this sample, tag 10 frequently behaves like a scalar float.
x = self.f32() if not self.real_is_double else self.f64()
if self.off + 4 <= len(self.data):
nxt = struct.unpack_from("<I", self.data, self.off)[0]
if nxt in plausible_next:
return x
y = self.f32() if not self.real_is_double else self.f64()
return {"Vector2": [x, y]}
if vt == VARIANT_VECTOR2I:
return {"Vector2i": [self.i32(), self.i32()]}
if vt == VARIANT_RECT2:
g = self.f64 if self.real_is_double else self.f32
return {"Rect2": [g(), g(), g(), g()]}
if vt == VARIANT_RECT2I:
return {"Rect2i": [self.i32(), self.i32(), self.i32(), self.i32()]}
if vt == VARIANT_VECTOR3:
g = self.f64 if self.real_is_double else self.f32
return {"Vector3": [g(), g(), g()]}
if vt == VARIANT_VECTOR3I:
return {"Vector3i": [self.i32(), self.i32(), self.i32()]}
if vt == VARIANT_VECTOR4:
g = self.f64 if self.real_is_double else self.f32
return {"Vector4": [g(), g(), g(), g()]}
if vt == VARIANT_VECTOR4I:
return {"Vector4i": [self.i32(), self.i32(), self.i32(), self.i32()]}
if vt == VARIANT_PLANE:
g = self.f64 if self.real_is_double else self.f32
return {"Plane": [g(), g(), g(), g()]}
if vt == VARIANT_QUATERNION:
g = self.f64 if self.real_is_double else self.f32
return {"Quaternion": [g(), g(), g(), g()]}
if vt == VARIANT_AABB:
g = self.f64 if self.real_is_double else self.f32
return {"AABB": [g(), g(), g(), g(), g(), g()]}
if vt == VARIANT_COLOR:
return {"Color": [self.f32(), self.f32(), self.f32(), self.f32()]}
if vt == VARIANT_BASIS:
g = self.f64 if self.real_is_double else self.f32
return {"Basis": [g() for _ in range(9)]}
if vt == VARIANT_TRANSFORM2D:
g = self.f64 if self.real_is_double else self.f32
return {"Transform2D": [g() for _ in range(6)]}
if vt == VARIANT_TRANSFORM3D:
g = self.f64 if self.real_is_double else self.f32
vals = [g() for _ in range(12)]
return {
"Transform3D": {
"basis": vals[:9],
"origin": vals[9:12],
}
}
if vt == VARIANT_NODE_PATH:
name_count = self.u16()
subname_count = self.u16()
absolute = bool(subname_count & 0x8000)
subname_count &= 0x7FFF
if self.ver_format < 3:
subname_count += 1
names = [self.get_string() for _ in range(name_count)]
subnames = [self.get_string() for _ in range(subname_count)]
return {"NodePath": {"absolute": absolute, "names": names, "subnames": subnames}}
if vt == VARIANT_OBJECT:
objtype = self.u32()
if objtype == OBJECT_EMPTY:
return {"Object": None}
if objtype == OBJECT_INTERNAL_RESOURCE:
idx = self.u32()
path = self.int_resources[idx].path if idx < len(self.int_resources) else None
return {"Object": {"internal_index": idx, "path": path}}
if objtype == OBJECT_EXTERNAL_RESOURCE:
typ = self.get_unicode_string()
path = self.get_unicode_string()
return {"Object": {"external_type": typ, "path": path}}
if objtype == OBJECT_EXTERNAL_RESOURCE_INDEX:
idx = self.u32()
er = self.ext_resources[idx] if idx < len(self.ext_resources) else None
return {"Object": {"external_index": idx, "resource": er}}
return {"ObjectUnknown": objtype}
if vt == VARIANT_RID:
return {"RID": self.u32()}
if vt == VARIANT_DICTIONARY:
n = self.u32() & 0x7FFFFFFF
d = {}
for _ in range(n):
k = self.parse_variant()
v = self.parse_variant()
d[self._dict_key(k)] = v
return d
if vt == VARIANT_ARRAY:
n = self.u32() & 0x7FFFFFFF
return [self.parse_variant() for _ in range(n)]
if vt == VARIANT_PACKED_BYTE_ARRAY:
n = self.u32()
out = self.raw(n)
self.advance_padding(n)
return {"PackedByteArray": out}
if vt == VARIANT_PACKED_INT32_ARRAY:
n = self.u32()
return [self.i32() for _ in range(n)]
if vt == VARIANT_PACKED_INT64_ARRAY:
n = self.u32()
return [self.i64() for _ in range(n)]
if vt == VARIANT_PACKED_FLOAT32_ARRAY:
n = self.u32()
return [self.f32() for _ in range(n)]
if vt == VARIANT_PACKED_FLOAT64_ARRAY:
n = self.u32()
return [self.f64() for _ in range(n)]
if vt == VARIANT_PACKED_STRING_ARRAY:
n = self.u32()
return [self.get_unicode_string() for _ in range(n)]
if vt == VARIANT_PACKED_VECTOR2_ARRAY:
n = self.u32()
g = self.f64 if self.real_is_double else self.f32
return [{"Vector2": [g(), g()]} for _ in range(n)]
if vt == VARIANT_PACKED_VECTOR3_ARRAY:
n = self.u32()
g = self.f64 if self.real_is_double else self.f32
return [{"Vector3": [g(), g(), g()]} for _ in range(n)]
if vt == VARIANT_PACKED_COLOR_ARRAY:
n = self.u32()
return [{"Color": [self.f32(), self.f32(), self.f32(), self.f32()]} for _ in range(n)]
if vt == VARIANT_PACKED_VECTOR4_ARRAY:
n = self.u32()
g = self.f64 if self.real_is_double else self.f32
return [{"Vector4": [g(), g(), g(), g()]} for _ in range(n)]
if vt == VARIANT_PROJECTION:
g = self.f64 if self.real_is_double else self.f32
return {"Projection": [g() for _ in range(16)]}
return {"UNSUPPORTED_VARIANT": vt, "offset": self.off}

@staticmethod
def _dict_key(k: Any) -> Any:
if isinstance(k, ExtResource):
return ("ExtResource", k.typ, k.path, k.uid)
if isinstance(k, IntResource):
return ("IntResource", k.path, k.offset)
if isinstance(k, list):
return tuple(Reader._dict_key(x) for x in k)
if isinstance(k, dict):
if "StringName" in k:
return k["StringName"]
if "NodePath" in k:
names = k["NodePath"]["names"]
prefix = "/" if k["NodePath"]["absolute"] else ""
return prefix + "/".join(names)
items = []
for kk, vv in sorted(k.items(), key=lambda kv: str(kv[0])):
items.append((str(kk), Reader._dict_key(vv)))
return tuple(items)
try:
hash(k)
return k
except Exception:
return repr(k)


def parse_scene_header(r: Reader) -> dict[str, Any]:
assert r.raw(4) == b"RSRC"
big_endian = r.u32()
use_real64 = r.u32()
ver_major = r.u32()
ver_minor = r.u32()
ver_fmt = r.u32()
r.ver_format = ver_fmt
typ = r.get_unicode_string()
importmd_ofs = r.u64()
flags = r.u32()
uid = r.u64()
reserved = [r.u32() for _ in range(11)]
r.real_is_double = bool(flags & 4)
str_count = r.u32()
r.string_map = [r.get_unicode_string() for _ in range(str_count)]
ext_count = r.u32()
r.ext_resources = []
for _ in range(ext_count):
t = r.get_unicode_string()
path = r.get_unicode_string()
uidv = r.u64() if (flags & 2) else 0
r.ext_resources.append(ExtResource(t, path, uidv))
int_count = r.u32()
r.int_resources = []
for _ in range(int_count):
path = r.get_unicode_string()
offs = r.u64()
r.int_resources.append(IntResource(path, offs))
return {
"big_endian": big_endian,
"use_real64": use_real64,
"ver_major": ver_major,
"ver_minor": ver_minor,
"ver_fmt": ver_fmt,
"type": typ,
"flags": flags,
"uid": uid,
"importmd_ofs": importmd_ofs,
"reserved": reserved,
}


def parse_resource_at(r: Reader, off: int) -> dict[str, Any]:
old = r.off
r.seek(off)
typ = r.get_unicode_string()
pc = r.u32()
props = []
for _ in range(pc):
name = r.get_string()
value = r.parse_variant()
props.append((name, value))
r.seek(old)
return {"type": typ, "properties": props}


def reconstruct_nodes(bundled: dict[str, Any]) -> list[dict[str, Any]]:
names = bundled["names"]
variants = bundled["variants"]
snodes = bundled["nodes"]
node_count = bundled["node_count"]
out = []
idx = 0
for i in range(node_count):
parent = snodes[idx]; idx += 1
owner = snodes[idx]; idx += 1
typ = snodes[idx]; idx += 1
name_index_raw = snodes[idx]; idx += 1
instance = snodes[idx]; idx += 1
prop_count = snodes[idx]; idx += 1
name_index = name_index_raw & NAME_MASK
child_index = (name_index_raw >> NAME_INDEX_BITS) - 1
props = []
for _ in range(prop_count):
pname_idx = snodes[idx]; idx += 1
pval_idx = snodes[idx]; idx += 1
pname = names[pname_idx]
pval = variants[pval_idx] if pval_idx < len(variants) else {"bad_variant_index": pval_idx}
props.append((pname, pval))
group_count = snodes[idx]; idx += 1
groups = []
for _ in range(group_count):
gidx = snodes[idx]; idx += 1
groups.append(names[gidx])
out.append({
"node_id": i,
"parent": parent,
"owner": owner,
"type_idx": typ,
"type": names[typ] if typ != 0x7FFFFFFF and typ < len(names) else typ,
"name_idx": name_index,
"name": names[name_index],
"child_index": child_index,
"instance": instance,
"groups": groups,
"properties": props,
})
return out


def print_node(nodes: list[dict[str, Any]], node: dict[str, Any]) -> None:
print(f"\n{node['name']} node:")
print(" node_id:", node["node_id"])
print(" type:", node["type"])
print(" parent:", node["parent"], nodes[node["parent"]]["name"] if node["parent"] >= 0 else None)
print(" owner:", node["owner"])
print(" child_index:", node["child_index"])
print(" groups:", node["groups"])
for pname, pval in node["properties"]:
print(" prop", pname, "=", pval)
for child in nodes:
if child["parent"] == node["node_id"]:
print(" child:", child["node_id"], child["name"], child["type"])
for pname, pval in child["properties"]:
print(" prop", pname, "=", pval)


def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("scene", type=Path)
ap.add_argument("--node", action="append", default=[], help="print a node by exact name; may be repeated")
args = ap.parse_args()

r = Reader(args.scene.read_bytes())
hdr = parse_scene_header(r)
print("header:", hdr["type"], "fmt", hdr["ver_major"], hdr["ver_minor"], hdr["ver_fmt"], "flags", hex(hdr["flags"]))
print("strings:", len(r.string_map), "ext:", len(r.ext_resources), "int:", len(r.int_resources))

main_res = parse_resource_at(r, r.int_resources[-1].offset)
print("main resource:", main_res["type"], "props:", [k for k, _ in main_res["properties"]])
bundled = dict(main_res["properties"])["_bundled"]
print("bundled keys:", list(bundled.keys()))
print("bundled counts: names=%d variants=%d node_count=%d conn_count=%d node_paths=%d" % (
len(bundled["names"]),
len(bundled["variants"]),
bundled["node_count"],
bundled["conn_count"],
len(bundled.get("node_paths", [])),
))

nodes = reconstruct_nodes(bundled)
wanted = args.node or ["Trigger4"]
found_any = False
for want in wanted:
matches = [n for n in nodes if n["name"] == want]
if not matches:
print(f"\n{want} node: <not found>")
continue
for n in matches:
print_node(nodes, n)
found_any = True

if args.node and not found_any:
raise SystemExit(1)


if __name__ == "__main__":
main()

image-20260418163509731

1
2
3
4
5
Trigger1.transform.origin = (-12.845476,  5.822042, -15.349906)
Trigger2.transform.origin = (-14.960197, 11.673913, -3.083051)
Trigger3.transform.origin = (-13.811505, 6.613474, -22.492664)
Trigger4.transform.origin = ( 3.749692, 4.592883, -16.559868)
InstancePos.transform.origin = ( 8.0, 3.364050, -16.0 )

part1 绿色方块

flag 逻辑获取分析

绿色方块,完全在 GDScript 内实现,是一个 8 轮 Feistel 密码:

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
extends Area3D
signal collided_with(name)
var _gx = GameExtension.new()
var _tv := 0.0
var _ix := 0

# Hex string → byte array
func _h2b(_s: String) -> PackedByteArray:
var _r := PackedByteArray()
var _n := _s.length()
var _j := 0
while _j < _n:
_r.append(_s.substr(_j, 2).hex_to_int())
_j += 2
return _r

# Byte array → hex string
func _b2h(_ba: PackedByteArray) -> String:
var _r := ''
for _v in _ba:
_r += '%02x' % _v
return _r

# XOR two byte arrays
func _xb(_a: PackedByteArray, _b: PackedByteArray) -> PackedByteArray:
var _r := PackedByteArray()
var _n := _a.size()
var _j := 0
while _j < _n:
_r.append(_a[_j] ^ _b[_j])
_j += 1
return _r

# Round function
func _rf(_bl: PackedByteArray, _ky: PackedByteArray, _rn: int) -> PackedByteArray:
var _r := PackedByteArray()
var _ks := _ky.size()
for _j in _bl.size():
var _v := _bl[_j] ^ _ky[(_j + _rn) % _ks]
_v = (_v * 7 + _rn) & 255
_v = ((_v << 3) | (_v >> 5)) & 255 # rotate-left 3
_r.append(_v)
return _r

# Feistel encryption
func _fe(_th: String) -> String:
var _da := _h2b(_th)
assert(_da.size() == (2 << 1)) # 4 bytes (token = 8 hex chars)
var _hl := 2
var _lo := _da.slice(0, _hl)
var _hi := _da.slice(_hl, _hl * 2)
var _kp := ('Sec' + '2026' + '_God' + 'ot').to_utf8_buffer() # "Sec2026_Godot"
var _rn := 0
while _rn < (4 * 2): # 8 rounds
var _fv := _rf(_hi, _kp, _rn)
var _nr := _xb(_lo, _fv)
_lo = _hi
_hi = _nr
_rn += 1
var _ot := PackedByteArray()
_ot.append_array(_lo)
_ot.append_array(_hi)
return 'sec' + '2026' + '_PART' + '1_' + _b2h(_ot)

func _ready() -> void:
body_entered.connect(_w7)

func _w7(_ar):
var _lb := get_node(NodePath('/root/TownScene/' + 'Label2'))
var _lt := get_node(NodePath('/root/TownScene/' + 'Label'))
var _tx := str(_lt.text)
var _tk := _tx.substr(3 + 4)
var _pf := 'flag{'
var _rs := _fe(_tk)
_lb.text = _pf + _rs + '}' + ' '

大致为输入 token,拆为 L+ R,然后过 8轮 Feistel,Feistel的内部大概如下:

  • F(R, key, rn) = 对每个字节 b: b' = ((((b ^ key[(j+rn)%13]) * 7 + rn) & 0xff) << 3 | ... >> 5) & 0xff
  • L’, R’ = R, L XOR F(R)
1
2
3
4
5
6
7
var _rn := 0
while _rn < (4 * 2):
var _fv := _rf(_hi, _kp, _rn) # F(R, key, round_number) → 2 bytes
var _nr := _xb(_lo, _fv) # L XOR F(R, K, r)
_lo = _hi # 新的 L = 旧的 R
_hi = _nr # 新的 R = 旧的 L XOR F(R, K, r)
_rn += 1

密钥用字符串拼接构造

1
var _kp := ('Sec' + '2026' + '_God' + 'ot').to_utf8_buffer() 

Sec2026_Godot

flag为flag{sec2026_PART1_<8 hex>}

获取flag

过程分析

根初赛类似,trigger都在房顶上,要想获得flag需要主动触发碰撞函数,由于之前用Frida hook会出现花屏,因此我们这使用纯C去进行绕过

有一种比较简单的方法是,其实大同小异:

把 Trigger2 挪到玩家车出生点 (8.0, 3.36405, -16.0)。挪靠 ptrace + 调 Godot setter,跟 trigger4_call.c 的范式一致。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
碰撞从硬件到屏幕走的路径:
玩家车 RigidBody3D 物理位置 挪车

PhysicsServer 步进 + 重叠检测 物理服务器内部缓存

PhysicsServer 通知 Area3D 有 body 进入 调内部通知"

Area3D::_body_inout 派发 body_entered 发信号

Godot Signal 总线分发到所有连接者

trigger2.gd::_w7(body) 直接调脚本回调

读 Label.text → _fe(token) → 写 Label2.text

链条上每一段都可以从外部直接 hook,越往下游越接近显示 flag,但对应的函数签名也越复杂(要构造 Godot 内部的数据结构)。trigger2_call 选的是最上游的挪车 ,因为只要传一个 12 字节 Vector3,最简单。

从外部进程用 pwrite(/proc/PID/mem, …) 直接把 Trigger2 对象内存里那 12 字节 transform.origin 改成 (8, 3.36, -16),下一帧物理引擎就该看到 trigger2 在新位置了 ,跟玩家车重叠 ,触发碰撞

不能用纯字节写:Godot 4 PhysicsServer 是 push 模式,光改 Area3D 内 transform 字节不会同步给物理引擎,下一帧 area_overlap 仍按旧位置算。必须真调 setter,让它通过 _propagate_transform_changedPhysicsServer::area_set_transform 的链路推送。

Godot 4 把 Node 层(场景树里的 Area3D 对象)和 Physics 层(PhysicsServer 内部的物理对象)完全分开存

image-20260418164611558

两边各存一份 transform。PhysicsServer 不会主动去 Node 内存读最新值(pull 模式),而是等 Node 层主动调 PhysicsServer::area_set_transform 把新值推过来(push 模式)。 正常游戏流程是:

  1. GDScript 写 area.position = …然后 触发 setter
  2. setter 写 Node 层 local_transform.origin
  3. setter 调 _propagate_transform_changed(this)
  4. 它调 PhysicsServer::area_set_transform(rid, new_transform)
  5. PhysicsServer 内部 areas[rid].transform 被更新
  6. 下一帧物理 step 用新 transform 算重叠

既然字节写不行,那就让 Godot 自己执行第 2-5 步,通过 ptrace 让游戏进程跳进 Node3D::set_global_position(Vector3) 函数入口,参数填 (8, 3.36, -16)。函数内部会按上面 5 步走完,PhysicsServer 自动同步。

需要确认这个函数的地址, Godot 中函数符号大部分被 strip 掉,所以

  1. 在 binary 里找字符串 “set_global_position”
  2. 反查谁引用这个字符串 , 那段引用代码就是 ClassDB 注册调用
  3. 注册调用的格式固定是 ClassDB::bind_method(D_METHOD(“set_global_position”, “position”), &Node3D::set_global_position) , 编译后会有一个 ADR X0, 真实函数地址 紧挨在加载字符串地址的前几条指令
  4. 找出那个 ADR 加载的地址,就是 Node3D::set_global_position 的真实偏移
1
2
3
4
5
strings -t x final/lib/arm64-v8a/libgodot_android.so | awk '
$2 == "set_position" || $2 == "set_global_position" || $2 == "set_global_transform" {print}'
# 3f591c set_position
# 4178ba set_global_position
# 454a9a set_global_transform

第一个 PT_LOAD 的 p_offset == p_vaddr == 0,所以 file offset == 库内 vaddr。set_global_position 字符串地址 = 0x4178ba

用Capstone 扫 ClassDB::bind_method 注册

Godot 4Node3D::_bind_methods() 内每条 bind_method 编译为 8 条指令 pattern:

1
2
3
4
5
6
7
8
9
10
11
ADR  X0, <method_ptr>       真正的 setter 函数地址
MOV X1, XZR
BL <create_method_bind>
MOV X1, X0
ADRP X3, "name"@PAGE
ADD X3, X3, #@PAGEOFF
MOV W0, #1
MOV W2, WZR
MOV X4, XZR
MOV W5, WZR
BL ClassDB::bind_method

BL 的目标是按签名共享的工厂,真正的 method ptr 是它前 4 条指令的 ADR X0 加载值。

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
import struct, capstone

LIB = '/path/to/libgodot_android.so'
data = open(LIB,'rb').read()

# Parse ELF program headers, find executable PT_LOAD
e_phoff = struct.unpack_from('<Q', data, 0x20)[0]
e_phentsz, e_phnum = struct.unpack_from('<HH', data, 0x36)
EXEC_VOFF = EXEC_VADDR = EXEC_SIZE = None
for i in range(e_phnum):
off = e_phoff + i * e_phentsz
p_type, p_flags = struct.unpack_from('<II', data, off)
if p_type == 1 and (p_flags & 1): # PT_LOAD + PF_X
p_offset, p_vaddr, _, p_filesz = struct.unpack_from('<QQQQ', data, off + 8)
EXEC_VOFF, EXEC_VADDR, EXEC_SIZE = p_offset, p_vaddr, p_filesz
break

# Target string addresses (from strings -t x)
TARGETS = {
0x3F591C: 'set_position',
0x4178BA: 'set_global_position',
0x454A9A: 'set_global_transform',
}

md = capstone.Cs(capstone.CS_ARCH_ARM64, capstone.CS_MODE_ARM)

# Scan in chunks to avoid OOM on full disasm
CHUNK = 0x80000
for chunk_start in range(EXEC_VADDR, EXEC_VADDR + EXEC_SIZE, CHUNK):
end = min(chunk_start + CHUNK, EXEC_VADDR + EXEC_SIZE)
fo = chunk_start - EXEC_VADDR + EXEC_VOFF
insns = list(md.disasm(data[fo:fo + (end - chunk_start)], chunk_start))

for i, ins in enumerate(insns):
if ins.mnemonic != 'adrp': continue
try:
reg, page = ins.op_str.split(', ')
page_val = int(page.replace('#',''), 0)
except: continue
if i + 1 >= len(insns): continue
nxt = insns[i + 1]
if nxt.mnemonic != 'add': continue
try:
dst, src, imm = nxt.op_str.split(', ')
if dst != reg or src != reg: continue
imm_val = int(imm.replace('#',''), 0)
except: continue
addr = page_val + imm_val
if addr not in TARGETS: continue

# The real setter is the ADR 4 instructions before the string ADRP
if i >= 4 and insns[i-4].mnemonic == 'adr':
try:
_, ctgt = insns[i-4].op_str.split(', ')
fn = int(ctgt.replace('#',''), 0)
print(f'{TARGETS[addr]:25s} fn=0x{fn:x}')
except: pass

输出

1
2
set_global_position  fn=0x254583c
set_global_transform fn=0x25458f8

Node3D::set_global_position 这个函数机器码,在 libgodot_android.so 里相对文件起始 0x254583c 字节的位置。

验证签名

把 set_global_position 函数前几条机器指令打印出来看,通过 prologue 用了哪些寄存器反推它的参数怎么传

从 ClassDB 注册点找到了 Node3D::set_global_position 0x254583c,但只知道地址,不知道调用约定

1
2
3
4
5
md = capstone.Cs(capstone.CS_ARCH_ARM64, capstone.CS_MODE_ARM)
fo = 0x254583c - EXEC_VADDR + EXEC_VOFF
for ins in md.disasm(data[fo:fo+128], 0x254583c):
print(f'0x{ins.address:08x} {ins.mnemonic} {ins.op_str}')
if ins.mnemonic == 'ret': break

读 .so 文件 0x254583c 处的 128 字节,让 capstone 按 ARM64 反汇编,逐条打印直到第一个 RET。

1
2
0x02545854 mov  x20, x1     ; 把 X1 备份到 X20
0x02545858 mov x19, x0 ; 把 X0 备份到 X19

ARM64 AAPCS 调用约定:函数被调用时 X0 是第 1 个参数,X1 是第 2 个,依此类推。X19/X20 是 callee-saved 寄存器。

函数一进来就把 X0 和 X1 备份到 X19/X20,说明 X0 和 X1 都是参数,且要在函数体里反复用到。

1
2
0x0254586c ldr  x8, [x20]      ; 从 [X20] 读 8 字节
0x02545870 ldr w9, [x20, #8] ; 从 [X20+8] 读 4 字节

X20 是备份的 X1。函数把 X1 当指针用(ldr x8, [x20] = X20 指向的内存读 8 字节):

  • [X1+0] 8 字节 = Vector3 的 x (4) + y (4)
  • [X1+8] 4 字节 = Vector3 的 z

加起来正好 12 字节 = 3 个 float = 完整 Vector3。

1
void Node3D::set_global_position(this, &Vector3);

拿到这些接下来就是去替换位置,我们在上一节已经知道几个坐标,我们需要先处理Trigger2的

1
(-14.960197, 11.673913, -3.083051)

观察内存发现4 个 Area3D 在堆上连续分配(间距 0xc00 字节)。每个 Area3D dump 时窗口要 < 0xc00 否则越界扫到下一个 area 的 transform,把它误判成自己的。用 0x800 窗口刚好

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static int find_trigger2(int mem_fd, uint64_t *area_objs, int n_area,
uint64_t *out_obj, size_t *out_origin_off) {
uint8_t needle[8];
memcpy(needle, TRIGGER2_POS, 8); // 前 8 字节 = (x, y)
uint8_t buf[0x800]; // 避免越界
for (int i = 0; i < n_area; i++) {
if (pread64(mem_fd, buf, sizeof(buf), area_objs[i]) != (ssize_t)sizeof(buf)) continue;
for (size_t k = 0; k + 8 <= sizeof(buf); k += 4) {
if (memcmp(buf + k, needle, 8) == 0) {
*out_obj = area_objs[i];
*out_origin_off = k;
return 0;
}
}
}
return -1;
}

多次尝试发现 Trigger2 的 origin 字段在对象 +0x3a4。

ptrace-call 传 Vector3 引用(vs trigger4 的整数参数)

传整数 set_monitoring(area, 1),传 Vector3 引用需要先把 12 字节数据写到目标进程能读到的内存,再让 X1 指向它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
static int remote_call_set_global_position(pid_t tid, int mem_fd,
uint64_t func, uint64_t this_ptr,
const float vec3[3]) {
struct arm64_regs saved, regs;
get_regs(tid, &saved);
regs = saved;

// 在目标栈下方 16 字节对齐处放 Vector3
uint64_t sp_new = (saved.sp - 16) & ~0xFULL;
pwrite64(mem_fd, vec3, 12, sp_new); // 写 12 字节 (x, y, z) 到目标内存

regs.regs[0] = this_ptr; // X0 = Node3D*
regs.regs[1] = sp_new; // X1 = Vector3*
regs.regs[30] = 0; // LR = 0 → 函数返回时 SIGSEGV
regs.pc = func;
regs.sp = sp_new;

set_regs(tid, &regs);
ptrace(PTRACE_CONT, tid, 0, 0);

int status; waitpid(tid, &status, __WALL); // 等 SIGSEGV
set_regs(tid, &saved); // 恢复原始寄存器
return 0;
}

代码总流程大致是

1
2
3
4
5
6
7
8
9
10
11
12
1. find_game_pid                     扫 /proc/*/cmdline 找游戏 PID
2. kill_self_tracer 读 /proc/PID/status 拿 TracerPid,kill -9
3. read_maps 解析 libgodot 4 个 PT_LOAD
4. find_vptr_by_name("6Area3D") typeinfo 字符串 → typeinfo struct → vtable → vptr-in-obj
5. scan_for_vptr 扫所有 RW 段找 4 个 Area3D 实例
6. find_trigger2 按 (x, y) 8-byte fingerprint 匹配,挑出 Trigger2
7. pick_idle_tid 找一个 'S' 状态的线程(一般是主线程睡在 epoll)
8. ptrace_attach 挂 idle 线程
9. remote_call_set_global_position ptrace 调 set_global_position(trigger2, &Vector3{8, 3.36, -16})
10. ptrace_detach
11. 物理引擎下一帧 area_overlap 检测到玩家车与 Trigger2 重叠
12. 触发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
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/types.h>
#include <sys/ptrace.h>
#include <sys/wait.h>
#include <sys/uio.h>
#include <linux/elf.h>
#include <dirent.h>
#include <signal.h>

#define PKG_NAME "com.tencent.ACE.gamesec2026.final"

#define NODE3D_SET_GLOBAL_POSITION_OFFSET 0x254583CULL

static const float TRIGGER2_POS[3] = { -14.960197f, 11.673913f, -3.083051f };
// Player spawn point — anywhere visible to the player works. Truck is here at game start.
static const float SPAWN_POS[3] = { 8.0f, 3.364050f, -16.0f };

#define MAX_REGIONS 512
#define MAX_CANDIDATES 256
#define CHUNK_SIZE (4 * 1024 * 1024)
#define MAX_SEGS 8

struct arm64_regs {
uint64_t regs[31]; uint64_t sp; uint64_t pc; uint64_t pstate;
};

typedef struct { uint64_t start, end; } mem_region_t;
typedef struct { uint64_t mstart, mend; uint64_t fstart, fend; char perms[8]; } godot_seg_t;

static godot_seg_t godot_segs[MAX_SEGS];
static int n_godot_segs = 0;

static int in_godot_ro(uint64_t addr) {
for (int i = 0; i < n_godot_segs; i++)
if (addr >= godot_segs[i].mstart && addr < godot_segs[i].mend &&
godot_segs[i].perms[0] == 'r' && godot_segs[i].perms[1] != 'w') return 1;
return 0;
}

static uint8_t *read_seg(int mem_fd, godot_seg_t *seg, size_t *out_size) {
size_t sz = seg->mend - seg->mstart;
uint8_t *buf = malloc(sz); if (!buf) return NULL;
if (pread64(mem_fd, buf, sz, seg->mstart) != (ssize_t)sz) { free(buf); return NULL; }
*out_size = sz; return buf;
}

// Dynamic Area3D vtable discovery (same trick as trigger4_call.c).
static uint64_t find_vptr_by_name(int mem_fd, const char *mangled) {
size_t nl = strlen(mangled);
uint64_t name_addr = 0;
for (int i = 0; i < n_godot_segs && !name_addr; i++) {
if (godot_segs[i].perms[0] != 'r' || godot_segs[i].perms[1] == 'w') continue;
size_t sz; uint8_t *buf = read_seg(mem_fd, &godot_segs[i], &sz);
if (!buf) continue;
for (size_t k = 0; k + nl + 1 <= sz; k++)
if (buf[k] == mangled[0] && memcmp(buf+k, mangled, nl) == 0 && buf[k+nl] == 0
&& (k == 0 || buf[k-1] == 0)) {
name_addr = godot_segs[i].mstart + k; break;
}
free(buf);
}
if (!name_addr) { fprintf(stderr,"name '%s' not found\n", mangled); return 0; }
fprintf(stderr," name '%s' @ 0x%lx\n", mangled, name_addr);

uint64_t typeinfo = 0;
for (int i = 0; i < n_godot_segs && !typeinfo; i++) {
if (godot_segs[i].perms[0] != 'r' || godot_segs[i].perms[1] == 'w') continue;
size_t sz; uint8_t *buf = read_seg(mem_fd, &godot_segs[i], &sz);
if (!buf) continue;
for (size_t k = 8; k + 8 <= sz; k += 8)
if (*(uint64_t *)(buf + k) == name_addr) { typeinfo = godot_segs[i].mstart + k - 8; break; }
free(buf);
}
if (!typeinfo) return 0;
fprintf(stderr," typeinfo @ 0x%lx\n", typeinfo);

uint64_t vtable = 0;
for (int i = 0; i < n_godot_segs && !vtable; i++) {
if (godot_segs[i].perms[0] != 'r' || godot_segs[i].perms[1] == 'w') continue;
size_t sz; uint8_t *buf = read_seg(mem_fd, &godot_segs[i], &sz);
if (!buf) continue;
for (size_t k = 8; k + 8 <= sz; k += 8)
if (*(uint64_t *)(buf + k) == typeinfo) {
int64_t off_top = (int64_t)*(uint64_t *)(buf + k - 8);
if (off_top > -0x10000 && off_top < 0x10000) {
vtable = godot_segs[i].mstart + k - 8; break;
}
}
free(buf);
}
if (!vtable) return 0;
fprintf(stderr," vtable @ 0x%lx → vptr 0x%lx\n", vtable, vtable + 16);
return vtable + 16;
}

static pid_t find_game_pid(void) {
DIR *p = opendir("/proc"); if (!p) return 0;
struct dirent *e; pid_t f = 0;
while ((e = readdir(p))) {
if (e->d_type != DT_DIR) continue;
for (const char *q = e->d_name; *q; q++) if (*q < '0' || *q > '9') goto nx;
{ char path[64]; snprintf(path, 64, "/proc/%s/cmdline", e->d_name);
FILE *fp = fopen(path,"r"); if (!fp) continue;
char b[256]={0}; fread(b,1,255,fp); fclose(fp);
if (strstr(b, PKG_NAME)) { f = atoi(e->d_name); break; } }
nx:;
}
closedir(p); return f;
}

static void kill_self_tracer(pid_t pid) {
char p[64]; snprintf(p, 64, "/proc/%d/status", pid);
FILE *f = fopen(p, "r"); if (!f) return;
char line[256]; pid_t tracer = 0;
while (fgets(line, sizeof(line), f))
if (sscanf(line, "TracerPid: %d", &tracer) == 1) break;
fclose(f);
if (tracer > 0 && tracer != getpid()) {
printf("Killing self-tracer PID %d\n", tracer);
kill(tracer, 9); usleep(300 * 1000);
}
}

static int read_maps(pid_t pid, mem_region_t *regions, int *n_regions) {
char path[64]; snprintf(path, 64, "/proc/%d/maps", pid);
FILE *f = fopen(path,"r"); if (!f) { perror("open maps"); return -1; }
*n_regions = 0; char line[1024];
while (fgets(line, sizeof(line), f)) {
uint64_t s, e; char perms[8], dev[32], pb[512]={0};
unsigned long off, ino;
if (sscanf(line, "%lx-%lx %7s %lx %31s %lu %511[^\n]",
&s,&e,perms,&off,dev,&ino,pb) < 6) continue;
if (strstr(pb, "libgodot_android.so") && n_godot_segs < MAX_SEGS) {
godot_segs[n_godot_segs].mstart = s;
godot_segs[n_godot_segs].mend = e;
godot_segs[n_godot_segs].fstart = off;
godot_segs[n_godot_segs].fend = off + (e - s);
strncpy(godot_segs[n_godot_segs].perms, perms, 7);
n_godot_segs++;
}
if (perms[0]=='r' && perms[1]=='w' && perms[3]=='p') {
if (strstr(pb,"[stack]") || strstr(pb,"[vvar]") || strstr(pb,"/dev/")) continue;
if (e - s > 512ULL*1024*1024) continue;
if (*n_regions >= MAX_REGIONS) break;
regions[*n_regions].start = s; regions[(*n_regions)++].end = e;
}
}
fclose(f); return 0;
}

static int scan_for_vptr(int mem_fd, mem_region_t *regions, int n_regions,
uint64_t vptr, uint64_t *out, int max_out) {
uint8_t *buf = malloc(CHUNK_SIZE); if (!buf) return 0;
int count = 0;
uint64_t needle = vptr & 0x00FFFFFFFFFFFFFFULL;
for (int r = 0; r < n_regions && count < max_out; r++) {
uint64_t size = regions[r].end - regions[r].start;
for (uint64_t off = 0; off < size && count < max_out; off += CHUNK_SIZE) {
uint64_t chunk = (size - off > CHUNK_SIZE) ? CHUNK_SIZE : (size - off);
if (pread64(mem_fd, buf, chunk, regions[r].start + off) != (ssize_t)chunk) continue;
for (uint64_t i = 0; i + 8 <= chunk; i += 8) {
uint64_t v = *(uint64_t *)(buf + i) & 0x00FFFFFFFFFFFFFFULL;
if (v == needle) {
out[count++] = regions[r].start + off + i;
if (count >= max_out) break;
}
}
}
}
free(buf); return count;
}

static int find_trigger2(int mem_fd, uint64_t *area_objs, int n_area,
uint64_t *out_obj, size_t *out_origin_off) {
uint8_t needle[8]; memcpy(needle, TRIGGER2_POS, 8);
// 0x800 < per-area allocation (~0xc00); avoids spilling into adjacent Area3D.
uint8_t buf[0x800];
for (int i = 0; i < n_area; i++) {
if (pread64(mem_fd, buf, sizeof(buf), area_objs[i]) != (ssize_t)sizeof(buf)) continue;
for (size_t k = 0; k + 8 <= sizeof(buf); k += 4) {
if (memcmp(buf + k, needle, 8) == 0) {
*out_obj = area_objs[i];
*out_origin_off = k;
return 0;
}
}
}
return -1;
}

// pick a thread in S (sleeping) state — safest to hijack
static pid_t pick_idle_tid(pid_t pid) {
char path[64]; snprintf(path, 64, "/proc/%d/task", pid);
DIR *d = opendir(path); if (!d) return pid;
struct dirent *e; pid_t best = 0;
while ((e = readdir(d))) {
if (e->d_name[0] < '0' || e->d_name[0] > '9') continue;
pid_t tid = atoi(e->d_name);
char sp[128]; snprintf(sp, 128, "/proc/%d/task/%d/stat", pid, tid);
FILE *f = fopen(sp, "r"); if (!f) continue;
char buf[512]; size_t n = fread(buf,1,sizeof(buf)-1,f); buf[n]=0; fclose(f);
char *st = strrchr(buf, ')'); if (!st || !st[1] || st[2] != 'S') continue;
best = tid; break;
}
closedir(d); return best ? best : pid;
}

static int get_regs(pid_t tid, struct arm64_regs *r) {
struct iovec iov = { .iov_base = r, .iov_len = sizeof(*r) };
return ptrace(PTRACE_GETREGSET, tid, NT_PRSTATUS, &iov);
}
static int set_regs(pid_t tid, const struct arm64_regs *r) {
struct iovec iov = { .iov_base = (void*)r, .iov_len = sizeof(*r) };
return ptrace(PTRACE_SETREGSET, tid, NT_PRSTATUS, &iov);
}

static int remote_call_set_global_position(pid_t tid, int mem_fd,
uint64_t func, uint64_t this_ptr,
const float vec3[3]) {
struct arm64_regs saved, regs;
if (get_regs(tid, &saved) < 0) { perror("getregs"); return -1; }
regs = saved;

uint64_t sp_new = (saved.sp - 16) & ~0xFULL;

if (pwrite64(mem_fd, vec3, 12, sp_new) != 12) {
perror("pwrite vec3"); return -1;
}

regs.regs[0] = this_ptr;
regs.regs[1] = sp_new;
regs.regs[30] = 0;
regs.pc = func;
regs.sp = sp_new;

if (set_regs(tid, &regs) < 0) { perror("setregs"); return -1; }
if (ptrace(PTRACE_CONT, tid, 0, 0) < 0) { perror("cont"); return -1; }

int status;
if (waitpid(tid, &status, __WALL) < 0) { perror("waitpid"); return -1; }
if (!WIFSTOPPED(status)) { fprintf(stderr,"unexpected status 0x%x\n", status); return -1; }
int sig = WSTOPSIG(status);
if (sig != SIGSEGV && sig != SIGBUS && sig != SIGILL && sig != SIGTRAP)
fprintf(stderr,"warn: stopped sig=%d (expected SIGSEGV)\n", sig);

if (set_regs(tid, &saved) < 0) { perror("restore"); return -1; }
return 0;
}

int main(int argc, char **argv) {
pid_t pid = find_game_pid();
if (!pid) { fprintf(stderr,"game not running\n"); return 1; }
printf("PID: %d\n", pid);
kill_self_tracer(pid);

mem_region_t regions[MAX_REGIONS]; int n_regions;
if (read_maps(pid, regions, &n_regions) < 0) return 1;
if (!n_godot_segs) { fprintf(stderr,"libgodot not mapped\n"); return 1; }
uint64_t godot_base = godot_segs[0].mstart;
printf("libgodot base: 0x%lx (%d rw regions, %d godot segs)\n",
godot_base, n_regions, n_godot_segs);

char mp[64]; snprintf(mp, 64, "/proc/%d/mem", pid);
int mem_fd = open(mp, O_RDWR);
if (mem_fd < 0) { perror("open mem"); return 1; }

fprintf(stderr,"Searching for Area3D vtable...\n");
uint64_t area3d_vptr = find_vptr_by_name(mem_fd, "6Area3D");
if (!area3d_vptr) { close(mem_fd); return 1; }

uint64_t area_objs[MAX_CANDIDATES];
int n_area = scan_for_vptr(mem_fd, regions, n_regions, area3d_vptr,
area_objs, MAX_CANDIDATES);
printf("Area3D objects: %d\n", n_area);
if (n_area == 0) { close(mem_fd); return 1; }

uint64_t trigger2 = 0; size_t origin_off = 0;
if (find_trigger2(mem_fd, area_objs, n_area, &trigger2, &origin_off) < 0) {
fprintf(stderr,"Trigger2 fingerprint not found in any Area3D.\n"
" (Looking for floats (-14.960197, 11.673913, -3.083051)\n"
" in the first 4KB of each object.)\n");
close(mem_fd); return 2;
}
printf("Trigger2 @ 0x%lx, transform.origin field offset = +0x%zx\n",
trigger2, origin_off);

pid_t tid = pick_idle_tid(pid);
printf("Hijacking idle TID %d\n", tid);
if (ptrace(PTRACE_ATTACH, tid, 0, 0) < 0) { perror("attach"); close(mem_fd); return 1; }
int status;
waitpid(tid, &status, __WALL);
if (!WIFSTOPPED(status)) {
fprintf(stderr,"initial wait status 0x%x\n", status);
ptrace(PTRACE_DETACH, tid, 0, 0); close(mem_fd); return 1;
}
printf("Attached, stop sig=%d\n", WSTOPSIG(status));

uint64_t f_set_position = godot_base + NODE3D_SET_GLOBAL_POSITION_OFFSET;
printf("→ set_position(0x%lx, &Vector3{%.3f, %.3f, %.3f})\n",
trigger2, SPAWN_POS[0], SPAWN_POS[1], SPAWN_POS[2]);

int rc = remote_call_set_global_position(tid, mem_fd, f_set_position, trigger2, SPAWN_POS);
if (rc < 0) fprintf(stderr,"remote_call failed\n");

if (ptrace(PTRACE_DETACH, tid, 0, 0) < 0) perror("detach");
close(mem_fd);

if (rc == 0)
printf("\nDONE — Trigger2 teleported to spawn. Truck should overlap it on next physics tick;\n"
"Label2 will display flag{sec2026_PART1_xxxxxxxx}.\n");
return rc < 0 ? 1 : 0;
}

编译:

1
$NDK/bin/aarch64-linux-android24-clang -O2 -pie trigger2_call.c -o trigger2_call

使用方法直接push到手机上给权限执行即可

peek可以查看从外部读运行中游戏进程的指定内存地址,dump 字节出来

1
2
3
4
5
6
7
int main(int argc, char **argv) {
pid_t pid = atoi(argv[1]);
uint64_t addr = strtoull(argv[2], NULL, 16);
int len = argc > 3 ? atoi(argv[3]) : 64;
int fd = open("/proc/$PID/mem", O_RDONLY);
pread64(fd, buf, len, addr);
}

flag截图

image-20260418143254149

后面可以直接拿去验证算法真实性

flag生成算法及逆算法 C

编译:

1
gcc -O2 -Wall -o part1_solver part1_solver.c 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>
#include <ctype.h>

static const char KEY[] = "Sec2026_Godot"; /* 13 bytes */
static const size_t KEY_LEN = sizeof(KEY) - 1; /* exclude '\0' */
static const int ROUNDS = 8;
static const size_t HALF = 2; /* 2 bytes per Feistel half */
#define FLAG_PREFIX "flag{sec2026_PART1_"
#define FLAG_SUFFIX "}"

/* hex char → 0..15, returns -1 on invalid */
static int hex_nibble(int c) {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return 10 + c - 'a';
if (c >= 'A' && c <= 'F') return 10 + c - 'A';
return -1;
}

/* hex string of length 2*n → n bytes; returns 0 on success, -1 on parse error */
static int hex_to_bytes(const char *hex, uint8_t *out, size_t n) {
for (size_t i = 0; i < n; i++) {
int hi = hex_nibble(hex[2 * i]);
int lo = hex_nibble(hex[2 * i + 1]);
if (hi < 0 || lo < 0) return -1;
out[i] = (uint8_t)((hi << 4) | lo);
}
return 0;
}

/* n bytes → 2n hex chars (lowercase), null-terminated */
static void bytes_to_hex(const uint8_t *bytes, size_t n, char *out) {
static const char *digits = "0123456789abcdef";
for (size_t i = 0; i < n; i++) {
out[2 * i] = digits[bytes[i] >> 4];
out[2 * i + 1] = digits[bytes[i] & 0x0F];
}
out[2 * n] = '\0';
}

/* rotate left of an 8-bit value */
static inline uint8_t rol8(uint8_t v, unsigned r) {
r &= 7;
return (uint8_t)((v << r) | (v >> (8 - r)));
}

/* ---------- core round function ---------- */
static void round_f(const uint8_t *half, int rn, uint8_t *out) {
for (size_t j = 0; j < HALF; j++) {
uint8_t v = half[j] ^ (uint8_t)KEY[(j + (size_t)rn) % KEY_LEN];
v = (uint8_t)(v * 7 + rn); /* mod 256 implicit */
v = rol8(v, 3);
out[j] = v;
}
}

/* ---------- encryption / decryption ---------- */
static void part1_encrypt(const uint8_t in[4], uint8_t out[4]) {
uint8_t lo[HALF], hi[HALF], fv[HALF], nr[HALF];
memcpy(lo, in, HALF);
memcpy(hi, in + HALF, HALF);

for (int rn = 0; rn < ROUNDS; rn++) {
round_f(hi, rn, fv);
for (size_t j = 0; j < HALF; j++) nr[j] = lo[j] ^ fv[j];
memcpy(lo, hi, HALF);
memcpy(hi, nr, HALF);
}

memcpy(out, lo, HALF);
memcpy(out + HALF, hi, HALF);
}

static void part1_decrypt(const uint8_t in[4], uint8_t out[4]) {
uint8_t lo[HALF], hi[HALF], fv[HALF], prev_lo[HALF];
memcpy(lo, in, HALF);
memcpy(hi, in + HALF, HALF);

for (int rn = ROUNDS - 1; rn >= 0; rn--) {
/* prev_hi = lo; prev_lo = hi XOR F(prev_hi, key, rn) */
round_f(lo, rn, fv);
for (size_t j = 0; j < HALF; j++) prev_lo[j] = hi[j] ^ fv[j];
memcpy(hi, lo, HALF);
memcpy(lo, prev_lo, HALF);
}

memcpy(out, lo, HALF);
memcpy(out + HALF, hi, HALF);
}

/* token_hex (8 chars) → cipher_hex (8 chars). Returns 0 / -1. */
static int encrypt_hex(const char *token_hex, char cipher_hex_out[9]) {
uint8_t in[4], out[4];
if (strlen(token_hex) != 8) return -1;
if (hex_to_bytes(token_hex, in, 4) != 0) return -1;
part1_encrypt(in, out);
bytes_to_hex(out, 4, cipher_hex_out);
return 0;
}

/* cipher_hex (8 chars) → token_hex (8 chars). Returns 0 / -1. */
static int decrypt_hex(const char *cipher_hex, char token_hex_out[9]) {
uint8_t in[4], out[4];
if (strlen(cipher_hex) != 8) return -1;
if (hex_to_bytes(cipher_hex, in, 4) != 0) return -1;
part1_decrypt(in, out);
bytes_to_hex(out, 4, token_hex_out);
return 0;
}

/* token_hex */
static int make_flag(const char *token_hex, char *flag_out) {
char cipher_hex[9];
if (encrypt_hex(token_hex, cipher_hex) != 0) return -1;
snprintf(flag_out, 35, FLAG_PREFIX "%s" FLAG_SUFFIX, cipher_hex);
return 0;
}

/* token_hex */
static int parse_flag_to_token(const char *flag, char token_hex_out[9]) {
size_t pl = sizeof(FLAG_PREFIX) - 1;
size_t sl = sizeof(FLAG_SUFFIX) - 1;
size_t fl = strlen(flag);
if (fl != pl + 8 + sl) return -1;
if (memcmp(flag, FLAG_PREFIX, pl) != 0) return -1;
if (memcmp(flag + pl + 8, FLAG_SUFFIX, sl) != 0) return -1;
char cipher_hex[9];
memcpy(cipher_hex, flag + pl, 8);
cipher_hex[8] = '\0';
return decrypt_hex(cipher_hex, token_hex_out);
}

int main(int argc, char **argv) {
if (argc == 3 && strcmp(argv[1], "enc") == 0) {
char flag[64];
if (make_flag(argv[2], flag) != 0) {
fprintf(stderr, "enc: bad token (need 8 hex chars)\n");
return 1;
}
printf("%s\n", flag);
return 0;
}

if (argc == 3 && strcmp(argv[1], "dec") == 0) {
char tok[9];
const char *arg = argv[2];
int rc;
if (strncmp(arg, FLAG_PREFIX, sizeof(FLAG_PREFIX) - 1) == 0)
rc = parse_flag_to_token(arg, tok);
else
rc = decrypt_hex(arg, tok);
if (rc != 0) {
fprintf(stderr, "dec: bad input (need 8-hex cipher or full flag string)\n");
return 1;
}
printf("%s\n", tok);
return 0;
}

return 1;
}

image-20260418143802928

part2 红色方块

获取flag

过程分析

原理和之前trigger2一样

trigger2.gd 默认 monitoring=1 , collision enabled , visible,只是位置高车开不到

trigger3 的 GDScript 结构相同,但初始可能 monitoring=0 disabled=1 visible=0(红色方块可能未开启碰撞属性)

对比一下就知道了

image-20260418184625150

可以试一下强制碰撞

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
1. find_game_pid                        找游戏进程号
2. kill_self_tracer 释放 ptrace 槽
3. read_maps 解析内存映射
4. find_vptr_by_name("6Area3D") typeinfo 字符串 → typeinfo struct → vtable + 16
5. scan_for_vptr 动态发现 vtable
6. find_trigger3 X-only 4 字节 fingerprint,0x800 扫描窗口
7. pick_idle_tid + ptrace_attach 挂 idle 线程
8. set_monitoring(trigger3, true) 让 area 监听碰撞
9. set_monitorable(trigger3, true) 让 area 能被其他 body 监听
10. get_child(trigger3, 0) → coll 拿 CollisionShape3D 子节点
11. set_disabled(coll, false) 启用碰撞 shape
12. get_child(trigger3, 1) → mesh 拿 MeshInstance3D 子节点
13. set_visible(mesh, true) 让 mesh 可见
14. set_global_position(trigger3, &Vector3{8, 3.36, -16}) 挪到出生点
15. ptrace_detach
16. 物理引擎下一帧 area_overlap → body_entered
17. trigger3.gd::_w7 跑 _gx.Process(token_buf) (libsec2026 native PART2)
18. Label2.text = "flag{sec2026_PART2_<32hex>}"

其中set_monitoring(trigger3, true) , 让 area 监听碰撞

ptrace_call libgodot+0x25F94A4 (Area3D::set_monitoring), X0=trigger3, X1=1

Area3D 有两个开关:

  • monitoring(监听):area 是否检测有 body 进入 , 离开
  • monitorable(被监听):area 是否对其他 area 可见

monitoring=false 时 PhysicsServer 不会发 body_entered 信号,车进入 area 也没反应。这步把它打开。

注意 set_monitoring(true) 不只是改字段值,会调 PhysicsServer::area_set_monitor_callback 注册物理回调。

还有提携setter代码的定位

之前定位的一些关键逻辑 再libgodotengine.so里

| 函数 | libgodot 偏移 |
| ——————————– | ————- - |
| Node::get_child | 0x1FD9BD0 |
| Area3D::set_monitoring | 0x25F94A4 |
| Area3D::set_monitorable | 0x25FAA0C |
| CollisionShape3D::set_disabled | 0x2624AB4 |
| Node3D::set_visible | 0x12424A8 |
| Node3D::set_global_position | 0x254583C |

完整代码:

1
$NDK/bin/aarch64-linux-android24-clang -O2 -pie trigger3_call.c -o trigger3_call
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/types.h>
#include <sys/ptrace.h>
#include <sys/wait.h>
#include <sys/uio.h>
#include <linux/elf.h>
#include <dirent.h>
#include <signal.h>

#define PKG_NAME "com.tencent.ACE.gamesec2026.final"

#define GET_CHILD_OFFSET 0x1FD9BD0ULL
#define SET_MONITORING_OFFSET 0x25F94A4ULL
#define SET_MONITORABLE_OFFSET 0x25FAA0CULL
#define SET_DISABLED_OFFSET 0x2624AB4ULL
#define SET_VISIBLE_OFFSET 0x12424A8ULL
#define NODE3D_SET_GLOBAL_POSITION_OFFSET 0x254583CULL

// Trigger3 known world coordinates (codex offline scene parse). X+Y are unique
// across the 4 Triggers, so 8-byte fingerprint suffices.
static const float TRIGGER3_POS[3] = { -13.811505f, 6.613474f, -22.492664f };
static const float SPAWN_POS[3] = { 8.0f, 3.364050f, -16.0f };

#define MAX_REGIONS 512
#define MAX_CANDIDATES 256
#define CHUNK_SIZE (4 * 1024 * 1024)
#define MAX_SEGS 8

struct arm64_regs {
uint64_t regs[31]; uint64_t sp; uint64_t pc; uint64_t pstate;
};

typedef struct { uint64_t start, end; } mem_region_t;
typedef struct { uint64_t mstart, mend; uint64_t fstart, fend; char perms[8]; } godot_seg_t;

static godot_seg_t godot_segs[MAX_SEGS];
static int n_godot_segs = 0;

static uint8_t *read_seg(int mem_fd, godot_seg_t *seg, size_t *out_size) {
size_t sz = seg->mend - seg->mstart;
uint8_t *buf = malloc(sz); if (!buf) return NULL;
if (pread64(mem_fd, buf, sz, seg->mstart) != (ssize_t)sz) { free(buf); return NULL; }
*out_size = sz; return buf;
}

// Dynamic vtable discovery via Itanium typeinfo string lookup.
static uint64_t find_vptr_by_name(int mem_fd, const char *mangled) {
size_t nl = strlen(mangled);
uint64_t name_addr = 0;
for (int i = 0; i < n_godot_segs && !name_addr; i++) {
if (godot_segs[i].perms[0] != 'r' || godot_segs[i].perms[1] == 'w') continue;
size_t sz; uint8_t *buf = read_seg(mem_fd, &godot_segs[i], &sz);
if (!buf) continue;
for (size_t k = 0; k + nl + 1 <= sz; k++)
if (buf[k] == mangled[0] && memcmp(buf+k, mangled, nl) == 0 && buf[k+nl] == 0
&& (k == 0 || buf[k-1] == 0)) {
name_addr = godot_segs[i].mstart + k; break;
}
free(buf);
}
if (!name_addr) { fprintf(stderr,"name '%s' not found\n", mangled); return 0; }

uint64_t typeinfo = 0;
for (int i = 0; i < n_godot_segs && !typeinfo; i++) {
if (godot_segs[i].perms[0] != 'r' || godot_segs[i].perms[1] == 'w') continue;
size_t sz; uint8_t *buf = read_seg(mem_fd, &godot_segs[i], &sz);
if (!buf) continue;
for (size_t k = 8; k + 8 <= sz; k += 8)
if (*(uint64_t *)(buf + k) == name_addr) { typeinfo = godot_segs[i].mstart + k - 8; break; }
free(buf);
}
if (!typeinfo) return 0;

uint64_t vtable = 0;
for (int i = 0; i < n_godot_segs && !vtable; i++) {
if (godot_segs[i].perms[0] != 'r' || godot_segs[i].perms[1] == 'w') continue;
size_t sz; uint8_t *buf = read_seg(mem_fd, &godot_segs[i], &sz);
if (!buf) continue;
for (size_t k = 8; k + 8 <= sz; k += 8)
if (*(uint64_t *)(buf + k) == typeinfo) {
int64_t off_top = (int64_t)*(uint64_t *)(buf + k - 8);
if (off_top > -0x10000 && off_top < 0x10000) {
vtable = godot_segs[i].mstart + k - 8; break;
}
}
free(buf);
}
if (!vtable) return 0;
return vtable + 16;
}

static pid_t find_game_pid(void) {
DIR *p = opendir("/proc"); if (!p) return 0;
struct dirent *e; pid_t f = 0;
while ((e = readdir(p))) {
if (e->d_type != DT_DIR) continue;
for (const char *q = e->d_name; *q; q++) if (*q < '0' || *q > '9') goto nx;
{ char path[64]; snprintf(path, 64, "/proc/%s/cmdline", e->d_name);
FILE *fp = fopen(path,"r"); if (!fp) continue;
char b[256]={0}; fread(b,1,255,fp); fclose(fp);
if (strstr(b, PKG_NAME)) { f = atoi(e->d_name); break; } }
nx:;
}
closedir(p); return f;
}

static void kill_self_tracer(pid_t pid) {
char p[64]; snprintf(p, 64, "/proc/%d/status", pid);
FILE *f = fopen(p, "r"); if (!f) return;
char line[256]; pid_t tracer = 0;
while (fgets(line, sizeof(line), f))
if (sscanf(line, "TracerPid: %d", &tracer) == 1) break;
fclose(f);
if (tracer > 0 && tracer != getpid()) {
printf("Killing self-tracer PID %d\n", tracer);
kill(tracer, 9); usleep(300 * 1000);
}
}

static int read_maps(pid_t pid, mem_region_t *regions, int *n_regions) {
char path[64]; snprintf(path, 64, "/proc/%d/maps", pid);
FILE *f = fopen(path,"r"); if (!f) { perror("open maps"); return -1; }
*n_regions = 0; char line[1024];
while (fgets(line, sizeof(line), f)) {
uint64_t s, e; char perms[8], dev[32], pb[512]={0};
unsigned long off, ino;
if (sscanf(line, "%lx-%lx %7s %lx %31s %lu %511[^\n]",
&s,&e,perms,&off,dev,&ino,pb) < 6) continue;
if (strstr(pb, "libgodot_android.so") && n_godot_segs < MAX_SEGS) {
godot_segs[n_godot_segs].mstart = s;
godot_segs[n_godot_segs].mend = e;
godot_segs[n_godot_segs].fstart = off;
godot_segs[n_godot_segs].fend = off + (e - s);
strncpy(godot_segs[n_godot_segs].perms, perms, 7);
n_godot_segs++;
}
if (perms[0]=='r' && perms[1]=='w' && perms[3]=='p') {
if (strstr(pb,"[stack]") || strstr(pb,"[vvar]") || strstr(pb,"/dev/")) continue;
if (e - s > 512ULL*1024*1024) continue;
if (*n_regions >= MAX_REGIONS) break;
regions[*n_regions].start = s; regions[(*n_regions)++].end = e;
}
}
fclose(f); return 0;
}

static int scan_for_vptr(int mem_fd, mem_region_t *regions, int n_regions,
uint64_t vptr, uint64_t *out, int max_out) {
uint8_t *buf = malloc(CHUNK_SIZE); if (!buf) return 0;
int count = 0;
uint64_t needle = vptr & 0x00FFFFFFFFFFFFFFULL;
for (int r = 0; r < n_regions && count < max_out; r++) {
uint64_t size = regions[r].end - regions[r].start;
for (uint64_t off = 0; off < size && count < max_out; off += CHUNK_SIZE) {
uint64_t chunk = (size - off > CHUNK_SIZE) ? CHUNK_SIZE : (size - off);
if (pread64(mem_fd, buf, chunk, regions[r].start + off) != (ssize_t)chunk) continue;
for (uint64_t i = 0; i + 8 <= chunk; i += 8) {
uint64_t v = *(uint64_t *)(buf + i) & 0x00FFFFFFFFFFFFFFULL;
if (v == needle) {
out[count++] = regions[r].start + off + i;
if (count >= max_out) break;
}
}
}
}
free(buf); return count;
}

static int find_trigger3(int mem_fd, uint64_t *area_objs, int n_area, uint64_t *out_obj) {
uint8_t needle[4]; memcpy(needle, TRIGGER3_POS, 4);
uint8_t buf[0x800];
for (int i = 0; i < n_area; i++) {
if (pread64(mem_fd, buf, sizeof(buf), area_objs[i]) != (ssize_t)sizeof(buf)) continue;
for (size_t k = 0; k + 4 <= sizeof(buf); k += 4) {
if (memcmp(buf + k, needle, 4) == 0) { *out_obj = area_objs[i]; return 0; }
}
}
return -1;
}

static pid_t pick_idle_tid(pid_t pid) {
char path[64]; snprintf(path, 64, "/proc/%d/task", pid);
DIR *d = opendir(path); if (!d) return pid;
struct dirent *e; pid_t best = 0;
while ((e = readdir(d))) {
if (e->d_name[0] < '0' || e->d_name[0] > '9') continue;
pid_t tid = atoi(e->d_name);
char sp[128]; snprintf(sp, 128, "/proc/%d/task/%d/stat", pid, tid);
FILE *f = fopen(sp, "r"); if (!f) continue;
char buf[512]; size_t n = fread(buf,1,sizeof(buf)-1,f); buf[n]=0; fclose(f);
char *st = strrchr(buf, ')'); if (!st || !st[1] || st[2] != 'S') continue;
best = tid; break;
}
closedir(d); return best ? best : pid;
}

static int get_regs(pid_t tid, struct arm64_regs *r) {
struct iovec iov = { .iov_base = r, .iov_len = sizeof(*r) };
return ptrace(PTRACE_GETREGSET, tid, NT_PRSTATUS, &iov);
}
static int set_regs(pid_t tid, const struct arm64_regs *r) {
struct iovec iov = { .iov_base = (void*)r, .iov_len = sizeof(*r) };
return ptrace(PTRACE_SETREGSET, tid, NT_PRSTATUS, &iov);
}

static int remote_call(pid_t tid, uint64_t func, uint64_t a0, uint64_t a1,
uint64_t a2, uint64_t a3, uint64_t *retval) {
struct arm64_regs saved, regs;
if (get_regs(tid, &saved) < 0) { perror("getregs"); return -1; }
regs = saved;

regs.regs[0] = a0;
regs.regs[1] = a1;
regs.regs[2] = a2;
regs.regs[3] = a3;
regs.regs[30] = 0;
regs.pc = func;
regs.sp = saved.sp & ~0xFULL;

if (set_regs(tid, &regs) < 0) { perror("setregs"); return -1; }
if (ptrace(PTRACE_CONT, tid, 0, 0) < 0) { perror("cont"); return -1; }

int status;
if (waitpid(tid, &status, __WALL) < 0) { perror("waitpid"); return -1; }
if (!WIFSTOPPED(status)) { fprintf(stderr,"unexpected status 0x%x\n", status); return -1; }

if (get_regs(tid, &regs) < 0) { perror("getregs2"); return -1; }
if (retval) *retval = regs.regs[0];
if (set_regs(tid, &saved) < 0) { perror("restore"); return -1; }
return 0;
}

static int remote_call_set_global_position(pid_t tid, int mem_fd,
uint64_t func, uint64_t this_ptr,
const float vec3[3]) {
struct arm64_regs saved, regs;
if (get_regs(tid, &saved) < 0) { perror("getregs"); return -1; }
regs = saved;

uint64_t sp_new = (saved.sp - 16) & ~0xFULL;
if (pwrite64(mem_fd, vec3, 12, sp_new) != 12) {
perror("pwrite vec3"); return -1;
}

regs.regs[0] = this_ptr;
regs.regs[1] = sp_new;
regs.regs[30] = 0;
regs.pc = func;
regs.sp = sp_new;

if (set_regs(tid, &regs) < 0) { perror("setregs"); return -1; }
if (ptrace(PTRACE_CONT, tid, 0, 0) < 0) { perror("cont"); return -1; }

int status;
if (waitpid(tid, &status, __WALL) < 0) { perror("waitpid"); return -1; }
if (!WIFSTOPPED(status)) { fprintf(stderr,"unexpected status 0x%x\n", status); return -1; }

if (set_regs(tid, &saved) < 0) { perror("restore"); return -1; }
return 0;
}

int main(int argc, char **argv) {
pid_t pid = find_game_pid();
if (!pid) { fprintf(stderr,"game not running\n"); return 1; }
printf("PID: %d\n", pid);
kill_self_tracer(pid);

mem_region_t regions[MAX_REGIONS]; int n_regions;
if (read_maps(pid, regions, &n_regions) < 0) return 1;
if (!n_godot_segs) { fprintf(stderr,"libgodot not mapped\n"); return 1; }
uint64_t godot_base = godot_segs[0].mstart;
printf("libgodot base: 0x%lx (%d rw regions)\n", godot_base, n_regions);

char mp[64]; snprintf(mp, 64, "/proc/%d/mem", pid);
int mem_fd = open(mp, O_RDWR);
if (mem_fd < 0) { perror("open mem"); return 1; }

fprintf(stderr,"Searching for Area3D vtable...\n");
uint64_t area3d_vptr = find_vptr_by_name(mem_fd, "6Area3D");
if (!area3d_vptr) { close(mem_fd); return 1; }

uint64_t area_objs[MAX_CANDIDATES];
int n_area = scan_for_vptr(mem_fd, regions, n_regions, area3d_vptr,
area_objs, MAX_CANDIDATES);
printf("Area3D objects: %d\n", n_area);
if (n_area == 0) { close(mem_fd); return 1; }

uint64_t trigger3 = 0;
if (find_trigger3(mem_fd, area_objs, n_area, &trigger3) < 0) {
fprintf(stderr,"Trigger3 fingerprint not found in any Area3D.\n"
" (Looking for floats (-13.81, 6.61) X+Y)\n");
close(mem_fd); return 2;
}
printf("Trigger3 @ 0x%lx\n", trigger3);

pid_t tid = pick_idle_tid(pid);
printf("Hijacking idle TID %d\n", tid);
if (ptrace(PTRACE_ATTACH, tid, 0, 0) < 0) { perror("attach"); close(mem_fd); return 1; }
int status; waitpid(tid, &status, __WALL);
if (!WIFSTOPPED(status)) {
fprintf(stderr,"initial wait status 0x%x\n", status);
ptrace(PTRACE_DETACH, tid, 0, 0); close(mem_fd); return 1;
}
printf("Attached, stop sig=%d\n", WSTOPSIG(status));

uint64_t f_set_monitoring = godot_base + SET_MONITORING_OFFSET;
uint64_t f_set_monitorable = godot_base + SET_MONITORABLE_OFFSET;
uint64_t f_get_child = godot_base + GET_CHILD_OFFSET;
uint64_t f_set_disabled = godot_base + SET_DISABLED_OFFSET;
uint64_t f_set_visible = godot_base + SET_VISIBLE_OFFSET;
uint64_t f_set_global_pos = godot_base + NODE3D_SET_GLOBAL_POSITION_OFFSET;

uint64_t rv;
int ok = 1;

printf("→ set_monitoring(trigger3, 1)\n");
if (remote_call(tid, f_set_monitoring, trigger3, 1, 0, 0, &rv) < 0) ok = 0;

printf("→ set_monitorable(trigger3, 1)\n");
if (ok && remote_call(tid, f_set_monitorable, trigger3, 1, 0, 0, &rv) < 0) ok = 0;

uint64_t collision = 0, mesh = 0;
if (ok && remote_call(tid, f_get_child, trigger3, 0, 0, 0, &collision) < 0) ok = 0;
printf("→ child[0] (CollisionShape3D) = 0x%lx\n", collision);
if (ok && collision) {
printf("→ set_disabled(collision, 0)\n");
if (remote_call(tid, f_set_disabled, collision, 0, 0, 0, &rv) < 0) ok = 0;
}

if (ok && remote_call(tid, f_get_child, trigger3, 1, 0, 0, &mesh) < 0) ok = 0;
printf("→ child[1] (MeshInstance3D) = 0x%lx\n", mesh);
if (ok && mesh) {
printf("→ set_visible(mesh, 1)\n");
if (remote_call(tid, f_set_visible, mesh, 1, 0, 0, &rv) < 0) ok = 0;
}

printf("→ set_global_position(trigger3, &Vector3{%.3f, %.3f, %.3f})\n",
SPAWN_POS[0], SPAWN_POS[1], SPAWN_POS[2]);
if (ok && remote_call_set_global_position(tid, mem_fd, f_set_global_pos,
trigger3, SPAWN_POS) < 0) ok = 0;

if (ptrace(PTRACE_DETACH, tid, 0, 0) < 0) perror("detach");
close(mem_fd);

if (ok) printf("\nDONE — Trigger3 enabled + teleported. Truck should overlap on next physics tick;\n"
"Label2 will display flag{sec2026_PART2_<32hex>}.\n");
return ok ? 0 : 1;
}

flag截图

image-20260418150152107

后面可以直接拿去验证算法真实性

flag 逻辑获取分析

trigger3.gd 红色方块,可以看到调用 native Process(buf) 方法

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
extends Area3D
signal collided_with(name)
var _gx = GameExtension.new()
var _tv := 0.0
var _ix := 0
var _kd := PackedByteArray() # 同样的密钥

# 与 trigger2 一样的 _h2b, _b2h, _xb, _rf 函数(疑似未使用,仅作为参考)

func _ready() -> void:
body_entered.connect(_w7)
var _ks := 'Sec2026'
var _ke := '_Godot'
_kd = (_ks + _ke).to_utf8_buffer() # "Sec2026_Godot" 同样的 key

func _w7(_ar):
var _lb := get_node(NodePath('/root/' + 'TownScene/Label2'))
var _lt := get_node(NodePath('/root/' + 'TownScene/Label'))
var _raw := str(_lt.text).substr(7) # 拿 token 字符串
var _buf := _raw.to_utf8_buffer() # UTF-8 bytes (8 字节)
var _pf := 'flag{'
var _mi := 'sec2026'
var _su := '_PART2_'
var _rv := _gx.Process(_buf) # 调 native
_lb.text = _pf + _mi + _su + _rv + '}' + ' '
  • 调用 _gx.Process(_buf),buf 是 token 字符串的 UTF-8(8 字节,例如 b”12345678”)
  • 输入是 8 字节,不是 4 字节解码后的 hex bytes
  • GDScript 中定义的 _h2b/_b2h/_xb/_rf 似乎没被调用,可能算法在 C++ 中重新实现
  • 密钥应仍是 “Sec2026_Godot”

两种解法,但是本质都是获取中间信息推动解密,算法识别

Frida调试 解法一

因为一开始先分析的process所以没绕过反调试,但是10s的空间足够我们进行一些调试,获取一些信息

  1. libsec2026.so 在 entry_funcs注册 GDExtension,通过 classdb_register_extension_class_method把 Process 字符串绑定到某个 native 函数。

  2. 用 IDA 查找字符串 Process的引用,找到注册调用点。注册结构体第二个字段指向一个 MethodBindCustom vtable,vtable 里含 call 回调。

  3. 用 Frida hook classdb_register_extension_class_method打印参数,来找process对应的native函数

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    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
    console.log("[+] Method dump v2");

    let secBase = null;
    let installed = false;

    ['android_dlopen_ext', 'dlopen', '__loader_dlopen', '__loader_android_dlopen_ext'].forEach(name => {
    const fn = Module.findExportByName(null, name);
    if (!fn) return;
    Interceptor.attach(fn, {
    onEnter(args) { this.path = args[0] ? args[0].readCString() : null; },
    onLeave(retval) {
    if (this.path && this.path.indexOf("libsec2026") >= 0) {
    console.log(`[*] dlopen libsec2026 done`);
    secBase = Module.findBaseAddress("libsec2026.so");
    if (secBase && !installed) { installed = true; install(); }
    }
    }
    });
    });

    secBase = Module.findBaseAddress("libsec2026.so");
    if (secBase && !installed) { installed = true; install(); }

    function hex(buf, len) {
    if (!buf) return "(null)";
    return Array.from(new Uint8Array(buf)).slice(0, len||32).map(b=>b.toString(16).padStart(2,'0')).join(' ');
    }

    function install() {
    console.log("[*] secBase =", secBase);

    // Anti-debug bypass
    const ANTIDEBUG = [0x9C654, 0x9CDC4, 0x9B7D8].map(o => secBase.add(o).toString());
    const ptrace = Module.findExportByName("libc.so", "ptrace");
    if (ptrace) Interceptor.replace(ptrace, new NativeCallback(()=>0,'long',['int','int','pointer','pointer']));
    const pthread_create = Module.findExportByName("libc.so", "pthread_create");
    if (pthread_create) {
    Interceptor.attach(pthread_create, {
    onEnter(args) {
    if (ANTIDEBUG.indexOf(args[2].toString()) >= 0) {
    console.log(`[BLOCK antidebug] ${args[2]}`);
    args[2] = ptr(0);
    }
    }
    });
    }

    // Poll for the API function pointer to be loaded
    let pollCount = 0;
    const poller = setInterval(() => {
    pollCount++;
    const off_183DF8 = secBase.add(0x183DF8);
    const apiFn = off_183DF8.readPointer();
    if (!apiFn.isNull()) {
    clearInterval(poller);
    console.log(`[*] register_extension_class_method API @ ${apiFn} (after ${pollCount*100}ms)`);
    hookRegister(apiFn);
    } else if (pollCount > 200) {
    clearInterval(poller);
    console.log("[!] Gave up waiting for API ptr");
    }
    }, 100);
    }

    function hookRegister(apiFn) {
    Interceptor.attach(apiFn, {
    onEnter(args) {
    // Only log if caller is in libsec2026
    const ret = this.returnAddress;
    const inSec = secBase && ret.compare(secBase) >= 0 && ret.sub(secBase).compare(ptr(0x200000)) < 0;
    if (!inSec) return;

    console.log(`\n[register_method] from ${ret} (libsec+0x${ret.sub(secBase).toString(16)})`);
    console.log(` lib:${args[0]} class_name:${args[1]} method_info:${args[2]}`);
    try {
    // Try to read class name (StringName, points to data structure)
    const sn = args[1];
    console.log(` class_name bytes: ${hex(sn.readByteArray(40))}`);
    // StringName layout: pointer to refcounted string
    const inner = sn.readPointer();
    if (!inner.isNull()) {
    console.log(` class_name->[+0]: ${hex(inner.readByteArray(40))}`);
    // Look for cstring
    for (const off of [0,4,8,12,16,20,24]) {
    try {
    const p = inner.add(off).readPointer();
    const s = p.readCString();
    if (s && /^[A-Za-z_][A-Za-z0-9_]*$/.test(s)) {
    console.log(` class_name->[${off}]->cstring = "${s}"`);
    }
    } catch(e){}
    }
    }
    } catch(e) {}

    try {
    const info = args[2];
    console.log(` info bytes: ${hex(info.readByteArray(80))}`);
    const namePtr = info.readPointer();
    console.log(` method.name (StringName ptr): ${namePtr}`);
    if (!namePtr.isNull()) {
    console.log(` name bytes: ${hex(namePtr.readByteArray(40))}`);
    try {
    const nameInner = namePtr.readPointer();
    if (!nameInner.isNull()) {
    console.log(` name->[+0]: ${hex(nameInner.readByteArray(40))}`);
    for (const off of [0,4,8,12,16,20,24]) {
    try {
    const p = nameInner.add(off).readPointer();
    const s = p.readCString();
    if (s && /^[A-Za-z_][A-Za-z0-9_]*$/.test(s)) {
    console.log(` name->[${off}]->cstring = "${s}"`);
    }
    } catch(e){}
    }
    }
    } catch(e) {}
    }
    const userdata = info.add(8).readPointer();
    const callFn = info.add(16).readPointer();
    const ptrcallFn = info.add(24).readPointer();
    console.log(` userdata: ${userdata}`);
    console.log(` call_func: ${callFn} (libsec+0x${callFn.sub(secBase).toString(16)})`);
    console.log(` ptrcall_func:${ptrcallFn} (libsec+0x${ptrcallFn.sub(secBase).toString(16)})`);
    } catch(e) { console.log(` parse err: ${e}`); }
    }
    });
    console.log("[*] register_method API hooked");
    }

    image-20260418191539890

    最主要的信息是

    1
    2
    call_func:    0x774bf17ab4  (libsec+0xb2ab4)
    ptrcall_func: 0x774bf17b0c (libsec+0xb2b0c)

    这意味着 Process 和 Tick 共用同一个分发函数 libsec+0xb2ab4

  4. sub_97704内部只做 userdata 解包,真正算法在其调用的Sub_A936C,sub_A936C 又调用sub_A7900(key schedule) 和sub_A7194(cipher)。

同时也发现反调试的端倪

image-20260418191925150

后续定位到sub_97704

sub_A7194这里一F5就会崩溃

image-20260418192253290

尽量从汇编分析

sub_A7194被 OLLVM 打散,IDA decompile 直接失败,disasm 看到 38 条 dispatcher 指令 + BR X9 间接跳。

做法

  1. 数 subfunction:在 IDA 里把sub_A7194全部直接 callees 列出来,发现几个反复被调用的函数:sub_A82C8, sub_A8F00, sub_AADE8, sub_A6F20, sub_AAB64, sub_AA9B0, sub_A7944, sub_A8D44。这有点像AES。

  2. Frida Stalker call summary 统计实际调用次数。得到:

    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
    console.log("[+] Stalker call trace post-AAB64(11)");
    let secBase = null, installed = false;

    ['android_dlopen_ext','dlopen','__loader_dlopen','__loader_android_dlopen_ext'].forEach(name => {
    const fn = Module.findExportByName(null, name);
    if (!fn) return;
    Interceptor.attach(fn, {
    onEnter(args) { this.path = args[0] ? args[0].readCString() : null; },
    onLeave(retval) {
    if (this.path && this.path.indexOf("libsec2026") >= 0 && !installed) {
    installed = true;
    secBase = Module.findBaseAddress("libsec2026.so");
    setTimeout(doit, 3000);
    }
    }
    });
    });
    secBase = Module.findBaseAddress("libsec2026.so");
    if (secBase && !installed) { installed = true; setTimeout(doit, 2000); }

    function hex(b) { return Array.from(new Uint8Array(b)).map(x=>x.toString(16).padStart(2,'0')).join(''); }

    function doit() {
    console.log("[*] secBase=" + secBase);
    const ptrace = Module.findExportByName("libc.so", "ptrace");
    if (ptrace) Interceptor.replace(ptrace, new NativeCallback(()=>0,'long',['int','int','pointer','pointer']));

    const secEnd = secBase.add(0x400000);
    let trackBlock = null;
    let trackState = null;
    let aab64_11_done = false;
    let post_calls = [];

    Interceptor.attach(secBase.add(0xaab64), {
    onEnter(args) { this.round = args[0].toInt32(); },
    onLeave(retval) {
    if (this.round === 0xb) {
    aab64_11_done = true;
    console.log(`[AAB64(11) returned]`);
    }
    }
    });

    Interceptor.attach(secBase.add(0xa7194), {
    onEnter(args) {
    trackState = args[0];
    trackBlock = args[1];
    aab64_11_done = false;
    post_calls = [];
    console.log(`[A7194] state=${args[0]} block=${args[1]}`);
    const tid = this.threadId;
    Stalker.follow(tid, {
    events: { call: true },
    onCallSummary(summary) {
    if (!aab64_11_done) return;
    for (const [target, count] of Object.entries(summary)) {
    const addr = ptr(target);
    if (addr.compare(secBase) >= 0 && addr.compare(secEnd) < 0) {
    const off = addr.sub(secBase).toString(16);
    post_calls.push(`+${off} x${count}`);
    }
    }
    }
    });
    },
    onLeave(retval) {
    try { Stalker.unfollow(this.threadId); } catch(e) {}
    Stalker.flush();
    console.log(`[A7194 EXIT] block=${hex(this.block ? this.block.readByteArray(16) : trackBlock.readByteArray(16))}`);
    console.log(`[POST-AAB64(11) calls in libsec2026]:`);
    for (const c of post_calls) console.log(" " + c);
    }
    });

    // Run cipher
    const sub_A7900 = new NativeFunction(secBase.add(0xa7900), 'pointer', ['pointer','pointer','pointer']);
    const sub_A7194 = new NativeFunction(secBase.add(0xa7194), 'pointer', ['pointer','pointer','int']);
    const state = Memory.alloc(512);
    const k1 = Memory.alloc(16), k2 = Memory.alloc(16), block = Memory.alloc(64);
    const k1B = "2c7e151618aec2a1abf7158809cf4f3c".match(/.{2}/g).map(x=>parseInt(x,16));
    for (let i = 0; i < 16; i++) k1.add(i).writeU8(k1B[i]);
    for (let i = 0; i < 16; i++) k2.add(i).writeU8(0);
    for (let i = 0; i < 512; i++) state.add(i).writeU8(0);
    const pt = "31323334353637383132333435363738".match(/.{2}/g).map(x=>parseInt(x,16));
    for (let i = 0; i < 16; i++) block.add(i).writeU8(pt[i]);

    sub_A7900(state, k1, k2);
    console.log("\n[*] Calling A7194...");
    sub_A7194(state, block, 16);
    console.log(`\n[*] Final block: ${hex(block.readByteArray(16))}`);
    }

    image-20260418192706960

a96f0 x64 = 10 × 16 × 4,看着 a96f0 是 GF(2^8) 乘法(MixColumns 的 inner mul),a6f20是 MixColumns。11 轮 + 初始 round key + 最终轮无 MC,这就是 AES-128 的骨架(10 或 11 round,根据 Nk 变形)。

image-20260418192536472

获取常量

首先有这么多的块,如何定位块的逻辑?

可以用Stalker去追踪

也就是之前的

image-20260418204813948

提取所有 S-box 和常量

SBOX_ENC

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
console.log("[+] Dump runtime tables");
let secBase = null, installed = false;

['android_dlopen_ext','dlopen','__loader_dlopen','__loader_android_dlopen_ext'].forEach(name => {
const fn = Module.findExportByName(null, name);
if (!fn) return;
Interceptor.attach(fn, {
onEnter(args) { this.path = args[0] ? args[0].readCString() : null; },
onLeave(retval) {
if (this.path && this.path.indexOf("libsec2026") >= 0 && !installed) {
installed = true;
secBase = Module.findBaseAddress("libsec2026.so");
setTimeout(doit, 3000);
}
}
});
});
secBase = Module.findBaseAddress("libsec2026.so");
if (secBase && !installed) { installed = true; setTimeout(doit, 2000); }

function hex(b) { return Array.from(new Uint8Array(b)).map(x=>x.toString(16).padStart(2,'0')).join(' '); }

function doit() {
console.log("[*] secBase=" + secBase);
const ANTIDEBUG = [0x9C654, 0x9CDC4, 0x9B7D8].map(o => secBase.add(o).toString());
const ptrace = Module.findExportByName("libc.so", "ptrace");
if (ptrace) Interceptor.replace(ptrace, new NativeCallback(()=>0,'long',['int','int','pointer','pointer']));

// Trigger the cipher to initialize tables
const sub_A7900 = new NativeFunction(secBase.add(0xa7900), 'pointer', ['pointer','pointer','pointer']);
const sub_A7194 = new NativeFunction(secBase.add(0xa7194), 'pointer', ['pointer','pointer','int']);
const state = Memory.alloc(512);
const k1 = Memory.alloc(16), k2 = Memory.alloc(16), block = Memory.alloc(64);
for (let i = 0; i < 512; i++) state.add(i).writeU8(0);
const k1B = "2c7e151618aec2a1abf7158809cf4f3c".match(/.{2}/g).map(x=>parseInt(x,16));
for (let i = 0; i < 16; i++) k1.add(i).writeU8(k1B[i]);
for (let i = 0; i < 16; i++) k2.add(i).writeU8(0);
for (let i = 0; i < 16; i++) block.add(i).writeU8(0);
sub_A7900(state, k1, k2);
sub_A7194(state, block, 16);

// Dump unk_183700 after running cipher
console.log("\n=== unk_183700 (after running cipher) ===");
const tab = secBase.add(0x183700);
for (let i = 0; i < 512; i += 16) {
console.log(` +${i.toString(16).padStart(3,'0')}: ${hex(tab.add(i).readByteArray(16))}`);
}

// Also dump some other possible table areas
console.log("\n=== Look for tables in other regions ===");
// Let me search for areas that look like S-box tables near known references
const candidates = [0x183700, 0x183800, 0x183900, 0x183a00, 0x183908];
for (const off of candidates) {
const buf = secBase.add(off).readByteArray(32);
console.log(` +${off.toString(16)}: ${hex(buf).substring(0,64)}...`);
}
}

image-20260418202701267

得到 256 字节的完整表。AES 标准是 0x63 … 看出来非标准了。

SBOX_KS和RCON也是类似做法

做法类似,但 SBOX_KS 与 SBOX_ENC是两张不同的表。通过跟踪 sub_A7DE8(key schedule 的 SubWord 子程)的内存读取定位表地址,dump 出 256 字节。

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
console.log("[+] Full S-box sweep");
let secBase = null;
let installed = false;

['android_dlopen_ext','dlopen','__loader_dlopen','__loader_android_dlopen_ext'].forEach(name => {
const fn = Module.findExportByName(null, name);
if (!fn) return;
Interceptor.attach(fn, {
onEnter(args) { this.path = args[0] ? args[0].readCString() : null; },
onLeave(retval) {
if (this.path && this.path.indexOf("libsec2026") >= 0 && !installed) {
installed = true;
secBase = Module.findBaseAddress("libsec2026.so");
setTimeout(sweep, 3000);
}
}
});
});
secBase = Module.findBaseAddress("libsec2026.so");
if (secBase && !installed) { installed = true; setTimeout(sweep, 2000); }

function hex(x) { return x.toString(16).padStart(2,'0'); }

function sweep() {
console.log("[*] secBase=" + secBase);
const ANTIDEBUG = [0x9C654, 0x9CDC4, 0x9B7D8].map(o => secBase.add(o).toString());
const ptrace = Module.findExportByName("libc.so", "ptrace");
if (ptrace) Interceptor.replace(ptrace, new NativeCallback(()=>0,'long',['int','int','pointer','pointer']));

const sub_A7900 = new NativeFunction(secBase.add(0xa7900), 'pointer', ['pointer','pointer','pointer']);
const state = Memory.alloc(512);
const keyBuf = Memory.alloc(64);
const scratch = Memory.alloc(32);

// Sweep each of the 4 bytes of last word (bytes 12, 13, 14, 15 of key)
// For each byte position, vary 0..255, other bytes = 0
// See which rhs is affected
const position_results = {};
for (const pos of [12, 13, 14, 15]) {
const mappings = [];
for (let b = 0; b < 256; b++) {
for (let i = 0; i < 512; i++) state.add(i).writeU8(0);
for (let i = 0; i < 64; i++) keyBuf.add(i).writeU8(0);
keyBuf.add(pos).writeU8(b);

sub_A7900(state, keyBuf, scratch);
const rk0 = new Uint8Array(state.readByteArray(16));
const rk1 = new Uint8Array(state.add(16).readByteArray(16));
const rhs = [rk1[0]^rk0[0], rk1[1]^rk0[1], rk1[2]^rk0[2], rk1[3]^rk0[3]];
mappings.push(rhs);
}
position_results[pos] = mappings;
}

console.log("\n=== Position → rhs correlation ===");
for (const pos of [12, 13, 14, 15]) {
const m = position_results[pos];
const distinct = [0,0,0,0].map((_, i) => new Set(m.map(r => r[i])).size);
console.log(` key byte ${pos}: distinct rhs counts = [${distinct.join(', ')}]`);
}

console.log("\n=== S-box mappings (extracted from each key-byte sweep) ===");
for (const pos of [12, 13, 14, 15]) {
const m = position_results[pos];
// Find which rhs position has most variance
const distinct = [0,0,0,0].map((_, i) => new Set(m.map(r => r[i])).size);
let maxIdx = 0;
for (let i = 1; i < 4; i++) if (distinct[i] > distinct[maxIdx]) maxIdx = i;
const sbox = {};
for (let b = 0; b < 256; b++) sbox[b] = m[b][maxIdx];

// Dump S-box
console.log(`\n-- key byte ${pos} → rhs[${maxIdx}] S-box --`);
let out = "const SBOX_pos" + maxIdx + " = [\n";
for (let i = 0; i < 256; i += 16) {
out += " " + Array.from({length:16}, (_,j) => "0x" + hex(sbox[i+j])).join(", ") + ",\n";
}
out += "];";
console.log(out);
}

console.log("\n[+] Done sweep");
}

image-20260418203139440

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
console.log("[+] Extract A8F00 constants per round");
let secBase = null, installed = false;

['android_dlopen_ext','dlopen','__loader_dlopen','__loader_android_dlopen_ext'].forEach(name => {
const fn = Module.findExportByName(null, name);
if (!fn) return;
Interceptor.attach(fn, {
onEnter(args) { this.path = args[0] ? args[0].readCString() : null; },
onLeave(retval) {
if (this.path && this.path.indexOf("libsec2026") >= 0 && !installed) {
installed = true;
secBase = Module.findBaseAddress("libsec2026.so");
setTimeout(doit, 3000);
}
}
});
});
secBase = Module.findBaseAddress("libsec2026.so");
if (secBase && !installed) { installed = true; setTimeout(doit, 2000); }

function hex(b) { return Array.from(new Uint8Array(b)).map(x=>x.toString(16).padStart(2,'0')).join(''); }

function doit() {
console.log("[*] secBase=" + secBase);
const ANTIDEBUG = [0x9C654, 0x9CDC4, 0x9B7D8].map(o => secBase.add(o).toString());
const ptrace = Module.findExportByName("libc.so", "ptrace");
if (ptrace) Interceptor.replace(ptrace, new NativeCallback(()=>0,'long',['int','int','pointer','pointer']));

// Warmup
const sub_A7900 = new NativeFunction(secBase.add(0xa7900), 'pointer', ['pointer','pointer','pointer']);
const sub_A7194 = new NativeFunction(secBase.add(0xa7194), 'pointer', ['pointer','pointer','int']);
const sub_A8F00 = new NativeFunction(secBase.add(0xa8f00), 'pointer', ['pointer','int','int']);
const state = Memory.alloc(512);
const k1 = Memory.alloc(16), k2 = Memory.alloc(16), block = Memory.alloc(64);
for (let i = 0; i < 512; i++) state.add(i).writeU8(0);
const k1B = "2c7e151618aec2a1abf7158809cf4f3c".match(/.{2}/g).map(x=>parseInt(x,16));
for (let i = 0; i < 16; i++) k1.add(i).writeU8(k1B[i]);
for (let i = 0; i < 16; i++) { k2.add(i).writeU8(0); block.add(i).writeU8(0); }
sub_A7900(state, k1, k2);
sub_A7194(state, block, 16);

// Extract constants for each round
console.log("\n=== A8F00 constants per round ===");
console.log("CONST_A8F00 = {");
for (let r = 0; r <= 11; r++) {
for (let j = 0; j < 16; j++) block.add(j).writeU8(0);
sub_A8F00(block, r, 0x20);
const ct = hex(block.readByteArray(16));
console.log(` ${r}: bytes.fromhex("${ct}"),`);
}
console.log("}");

// Also extract size argument variations - is the 3rd arg (0x20) significant?
// The sub_A8D44 always calls with 0x20. Let me verify by testing different values.
console.log("\n=== Test size argument variation ===");
for (const sz of [0x10, 0x20, 0x40]) {
for (let j = 0; j < 16; j++) block.add(j).writeU8(0);
sub_A8F00(block, 1, sz);
console.log(` A8F00(0s, r=1, size=0x${sz.toString(16)}) = ${hex(block.readByteArray(16))}`);
}
}

image-20260418203436103

提取K_INIT

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
console.log("[+] Sweep A7944");
let secBase = null, installed = false;

['android_dlopen_ext','dlopen','__loader_dlopen','__loader_android_dlopen_ext'].forEach(name => {
const fn = Module.findExportByName(null, name);
if (!fn) return;
Interceptor.attach(fn, {
onEnter(args) { this.path = args[0] ? args[0].readCString() : null; },
onLeave(retval) {
if (this.path && this.path.indexOf("libsec2026") >= 0 && !installed) {
installed = true;
secBase = Module.findBaseAddress("libsec2026.so");
setTimeout(doit, 3000);
}
}
});
});
secBase = Module.findBaseAddress("libsec2026.so");
if (secBase && !installed) { installed = true; setTimeout(doit, 2000); }

function hex(b) { return Array.from(new Uint8Array(b)).map(x=>x.toString(16).padStart(2,'0')).join(' '); }

function doit() {
console.log("[*] secBase=" + secBase);
const ANTIDEBUG = [0x9C654, 0x9CDC4, 0x9B7D8].map(o => secBase.add(o).toString());
const ptrace = Module.findExportByName("libc.so", "ptrace");
if (ptrace) Interceptor.replace(ptrace, new NativeCallback(()=>0,'long',['int','int','pointer','pointer']));

// Init cipher first to populate tables
const sub_A7900 = new NativeFunction(secBase.add(0xa7900), 'pointer', ['pointer','pointer','pointer']);
const sub_A7194 = new NativeFunction(secBase.add(0xa7194), 'pointer', ['pointer','pointer','int']);
const sub_A7944 = new NativeFunction(secBase.add(0xa7944), 'pointer', ['pointer','pointer']);

const state = Memory.alloc(512);
const k1 = Memory.alloc(16), k2 = Memory.alloc(16), block = Memory.alloc(64);
for (let i = 0; i < 512; i++) state.add(i).writeU8(0);
const k1B = "2c7e151618aec2a1abf7158809cf4f3c".match(/.{2}/g).map(x=>parseInt(x,16));
for (let i = 0; i < 16; i++) k1.add(i).writeU8(k1B[i]);
for (let i = 0; i < 16; i++) k2.add(i).writeU8(0);
// Warmup to populate tables
for (let i = 0; i < 16; i++) block.add(i).writeU8(0);
sub_A7900(state, k1, k2);
sub_A7194(state, block, 16);

// Now test A7944 directly
console.log("\n=== A7944 test with K1=actual, state populated ===");
// First, re-expand to get fresh state
sub_A7900(state, k1, k2);

function test_a7944(input_hex, desc) {
const b = input_hex.match(/.{2}/g).map(x => parseInt(x, 16));
for (let i = 0; i < 16; i++) block.add(i).writeU8(b[i]);
sub_A7944(block, state);
const out = hex(block.readByteArray(16));
console.log(` A7944(${input_hex}) = ${out} [${desc}]`);
return out;
}

test_a7944("00000000000000000000000000000000", "all zero");
test_a7944("01000000000000000000000000000000", "byte 0 = 1");
test_a7944("02000000000000000000000000000000", "byte 0 = 2");
test_a7944("ff000000000000000000000000000000", "byte 0 = 0xff");
test_a7944("00010000000000000000000000000000", "byte 1 = 1");
test_a7944("00000001000000000000000000000000", "byte 3 = 1");
test_a7944("31323334353637383132333435363738", "PT duplicated");

// Sweep byte 0 and look at output[0]
console.log("\n=== Byte 0 sweep ===");
const map0 = new Array(256);
for (let b = 0; b < 256; b++) {
for (let j = 0; j < 16; j++) block.add(j).writeU8(0);
block.add(0).writeU8(b);
sub_A7944(block, state);
const out = new Uint8Array(block.readByteArray(16));
map0[b] = out[0];
}
console.log("A7944 byte 0 S-box (with state populated from K1):");
for (let i = 0; i < 256; i += 16) {
let row = [];
for (let j = 0; j < 16; j++) row.push(map0[i+j].toString(16).padStart(2,'0'));
console.log(` ${(i).toString(16).padStart(2,'0')}: ${row.join(' ')}`);
}

// Also check: does A7944 output change if state changes?
console.log("\n=== A7944 with different states ===");
// state = all 0
for (let j = 0; j < 512; j++) state.add(j).writeU8(0);
for (let j = 0; j < 16; j++) block.add(j).writeU8(0);
sub_A7944(block, state);
console.log(` A7944(0s, state=0s) = ${hex(block.readByteArray(16))}`);

for (let j = 0; j < 16; j++) block.add(j).writeU8(0);
block.add(0).writeU8(1);
sub_A7944(block, state);
console.log(` A7944([1,0,...], state=0s) = ${hex(block.readByteArray(16))}`);
}

image-20260418203615898

提取K1 K2

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
// Probe how K2 is used in sub_A7194
console.log("[+] K2 probe");
let secBase = null, installed = false;

['android_dlopen_ext','dlopen','__loader_dlopen','__loader_android_dlopen_ext'].forEach(name => {
const fn = Module.findExportByName(null, name);
if (!fn) return;
Interceptor.attach(fn, {
onEnter(args) { this.path = args[0] ? args[0].readCString() : null; },
onLeave(retval) {
if (this.path && this.path.indexOf("libsec2026") >= 0 && !installed) {
installed = true;
secBase = Module.findBaseAddress("libsec2026.so");
setTimeout(doit, 3000);
}
}
});
});
secBase = Module.findBaseAddress("libsec2026.so");
if (secBase && !installed) { installed = true; setTimeout(doit, 2000); }

function hex(b) { return Array.from(new Uint8Array(b)).map(x=>x.toString(16).padStart(2,'0')).join(''); }

function doit() {
console.log("[*] secBase=" + secBase);
const ANTIDEBUG = [0x9C654, 0x9CDC4, 0x9B7D8].map(o => secBase.add(o).toString());
const ptrace = Module.findExportByName("libc.so", "ptrace");
if (ptrace) Interceptor.replace(ptrace, new NativeCallback(()=>0,'long',['int','int','pointer','pointer']));

const sub_A7900 = new NativeFunction(secBase.add(0xa7900), 'pointer', ['pointer','pointer','pointer']);
const sub_A7194 = new NativeFunction(secBase.add(0xa7194), 'pointer', ['pointer','pointer','int']);

function encrypt(k1Hex, k2Hex, ptHex) {
const state = Memory.alloc(512);
const k1 = Memory.alloc(16);
const k2 = Memory.alloc(16);
const block = Memory.alloc(64);
for (let i = 0; i < 512; i++) state.add(i).writeU8(0);
const k1B = k1Hex.match(/.{2}/g).map(x=>parseInt(x,16));
const k2B = k2Hex.match(/.{2}/g).map(x=>parseInt(x,16));
const ptB = ptHex.match(/.{2}/g).map(x=>parseInt(x,16));
for (let i = 0; i < 16; i++) k1.add(i).writeU8(k1B[i]);
for (let i = 0; i < 16; i++) k2.add(i).writeU8(k2B[i]);
for (let i = 0; i < 16; i++) block.add(i).writeU8(ptB[i]);
sub_A7900(state, k1, k2);
sub_A7194(state, block, 16);
return hex(block.readByteArray(16));
}

// Tests
const K1_ACTUAL = "2c7e151618aec2a1abf7158809cf4f3c";
const K2_ACTUAL = "2c7e15161a2de471ccff11cf04882f1d";
const PT1 = "00000000000000000000000000000000";
const PT2 = "31323334353637383132333435363738";

const tests = [
["K1=actual, K2=actual, PT=zero", K1_ACTUAL, K2_ACTUAL, PT1],
["K1=actual, K2=zero, PT=zero", K1_ACTUAL, "00".repeat(16), PT1],
["K1=actual, K2=ones, PT=zero", K1_ACTUAL, "ff".repeat(16), PT1],
["K1=zero, K2=actual, PT=zero", "00".repeat(16), K2_ACTUAL, PT1],
["K1=zero, K2=zero, PT=zero", "00".repeat(16), "00".repeat(16), PT1],
["K1=actual, K2=actual, PT=12345678*2", K1_ACTUAL, K2_ACTUAL, PT2],
["K1=actual, K2=zero, PT=12345678*2", K1_ACTUAL, "00".repeat(16), PT2],
];

for (const [name, k1, k2, pt] of tests) {
const ct = encrypt(k1, k2, pt);
console.log(` ${name.padEnd(40)}: ${ct}`);
}

// Specifically: does K2 affect output? Compare K2=actual vs K2=zero with same K1, PT
const ct_with = encrypt(K1_ACTUAL, K2_ACTUAL, PT2);
const ct_without = encrypt(K1_ACTUAL, "00".repeat(16), PT2);
console.log(`\n Diff of (K2=actual vs K2=zero): `);
let diff_bytes = [];
for (let i = 0; i < 16; i++) {
diff_bytes.push((parseInt(ct_with.substr(i*2,2),16) ^ parseInt(ct_without.substr(i*2,2),16)).toString(16).padStart(2,'0'));
}
console.log(` ${diff_bytes.join(' ')}`);
console.log(` K2: ${K2_ACTUAL}`);
}

image-20260418203741494

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
// Dump AES round keys from sub_A7900 (key schedule)
// 然后和标准 AES-256 key schedule 对比,找出修改点
console.log("[+] Round key dump");

let secBase = null;
let installed = false;

['android_dlopen_ext','dlopen','__loader_dlopen','__loader_android_dlopen_ext'].forEach(name => {
const fn = Module.findExportByName(null, name);
if (!fn) return;
Interceptor.attach(fn, {
onEnter(args) { this.path = args[0] ? args[0].readCString() : null; },
onLeave(retval) {
if (this.path && this.path.indexOf("libsec2026") >= 0 && !installed) {
installed = true;
secBase = Module.findBaseAddress("libsec2026.so");
setTimeout(dump, 3000);
}
}
});
});
secBase = Module.findBaseAddress("libsec2026.so");
if (secBase && !installed) { installed = true; setTimeout(dump, 2000); }

function hex(buf, start, len) {
const arr = new Uint8Array(buf);
return Array.from(arr).slice(start||0, (start||0)+(len||arr.length)).map(b=>b.toString(16).padStart(2,'0')).join(' ');
}

function dump() {
console.log("[*] secBase=" + secBase);
// Anti-debug
const ANTIDEBUG = [0x9C654, 0x9CDC4, 0x9B7D8].map(o => secBase.add(o).toString());
const ptrace = Module.findExportByName("libc.so", "ptrace");
if (ptrace) Interceptor.replace(ptrace, new NativeCallback(()=>0,'long',['int','int','pointer','pointer']));

// sub_A7900(state, key_32B, unused?) — expands 32-byte key to round keys in state buffer
const sub_A7900 = new NativeFunction(secBase.add(0xa7900), 'pointer', ['pointer','pointer','pointer']);
// sub_A7194(state, block, 16) — encrypts 16-byte block in place
const sub_A7194 = new NativeFunction(secBase.add(0xa7194), 'pointer', ['pointer','pointer','int']);

const state = Memory.alloc(512); // big enough for round keys
const key = Memory.alloc(64);
const scratch = Memory.alloc(32);
// Zero
for (let i = 0; i < 512; i++) state.add(i).writeU8(0);

// Test vector 1: NIST AES-256 test key
const testKey = [
0x60,0x3d,0xeb,0x10,0x15,0xca,0x71,0xbe,0x2b,0x73,0xae,0xf0,0x85,0x7d,0x77,0x81,
0x1f,0x35,0x2c,0x07,0x3b,0x61,0x08,0xd7,0x2d,0x98,0x10,0xa3,0x09,0x14,0xdf,0xf4
];
for (let i = 0; i < 32; i++) key.add(i).writeU8(testKey[i]);

console.log("\n=== Test 1: NIST AES-256 test key ===");
console.log("Input key: " + hex(key.readByteArray(32)));

try {
sub_A7900(state, key, scratch);

// Standard AES-256 produces 15 round keys × 16 bytes = 240 bytes
console.log("\nRound keys (dumped 256 bytes):");
for (let rk = 0; rk < 16; rk++) {
const b = state.readByteArray(16);
console.log(` RK[${rk}]: ${hex(b)}`);
state = state.add(16);
}
} catch(e) { console.log("[ERR A7900]:", e); }

// Dump original 16x16 region at state pointer (avoid race)
const state2 = Memory.alloc(512);
for (let i = 0; i < 512; i++) state2.add(i).writeU8(0);
try {
sub_A7900(state2, key, scratch);
console.log("\n=== State buffer 256 bytes after A7900 ===");
for (let off = 0; off < 256; off += 16) {
console.log(` +${off.toString(16).padStart(3,'0')}: ${hex(state2.readByteArray(16).slice ? state2.readByteArray(16) : state2.readByteArray(16))}`);
state2 = state2.add(16);
}
} catch(e) {}

// Test encryption of all-zero block with this key
const state3 = Memory.alloc(512);
const block = Memory.alloc(32);
for (let i = 0; i < 512; i++) state3.add(i).writeU8(0);
for (let i = 0; i < 32; i++) key.add(i).writeU8(testKey[i]);
for (let i = 0; i < 16; i++) block.add(i).writeU8(0);
try {
sub_A7900(state3, key, scratch);
sub_A7194(state3, block, 16);
console.log("\n=== AES encrypt of 16 zero bytes ===");
console.log(" block after: " + hex(block.readByteArray(16)));
// NIST FIPS-197 App F.5.6 says AES-256 with this key + 0s block =?
} catch(e) { console.log("[ERR A7194]:", e); }

// Also test with zero-key
console.log("\n=== Test 2: all-zero key, encrypt all-zero block ===");
for (let i = 0; i < 512; i++) state3.add(i).writeU8(0);
for (let i = 0; i < 32; i++) key.add(i).writeU8(0);
for (let i = 0; i < 16; i++) block.add(i).writeU8(0);
try {
sub_A7900(state3, key, scratch);
sub_A7194(state3, block, 16);
console.log(" block after: " + hex(block.readByteArray(16)));
} catch(e) { console.log("[ERR]:", e); }
console.log("\n[+] Done dump");
}

image-20260418203819824

做了非常多的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
SBOX_ENC
F1 12 5D C6 A7 8A 6A 48 DA 0F 11 3B 3C B3 2D 27
64 2A D3 13 DC 68 C9 CF 17 2E D0 CD C2 B7 C7 F5
66 9B 1C 73 E0 56 DD A5 3A C8 3D D2 B0 E5 EE EC
82 DF 43 B8 61 50 EF B1 35 72 8F C3 EA 84 AE 86
67 23 C4 A6 07 4D ED B4 24 7B 22 E6 BA 76 DB 9A
49 77 30 EB 97 A0 60 1B 51 58 FB 52 7E 69 7F F8
15 BF BB 28 A8 70 55 31 B9 AB 7C 80 FE 53 D1 05
93 FA 6D F7 CE 45 B5 02 21 BC 4B 19 5E 79 4A 90
E7 47 98 16 6B 20 5A 89 D7 42 F2 CA FF A9 0E A2
46 C5 E9 9E 18 0C 7A 59 54 5F 32 C1 E4 65 44 9D
AD 63 B2 CB 4C 2F A1 1F 9F 4E 04 E3 39 0D D9 71
FC 34 78 8D F4 33 7D F9 36 25 BD 38 B6 DE 75 5C
83 9C 8B 40 D4 AA 1D 06 00 A4 6C 88 A3 3F CC F3
D5 BE 81 0B 37 94 14 8C 2B 92 FD 1E E1 03 D6 4F
C0 09 74 5B E2 62 AF 10 6E 85 F6 96 8E 6F 08 F0
99 95 0A 1A AC 91 D8 29 26 3E E8 01 2C 87 41 57

RCON 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x71, 0xE2, 0x5B]

C_INIT(16 字节,初始 XOR) EF 4E 15 34 16 E9 66 31 A9 A4 25 D0 A7 40 E6 60 CONST_A8F00[1..11](11 个 round constants,每个 16 字节)

r= 1: e4db002ffc23d8b794eb30bfac330847
r= 2: 817205fea9ea6d3651e255ee795abd26
r= 3: 1e090acd56b102b50ed97a1d46817205
r= 4: bba00f9c03789734cbd09f4c13a827e4
r= 5: 5837146bb03f2cb388c7c47be0cfdcc3
r= 6: f5ce193a5d06c13245bee9aaadf691a2
r= 7: 92651e090acd56b102b50ed97a1d4681
r= 8: 2ffc23d8b794eb30bfac33084744fb60
r= 9: cc9328a7645b80af7ca35837146bb03f
r=10: 692a2d761122152e399a7d66e192651e
r=11: 06c13245bee9aaadf691a295aeb91afd

ROUND_KEYS[1..11](11 个轮密钥,每个 16 字节)

r= 1: d8d277e1c07cb5406b8ba0c86244eff4
r= 2: 771ee381ec3e0b9fdce9f609e5f144a3
r= 3: d90596ee838c25c8e9d26b78ba949762
r= 4: f7444e2365da78ff9d1a009336 9c84e5
r= 5: 635eb0e96ae9a6799b9ec885c16f220f
r= 6: 0c13ac6fa132c3dcfd64c293fbc32956
r= 7: 6c67fbf1ef761c083031fabee9d1f7cd
r= 8: 2e4149d7bc492a5ff106af6165a9272c
r= 9: 26419f4242d16fc66b0e1a7cd67ee78b
r=10: 94dc18fde539420dbd036d475849bffa
r=11: dc1b9adf6d1390c643ab9adc64315eef K1(16 字节,key schedule 的第一个输入)
2c7e1516...4f3c

K2(16 字节,key schedule 的第二个输入)
2c7e1516...2f1d

MIX 矩阵(4×4,MixColumns 系数) [[6, 3, 5, 2],
[2, 6, 3, 5],
[5, 2, 6, 3],
[3, 5, 2, 6]]

INV_MIX 矩阵(4×4,逆 MixColumns)

[[0x80, 0xF3, 0x64, 0xAF],
[0xAF, 0x80, 0xF3, 0x64],
[0x64, 0xAF, 0x80, 0xF3],
[0xF3, 0x64, 0xAF, 0x80]]

GF poly = 0x171(即 x^8 + 0x71)
GF(2^8) reduction polynomial。非标 AES 是 0x11b。这是整个 PART2 算法的核心非标点。

P1 / P2 置换表(各 16 字节,AES 的 ShiftRows + 列重排)

P1 = [12, 8, 4, 0, 13, 9, 5, 1, 14, 10, 6, 2, 15, 11, 7, 3]
P2 = [ 0, 13, 6, 11, 4, 1, 10, 15, 8, 5, 14, 3, 12, 9, 2, 7]

POST_XOR(最后压轴的 16 字节常量,inline 在 sub_A7194 末尾)
7C E3 28 91 A6 5D F0 14 BB 69 07 D8 4A 35 EC 80
识别每个算法原语

AAB64 = AddRoundKey + pattern(round)

hook sub_AAB64(round, block, state):

  • before = block[0..16]
  • rk = state[round*16 .. round*16+16]
  • after = block[0..16]
  • 计算 eff_xor = before ^ after
  • 观察 eff_xor ^ rk 是什么

实测每轮:eff_xor ^ rk = [base, base+1, base+2, base+3, base, ...],其中 base = (round * 0x5b) & 0xff。即:

1
2
3
4
def aab64(state, rk, r):
base = (r * 0x5b) & 0xff
pat = bytes([(base + (i%4)) & 0xff for i in range(16)])
return bytes(a ^ b ^ c for a,b,c in zip(state, rk, pat))

这个 (r*0x5b + i%4) 的 pattern 是我手动试探得来的,先检查 eff_xor ^ rk 是否为 0(不是),再看是否只和 round 有关(是),最后拟合出 base 和 stride 都是简单线性函数。

A82C8 = SubBytes(直接 SBOX 查表)

跟之前的脚本类似做法:喂不同 block 观察 after。对 block = 0,1,2,...,255 喂进去看输出,直接得到 S-box 映射。验证 = SBOX_ENC(同一张表)。

A8F00 = rot90 + const XOR

通过基矢测试:喂 block = [1,0,0,0,...][0,1,0,0,...]、…,看每一位 → 输出的映射。发现是纯字节置换(线性,不混 bit)。进一步验证置换就是 4×4 矩阵按列主序的 90 度旋转:

1
dst[row*4 + (3-c)] = src[c*4 + row]

再加一个 round 相关的 XOR 常量,就是 CONST_A8F00[r]。

AADE8 = ShiftRows

类似基矢测试得到字节置换。拟合出每行的 shift 量为 (0, 3, 1, 2)——row 0 不动,row 1 左移 3,row 2 左移 1,row 3 左移 2。不是标准 AES 的 (0,1,2,3)

A6F20 = MixColumns(带非标准 poly)

通过基矢测试得到线性响应后,每列的 4 个输入对 4 个输出都有贡献(区分于 permute 的纯字节移动)。这意味着存在 GF 乘法。

拟合矩阵系数时,标准 GF(2^8) poly 0x1b 下解不出整系数。脚本暴力尝试 256 个 poly:对每个 poly,用矩阵反解看能否得到 0..255 范围内的小系数。最终 poly = 0x71 才给出干净的矩阵:

image-20260418205114410

这与 Rcon 后 3 项用 0x71 翻倍的发现吻合,整套密码使用 poly 0x71。

AA9B0 = XOR permute_k2(K2)

类似 AAB64 的做法,hook sub_AA9B0(block, k2, size),观察不同 K2 值对 block 的影响。测试 K2=0、K2=单点 1,发现输出是 block XOR 一个置换过的 K2。置换规则:

1
permute_k2(K2)[i] = K2[i] if i是奇数 else K2[15-i]

密钥扩展 sub_A7900 (Key Schedule)

类似标准 AES-128 key schedule,但 SubWord 用 SBOX_KS,RotWord 方向反转(右旋 1 而非左旋 1),Rcon 用 [01,02,04,08,10,20,40,80,71,e2,5b]

但是按照 Frida Stalker 观察到的调用顺序拼出完整算法。

第一次验证失败:用 token 12345678,K2=0:

  • Python 输出:50ca048e75e2ff932e900f1157db674b
  • Frida 实际输出:2c292c1fd3bf0f8795f908c91dee8bcb

中间步骤到 AAB64(11) 为止逐步完全匹配,但 sub_A7194 返回后 block 又被改了,可能还有其它逻辑

抓最终隐藏 XOR

确认修改发生的位置

脚本 hook AAB64(11) 的 onLeave、hook memcpy/__memcpy_chk/memmove

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
console.log("[+] Post-AAB64(11) tracking");
let secBase = null, installed = false;

['android_dlopen_ext','dlopen','__loader_dlopen','__loader_android_dlopen_ext'].forEach(name => {
const fn = Module.findExportByName(null, name);
if (!fn) return;
Interceptor.attach(fn, {
onEnter(args) { this.path = args[0] ? args[0].readCString() : null; },
onLeave(retval) {
if (this.path && this.path.indexOf("libsec2026") >= 0 && !installed) {
installed = true;
secBase = Module.findBaseAddress("libsec2026.so");
setTimeout(doit, 3000);
}
}
});
});
secBase = Module.findBaseAddress("libsec2026.so");
if (secBase && !installed) { installed = true; setTimeout(doit, 2000); }

function hex(b) { return Array.from(new Uint8Array(b)).map(x=>x.toString(16).padStart(2,'0')).join(''); }

function doit() {
console.log("[*] secBase=" + secBase);
const ANTIDEBUG = [0x9C654, 0x9CDC4, 0x9B7D8].map(o => secBase.add(o).toString());
const ptrace = Module.findExportByName("libc.so", "ptrace");
if (ptrace) Interceptor.replace(ptrace, new NativeCallback(()=>0,'long',['int','int','pointer','pointer']));

// Hook AAB64 — on onLeave, we'll check what comes next
let aab64CallCount = 0;
let block_global = null;
let state_global = null;

Interceptor.attach(secBase.add(0xaab64), {
onEnter(args) {
this.round = args[0].toInt32();
this.block = args[1];
this.state = args[2];
block_global = args[1];
state_global = args[2];
try { this.before = hex(this.block.readByteArray(16)); } catch(e) { this.before = "?"; }
},
onLeave(retval) {
aab64CallCount++;
let after = "?";
try { after = hex(this.block.readByteArray(16)); } catch(e) {}
if (this.round === 0xb) {
console.log(`[AAB64(11) EXIT] before=${this.before}, after=${after}`);
// Check state[0xC0..0xD0]
try {
const state_k2 = hex(this.state.add(0xc0).readByteArray(16));
console.log(` state[0xC0] at AAB64(11) exit: ${state_k2}`);
} catch(e) {}
}
}
});

// Hook memcpy to catch any final copy
const memcpy = Module.findExportByName("libc.so", "memcpy");
const memcpy_chk = Module.findExportByName("libc.so", "__memcpy_chk");
for (const fn of [memcpy, memcpy_chk]) {
if (!fn) continue;
Interceptor.attach(fn, {
onEnter(args) {
// Only log memcpy calls where dst or src is our block/state
if (!block_global || !state_global) return;
const dst = args[0];
const src = args[1];
const sz = args[2] ? args[2].toInt32() : 0;
if (sz === 16 && (dst.equals(block_global) || src.equals(block_global) ||
dst.equals(state_global.add(0xc0)) || src.equals(state_global.add(0xc0)))) {
try {
const srcData = hex(src.readByteArray(16));
console.log(`[MEMCPY ${fn === memcpy ? 'mc' : 'mchk'}] dst=${dst} src=${src} sz=${sz} data=${srcData}`);
} catch(e) {}
}
}
});
}

// Run cipher
const sub_A7900 = new NativeFunction(secBase.add(0xa7900), 'pointer', ['pointer','pointer','pointer']);
const sub_A7194 = new NativeFunction(secBase.add(0xa7194), 'pointer', ['pointer','pointer','int']);
const state = Memory.alloc(512);
const k1 = Memory.alloc(16), k2 = Memory.alloc(16), block = Memory.alloc(64);
const k1B = "2c7e151618aec2a1abf7158809cf4f3c".match(/.{2}/g).map(x=>parseInt(x,16));
for (let i = 0; i < 16; i++) k1.add(i).writeU8(k1B[i]);
for (let i = 0; i < 16; i++) k2.add(i).writeU8(0);
for (let i = 0; i < 512; i++) state.add(i).writeU8(0);
const pt = "31323334353637383132333435363738".match(/.{2}/g).map(x=>parseInt(x,16));
for (let i = 0; i < 16; i++) block.add(i).writeU8(pt[i]);

sub_A7900(state, k1, k2);
console.log(`\nBlock before A7194: ${hex(block.readByteArray(16))}`);
sub_A7194(state, block, 16);
console.log(`Block after A7194: ${hex(block.readByteArray(16))}`);
console.log(`State[0xC0] after: ${hex(state.add(0xc0).readByteArray(16))}`);
}

image-20260418205537622

发现AAB64(11) 返回时 block = 50ca04…,但最后 memcpy 从 block 到 state+0xC0 的数据已经是 2c29.。说明在 AAB64(11) 返回到 memcpy 之间,block 被代码改写了

验证 DIFF 是常量,4 个不同 token × 2 种 K2 值测试:

token K2 my output real output diff
12345678 0 50ca04… 2c292c… 7ce32891…ec80
00000000 0 6301e6… 1fe2ce… 7ce32891…ec80
10453fc3 actual 3ee4c5… 4207ed… 7ce32891…ec80
12345678 actual c6cbfc… ba28d4… 7ce32891…ec80

DIFF 恒为 7ce32891a65df014bb6907d84a35ec80

没有在代码里发现哪里做了手脚,但是通过diff得到是个定值

可能是通过运行时派生。

把这个diff作为最后的xor进去就得到了完整的算法

Unicorn 解法二

思想跟frida一样

定位到sub_97704后,有 一堆 flattening,题目做了很多懒初始化和跳表,可以写个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
from __future__ import annotations

import sys

from common_emu import run_part2

WATCH = {
0xA7944: "A7944",
0xAAB64: "AAB64",
0xA82C8: "A82C8",
0xA8F00: "A8F00",
0xAADE8: "AADE8",
0xA6F20: "A6F20",
0xA84A4: "A84A4",
}

def main() -> int:
token = sys.argv[1] if len(sys.argv) > 1 else "10453fc3"
counts: dict[str, int] = {}
order: list[str] = []

def hook(_uc, address: int) -> bool:
name = WATCH.get(address)
if name:
counts[name] = counts.get(name, 0) + 1
order.append(name)
return False

run_part2(token, hook)
print("counts =", counts)
print("order =", order)
return 0


if __name__ == "__main__":
raise SystemExit(main())

能看到执行了哪些函数统计执行次数

image-20260418215550541

1
2
3
4
5
6
7
A7944 1 次
AAB64 12 次
A82C8 11 次
A8F00 11 次
AADE8 11 次
A6F20 10 次
A84A4 1 次

说明:

  • 有 round 0 whitening
  • 有 10 个完整 round
  • 有 1 个 final round
  • final round 没有A6F20
  • 最后还有单独尾处理 A84A4

然后再抓AA9B0,A8D44,A7194,各 round 的中间状态

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
#!/usr/bin/env python3
from __future__ import annotations

import sys

from unicorn.arm64_const import UC_ARM64_REG_X0, UC_ARM64_REG_X1, UC_ARM64_REG_X2, UC_ARM64_REG_X30

from common_emu import run_part2


def main() -> int:
token = sys.argv[1] if len(sys.argv) > 1 else "10453fc3"
state: dict[str, object] = {}

def hook(uc, address: int) -> bool:
if address == state.get("aa9b0_ret"):
x0, x1, pre0, pre1 = state["aa9b0"] # type: ignore[misc]
print("AA9B0")
print(" x0 =", hex(x0))
print(" x1 =", hex(x1))
print(" pre_x0 =", pre0.hex())
print(" pre_x1 =", pre1.hex())
print(" post_x0 =", bytes(uc.mem_read(x0, 16)).hex())
print(" post_x1 =", bytes(uc.mem_read(x1, 16)).hex())

if address == state.get("a8d44_ret"):
x0, x1, pre0, pre1 = state["a8d44"] # type: ignore[misc]
print("A8D44")
print(" x0 =", hex(x0))
print(" x1 =", hex(x1))
print(" pre_x0 =", pre0.hex())
print(" post_x0 =", bytes(uc.mem_read(x0, 16)).hex())
print(" x1_first32_pre =", pre1.hex())
print(" x1_first32_post=", bytes(uc.mem_read(x1, 32)).hex())

if address == state.get("a7194_ret"):
x0, x1, x2, buf_pre, ctx_c0_pre = state["a7194"] # type: ignore[misc]
print("A7194")
print(" ctx =", hex(x0))
print(" buf =", hex(x1))
print(" len =", x2)
print(" buf_before =", buf_pre.hex())
print(" buf_after =", bytes(uc.mem_read(x1, 16)).hex())
print(" ctx_c0_before=", ctx_c0_pre.hex())
print(" ctx_c0_after =", bytes(uc.mem_read(x0 + 0xC0, 16)).hex())

if address == 0xAA9B0:
x0 = uc.reg_read(UC_ARM64_REG_X0)
x1 = uc.reg_read(UC_ARM64_REG_X1)
state["aa9b0"] = (x0, x1, bytes(uc.mem_read(x0, 16)), bytes(uc.mem_read(x1, 16)))
state["aa9b0_ret"] = uc.reg_read(UC_ARM64_REG_X30)
elif address == 0xA8D44:
x0 = uc.reg_read(UC_ARM64_REG_X0)
x1 = uc.reg_read(UC_ARM64_REG_X1)
state["a8d44"] = (x0, x1, bytes(uc.mem_read(x0, 16)), bytes(uc.mem_read(x1, 32)))
state["a8d44_ret"] = uc.reg_read(UC_ARM64_REG_X30)
elif address == 0xA7194:
x0 = uc.reg_read(UC_ARM64_REG_X0)
x1 = uc.reg_read(UC_ARM64_REG_X1)
x2 = uc.reg_read(UC_ARM64_REG_X2)
state["a7194"] = (
x0,
x1,
x2,
bytes(uc.mem_read(x1, 16)),
bytes(uc.mem_read(x0 + 0xC0, 16)),
)
state["a7194_ret"] = uc.reg_read(UC_ARM64_REG_X30)
return False

run_part2(token, hook)
return 0


if __name__ == "__main__":
raise SystemExit(main())

image-20260418220427308

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
#!/usr/bin/env python3
from __future__ import annotations

import sys

from unicorn.arm64_const import UC_ARM64_REG_X0, UC_ARM64_REG_X1, UC_ARM64_REG_X30

from common_emu import run_part2


def main() -> int:
token = sys.argv[1] if len(sys.argv) > 1 else "10453fc3"
pending: dict[int, list[tuple[str, int, int | None]]] = {}
serial: dict[str, int] = {}

def push(ret_addr: int, item: tuple[str, int, int | None]) -> None:
pending.setdefault(ret_addr, []).append(item)

def bump(name: str) -> int:
serial[name] = serial.get(name, 0) + 1
return serial[name]

def hook(uc, address: int) -> bool:
items = pending.pop(address, [])
for name, ptr, ordinal in items:
data = bytes(uc.mem_read(ptr, 16)).hex()
tag = f"{name}#{ordinal}" if ordinal is not None else name
print(f"{tag} -> {data}")

if address == 0xA7944:
push(uc.reg_read(UC_ARM64_REG_X30), ("A7944", uc.reg_read(UC_ARM64_REG_X0), bump("A7944")))
elif address == 0xA82C8:
push(uc.reg_read(UC_ARM64_REG_X30), ("A82C8", uc.reg_read(UC_ARM64_REG_X0), bump("A82C8")))
elif address == 0xA8F00:
push(
uc.reg_read(UC_ARM64_REG_X30),
("A8F00", uc.reg_read(UC_ARM64_REG_X0), uc.reg_read(UC_ARM64_REG_X1) & 0xFF),
)
elif address == 0xAADE8:
push(uc.reg_read(UC_ARM64_REG_X30), ("AADE8", uc.reg_read(UC_ARM64_REG_X0), bump("AADE8")))
elif address == 0xA6F20:
push(uc.reg_read(UC_ARM64_REG_X30), ("A6F20", uc.reg_read(UC_ARM64_REG_X0), bump("A6F20")))
elif address == 0xAAB64:
push(
uc.reg_read(UC_ARM64_REG_X30),
("AAB64", uc.reg_read(UC_ARM64_REG_X1), uc.reg_read(UC_ARM64_REG_X0) & 0xFF),
)
elif address == 0xA84A4:
push(uc.reg_read(UC_ARM64_REG_X30), ("A84A4", uc.reg_read(UC_ARM64_REG_X0), bump("A84A4")))
return False

run_part2(token, hook)
return 0


if __name__ == "__main__":
raise SystemExit(main())

image-20260418220507704

确认:

  • AA9B0只是 duplicated token 的逐字节 xor
  • A8D44是原地改写 16-byte state
  • A7194只是调度器,不是实际轮函数

接着针对每一层分别识别:

A82C8

  • 通过读 0x183700
  • 确认是 256-byte 自定义 S-box

image-20260418220718525

A8F00

  • 通过 basis test
  • 确认为纯字节置换 Perm1,加一组 round 常量

image-20260418220748095

AADE8通过输入 00..0f,确认为固定字节置换Perm2

A6F20,起初最难,先看它自身只像个 flattened dispatcher,再继续追它内部 helper A96F0,识别A96F0是 GF 乘法

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
#!/usr/bin/env python3
from __future__ import annotations

from unicorn.arm64_const import UC_ARM64_REG_X0, UC_ARM64_REG_X1, UC_ARM64_REG_X30

from common_emu import RET, create_emulator, run_until_ret


def run_a96f0(x: int, k: int) -> int:
mu = create_emulator()
mu.reg_write(UC_ARM64_REG_X0, x & 0xFF)
mu.reg_write(UC_ARM64_REG_X1, k & 0xFF)
mu.reg_write(UC_ARM64_REG_X30, RET)
run_until_ret(mu, 0xA96F0)
return mu.reg_read(UC_ARM64_REG_X0) & 0xFF


def main() -> int:
print("k = 0..7, x in [0,1,2,3,0x57,0x83,0xff]")
for k in range(8):
values = [run_a96f0(x, k) for x in [0, 1, 2, 3, 0x57, 0x83, 0xFF]]
print(k, values)

print("xtime checks under poly 0x171:")
for x in [0x40, 0x80, 0xAE]:
print(hex(x), "x2 =", hex(run_a96f0(x, 2)), "x4 =", hex(run_a96f0(x, 4)))
return 0


if __name__ == "__main__":
raise SystemExit(main())

image-20260418220904205

A96F0(x, k)的行为表明k=1是恒等,k=2/3/4/5/6/7 是 GF 上的常数乘法,约简多项式不是 AES 的 0x11b,而是 0x171

于是 A6F20 最终可以写成:

  • 连续 4-byte 列混合
  • GF(0x171)
  • 4x4 矩阵
1
2
3
4
[6 3 5 2]
[2 6 3 5]
[5 2 6 3]
[3 5 2 6]

最后再抓:

  • S-box
  • round constants
  • round keys
  • A7944_CONST
  • A84A4_CONST
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
#!/usr/bin/env python3
from __future__ import annotations

import sys

from unicorn.arm64_const import UC_ARM64_REG_X0, UC_ARM64_REG_X1, UC_ARM64_REG_X30

from common_emu import run_part2


P1 = [12, 8, 4, 0, 13, 9, 5, 1, 14, 10, 6, 2, 15, 11, 7, 3]


def xor_bytes(a: bytes, b: bytes) -> bytes:
return bytes(x ^ y for x, y in zip(a, b))


def permute(state: bytes, table: list[int]) -> bytes:
return bytes(state[i] for i in table)


def main() -> int:
token = sys.argv[1] if len(sys.argv) > 1 else "10453fc3"
pending: dict[int, tuple[str, tuple[object, ...]]] = {}
round_constants: dict[int, str] = {}
round_keys: dict[int, str] = {}
a7944_const = ""
a84a4_const = ""
sbox_hex = ""

def hook(uc, address: int) -> bool:
nonlocal a7944_const, a84a4_const, sbox_hex

item = pending.pop(address, None)
if item:
kind, payload = item
if kind == "A7944":
ptr, before = payload
after = bytes(uc.mem_read(ptr, 16))
a7944_const = xor_bytes(before, after).hex()
elif kind == "A8F00":
round_idx, ptr, before = payload
after = bytes(uc.mem_read(ptr, 16))
round_constants[round_idx] = xor_bytes(after, permute(before, P1)).hex()
elif kind == "AAB64":
round_idx, ptr, before = payload
after = bytes(uc.mem_read(ptr, 16))
round_keys[round_idx] = xor_bytes(before, after).hex()
elif kind == "A84A4":
ptr, before = payload
after = bytes(uc.mem_read(ptr, 16))
a84a4_const = xor_bytes(before, after).hex()

if address == 0xA82C8 and not sbox_hex:
sbox_hex = bytes(uc.mem_read(0x183700, 256)).hex()
elif address == 0xA7944:
ptr = uc.reg_read(UC_ARM64_REG_X0)
pending[uc.reg_read(UC_ARM64_REG_X30)] = ("A7944", (ptr, bytes(uc.mem_read(ptr, 16))))
elif address == 0xA8F00:
ptr = uc.reg_read(UC_ARM64_REG_X0)
round_idx = uc.reg_read(UC_ARM64_REG_X1) & 0xFF
pending[uc.reg_read(UC_ARM64_REG_X30)] = ("A8F00", (round_idx, ptr, bytes(uc.mem_read(ptr, 16))))
elif address == 0xAAB64:
ptr = uc.reg_read(UC_ARM64_REG_X1)
round_idx = uc.reg_read(UC_ARM64_REG_X0) & 0xFF
pending[uc.reg_read(UC_ARM64_REG_X30)] = ("AAB64", (round_idx, ptr, bytes(uc.mem_read(ptr, 16))))
elif address == 0xA84A4:
ptr = uc.reg_read(UC_ARM64_REG_X0)
pending[uc.reg_read(UC_ARM64_REG_X30)] = ("A84A4", (ptr, bytes(uc.mem_read(ptr, 16))))
return False

run_part2(token, hook)

print("SBOX =", sbox_hex)
print("A7944_CONST =", a7944_const)
for round_idx in sorted(round_constants):
print(f"ROUND_CONST_{round_idx} =", round_constants[round_idx])
for round_idx in sorted(round_keys):
print(f"ROUND_KEY_{round_idx} =", round_keys[round_idx])
print("A84A4_CONST =", a84a4_const)
return 0


if __name__ == "__main__":
raise SystemExit(main())

image-20260418221100225

自此所有材料已经具备

Part2的最终 pure forward 已经可以写成:

1
2
3
4
5
6
7
8
9
10
11
12
13
state = (token_ascii || token_ascii) XOR C_init

for round = 1..10:
state = SubBytes(SBOX, state)
state = Perm1(state) XOR round_const[round]
state = Perm2(state)
state = Mix_GF171(state)
state = state XOR round_key[round]

state = SubBytes(SBOX, state)
state = Perm1(state) XOR round_const[11]
state = Perm2(state)
state = state XOR rk11_folded

其中:

  • C_init = ef4e153416e96631a9a425d0a740e660
  • Perm1 = [12,8,4,0,13,9,5,1,14,10,6,2,15,11,7,3]
  • Perm2 = [0,13,6,11,4,1,10,15,8,5,14,3,12,9,2,7]
  • Mix_GF17 的域多项式是 0x171

中间还测了一些样例如

  • 10453fc3 -> 4207ed2de80b6646e802106c49ddf10f
  • deadbeef -> 6ae003283e97f2606f3e668bb5a6be0
  • 00000000 -> be75f176a587d29af20b445dc2c0f3a8
  • aaaaaaaa -> 3b7030bf9fbeb26c4313a51ed9bfa88e

去混淆 解法三

打印出一些patch必要信息,流程跟之前一样, 通用 OLLVM CFF 求解器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
import struct, sys
from pathlib import Path
from collections import defaultdict
from capstone import Cs, CS_ARCH_ARM64, CS_MODE_ARM, CS_OPT_DETAIL
from capstone.arm64 import (
ARM64_OP_REG, ARM64_OP_IMM, ARM64_OP_MEM,
ARM64_INS_MOV, ARM64_INS_MOVZ, ARM64_INS_MOVK, ARM64_INS_MOVN,
ARM64_INS_ADD, ARM64_INS_SUB, ARM64_INS_EOR, ARM64_INS_AND, ARM64_INS_ORR,
ARM64_INS_LSL, ARM64_INS_LSR,
ARM64_INS_CMP, ARM64_INS_CSEL, ARM64_INS_LDR, ARM64_INS_LDUR,
ARM64_INS_STR, ARM64_INS_STUR, ARM64_INS_LDP, ARM64_INS_STP,
ARM64_INS_BR, ARM64_INS_BLR, ARM64_INS_B, ARM64_INS_BL, ARM64_INS_RET,
ARM64_INS_NOP, ARM64_INS_ADRP, ARM64_INS_ADR,
ARM64_CC_EQ, ARM64_CC_NE, ARM64_CC_HI, ARM64_CC_LS, ARM64_CC_HS, ARM64_CC_LO,
ARM64_CC_LT, ARM64_CC_LE, ARM64_CC_GT, ARM64_CC_GE,
)
ARM64_CC_CC = ARM64_CC_LO # alias
ARM64_CC_CS = ARM64_CC_HS # alias

LIB = Path(__file__).parent / 'final/lib/arm64-v8a/libsec2026.so'

# --- ELF .text loader -------------------------------------------------------
def load_text(path):
raw = path.read_bytes()
# ELF64 e_phoff @ 0x20, e_phnum @ 0x38, e_phentsize @ 0x36
e_phoff = struct.unpack('<Q', raw[0x20:0x28])[0]
e_phnum = struct.unpack('<H', raw[0x38:0x3a])[0]
e_phentsize = struct.unpack('<H', raw[0x36:0x38])[0]
segs = []
for i in range(e_phnum):
ph = raw[e_phoff + i*e_phentsize : e_phoff + (i+1)*e_phentsize]
p_type = struct.unpack('<I', ph[:4])[0]
if p_type != 1: continue # PT_LOAD
p_off = struct.unpack('<Q', ph[8:16])[0]
p_va = struct.unpack('<Q', ph[16:24])[0]
p_fsz = struct.unpack('<Q', ph[32:40])[0]
segs.append((p_va, p_off, p_fsz))
return raw, segs

def va_to_off(segs, va):
for p_va, p_off, p_fsz in segs:
if p_va <= va < p_va + p_fsz:
return p_off + (va - p_va)
return None

def read(raw, segs, va, n):
off = va_to_off(segs, va)
if off is None: return None
return raw[off:off+n]

def load_relocations(raw):
"""Parse R_AARCH64_RELATIVE entries → {va: target_va}."""
e_shoff = struct.unpack('<Q', raw[0x28:0x30])[0]
e_shentsize = struct.unpack('<H', raw[0x3a:0x3c])[0]
e_shnum = struct.unpack('<H', raw[0x3c:0x3e])[0]
relocs = {}
for i in range(e_shnum):
sh = raw[e_shoff + i*e_shentsize : e_shoff + (i+1)*e_shentsize]
sh_type = struct.unpack('<I', sh[4:8])[0]
sh_off = struct.unpack('<Q', sh[0x18:0x20])[0]
sh_size = struct.unpack('<Q', sh[0x20:0x28])[0]
sh_entsize = struct.unpack('<Q', sh[0x38:0x40])[0]
if sh_type != 4 or sh_entsize != 24: continue # SHT_RELA, 24-byte entries
for j in range(sh_size // 24):
r_off, r_info, r_add = struct.unpack('<QQq', raw[sh_off + j*24 : sh_off + (j+1)*24])
r_type = r_info & 0xFFFFFFFF
if r_type == 1027: # R_AARCH64_RELATIVE
relocs[r_off] = r_add & 0xFFFFFFFFFFFFFFFF
return relocs

def read_table_entry(raw, segs, relocs, va):
"""Read 8 bytes at VA, preferring relocation if present."""
if va in relocs: return relocs[va]
off = va_to_off(segs, va)
if off is None: return None
bs = raw[off:off+8]
if len(bs) < 8: return None
return struct.unpack('<Q', bs)[0]

SP_BASE = 0x10000000 # logical SP at function entry

class Em:
"""Const-propagation emulator. Memory is keyed by absolute logical address
(sp/x29 are tracked as concrete ints)."""
def __init__(self, init_regs):
self.regs = dict(init_regs)
if 'sp' not in self.regs or self.regs['sp'] is None:
self.regs['sp'] = SP_BASE
self.mem = {} # {addr: (size, value)}
self.calls = []
self.stores = []
self.cmp_lhs = None
self.cmp_rhs = None
def get(self, name):
if name in ('xzr', 'wzr'): return 0
if name and name.startswith('w') and len(name) >= 2 and name[1].isdigit():
name = 'x' + name[1:]
return self.regs.get(name)
def set(self, name, val):
if name in ('xzr', 'wzr'): return
is_w = name and name.startswith('w') and len(name) >= 2 and name[1].isdigit()
if is_w:
name = 'x' + name[1:]
if val is not None:
val &= 0xFFFFFFFF
self.regs[name] = val
def mem_load(self, addr, size):
if addr is None: return None
# exact-size hit
if addr in self.mem:
sz, val = self.mem[addr]
if sz == size: return val
if sz > size: # take low bits
return val & ((1 << (size*8)) - 1) if val is not None else None
return None
def mem_store(self, addr, size, val):
if addr is None: return
self.mem[addr] = (size, val)
def get(self, name):
if name in ('xzr', 'wzr'): return 0
# canonicalize w<N> -> x<N>
if name and name.startswith('w') and len(name) >= 2 and name[1].isdigit():
name = 'x' + name[1:]
return self.regs.get(name)
def set(self, name, val):
if name in ('xzr', 'wzr'): return
is_w = name and name.startswith('w') and len(name) >= 2 and name[1].isdigit()
if is_w:
name = 'x' + name[1:]
if val is not None:
val &= 0xFFFFFFFF
self.regs[name] = val

def reg32(name):
if name.startswith('w'):
return 'x' + name[1:]
return name

def reg_name(insn, opnd):
return insn.reg_name(opnd.reg).lower()

def mem_addr(em, insn, opnd):
"""Resolve [base + offset] to absolute logical address (or None)."""
if opnd.type != ARM64_OP_MEM: return None
mb = opnd.mem
base = insn_reg_name_capstone(insn, mb.base) if mb.base else None
base_v = em.get(base) if base else 0
if base_v is None: return None
if mb.index:
idx = insn_reg_name_capstone(insn, mb.index)
idx_v = em.get(idx)
if idx_v is None: return None
return (base_v + idx_v + mb.disp) & 0xFFFFFFFFFFFFFFFF
return (base_v + mb.disp) & 0xFFFFFFFFFFFFFFFF

def reg_size(name):
return 4 if (name and name[0] == 'w') else 8

DISPATCH_BRANCH = ('br_to_dispatcher', None)

def step(em, insn, dispatcher_pc):
"""Execute one instruction. Return (continue?, exit_info_or_None)."""
opc = insn.id
ops = insn.operands
addr = insn.address

if opc == ARM64_INS_NOP:
return True, None

# MOV reg, #imm / MOV reg, reg
if opc == ARM64_INS_MOV:
dst = reg_name(insn, ops[0])
src = ops[1]
if src.type == ARM64_OP_REG:
em.set(dst, em.get(reg_name(insn, src)))
elif src.type == ARM64_OP_IMM:
em.set(dst, src.imm & 0xFFFFFFFFFFFFFFFF)
return True, None

if opc == ARM64_INS_MOVZ:
dst = reg_name(insn, ops[0])
imm = ops[1].imm
sh = 0
# shift encoded as second operand if present (capstone exposes via shift on op2)
if hasattr(ops[1], 'shift') and ops[1].shift.value:
sh = ops[1].shift.value
em.set(dst, (imm << sh) & 0xFFFFFFFFFFFFFFFF)
return True, None

if opc == ARM64_INS_MOVK:
dst = reg_name(insn, ops[0])
imm = ops[1].imm
sh = 0
if hasattr(ops[1], 'shift') and ops[1].shift.value:
sh = ops[1].shift.value
cur = em.get(dst) or 0
mask = (0xFFFF << sh)
em.set(dst, (cur & ~mask) | ((imm << sh) & mask))
return True, None

if opc == ARM64_INS_MOVN:
dst = reg_name(insn, ops[0])
imm = ops[1].imm
sh = 0
if hasattr(ops[1], 'shift') and ops[1].shift.value:
sh = ops[1].shift.value
em.set(dst, (~(imm << sh)) & 0xFFFFFFFFFFFFFFFF)
return True, None

if opc in (ARM64_INS_ADD, ARM64_INS_SUB, ARM64_INS_EOR, ARM64_INS_AND, ARM64_INS_ORR,
ARM64_INS_LSL, ARM64_INS_LSR):
dst = reg_name(insn, ops[0])
a = em.get(reg_name(insn, ops[1]))
if ops[2].type == ARM64_OP_IMM:
b = ops[2].imm
elif ops[2].type == ARM64_OP_REG:
b = em.get(reg_name(insn, ops[2]))
else:
b = None
if a is None or b is None:
em.set(dst, None); return True, None
if opc == ARM64_INS_ADD: r = a + b
elif opc == ARM64_INS_SUB: r = a - b
elif opc == ARM64_INS_EOR: r = a ^ b
elif opc == ARM64_INS_AND: r = a & b
elif opc == ARM64_INS_ORR: r = a | b
elif opc == ARM64_INS_LSL: r = a << b
elif opc == ARM64_INS_LSR: r = a >> b
em.set(dst, r & 0xFFFFFFFFFFFFFFFF)
return True, None

if opc == ARM64_INS_CMP:
em.cmp_lhs = em.get(reg_name(insn, ops[0]))
if ops[1].type == ARM64_OP_REG:
em.cmp_rhs = em.get(reg_name(insn, ops[1]))
else:
em.cmp_rhs = ops[1].imm
return True, None

if opc == ARM64_INS_CSEL:
dst = reg_name(insn, ops[0])
a = em.get(reg_name(insn, ops[1])) # if cond TRUE
b = em.get(reg_name(insn, ops[2])) # if cond FALSE
cc = insn.cc
if em.cmp_lhs is None or em.cmp_rhs is None:
em.set(dst, None); return True, None
if cc == ARM64_CC_EQ: take = (em.cmp_lhs == em.cmp_rhs)
elif cc == ARM64_CC_NE: take = (em.cmp_lhs != em.cmp_rhs)
elif cc == ARM64_CC_CC or cc == ARM64_CC_LO: take = ((em.cmp_lhs & 0xFFFFFFFF) < (em.cmp_rhs & 0xFFFFFFFF))
elif cc == ARM64_CC_CS or cc == ARM64_CC_HS: take = ((em.cmp_lhs & 0xFFFFFFFF) >= (em.cmp_rhs & 0xFFFFFFFF))
elif cc == ARM64_CC_HI: take = ((em.cmp_lhs & 0xFFFFFFFF) > (em.cmp_rhs & 0xFFFFFFFF))
elif cc == ARM64_CC_LS: take = ((em.cmp_lhs & 0xFFFFFFFF) <= (em.cmp_rhs & 0xFFFFFFFF))
else: take = None
if take is None:
em.set(dst, None)
else:
em.set(dst, a if take else b)
return True, None

if opc in (ARM64_INS_LDR, ARM64_INS_LDUR):
dst = reg_name(insn, ops[0])
a = mem_addr(em, insn, ops[1])
em.set(dst, em.mem_load(a, reg_size(dst)) if a is not None else None)
return True, None

if opc in (ARM64_INS_STR, ARM64_INS_STUR):
src = reg_name(insn, ops[0])
val = em.get(src)
a = mem_addr(em, insn, ops[1])
em.mem_store(a, reg_size(src), val)
em.stores.append((addr, src, val))
return True, None

if opc == ARM64_INS_LDP:
d1 = reg_name(insn, ops[0]); d2 = reg_name(insn, ops[1])
a = mem_addr(em, insn, ops[2])
sz = reg_size(d1)
em.set(d1, em.mem_load(a, sz) if a is not None else None)
em.set(d2, em.mem_load(a + sz, sz) if a is not None else None)
return True, None

if opc == ARM64_INS_STP:
s1 = reg_name(insn, ops[0]); s2 = reg_name(insn, ops[1])
a = mem_addr(em, insn, ops[2])
sz = reg_size(s1)
em.mem_store(a, sz, em.get(s1))
em.mem_store(a + sz if a is not None else None, sz, em.get(s2))
return True, None

if opc == ARM64_INS_ADRP:
dst = reg_name(insn, ops[0])
em.set(dst, ops[1].imm)
return True, None

if opc == ARM64_INS_ADR:
dst = reg_name(insn, ops[0])
em.set(dst, ops[1].imm)
return True, None

if opc == ARM64_INS_BL:
# function call → record but ignore effect on regs (assume caller-saved x0 lost)
em.calls.append((addr, ops[0].imm, [em.get(f'x{i}') for i in range(4)]))
em.set('x0', None)
return True, None

if opc == ARM64_INS_BLR:
target_reg = reg_name(insn, ops[0])
em.calls.append((addr, em.get(target_reg), [em.get(f'x{i}') for i in range(4)]))
em.set('x0', None)
return True, None

if opc == ARM64_INS_B:
return False, ('b_direct', ops[0].imm)

if opc == ARM64_INS_BR:
target_reg = reg_name(insn, ops[0])
return False, ('br_indirect', em.get(target_reg))

if opc == ARM64_INS_RET:
return False, ('ret', None)

# unknown / ignored — keep going but invalidate dst if there is one
if ops and ops[0].type == ARM64_OP_REG:
em.set(reg_name(insn, ops[0]), None)
return True, None

# --- dispatcher detection ---------------------------------------------------
def find_dispatchers(md, code, base_va, max_va):
"""Locate `LDR Xn, [Xm, Xreg]; BR Xn` patterns. Returns list of dicts."""
res = []
insns = list(md.disasm(code, base_va))
by_addr = {ins.address: i for i, ins in enumerate(insns)}
for i, ins in enumerate(insns):
if ins.id != ARM64_INS_BR: continue
if i == 0: continue
prev = insns[i-1]
if prev.id != ARM64_INS_LDR: continue
ops = prev.operands
if len(ops) < 2 or ops[1].type != ARM64_OP_MEM: continue
mem = ops[1].mem
if mem.index == 0: continue
if reg_name(ins, ins.operands[0]) != reg_name(prev, ops[0]): continue
base_reg = insn_reg_name_capstone(prev, mem.base)
idx_reg = insn_reg_name_capstone(prev, mem.index)
# find dispatcher start: walk back collecting CMP/CSEL/ADD/SUB/STR/MOV/LDUR
seq_start = prev.address
for j in range(i-2, max(0, i-40), -1):
p = insns[j]
if p.id in (ARM64_INS_CMP, ARM64_INS_CSEL, ARM64_INS_ADD,
ARM64_INS_SUB, ARM64_INS_MOV, ARM64_INS_LDUR,
ARM64_INS_STR, ARM64_INS_STUR, ARM64_INS_NOP):
seq_start = p.address
else:
break
res.append({
'br_va': ins.address,
'ldr_va': prev.address,
'disp_start': seq_start,
'base_reg': base_reg,
'idx_reg': idx_reg,
'jump_table': None, # will be filled from static_regs
})
return res

def insn_reg_name_capstone(insn, reg_id):
"""Get register name from a register ID using insn's reg_name()."""
return insn.reg_name(reg_id).lower() if reg_id else None

# --- collect prologue static regs -------------------------------------------
def collect_prologue(md, code, base_va, prologue_end):
"""Run prologue, capture register file + memory snapshot at end."""
init = {f'x{i}': None for i in range(31)}
init['xzr'] = 0; init['wzr'] = 0
init['sp'] = SP_BASE
init['x0'] = 0xA0; init['x1'] = 0xA1; init['x2'] = 0xA2
em = Em(init)
for ins in md.disasm(code, base_va):
if ins.address >= prologue_end: break
step(em, ins, None)
return em.regs, em.mem

# --- enumerate states for one dispatcher ------------------------------------
def state_to_entry(md, code, base_va, disp_info, static_regs, extra_states=()):
disp_start = disp_info['disp_start']
br_va = disp_info['br_va']
table = disp_info['jump_table']
base_reg = disp_info['base_reg']
idx_reg = disp_info['idx_reg']

state_reg = disp_info.get('state_reg')
if state_reg is None:
for ins in md.disasm(code[disp_start - base_va:], disp_start):
if ins.address > br_va: break
if ins.id == ARM64_INS_CMP:
state_reg = reg_name(ins, ins.operands[0])
break
disp_info['state_reg'] = state_reg

cands = set(extra_states)
em_scan = Em(dict(static_regs))
for ins in md.disasm(code[disp_start - base_va:], disp_start):
if ins.address > br_va: break
if ins.id == ARM64_INS_CMP:
ops = ins.operands
if ops[1].type == ARM64_OP_IMM:
cands.add(ops[1].imm & 0xFFFFFFFF)
elif ops[1].type == ARM64_OP_REG:
v = em_scan.get(reg_name(ins, ops[1]))
if v is not None: cands.add(v & 0xFFFFFFFF)
step(em_scan, ins, None)
init_st = static_regs.get(state_reg)
if init_st is not None: cands.add(init_st & 0xFFFFFFFF)
cands.add(0xDEADBEEF)

if state_reg is None: return {}
ldr_va = disp_info['ldr_va']
out = {}
for st in cands:
em = Em(dict(static_regs))
em.set(state_reg, st)
for ins in md.disasm(code[disp_start - base_va:], disp_start):
if ins.address >= ldr_va: break
step(em, ins, None)
idx = em.get(idx_reg)
tbl_addr = em.get(base_reg) if table is None else table
if idx is None or tbl_addr is None: continue
target = read_table_entry(raw_global, segs_global, relocs_global, tbl_addr + idx)
if target is None: continue
out[st] = (idx, target)
return out

# --- entry analyzer ---------------------------------------------------------
def analyze_entry(md, code, base_va, entry_va, disp_range, state_reg, static_regs, static_mem, max_va,
max_steps=200):
"""Symbolically execute entry block. Returns {'calls':..., 'nexts':...}."""
em0 = Em(dict(static_regs))
em0.mem = dict(static_mem)
em0.set(state_reg, None)
calls = []
nexts = []
branches = [(entry_va, dict(em0.regs), dict(em0.mem), None)]
visited = set()
disp_start, disp_end = disp_range
while branches:
pc, regs0, mem0, cond = branches.pop()
key = (pc, cond)
if key in visited: continue
visited.add(key)
em = Em(regs0)
em.mem = dict(mem0)
steps = 0
while steps < max_steps:
if pc < base_va or pc >= max_va: break
chunk = code[pc - base_va: pc - base_va + 4]
it = list(md.disasm(chunk, pc))
if not it: break
ins = it[0]
opc = ins.id
ops = ins.operands

# CSEL fork on unknown condition (only useful if it writes state_reg path)
if opc == ARM64_INS_CSEL and (em.cmp_lhs is None or em.cmp_rhs is None):
dst = reg_name(ins, ops[0])
a = em.get(reg_name(ins, ops[1]))
b = em.get(reg_name(ins, ops[2]))
if a is not None and b is not None and a != b:
# fork both arms
em_t = Em(dict(em.regs)); em_t.mem = dict(em.mem); em_t.set(dst, a)
em_f = Em(dict(em.regs)); em_f.mem = dict(em.mem); em_f.set(dst, b)
branches.append((pc + 4, dict(em_t.regs), dict(em_t.mem), (cond or '') + f'|csel@{pc:x}=T'))
branches.append((pc + 4, dict(em_f.regs), dict(em_f.mem), (cond or '') + f'|csel@{pc:x}=F'))
break

if opc in (ARM64_INS_BL, ARM64_INS_BLR):
# also resolve indirect via reg
if opc == ARM64_INS_BL:
tgt = ops[0].imm
else:
tgt = em.get(reg_name(ins, ops[0]))
args = [em.get(f'x{i}') for i in range(4)]
calls.append((pc, tgt, args))
em.set('x0', None)
pc += 4; steps += 1; continue

# conditional branch (B.cc): capstone exposes as B with cc != AL
if opc == ARM64_INS_B and hasattr(ins, 'cc') and ins.cc and ins.cc not in (0, 16):
tgt = ops[0].imm
# taken branch
branches.append((tgt, dict(em.regs), dict(em.mem), f'cc={ins.cc}'))
# fallthrough
pc += 4; steps += 1; continue

if opc == ARM64_INS_B:
tgt = ops[0].imm
if disp_start <= tgt < disp_end:
nxt = em.get(state_reg)
nexts.append((cond or 'unconditional', nxt))
break
else:
pc = tgt; steps += 1; continue

if opc == ARM64_INS_BR:
tgt = em.get(reg_name(ins, ops[0]))
if tgt is None:
nexts.append((cond or 'br_indirect_unknown', None))
else:
if disp_start <= tgt < disp_end:
nxt = em.get(state_reg)
nexts.append((cond or 'unconditional', nxt))
else:
nexts.append((cond or f'br→0x{tgt:x}', None))
break

if opc == ARM64_INS_RET:
nexts.append((cond or 'unconditional', 'RETURN'))
break

cont, exit_info = step(em, ins, None)
if not cont:
# already handled above via specific opcodes
break
pc += 4; steps += 1
if steps >= max_steps:
nexts.append((cond or 'too_long', None))
return {'calls': calls, 'nexts': nexts}

# --- main -------------------------------------------------------------------
raw_global = None
segs_global = None
relocs_global = None

def deobf_function(name, va, size, expected_dispatchers=1):
global raw_global, segs_global, relocs_global
raw_global, segs_global = load_text(LIB)
if relocs_global is None:
relocs_global = load_relocations(raw_global)
code_off = va_to_off(segs_global, va)
code = raw_global[code_off:code_off + size]
md = Cs(CS_ARCH_ARM64, CS_MODE_ARM)
md.detail = True

print(f'\n=== {name} @ 0x{va:x} (range 0x{va:x}..0x{va+size:x}) ===\n')

# find dispatchers
disps = find_dispatchers(md, code, va, va + size)
print(f'found {len(disps)} dispatcher(s):')
for d in disps:
print(f' disp_start=0x{d["disp_start"]:x} ldr=0x{d["ldr_va"]:x} br=0x{d["br_va"]:x} '
f'table={"0x%x" % d["jump_table"] if d["jump_table"] else "?"}')
if not disps:
print(' no CFF detected (function may not be obfuscated).')
return

# collect prologue static regs (bytes 0..disp_start of first dispatcher)
static, static_mem = collect_prologue(md, code, va, disps[0]['disp_start'])
print(f'\nstatic regs at first dispatcher entry:')
for k, v in sorted(static.items()):
if v is not None and isinstance(v, int) and v not in (0, 0xA0, 0xA1, 0xA2, SP_BASE, SP_BASE-0x20, SP_BASE-0x80, SP_BASE-0x70, SP_BASE-0xA0):
print(f' {k} = 0x{v & 0xFFFFFFFFFFFFFFFF:x}')

# for each dispatcher, compute state→entry map (with fixpoint over discovered states)
primary = disps[0]
print(f'\n-- dispatcher @ 0x{primary["disp_start"]:x} --')
state_map = {}
discovered_states = set()
transitions = {}
entry_calls = {}
seen = set()
iters = 0
while iters < 8:
iters += 1
m = state_to_entry(md, code, va, primary, static, extra_states=discovered_states)
new = False
for st, (idx, tgt) in m.items():
if st not in state_map:
state_map[st] = tgt
new = True
if not new and iters > 1: break
# analyze new entries
queue = [s for s in state_map.keys() if s not in seen and s != 0xDEADBEEF]
while queue:
st = queue.pop(0)
if st in seen: continue
seen.add(st)
entry_va = state_map.get(st)
if entry_va is None: continue
info = analyze_entry(md, code, va, entry_va,
(primary['disp_start'], primary['br_va'] + 4),
primary['state_reg'], static, static_mem, va + size)
transitions[st] = info['nexts']
entry_calls[st] = info['calls']
for nxt_kind, nxt_st in info['nexts']:
if isinstance(nxt_st, int) and nxt_st not in seen:
discovered_states.add(nxt_st)
queue.append(nxt_st)
for st, tgt in sorted(state_map.items()):
print(f' state 0x{st:08x} → entry 0x{tgt:x}')

print(f'\n=== entry analysis ===')
# print state graph
print(f'\nstate graph ({len(transitions)} entries):')
for st in sorted(transitions.keys()):
ev = state_map[st]
print(f'\n STATE 0x{st:08x} @ 0x{ev:x}')
for addr, fn, args in entry_calls[st]:
fn_s = f'0x{fn:x}' if isinstance(fn, int) else str(fn)
print(f' call @ 0x{addr:x}: {fn_s}({args})')
for kind, nxt in transitions[st]:
if nxt is None:
tgt_s = '???'; tgt_va_s = ''
elif isinstance(nxt, str):
tgt_s = nxt; tgt_va_s = ''
else:
tgt_s = f'0x{nxt:08x}'
tgt_va = state_map.get(nxt)
tgt_va_s = f' → @ 0x{tgt_va:x}' if tgt_va else ''
print(f' NEXT [{kind}] state {tgt_s}{tgt_va_s}')

if __name__ == '__main__':
# Function ranges (best guesses, conservative upper bounds):
targets = [
('sub_A7194 (modified-AES wrapper)', 0xA7194, 0xa72d0 - 0xA7194 + 0x40), # just dispatcher region
('sub_A7DE8 (key schedule)', 0xA7DE8, 0x300),
('sub_AA9B0 (AES round)', 0xAA9B0, 0xAAB64 - 0xAA9B0),
('sub_A8D44 (AES driver)', 0xA8D44, 0x300),
('sub_AAB64 (AES sub-helper)', 0xAAB64, 0x300),
]
only = sys.argv[1] if len(sys.argv) > 1 else None
for name, va, sz in targets:
if only and only not in name: continue
deobf_function(name, va, sz)

经过多次调试写出去混淆脚本,主循环patch(相当于手动patch)

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
import ida_bytes, ida_funcs, ida_auto, ida_ua, idaapi

PATCHES = [
(0xa8df4, '06000014'), # dispatcher BR → B 0xa8e0c (initial state 0xD20C0226)
(0xa8e68, '80020054'), # B.EQ 0xa8eb8 (round==11 → final round) ★ MISSING from auto-patch
(0xa8e6c, '01000014'), # B 0xa8e70 (continue: p1 → p2)
(0xa8eb4, 'd6ffff17'), # B 0xa8e0c (p2 → p1, main loop back-edge)
]

print('=== applying sub_A8D44 patches ===')
for ea, hb in PATCHES:
cur = ida_bytes.get_bytes(ea, 4).hex()
new = bytes.fromhex(hb)
ida_bytes.patch_bytes(ea, new)
print(f' 0x{ea:x}: {cur}{hb}')

# Rebuild function with bounds covering final round entry @ 0xa8eb8 + epilogue
print()
print('=== rebuild sub_A8D44 (0xa8d44..0xa8f00) ===')
RANGE_END = 0xa8f00
cur = 0xa8d44
while cur < RANGE_END:
f = ida_funcs.get_func(cur)
if f:
ida_funcs.del_func(f.start_ea)
cur += 4
ida_bytes.del_items(0xa8d44, ida_bytes.DELIT_EXPAND, RANGE_END - 0xa8d44)
ida_auto.auto_wait()
ida_ua.create_insn(0xa8d44)
ok = idaapi.add_func(0xa8d44, RANGE_END)
print(f' add_func: {ok}')
ida_auto.auto_wait()
f = ida_funcs.get_func(0xa8d44)
if f:
print(f' bounds: 0x{f.start_ea:x}..0x{f.end_ea:x}')

print()
print('DONE. F5 sub_A8D44 — 11-round AES loop with final-round tail call.')

image-20260419214305533

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
import ida_bytes, ida_funcs, ida_auto, ida_ua, idaapi

PATCHES = [
# Dispatcher BR → initial entry
(0xa7f6c, '06000014'), # B 0xa7f84 (was wrongly B 0xa7f70 from old patch)
# Entry 0xa7ea8 tail (no original B — replace STUR fall-through)
(0xa7f08, '4f000014'), # B 0xa8044
# Entry 0xa7f84 fork (CMP W11,#4 LO/HS)
(0xa7fa8, '03040054'), # B.LO 0xa8028
(0xa7fac, '01000014'), # B 0xa7fb0
# Entry 0xa7fb0 tail
(0xa8024, 'd8ffff17'), # B 0xa7f84
# Entry 0xa8028 tail
(0xa8040, '01000014'), # B 0xa8044
# Entry 0xa8044 fork (CMP W25,#0x30 LO/HS)
(0xa8064, '43000054'), # B.LO 0xa806c
(0xa8068, '5b000014'), # B 0xa81d4
# Entry 0xa806c fork (TST W19,#3 EQ/NE)
(0xa80cc, '40000054'), # B.EQ 0xa80d4
(0xa80d0, '76ffff17'), # B 0xa7ea8
# Entry 0xa80d4 tail
(0xa81d0, '36ffff17'), # B 0xa7ea8
]

print('=== applying sub_A7DE8 patches ===')
for ea, hb in PATCHES:
cur = ida_bytes.get_bytes(ea, 4).hex()
new = bytes.fromhex(hb)
ida_bytes.patch_bytes(ea, new)
print(f' 0x{ea:x}: {cur}{hb}')

# Rebuild function
print()
print('=== rebuild sub_A7DE8 (0xa7de8..0xa81f4) ===')
RANGE_END = 0xa81f4
cur = 0xa7de8
while cur < RANGE_END:
f = ida_funcs.get_func(cur)
if f:
ida_funcs.del_func(f.start_ea)
cur += 4
ida_bytes.del_items(0xa7de8, ida_bytes.DELIT_EXPAND, RANGE_END - 0xa7de8)
ida_auto.auto_wait()
ida_ua.create_insn(0xa7de8)
ok = idaapi.add_func(0xa7de8, RANGE_END)
print(f' add_func: {ok}')
ida_auto.auto_wait()
f = ida_funcs.get_func(0xa7de8)
if f:
print(f' bounds: 0x{f.start_ea:x}..0x{f.end_ea:x}')

print()
print('DONE. F5 sub_A7DE8 — should show outer/inner loop with sub_A780C calls')

sbox处

image-20260419214439003

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
import ida_bytes, ida_funcs, ida_auto, ida_ua, idaapi

# (va, hex bytes) — encoding pre-computed
PATCHES = [
# Revert wrong patches first
(0xaaae8, 'b81f00b9'), # restore STR (was bogus B.EQ)
# Correct dispatcher entry
(0xaaa88, '07000014'), # B 0xaaaa4
# Correct entry tails
(0xaaabc, '60000054'), # B.EQ 0xaaac8 (uses TST W21,#1 flag)
(0xaaaec, '0e000014'), # B 0xaab24
(0xaab40, 'ecffff17'), # B 0xaaaf0 (offset = -0x14/4 = -5 = 0x3FFFFFB)
(0xaab1c, '40fcff54'), # B.EQ 0xaaaa4 (uses TST W13,#0xf0 flag)
(0xaab20, '09000014'), # B 0xaab44
]

print('=== applying sub_AA9B0 corrected patches ===')
for ea, hb in PATCHES:
cur = ida_bytes.get_bytes(ea, 4).hex()
new = bytes.fromhex(hb)
ida_bytes.patch_bytes(ea, new)
print(f' 0x{ea:x}: {cur}{hb}')

# Rebuild function with extended bounds
print()
print('=== rebuild sub_AA9B0 (0xaa9b0..0xaab64) ===')
RANGE_END = 0xaab64
cur = 0xaa9b0
while cur < RANGE_END:
f = ida_funcs.get_func(cur)
if f:
ida_funcs.del_func(f.start_ea)
cur += 4
ida_bytes.del_items(0xaa9b0, ida_bytes.DELIT_EXPAND, RANGE_END - 0xaa9b0)
ida_auto.auto_wait()
ida_ua.create_insn(0xaa9b0)
ok = idaapi.add_func(0xaa9b0, RANGE_END)
print(f' add_func: {ok}')
ida_auto.auto_wait()
f = ida_funcs.get_func(0xaa9b0)
if f:
print(f' bounds: 0x{f.start_ea:x}..0x{f.end_ea:x}')

print()
print('DONE. F5 sub_AA9B0 — should show byte-XOR loop with TST-based fork.')

image-20260419214520002

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
import ida_bytes, ida_funcs, ida_auto, ida_ua, idaapi

PATCHES = [
(0xaad34, 'd6ffff17'), # B 0xaac8c (state 0x10210D13 → 0x20A17326)
(0xaacd4, 'e3ffff17'), # B 0xaac60 (state 0x43197870 → INIT)
(0xaacf0, 'e7ffff17'), # B 0xaac8c (state 0x48266908 → 0x20A17326)
]

print('=== applying sub_AAB64 missing patches ===')
for ea, hb in PATCHES:
cur = ida_bytes.get_bytes(ea, 4).hex()
new = bytes.fromhex(hb)
ida_bytes.patch_bytes(ea, new)
print(f' 0x{ea:x}: {cur}{hb}')

# Rebuild function
print()
print('=== rebuild sub_AAB64 (0xaab64..0xaad80) ===')
RANGE_END = 0xaad80
cur = 0xaab64
while cur < RANGE_END:
f = ida_funcs.get_func(cur)
if f:
ida_funcs.del_func(f.start_ea)
cur += 4
ida_bytes.del_items(0xaab64, ida_bytes.DELIT_EXPAND, RANGE_END - 0xaab64)
ida_auto.auto_wait()
ida_ua.create_insn(0xaab64)
ok = idaapi.add_func(0xaab64, RANGE_END)
print(f' add_func: {ok}')
ida_auto.auto_wait()
f = ida_funcs.get_func(0xaab64)
if f:
print(f' bounds: 0x{f.start_ea:x}..0x{f.end_ea:x}')

print()
print('DONE. F5 sub_AAB64 — should show nested loop with TST-based forks.')

image-20260419214615290

自此全部逻辑已获取

Python复现算法脚本

再三核实,三种方法的结果都对应下面的脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
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
#!/usr/bin/env python3
from __future__ import annotations

import sys
from binascii import unhexlify


SBOX = bytes.fromhex(
"f1125dc6a78a6a48da0f113b3cb32d27642ad313dc68c9cf172ed0cdc2b7c7f5"
"669b1c73e056dda53ac83dd2b0e5eeec82df43b86150efb135728fc3ea84ae86"
"6723c4a6074dedb4247b22e6ba76db9a497730eb97a0601b5158fb527e697ff8"
"15bfbb28a8705531b9ab7c80fe53d10593fa6df7ce45b50221bc4b195e794a90"
"e74798166b205a89d742f2caffa90ea246c5e99e180c7a59545f32c1e465449d"
"ad63b2cb4c2fa11f9f4e04e3390dd971fc34788df4337df93625bd38b6de755c"
"839c8b40d4aa1d0600a46c88a33fccf3d5be810b3794148c2b92fd1ee103d64f"
"c009745be262af106e85f6968e6f08f099950a1aac91d829263ee8012c874157"
)

P1 = [12, 8, 4, 0, 13, 9, 5, 1, 14, 10, 6, 2, 15, 11, 7, 3]
P2 = [0, 13, 6, 11, 4, 1, 10, 15, 8, 5, 14, 3, 12, 9, 2, 7]

ROUND_CONSTANTS = [
None,
unhexlify("e4db002ffc23d8b794eb30bfac330847"),
unhexlify("817205fea9ea6d3651e255ee795abd26"),
unhexlify("1e090acd56b102b50ed97a1d46817205"),
unhexlify("bba00f9c03789734cbd09f4c13a827e4"),
unhexlify("5837146bb03f2cb388c7c47be0cfdcc3"),
unhexlify("f5ce193a5d06c13245bee9aaadf691a2"),
unhexlify("92651e090acd56b102b50ed97a1d4681"),
unhexlify("2ffc23d8b794eb30bfac33084744fb60"),
unhexlify("cc9328a7645b80af7ca35837146bb03f"),
unhexlify("692a2d761122152e399a7d66e192651e"),
unhexlify("06c13245bee9aaadf691a295aeb91afd"),
]

ROUND_KEYS = [
None,
unhexlify("d8d277e1c07cb5406b8ba0c86244eff4"),
unhexlify("771ee381ec3e0b9fdce9f609e5f144a3"),
unhexlify("d90596ee838c25c8e9d26b78ba949762"),
unhexlify("f7444e2365da78ff9d1a0093369c84e5"),
unhexlify("635eb0e96ae9a6799b9ec885c16f220f"),
unhexlify("0c13ac6fa132c3dcfd64c293fbc32956"),
unhexlify("6c67fbf1ef761c083031fabee9d1f7cd"),
unhexlify("2e4149d7bc492a5ff106af6165a9272c"),
unhexlify("26419f4242d16fc66b0e1a7cd67ee78b"),
unhexlify("94dc18fde539420dbd036d475849bffa"),
unhexlify("dc1b9adf6d1390c643ab9adc64315eef"),
]

C_INIT = unhexlify("ef4e153416e96631a9a425d0a740e660")
GF_POLY = 0x171
MIX_MATRIX = (
(6, 3, 5, 2),
(2, 6, 3, 5),
(5, 2, 6, 3),
(3, 5, 2, 6),
)
INV_MIX_MATRIX = (
(0x80, 0xF3, 0x64, 0xAF),
(0xAF, 0x80, 0xF3, 0x64),
(0x64, 0xAF, 0x80, 0xF3),
(0xF3, 0x64, 0xAF, 0x80),
)


def invert_permutation(table: list[int]) -> list[int]:
return [table.index(i) for i in range(len(table))]


INV_P1 = invert_permutation(P1)
INV_P2 = invert_permutation(P2)
INV_SBOX = bytes([SBOX.index(i) for i in range(256)])


def xor_bytes(a: bytes, b: bytes) -> bytes:
return bytes(x ^ y for x, y in zip(a, b))


def permute(state: bytes, table: list[int]) -> bytes:
return bytes(state[i] for i in table)


def xtime(x: int) -> int:
x <<= 1
if x & 0x100:
x ^= GF_POLY
return x & 0xFF


def gmul(x: int, n: int) -> int:
out = 0
acc = x
mul = n
while mul:
if mul & 1:
out ^= acc
acc = xtime(acc)
mul >>= 1
return out


def mix_columns_with_matrix(state: bytes, matrix: tuple[tuple[int, ...], ...]) -> bytes:
out = bytearray(16)
for off in range(0, 16, 4):
col = state[off : off + 4]
for row in range(4):
v = 0
for i in range(4):
v ^= gmul(col[i], matrix[row][i])
out[off + row] = v
return bytes(out)


def mix_columns(state: bytes) -> bytes:
return mix_columns_with_matrix(state, MIX_MATRIX)


def inv_mix_columns(state: bytes) -> bytes:
return mix_columns_with_matrix(state, INV_MIX_MATRIX)


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

state = xor_bytes(token.encode("ascii") * 2, C_INIT)

for round_idx in range(1, 11):
state = bytes(SBOX[b] for b in state)
state = xor_bytes(permute(state, P1), ROUND_CONSTANTS[round_idx])
state = permute(state, P2)
state = mix_columns(state)
state = xor_bytes(state, ROUND_KEYS[round_idx])

state = bytes(SBOX[b] for b in state)
state = xor_bytes(permute(state, P1), ROUND_CONSTANTS[11])
state = permute(state, P2)
state = xor_bytes(state, ROUND_KEYS[11])
return state.hex()


def part2_token_from_suffix(suffix_hex: str) -> str:
if len(suffix_hex) != 32:
raise ValueError("suffix must be exactly 32 hex characters")

state = unhexlify(suffix_hex)
state = xor_bytes(state, ROUND_KEYS[11])
state = permute(state, INV_P2)
state = xor_bytes(state, ROUND_CONSTANTS[11])
state = permute(state, INV_P1)
state = bytes(INV_SBOX[b] for b in state)

for round_idx in range(10, 0, -1):
state = xor_bytes(state, ROUND_KEYS[round_idx])
state = inv_mix_columns(state)
state = permute(state, INV_P2)
state = xor_bytes(state, ROUND_CONSTANTS[round_idx])
state = permute(state, INV_P1)
state = bytes(INV_SBOX[b] for b in state)

dup = xor_bytes(state, C_INIT)
token = dup[:8]
if dup[8:] != token:
raise ValueError("suffix does not map back to duplicated token bytes")
try:
token_str = token.decode("ascii")
except UnicodeDecodeError as e:
raise ValueError("recovered token is not ASCII") from e
if any(c not in "0123456789abcdef" for c in token_str):
raise ValueError("recovered token is outside expected hex alphabet")
return token_str


def main() -> int:
if len(sys.argv) != 2:
print(f"usage: {sys.argv[0]} <8-char-token>", file=sys.stderr)
return 1

token = sys.argv[1]
suffix = part2_suffix(token)
print("suffix =", suffix)
print("flag = flag{sec2026_PART2_" + suffix + "}")
return 0


if __name__ == "__main__":
raise SystemExit(main())

image-20260418210512528

flag生成算法及逆算法 C

编译

1
gcc -O2 -Wall -o part1_solver part1_solver.c 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>

/* ---------- algorithm constants ---------- */

static const uint8_t SBOX[256] = {
0xf1,0x12,0x5d,0xc6,0xa7,0x8a,0x6a,0x48,0xda,0x0f,0x11,0x3b,0x3c,0xb3,0x2d,0x27,
0x64,0x2a,0xd3,0x13,0xdc,0x68,0xc9,0xcf,0x17,0x2e,0xd0,0xcd,0xc2,0xb7,0xc7,0xf5,
0x66,0x9b,0x1c,0x73,0xe0,0x56,0xdd,0xa5,0x3a,0xc8,0x3d,0xd2,0xb0,0xe5,0xee,0xec,
0x82,0xdf,0x43,0xb8,0x61,0x50,0xef,0xb1,0x35,0x72,0x8f,0xc3,0xea,0x84,0xae,0x86,
0x67,0x23,0xc4,0xa6,0x07,0x4d,0xed,0xb4,0x24,0x7b,0x22,0xe6,0xba,0x76,0xdb,0x9a,
0x49,0x77,0x30,0xeb,0x97,0xa0,0x60,0x1b,0x51,0x58,0xfb,0x52,0x7e,0x69,0x7f,0xf8,
0x15,0xbf,0xbb,0x28,0xa8,0x70,0x55,0x31,0xb9,0xab,0x7c,0x80,0xfe,0x53,0xd1,0x05,
0x93,0xfa,0x6d,0xf7,0xce,0x45,0xb5,0x02,0x21,0xbc,0x4b,0x19,0x5e,0x79,0x4a,0x90,
0xe7,0x47,0x98,0x16,0x6b,0x20,0x5a,0x89,0xd7,0x42,0xf2,0xca,0xff,0xa9,0x0e,0xa2,
0x46,0xc5,0xe9,0x9e,0x18,0x0c,0x7a,0x59,0x54,0x5f,0x32,0xc1,0xe4,0x65,0x44,0x9d,
0xad,0x63,0xb2,0xcb,0x4c,0x2f,0xa1,0x1f,0x9f,0x4e,0x04,0xe3,0x39,0x0d,0xd9,0x71,
0xfc,0x34,0x78,0x8d,0xf4,0x33,0x7d,0xf9,0x36,0x25,0xbd,0x38,0xb6,0xde,0x75,0x5c,
0x83,0x9c,0x8b,0x40,0xd4,0xaa,0x1d,0x06,0x00,0xa4,0x6c,0x88,0xa3,0x3f,0xcc,0xf3,
0xd5,0xbe,0x81,0x0b,0x37,0x94,0x14,0x8c,0x2b,0x92,0xfd,0x1e,0xe1,0x03,0xd6,0x4f,
0xc0,0x09,0x74,0x5b,0xe2,0x62,0xaf,0x10,0x6e,0x85,0xf6,0x96,0x8e,0x6f,0x08,0xf0,
0x99,0x95,0x0a,0x1a,0xac,0x91,0xd8,0x29,0x26,0x3e,0xe8,0x01,0x2c,0x87,0x41,0x57
};

static const uint8_t P1[16] = {12,8,4,0,13,9,5,1,14,10,6,2,15,11,7,3};
static const uint8_t P2[16] = { 0,13,6,11, 4, 1,10,15, 8, 5,14, 3,12, 9,2,7};

/* 0x171 = x^8 + x^6 + x^5 + x^4 + 1 (non-standard, AES uses 0x11b) */
#define GF_POLY_LOW8 0x71

static const uint8_t MIX[4][4] = {
{6,3,5,2}, {2,6,3,5}, {5,2,6,3}, {3,5,2,6}
};
static const uint8_t INV_MIX[4][4] = {
{0x80,0xF3,0x64,0xAF}, {0xAF,0x80,0xF3,0x64},
{0x64,0xAF,0x80,0xF3}, {0xF3,0x64,0xAF,0x80}
};

static const uint8_t C_INIT[16] = {
0xef,0x4e,0x15,0x34,0x16,0xe9,0x66,0x31,0xa9,0xa4,0x25,0xd0,0xa7,0x40,0xe6,0x60
};

/* 11 round constants (index 1..11 used; index 0 unused) */
static const uint8_t ROUND_CONSTANTS[12][16] = {
{0},
{0xe4,0xdb,0x00,0x2f,0xfc,0x23,0xd8,0xb7,0x94,0xeb,0x30,0xbf,0xac,0x33,0x08,0x47},
{0x81,0x72,0x05,0xfe,0xa9,0xea,0x6d,0x36,0x51,0xe2,0x55,0xee,0x79,0x5a,0xbd,0x26},
{0x1e,0x09,0x0a,0xcd,0x56,0xb1,0x02,0xb5,0x0e,0xd9,0x7a,0x1d,0x46,0x81,0x72,0x05},
{0xbb,0xa0,0x0f,0x9c,0x03,0x78,0x97,0x34,0xcb,0xd0,0x9f,0x4c,0x13,0xa8,0x27,0xe4},
{0x58,0x37,0x14,0x6b,0xb0,0x3f,0x2c,0xb3,0x88,0xc7,0xc4,0x7b,0xe0,0xcf,0xdc,0xc3},
{0xf5,0xce,0x19,0x3a,0x5d,0x06,0xc1,0x32,0x45,0xbe,0xe9,0xaa,0xad,0xf6,0x91,0xa2},
{0x92,0x65,0x1e,0x09,0x0a,0xcd,0x56,0xb1,0x02,0xb5,0x0e,0xd9,0x7a,0x1d,0x46,0x81},
{0x2f,0xfc,0x23,0xd8,0xb7,0x94,0xeb,0x30,0xbf,0xac,0x33,0x08,0x47,0x44,0xfb,0x60},
{0xcc,0x93,0x28,0xa7,0x64,0x5b,0x80,0xaf,0x7c,0xa3,0x58,0x37,0x14,0x6b,0xb0,0x3f},
{0x69,0x2a,0x2d,0x76,0x11,0x22,0x15,0x2e,0x39,0x9a,0x7d,0x66,0xe1,0x92,0x65,0x1e},
{0x06,0xc1,0x32,0x45,0xbe,0xe9,0xaa,0xad,0xf6,0x91,0xa2,0x95,0xae,0xb9,0x1a,0xfd}
};

/* 11 round keys (index 1..11 used) */
static const uint8_t ROUND_KEYS[12][16] = {
{0},
{0xd8,0xd2,0x77,0xe1,0xc0,0x7c,0xb5,0x40,0x6b,0x8b,0xa0,0xc8,0x62,0x44,0xef,0xf4},
{0x77,0x1e,0xe3,0x81,0xec,0x3e,0x0b,0x9f,0xdc,0xe9,0xf6,0x09,0xe5,0xf1,0x44,0xa3},
{0xd9,0x05,0x96,0xee,0x83,0x8c,0x25,0xc8,0xe9,0xd2,0x6b,0x78,0xba,0x94,0x97,0x62},
{0xf7,0x44,0x4e,0x23,0x65,0xda,0x78,0xff,0x9d,0x1a,0x00,0x93,0x36,0x9c,0x84,0xe5},
{0x63,0x5e,0xb0,0xe9,0x6a,0xe9,0xa6,0x79,0x9b,0x9e,0xc8,0x85,0xc1,0x6f,0x22,0x0f},
{0x0c,0x13,0xac,0x6f,0xa1,0x32,0xc3,0xdc,0xfd,0x64,0xc2,0x93,0xfb,0xc3,0x29,0x56},
{0x6c,0x67,0xfb,0xf1,0xef,0x76,0x1c,0x08,0x30,0x31,0xfa,0xbe,0xe9,0xd1,0xf7,0xcd},
{0x2e,0x41,0x49,0xd7,0xbc,0x49,0x2a,0x5f,0xf1,0x06,0xaf,0x61,0x65,0xa9,0x27,0x2c},
{0x26,0x41,0x9f,0x42,0x42,0xd1,0x6f,0xc6,0x6b,0x0e,0x1a,0x7c,0xd6,0x7e,0xe7,0x8b},
{0x94,0xdc,0x18,0xfd,0xe5,0x39,0x42,0x0d,0xbd,0x03,0x6d,0x47,0x58,0x49,0xbf,0xfa},
{0xdc,0x1b,0x9a,0xdf,0x6d,0x13,0x90,0xc6,0x43,0xab,0x9a,0xdc,0x64,0x31,0x5e,0xef}
};

#define FLAG_PREFIX "flag{sec2026_PART2_"
#define FLAG_SUFFIX "}"

/* ---------- precomputed inverse tables ---------- */

static uint8_t INV_SBOX[256];
static uint8_t INV_P1[16];
static uint8_t INV_P2[16];
static int tables_ready = 0;

static void init_tables(void) {
if (tables_ready) return;
tables_ready = 1;
for (int i = 0; i < 256; i++) INV_SBOX[SBOX[i]] = (uint8_t)i;
for (int i = 0; i < 16; i++) INV_P1[P1[i]] = (uint8_t)i;
for (int i = 0; i < 16; i++) INV_P2[P2[i]] = (uint8_t)i;
}

/* ---------- GF(2^8) helpers ---------- */
static inline uint8_t xtime(uint8_t x) {
int t = (int)x << 1;
if (t & 0x100) t ^= GF_POLY_LOW8;
return (uint8_t)t;
}

static uint8_t gmul(uint8_t a, uint8_t b) {
uint8_t out = 0;
while (b) {
if (b & 1) out ^= a;
a = xtime(a);
b >>= 1;
}
return out;
}

/* ---------- core ops on 16-byte state ---------- */
static void apply_sbox(uint8_t s[16]) {
for (int i = 0; i < 16; i++) s[i] = SBOX[s[i]];
}
static void apply_inv_sbox(uint8_t s[16]) {
for (int i = 0; i < 16; i++) s[i] = INV_SBOX[s[i]];
}

static void permute(uint8_t s[16], const uint8_t *table) {
uint8_t tmp[16];
for (int i = 0; i < 16; i++) tmp[i] = s[table[i]];
memcpy(s, tmp, 16);
}

static void xor16(uint8_t dst[16], const uint8_t src[16]) {
for (int i = 0; i < 16; i++) dst[i] ^= src[i];
}

static void mix_columns_with(uint8_t s[16], const uint8_t M[4][4]) {
uint8_t out[16];
for (int off = 0; off < 16; off += 4) {
for (int row = 0; row < 4; row++) {
uint8_t v = 0;
for (int i = 0; i < 4; i++) v ^= gmul(s[off + i], M[row][i]);
out[off + row] = v;
}
}
memcpy(s, out, 16);
}

static void mix_columns(uint8_t s[16]) { mix_columns_with(s, MIX); }
static void inv_mix_columns(uint8_t s[16]) { mix_columns_with(s, INV_MIX); }

/* ---------- forward / inverse ---------- */

static void part2_encrypt(const char token[8], uint8_t out[16]) {
init_tables();
uint8_t s[16];
memcpy(s, token, 8);
memcpy(s + 8, token, 8); /* duplicate */
xor16(s, C_INIT);

for (int r = 1; r <= 10; r++) {
apply_sbox(s);
permute(s, P1); xor16(s, ROUND_CONSTANTS[r]);
permute(s, P2);
mix_columns(s);
xor16(s, ROUND_KEYS[r]);
}
/* final round (no MixColumns) */
apply_sbox(s);
permute(s, P1); xor16(s, ROUND_CONSTANTS[11]);
permute(s, P2);
xor16(s, ROUND_KEYS[11]);

memcpy(out, s, 16);
}

static int part2_decrypt(const uint8_t in[16], char token_out[9]) {
init_tables();
uint8_t s[16];
memcpy(s, in, 16);

/* undo final round */
xor16(s, ROUND_KEYS[11]);
permute(s, INV_P2);
xor16(s, ROUND_CONSTANTS[11]);
permute(s, INV_P1);
apply_inv_sbox(s);

for (int r = 10; r >= 1; r--) {
xor16(s, ROUND_KEYS[r]);
inv_mix_columns(s);
permute(s, INV_P2);
xor16(s, ROUND_CONSTANTS[r]);
permute(s, INV_P1);
apply_inv_sbox(s);
}
xor16(s, C_INIT);

if (memcmp(s, s + 8, 8) != 0) return -1;
memcpy(token_out, s, 8);
token_out[8] = '\0';
return 0;
}

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

static int hex_to_bytes(const char *hex, uint8_t *out, size_t n) {
for (size_t i = 0; i < n; i++) {
int hi = hex_nibble(hex[2 * i]);
int lo = hex_nibble(hex[2 * i + 1]);
if (hi < 0 || lo < 0) return -1;
out[i] = (uint8_t)((hi << 4) | lo);
}
return 0;
}

static void bytes_to_hex(const uint8_t *bytes, size_t n, char *out) {
static const char *digits = "0123456789abcdef";
for (size_t i = 0; i < n; i++) {
out[2 * i] = digits[bytes[i] >> 4];
out[2 * i + 1] = digits[bytes[i] & 0x0F];
}
out[2 * n] = '\0';
}

/* token → flag */
static int make_flag(const char *token, char *flag_out) {
if (strlen(token) != 8) return -1;
uint8_t cipher[16];
char hex[33];
part2_encrypt(token, cipher);
bytes_to_hex(cipher, 16, hex);
snprintf(flag_out, 64, FLAG_PREFIX "%s" FLAG_SUFFIX, hex);
return 0;
}

/* cipher → token */
static int parse_input_to_token(const char *input, char *token_out) {
const char *hex = input;
size_t pl = sizeof(FLAG_PREFIX) - 1;
size_t sl = sizeof(FLAG_SUFFIX) - 1;
size_t in_len = strlen(input);

if (in_len == pl + 32 + sl
&& memcmp(input, FLAG_PREFIX, pl) == 0
&& memcmp(input + pl + 32, FLAG_SUFFIX, sl) == 0) {
hex = input + pl;
} else if (in_len != 32) {
return -1;
}

uint8_t cipher[16];
if (hex_to_bytes(hex, cipher, 16) < 0) return -1;
return part2_decrypt(cipher, token_out);
}


int main(int argc, char **argv) {
if (argc == 3 && strcmp(argv[1], "enc") == 0) {
char flag[64];
if (make_flag(argv[2], flag) != 0) {
fprintf(stderr, "enc: bad token (need 8 ASCII chars)\n");
return 1;
}
printf("%s\n", flag);
return 0;
}

if (argc == 3 && strcmp(argv[1], "dec") == 0) {
char tok[9];
int rc = parse_input_to_token(argv[2], tok);
if (rc != 0) {
fprintf(stderr, "dec: bad input (need 32-hex cipher or full flag string),\n"
" or cipher does not decrypt to a duplicated 8-byte token\n");
return 1;
}
printf("%s\n", tok);
return 0;
}

return 1;
}

image-20260418150237118

part3 隐形方块

获取flag

过程分析

跟之前一样思路,在前面我们已经可以看到了,下面那个visiable为false

image-20260418223001669

1
2
3
4
5
6
7
8
9
Trigger4 node (id=69):
prop transform.origin = (3.749692, 4.592883, -16.559868) ← 离玩家出生点 4.46m,够近
prop monitoring = False ← area 不监听碰撞
prop monitorable = False ← area 不被其他 area 监听
prop priority = 6
child: CollisionShape3D
prop disabled = True ← 物理形状被禁用
child: MeshInstance3D
prop visible = False ← mesh 不可见

4 层全关

  1. Area3D.monitoring = False PhysicsServer 压根没把这个 area 当回事,不会发 body_entered
  2. Area3D.monitorable = False 对称的另一半
  3. CollisionShape3D.disabled = True 就算 monitoring 打开,area 也没碰撞形状
  4. MeshInstance3D.visible = False 隐形方块

所以 trigger4_call 的任务就是一次性把这 4 层全部打开,然后因为 Trigger4 离出生点只有很近看起来是,车稍微动一下就自然碰撞,不需要挪位置(不像 trigger2/3 位置高不可达必须传送)。

所以可以ptrace + 调 Godot 4 个 setter 函数。

找 setter 函数偏移,需要的 4 个 setter + 1 个 getter

1
2
3
4
5
Area3D::set_monitoring(bool)           @ libgodot+0x25F94A4
Area3D::set_monitorable(bool) @ libgodot+0x25FAA0C
CollisionShape3D::set_disabled(bool) @ libgodot+0x2624AB4
Node3D::set_visible(bool) @ libgodot+0x12424A8
Node::get_child(int, bool) @ libgodot+0x1FD9BD0 (拿 child 用)

跟之前一样定位方法(capstone 扫描)对每个字符串做 ClassDB::bind_method 注册点查找:

1
strings -t x libgodot_android.so | awk '$2 == "set_monitoring" {print}'

ADRP + ADD指向字符串地址的代码点,在 ADRP 前 4 条指令找 ADR X0, 。 那就是 setter 的真实偏移。

set_monitoring set_monitorable set_disabled set_visible get_child
地址 0x25F94A4 0x25FAA0C 0x2624AB4 0x12424A8 0x1FD9BD0

5 个函数都是ARM64 标准调用约定:

1
2
set_xxx(this, value):  X0 = this, X1 = value (整数)
get_child(this, idx, internal): X0 = this, X1 = idx, X2 = internal_flag 返回 X0

识别 Trigger4 实例

trigger2/3 得靠世界坐标 fingerprint 区分 4 个 Area3D,trigger4 不用

识别规则扫内存找 Area3D vptr 拿到 4 个实例,读每个实例 offset +0x578(monitoring 字段):

1
2
3
4
5
6
7
8
9
for (int i = 0; i < n_area; i++) {
uint8_t b;
pread64(mem_fd, &b, 1, area_objs[i] + AREA3D_MONITORING_OFFSET);
if (b == 0) {
// 这就是 trigger4 —— 其他 3 个 trigger monitoring 都默认 True (1)
trigger4 = area_objs[i];
break;
}
}

其实就是找唯一不可见的那个

AREA3D_MONITORING_OFFSET= 0x578,通过反汇编 Area3D::set_monitoring 函数体看到 STRB W1, [X0, #0x578] 反推。

ptrace-call 调 setter,通用 remote_call 框架

因为所有 setter 都是 X0, X1, ... 纯寄存器传参,一个通用函数覆盖所有调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
static int remote_call(pid_t tid, uint64_t func,
uint64_t a0, uint64_t a1, uint64_t a2, uint64_t a3,
uint64_t *retval) {
struct arm64_regs saved, regs;
get_regs(tid, &saved); regs = saved;

regs.regs[0] = a0;
regs.regs[1] = a1;
regs.regs[2] = a2;
regs.regs[3] = a3;
regs.regs[30] = 0; // LR = 0 → 返回时 SIGSEGV
regs.pc = func;
regs.sp = saved.sp & ~0xF; // 16-align

set_regs(tid, &regs);
ptrace(PTRACE_CONT, tid, 0, 0);
waitpid(tid, &status, __WALL); // 等 SIGSEGV

get_regs(tid, &regs);
if (retval) *retval = regs.regs[0]; // X0 = 返回值
set_regs(tid, &saved);
return 0;
}

force-zero 字节绕过 setter 早返回,这是 trigger4_call 特有的一个细节。Godot 4 的 setter 都有防抖:

1
2
3
4
5
void Area3D::set_monitoring(bool p_enable) {
if (p_enable == monitoring) return; // 早返回
monitoring = p_enable;
...PhysicsServer 注册...
}

如果 monitoring 当前值已经是 p_enable,setter 不会跑 PhysicsServer 注册。但我们希望无论当前值是什么,都强制重新注册一次(确保 PhysicsServer 状态和场景一致)。

因此可以调 setter 之前,用 pwrite64把字节预先清 0

1
2
3
4
5
6
7
// 对所有 4 个 Area3D 强制预清 monitoring/monitorable = 0
for (int i = 0; i < n_area; i++) {
uint8_t zero[2] = {0, 0};
pwrite64(mem_fd, zero, 2, area_objs[i] + AREA3D_MONITORING_OFFSET);
}

// 再调 set_monitoring(true) 看到 0 → 1 的变化,一定会跑注册逻辑

初次解谜时开车撞过 trigger4(导致 monitoring 已经变 1),再跑 trigger4_call 时没 force-zero 的话 setter 全部早返回、PhysicsServer 没重注册导致表面看调用成功但物理层没刷新。

完整流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
1. find_game_pid                        扫 /proc/*/cmdline
2. kill_self_tracer 释放 ptrace 槽
3. read_maps 解析 libgodot 4 个 PT_LOAD + 收集 RW 段
4. find_vptr_by_name("6Area3D") Itanium typeinfo 动态发现 vtable
5. scan_for_vptr 扫 RW 找 4 个 Area3D 实例
6. force-zero monitoring/monitorable 对所有 4 个 area 预清字节(绕早返回)
7. pick_idle_tid + ptrace_attach 挂 idle 线程
8-19. 4 × 3 套 setter 调用:
set_monitoring(area, 1)
set_monitorable(area, 1)
get_child(area, 0) → coll
set_disabled(coll, 0)
get_child(area, 1) → mesh
set_visible(mesh, 1)
20. ptrace_detach
21. Trigger4 现在:
monitoring=1 / monitorable=1 / disabled=0 / visible=1
位置仍在 (3.75, 4.59, -16.56) — 离玩家出生点 (8, 3.36, -16) 约 4.46m
22. 玩家车稍一动(方向键小幅前进)就物理重叠 Trigger4
23. body_entered 信号 → trigger4.gd 的 _w7 (如果有;实际 trigger4 没 _w7)
但 Tick() 每帧跑 SBC0 VM,碰撞态改变时某条路径把 flag 写 Label2
24. Label2 显示 flag{sec2026_PART3_<16hex>}

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/types.h>
#include <sys/ptrace.h>
#include <sys/wait.h>
#include <sys/uio.h>
#include <linux/elf.h>
#include <dirent.h>
#include <errno.h>
#include <signal.h>

#define PKG_NAME "com.tencent.ACE.gamesec2026.final"

#define AREA3D_MONITORING_OFFSET 0x578

#define AREA3D_VTABLE_FOFF 0x3F43A88ULL

#define GET_CHILD_OFFSET 0x1FD9BD0ULL // Node::get_child(int, bool)
#define SET_MONITORING_OFFSET 0x25F94A4ULL // Area3D::set_monitoring(bool)
#define SET_MONITORABLE_OFFSET 0x25FAA0CULL // Area3D::set_monitorable(bool)
#define SET_DISABLED_OFFSET 0x2624AB4ULL // CollisionShape3D::set_disabled(bool)
#define SET_VISIBLE_OFFSET 0x12424A8ULL // Node3D::set_visible(bool)

#define MAX_REGIONS 512
#define MAX_CANDIDATES 256
#define CHUNK_SIZE (4 * 1024 * 1024)

// arm64 user regs (matches struct user_pt_regs in <asm/ptrace.h>)
struct arm64_regs {
uint64_t regs[31]; // x0-x30
uint64_t sp;
uint64_t pc;
uint64_t pstate;
};

typedef struct {
uint64_t start;
uint64_t end;
} mem_region_t;

// libgodot file_offset → mapping (start, file_offset)
typedef struct {
uint64_t mstart; // mapped start address
uint64_t mend; // mapped end address
uint64_t fstart; // file offset of this mapping
uint64_t fend; // file offset of end (= fstart + size)
char perms[8];
} godot_seg_t;
#define MAX_SEGS 8
static godot_seg_t godot_segs[MAX_SEGS];
static int n_godot_segs = 0;

// Is `addr` inside any libgodot RO mapping? (for validating typeinfo/vtable pointers)
static int in_godot_ro(uint64_t addr) {
for (int i = 0; i < n_godot_segs; i++) {
if (addr >= godot_segs[i].mstart && addr < godot_segs[i].mend &&
godot_segs[i].perms[0] == 'r' && godot_segs[i].perms[1] != 'w') {
return 1;
}
}
return 0;
}

// Read entire libgodot RO mapping into a malloc'd buffer.
static uint8_t *read_seg(int mem_fd, godot_seg_t *seg, size_t *out_size) {
size_t sz = seg->mend - seg->mstart;
uint8_t *buf = malloc(sz);
if (!buf) return NULL;
if (pread64(mem_fd, buf, sz, seg->mstart) != (ssize_t)sz) { free(buf); return NULL; }
*out_size = sz;
return buf;
}

static uint64_t find_vptr_by_name(int mem_fd, const char *mangled_name) {
size_t nl = strlen(mangled_name);

// Step 1: find the name string in any RO seg
uint64_t name_addr = 0;
for (int i = 0; i < n_godot_segs && !name_addr; i++) {
if (godot_segs[i].perms[0] != 'r' || godot_segs[i].perms[1] == 'w') continue;
size_t sz; uint8_t *buf = read_seg(mem_fd, &godot_segs[i], &sz);
if (!buf) continue;
for (size_t k = 0; k + nl + 1 <= sz; k++) {
if (buf[k] == mangled_name[0] && memcmp(buf+k, mangled_name, nl) == 0 && buf[k+nl] == 0) {
// ensure the byte BEFORE is also 0 or non-digit — avoids matching mid-string
if (k == 0 || buf[k-1] == 0) {
name_addr = godot_segs[i].mstart + k;
break;
}
}
}
free(buf);
}
if (!name_addr) { fprintf(stderr,"name string %s not found in libgodot RO\n", mangled_name); return 0; }
fprintf(stderr," name '%s' @ 0x%lx\n", mangled_name, name_addr);

// Step 2: find a pointer to name_addr inside an RO seg — that's typeinfo+8 (the name field)
uint64_t typeinfo_addr = 0;
for (int i = 0; i < n_godot_segs && !typeinfo_addr; i++) {
if (godot_segs[i].perms[0] != 'r' || godot_segs[i].perms[1] == 'w') continue;
size_t sz; uint8_t *buf = read_seg(mem_fd, &godot_segs[i], &sz);
if (!buf) continue;
for (size_t k = 8; k + 8 <= sz; k += 8) {
uint64_t v = *(uint64_t *)(buf + k);
if (v == name_addr) {
typeinfo_addr = godot_segs[i].mstart + k - 8;
fprintf(stderr," typeinfo candidate @ 0x%lx\n", typeinfo_addr);
break; // try the first one; could iterate if needed
}
}
free(buf);
}
if (!typeinfo_addr) { fprintf(stderr,"typeinfo struct for %s not found\n", mangled_name); return 0; }

uint64_t vtable_addr = 0;
for (int i = 0; i < n_godot_segs && !vtable_addr; i++) {
if (godot_segs[i].perms[0] != 'r' || godot_segs[i].perms[1] == 'w') continue;
size_t sz; uint8_t *buf = read_seg(mem_fd, &godot_segs[i], &sz);
if (!buf) continue;
for (size_t k = 0; k + 8 <= sz; k += 8) {
uint64_t v = *(uint64_t *)(buf + k);
if (v == typeinfo_addr) {
// The slot before should be offset_to_top (small signed int, usually 0)
if (k >= 8) {
int64_t off_top = (int64_t)*(uint64_t *)(buf + k - 8);
if (off_top > -0x10000 && off_top < 0x10000) {
vtable_addr = godot_segs[i].mstart + k - 8;
break;
}
}
}
}
free(buf);
}
if (!vtable_addr) { fprintf(stderr,"vtable for %s not found\n", mangled_name); return 0; }
fprintf(stderr," vtable @ 0x%lx → vptr-in-obj 0x%lx\n", vtable_addr, vtable_addr + 16);
return vtable_addr + 16;
}

static pid_t find_game_pid(void) {
DIR *proc = opendir("/proc");
if (!proc) return 0;
struct dirent *e;
pid_t found = 0;
while ((e = readdir(proc))) {
if (e->d_type != DT_DIR) continue;
for (const char *p = e->d_name; *p; p++) if (*p < '0' || *p > '9') goto next;
{
char path[64];
snprintf(path, sizeof(path), "/proc/%s/cmdline", e->d_name);
FILE *f = fopen(path, "r");
if (!f) continue;
char cmd[256] = {0};
fread(cmd, 1, sizeof(cmd) - 1, f);
fclose(f);
if (strstr(cmd, PKG_NAME)) { found = atoi(e->d_name); break; }
}
next: ;
}
closedir(proc);
return found;
}

static void kill_self_tracer(pid_t pid) {
char p[64]; snprintf(p, 64, "/proc/%d/status", pid);
FILE *f = fopen(p, "r"); if (!f) return;
char line[256]; pid_t tracer = 0;
while (fgets(line, sizeof(line), f)) {
if (sscanf(line, "TracerPid: %d", &tracer) == 1) break;
}
fclose(f);
if (tracer > 0 && tracer != getpid()) {
printf("Killing self-tracer PID %d\n", tracer);
kill(tracer, 9);
usleep(300 * 1000);
}
}

static int read_maps(pid_t pid, uint64_t *godot_base, mem_region_t *regions, int *n_regions) {
char path[64];
snprintf(path, sizeof(path), "/proc/%d/maps", pid);
FILE *f = fopen(path, "r");
if (!f) { perror("open maps"); return -1; }
*godot_base = 0;
*n_regions = 0;
char line[1024];
while (fgets(line, sizeof(line), f)) {
uint64_t start, end;
char perms[8], dev[32], path_buf[512] = {0};
unsigned long offset, inode;
int n = sscanf(line, "%lx-%lx %7s %lx %31s %lu %511[^\n]",
&start, &end, perms, &offset, dev, &inode, path_buf);
if (n < 6) continue;
// First PT_LOAD of libgodot (offset==0) is the load base
if (strstr(path_buf, "libgodot_android.so") && offset == 0 && !*godot_base) {
*godot_base = start;
}
// Track all libgodot mappings — used for typeinfo/vtable string searches.
if (strstr(path_buf, "libgodot_android.so") && n_godot_segs < MAX_SEGS) {
godot_segs[n_godot_segs].mstart = start;
godot_segs[n_godot_segs].mend = end;
godot_segs[n_godot_segs].fstart = offset;
godot_segs[n_godot_segs].fend = offset + (end - start);
strncpy(godot_segs[n_godot_segs].perms, perms, 7);
n_godot_segs++;
}
if (perms[0] == 'r' && perms[1] == 'w' && perms[3] == 'p') {
if (strstr(path_buf, "[stack]")) continue;
if (strstr(path_buf, "[vvar]")) continue;
if (strstr(path_buf, "/dev/")) continue;
if (end - start > 512ULL * 1024 * 1024) continue;
if (*n_regions >= MAX_REGIONS) break;
regions[*n_regions].start = start;
regions[*n_regions].end = end;
(*n_regions)++;
}
}
fclose(f);
return 0;
}

static int scan_for_vptr(int mem_fd, mem_region_t *regions, int n_regions,
uint64_t vptr, uint64_t *out, int max_out) {
uint8_t *buf = malloc(CHUNK_SIZE);
if (!buf) return 0;
int count = 0;
for (int r = 0; r < n_regions && count < max_out; r++) {
uint64_t size = regions[r].end - regions[r].start;
for (uint64_t off = 0; off < size && count < max_out; off += CHUNK_SIZE) {
uint64_t chunk = (size - off > CHUNK_SIZE) ? CHUNK_SIZE : (size - off);
if (pread64(mem_fd, buf, chunk, regions[r].start + off) != (ssize_t)chunk) continue;
// Compare with ARM64 TBI semantics: top byte of stored pointers may
// carry MTE/PAC tags so we mask both sides to the low 56 bits.
uint64_t needle = vptr & 0x00FFFFFFFFFFFFFFULL;
for (uint64_t i = 0; i + 8 <= chunk; i += 8) {
uint64_t v = *(uint64_t *)(buf + i) & 0x00FFFFFFFFFFFFFFULL;
if (v == needle) {
out[count++] = regions[r].start + off + i;
if (count >= max_out) break;
}
}
}
}
free(buf);
return count;
}

static pid_t pick_idle_tid(pid_t pid) {
char path[64];
snprintf(path, sizeof(path), "/proc/%d/task", pid);
DIR *d = opendir(path);
if (!d) return pid;
struct dirent *e;
pid_t best = 0;
while ((e = readdir(d))) {
if (e->d_name[0] < '0' || e->d_name[0] > '9') continue;
pid_t tid = atoi(e->d_name);
char status_path[128];
snprintf(status_path, sizeof(status_path), "/proc/%d/task/%d/stat", pid, tid);
FILE *f = fopen(status_path, "r");
if (!f) continue;
char buf[512]; size_t n = fread(buf, 1, sizeof(buf)-1, f); buf[n] = 0; fclose(f);
// Field 3 of stat = state; format: pid (comm) state ...
char *state = strrchr(buf, ')');
if (!state || !state[1] || state[2] != 'S') continue;
best = tid;
break;
}
closedir(d);
return best ? best : pid;
}

static int get_regs(pid_t tid, struct arm64_regs *r) {
struct iovec iov = { .iov_base = r, .iov_len = sizeof(*r) };
return ptrace(PTRACE_GETREGSET, tid, NT_PRSTATUS, &iov);
}
static int set_regs(pid_t tid, const struct arm64_regs *r) {
struct iovec iov = { .iov_base = (void*)r, .iov_len = sizeof(*r) };
return ptrace(PTRACE_SETREGSET, tid, NT_PRSTATUS, &iov);
}

static int remote_call(pid_t tid, uint64_t func, uint64_t a0, uint64_t a1,
uint64_t a2, uint64_t a3, uint64_t *retval) {
struct arm64_regs saved, regs;
if (get_regs(tid, &saved) < 0) { perror("getregs"); return -1; }
regs = saved;
regs.regs[0] = a0;
regs.regs[1] = a1;
regs.regs[2] = a2;
regs.regs[3] = a3;
regs.regs[30] = 0; // LR = 0 -> fault on return, gives us a stop
regs.pc = func;
regs.sp &= ~0xFULL;

if (set_regs(tid, &regs) < 0) { perror("setregs"); return -1; }
if (ptrace(PTRACE_CONT, tid, 0, 0) < 0) { perror("cont"); return -1; }

int status;
if (waitpid(tid, &status, __WALL) < 0) { perror("waitpid"); return -1; }
if (!WIFSTOPPED(status)) {
fprintf(stderr, "remote_call: unexpected status 0x%x\n", status);
return -1;
}
int sig = WSTOPSIG(status);
if (sig != SIGSEGV && sig != SIGBUS && sig != SIGILL && sig != SIGTRAP) {
fprintf(stderr, "remote_call: stopped with sig %d (expected SIGSEGV)\n", sig);
}
if (get_regs(tid, &regs) < 0) { perror("getregs2"); return -1; }
*retval = regs.regs[0];
if (set_regs(tid, &saved) < 0) { perror("restore"); return -1; }
return 0;
}

int main(int argc, char **argv) {
pid_t pid = find_game_pid();
if (!pid) { fprintf(stderr, "game not running\n"); return 1; }
printf("PID: %d\n", pid);
kill_self_tracer(pid);

uint64_t godot_base;
mem_region_t regions[MAX_REGIONS];
int n_regions;
if (read_maps(pid, &godot_base, regions, &n_regions) < 0) return 1;
if (!godot_base) { fprintf(stderr, "libgodot not loaded\n"); return 1; }
printf("libgodot base: 0x%lx (%d rw regions)\n", godot_base, n_regions);

char mem_path[64];
snprintf(mem_path, sizeof(mem_path), "/proc/%d/mem", pid);
int mem_fd = open(mem_path, O_RDWR);
if (mem_fd < 0) { perror("open mem"); return 1; }

fprintf(stderr,"Searching libgodot for Area3D typeinfo...\n");
uint64_t area3d_vptr = find_vptr_by_name(mem_fd, "6Area3D");
if (!area3d_vptr) { close(mem_fd); return 1; }
uint64_t area_objs[MAX_CANDIDATES];
int n_area = scan_for_vptr(mem_fd, regions, n_regions, area3d_vptr, area_objs, MAX_CANDIDATES);
printf("Area3D objects: %d\n", n_area);
if (n_area == 0) { fprintf(stderr, "no Area3D — load Truck Town first\n"); close(mem_fd); return 1; }

for (int i = 0; i < n_area; i++) {
uint8_t zero[2] = {0, 0};
if (pwrite64(mem_fd, zero, 2, area_objs[i] + AREA3D_MONITORING_OFFSET) != 2) {
perror("pwrite zero monitoring");
}
}
close(mem_fd);

// Pick an idle thread to hijack
pid_t tid = pick_idle_tid(pid);
printf("Hijacking TID: %d\n", tid);

if (ptrace(PTRACE_ATTACH, tid, 0, 0) < 0) { perror("attach"); return 1; }
int status;
if (waitpid(tid, &status, __WALL) < 0) { perror("wait initial"); ptrace(PTRACE_DETACH, tid, 0, 0); return 1; }
if (!WIFSTOPPED(status)) {
fprintf(stderr, "initial wait status 0x%x not stopped\n", status);
ptrace(PTRACE_DETACH, tid, 0, 0);
return 1;
}
printf("Attached, stop sig=%d\n", WSTOPSIG(status));

uint64_t f_set_monitoring = godot_base + SET_MONITORING_OFFSET;
uint64_t f_set_monitorable = godot_base + SET_MONITORABLE_OFFSET;
uint64_t f_get_child = godot_base + GET_CHILD_OFFSET;
uint64_t f_set_disabled = godot_base + SET_DISABLED_OFFSET;
uint64_t f_set_visible = godot_base + SET_VISIBLE_OFFSET;

uint64_t rv;
int ok = 1;


for (int i = 0; i < n_area; i++) {
uint64_t area = area_objs[i];
printf("\n[Area3D #%d @ 0x%lx]\n", i, area);

printf(" → set_monitoring(area, 1)\n");
if (remote_call(tid, f_set_monitoring, area, 1, 0, 0, &rv) < 0) { ok = 0; break; }

printf(" → set_monitorable(area, 1)\n");
if (remote_call(tid, f_set_monitorable, area, 1, 0, 0, &rv) < 0) { ok = 0; break; }

uint64_t collision = 0, mesh = 0;
if (remote_call(tid, f_get_child, area, 0, 0, 0, &collision) < 0) { ok = 0; break; }
printf(" → child[0]=0x%lx\n", collision);
if (collision) {
printf(" → set_disabled(collision, 0)\n");
if (remote_call(tid, f_set_disabled, collision, 0, 0, 0, &rv) < 0) { ok = 0; break; }
}

if (remote_call(tid, f_get_child, area, 1, 0, 0, &mesh) < 0) { ok = 0; break; }
printf(" → child[1]=0x%lx\n", mesh);
if (mesh) {
printf(" → set_visible(mesh, 1)\n");
if (remote_call(tid, f_set_visible, mesh, 1, 0, 0, &rv) < 0) { ok = 0; break; }
}
}

if (ptrace(PTRACE_DETACH, tid, 0, 0) < 0) perror("detach");
printf("\n%s — drive into Trigger4 area now.\n", ok ? "DONE" : "FAILED (check logs)");
return ok ? 0 : 1;
}

编译

1
/mnt/d/AndroidNdk/android-ndk-r27d/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android24-clang -O2 -pie trigger4_call.c -o trigger4_call

flag截图

image-20260418150930383

后面可以直接拿去验证算法真实性

分析线性关系 [解法一]

主要有两个任务

  1. 定位找算法位置
  2. 逆向算法

因为一开始以为tick是part3绕了好久,一直没找到part3位置

逻辑定位

入口定位从 GDScript 追到 sub_A9A7C,

直接搜flagd等字符串也搜不到,感觉libsec2026 不直接构造 flag 字符串,可能有某种收发机制,转向 libgodot 找collided_with发射点。

collided_with 是 trigger4.gd 通过 _w7 接收的信号名,但 trigger4.gd 自己根本没 _w7,证明这个信号由 native 发出。grep libgodot 字符串:

1
2
"collided_with" @ libgodot+0x42BBD1
交叉引用到 sub_25F6718 @ libgodot+0x25F6B74

直接发现尾部函数尾部确实有:

1
2
sub_3C91744(&s_1, "collided_with", 0);
sub_3C58F78(a1, &s_1, &s_6, 1); // emit_signal(self, "collided_with", arg)

image-20260418230347176

但这只是发射机制,算法不在这里。算法藏在 arg的构造过程里

在F12搜索里没搜到,但是通过直接对整个文件过滤发现了相关字符串

image-20260418231835229

image-20260418232027574

一眼顶真 flag

拼起来就是flag{sec2026_PART3_” + + }。中间的 <hash>v27(s_7) 这个函数调用产出:

image-20260418232515555

1
2
v27 = (qword_4011848 + dword_400A054);   // 动态计算的函数指针
v29 = v27(s_7); // 关键调用

image-20260418232143841

跟踪qword_4011848 :

image-20260418233111065

image-20260418234020856

1
2
qword_4011848 = qword_40111F8 - dword_400A058;
qword_40111F8 = dlsym(libsec2026, "extension_init");

把 dword_400A054 = 0x000A9A7C 和 dword_400A058 = 0x000A4074代回去:

1
2
3
4
v27 = libsec2026.extension_init - 0x000A4074 + 0x000A9A7C
= libsec2026.base + extension_init_offset + offset_diff
= libsec2026 + 0x000A9A7C
= libsec2026.so::sub_A9A7C

PART3 = libsec2026.so::sub_A9A7C(token_string)

image-20260418233754974

1
2
3
4
5
6
trigger4.gd::_process              
→ _gx.Tick()
→ libgodot::sub_25F6718 collision 发射器
arg = libgodot常量 + v27(s_7) + 后缀
v27 = libsec2026 + 0xA9A7C PART3 hash 算法在这
→ emit_signal("collided_with", arg)

VM分析

sub_A9A7C 是 OLLVM 扁平化 dispatcher,每个 state 跳到一个 handler,handler 自己又是 OLLVM 状态机。多层嵌套 OLLVM。直接读汇编基本不可行

观察sub_A9A7C的代码结构,dispatcher loop 形态像解释器,每次循环:

  1. memcpy 32 字节到栈缓冲
  2. 一些 CPU 指令做解码
  3. memcpy 把结果写到某处

这是经典 VM 解释器模式。0x63D80 处发现 SBC0 magic + 5414 字节字节码,确认是 VM 程序段:

image-20260418234303962

一般VM的处理思路就是动态trace,观察执行,观察它对内存做了什么。

每条 VM 指令的执行模式,这也是大部分CTF题中的例子,一般我们只需要对中间这下断点读出来就能一定程度上反推逻辑。

1
2
3
memcpy(stack_buf, BC_HEAP + VM_PC, 32)   
# 做一些操作
memcpy(some_addr, ..., write_size)

所以BC_HEAP + 32 字节 memcpy出现一次就是一条 VM 指令开始。基于此写第一个工具:

1
2
3
4
5
6
7
8
9
# disas_sbc0.py
def stub_handler(mu, addr):
if name in ('memcpy', '__memcpy_chk'):
dst, src, sz = ...
if BC_HEAP <= src < BC_HEAP + 5414 and sz == 32:
# 字节码预取 新 VM 指令开始
instructions.append({'pc': pc_in_bc, 'memops': []})
elif current_inst:
current_inst['memops'].append((dst, sz, data))

完整代码

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
#!/usr/bin/env python3
"""SBC0 bytecode disassembler: trace each VM instruction's PC + activity."""
import struct, sys, os
sys.path.insert(0, os.path.dirname(__file__))
from unicorn import Uc, UcError, UC_ARCH_ARM64, UC_MODE_ARM, UC_HOOK_CODE
from unicorn.arm64_const import *
from pathlib import Path

LIB = Path("/home/matriy/RE/game/tencent_ctf/2026_end/final/lib/arm64-v8a/libsec2026.so")
SUB = 0xA9A7C
CODE_BASE=0; CODE_SIZE=0x200000
STACK_BASE=0x10000000; STACK_SIZE=0x100000
HEAP_BASE=0x20000000; HEAP_SIZE=0x400000
TLS_BASE=0x30000000; TLS_SIZE=0x1000
STUB_BASE=0x40000000; STUB_SIZE=0x1000
INPUT_BASE=0x50000000; INPUT_SIZE=0x1000
RET_MAGIC=0x7FFF0000

STUBS = {}; HEAP_PTR = HEAP_BASE + 0x1000; BC_HEAP = None
BC_LEN = 5414

instructions = [] # list of dicts: {pc, opcode_bytes, memops, calls}
current_inst = None

def stub_handler(mu, addr):
global HEAP_PTR, BC_HEAP, current_inst
name = next((k for k,v in STUBS.items() if v == addr), f'unk')
if name in ('pthread_mutex_lock','pthread_mutex_unlock','free','pthread_mutex_destroy','__cxa_atexit','__cxa_finalize'):
mu.reg_write(UC_ARM64_REG_X0, 0)
elif name in ('memset','__memset_chk'):
dst=mu.reg_read(UC_ARM64_REG_X0); val=mu.reg_read(UC_ARM64_REG_X1)&0xFF; sz=mu.reg_read(UC_ARM64_REG_X2)
try: mu.mem_write(dst, bytes([val])*sz)
except Exception: pass
mu.reg_write(UC_ARM64_REG_X0, dst)
# Track if inside an instruction
if current_inst is not None:
current_inst['memops'].append(('memset', dst, sz, val))
elif name in ('memcpy','__memcpy_chk','memmove','__memmove_chk'):
dst=mu.reg_read(UC_ARM64_REG_X0); src=mu.reg_read(UC_ARM64_REG_X1); sz=mu.reg_read(UC_ARM64_REG_X2)
try:
data = bytes(mu.mem_read(src, sz))
mu.mem_write(dst, data)
if sz == BC_LEN and src == 0x63DA0: globals()['BC_HEAP'] = dst
# Detect bytecode prefetch (32-byte read from BC_HEAP region)
if BC_HEAP is not None and BC_HEAP <= src < BC_HEAP + BC_LEN and sz == 32:
# Start a new instruction
pc_in_bc = src - BC_HEAP
if current_inst is not None:
instructions.append(current_inst)
current_inst = {'pc': pc_in_bc, 'op_bytes': data[:16], 'memops': [], 'calls': []}
elif current_inst is not None:
# Track memops within current instruction
current_inst['memops'].append(('memcpy', dst, sz, data[:16]))
except Exception: pass
mu.reg_write(UC_ARM64_REG_X0, dst)
elif name in ('malloc','calloc','realloc'):
sz = mu.reg_read(UC_ARM64_REG_X1) if name == 'calloc' else mu.reg_read(UC_ARM64_REG_X0)
p = HEAP_PTR; HEAP_PTR = (HEAP_PTR + sz + 0xF) & ~0xF
if name == 'calloc': mu.mem_write(p, b'\x00'*sz)
mu.reg_write(UC_ARM64_REG_X0, p)
elif name == 'strlen':
addr = mu.reg_read(UC_ARM64_REG_X0); n = 0
while True:
b = bytes(mu.mem_read(addr+n, 1))[0]
if b == 0: break
n += 1
if n > 0x1000: break
mu.reg_write(UC_ARM64_REG_X0, n)
elif name == 'getpid': mu.reg_write(UC_ARM64_REG_X0, 1000)
elif name == '__errno': mu.reg_write(UC_ARM64_REG_X0, HEAP_BASE+0x10)
else:
mu.reg_write(UC_ARM64_REG_X0, 0)

def main(token, max_inst=200):
global HEAP_PTR, BC_HEAP, current_inst
HEAP_PTR = HEAP_BASE + 0x1000; BC_HEAP = None; current_inst = None
f = LIB.read_bytes()
mu = Uc(UC_ARCH_ARM64, UC_MODE_ARM)
for base, size in [(CODE_BASE, CODE_SIZE), (STACK_BASE, STACK_SIZE), (HEAP_BASE, HEAP_SIZE),
(TLS_BASE, TLS_SIZE), (STUB_BASE, STUB_SIZE), (INPUT_BASE, INPUT_SIZE),
(RET_MAGIC & ~0xFFF, 0x1000)]:
mu.mem_map(base, size)
e_phoff = struct.unpack('<Q', f[0x20:0x28])[0]
e_phnum = struct.unpack('<H', f[0x38:0x3a])[0]
e_phentsize = struct.unpack('<H', f[0x36:0x38])[0]
for i in range(e_phnum):
ph = f[e_phoff + i*e_phentsize : e_phoff + (i+1)*e_phentsize]
if struct.unpack('<I', ph[:4])[0] != 1: continue
p_off = struct.unpack('<Q', ph[8:16])[0]
p_va = struct.unpack('<Q', ph[16:24])[0]
p_fs = struct.unpack('<Q', ph[32:40])[0]
p_ms = struct.unpack('<Q', ph[40:48])[0]
if p_va + p_ms > CODE_SIZE: continue
mu.mem_write(p_va, f[p_off:p_off+p_fs])
mu.mem_write(HEAP_BASE, b'\x00'*0x40)
mu.mem_write(0x1668D0, struct.pack('<Q', HEAP_BASE))
mu.mem_write(0x1668D8, struct.pack('<Q', 0x183498))

e_shoff = struct.unpack('<Q', f[0x28:0x30])[0]
e_shentsize = struct.unpack('<H', f[0x3a:0x3c])[0]
e_shnum = struct.unpack('<H', f[0x3c:0x3e])[0]
e_shstrndx = struct.unpack('<H', f[0x3e:0x40])[0]
strtab_hdr = f[e_shoff + e_shstrndx * e_shentsize : e_shoff + (e_shstrndx+1) * e_shentsize]
strtab_off = struct.unpack('<Q', strtab_hdr[0x18:0x20])[0]
def cstr(off):
end = off
while end < len(f) and f[end] != 0: end += 1
return f[off:end].decode()
dynsym_off = dynstr_off = 0
for i in range(e_shnum):
sh = f[e_shoff + i*e_shentsize : e_shoff + (i+1)*e_shentsize]
name = cstr(strtab_off + struct.unpack('<I', sh[:4])[0])
if name == '.dynsym': dynsym_off = struct.unpack('<Q', sh[0x18:0x20])[0]
elif name == '.dynstr': dynstr_off = struct.unpack('<Q', sh[0x18:0x20])[0]
all_names = set()
for i in range(500):
s = f[dynsym_off + i*24 : dynsym_off + (i+1)*24]
if len(s) < 24: break
st_name = struct.unpack('<I', s[:4])[0]
if st_name == 0 or dynstr_off + st_name >= len(f): continue
try: all_names.add(cstr(dynstr_off + st_name))
except Exception: continue
for i, name in enumerate(sorted(all_names)):
stub_addr = STUB_BASE + 0x100 + i * 4
mu.mem_write(stub_addr, struct.pack('<I', 0xD65F03C0))
STUBS[name] = stub_addr
for i in range(e_shnum):
sh = f[e_shoff + i*e_shentsize : e_shoff + (i+1)*e_shentsize]
sh_type = struct.unpack('<I', sh[4:8])[0]
sh_off = struct.unpack('<Q', sh[0x18:0x20])[0]
sh_size = struct.unpack('<Q', sh[0x20:0x28])[0]
sh_entsize = struct.unpack('<Q', sh[0x38:0x40])[0]
if sh_type == 4 and sh_entsize == 24:
for j in range(sh_size // 24):
ro, ri, ra = struct.unpack('<QQq', f[sh_off + j*24 : sh_off + (j+1)*24])
rt = ri & 0xffffffff; rs = ri >> 32
if rt == 1027:
try: mu.mem_write(ro, struct.pack('<Q', ra & 0xFFFFFFFFFFFFFFFF))
except Exception: pass
elif rt in (1025, 1026):
sym = f[dynsym_off + rs*24 : dynsym_off + (rs+1)*24]
st_name = struct.unpack('<I', sym[:4])[0]
try:
sn = cstr(dynstr_off + st_name)
if sn in STUBS: mu.mem_write(ro, struct.pack('<Q', STUBS[sn]))
except Exception: pass
adrp = struct.unpack('<I', f[0xA9AC8:0xA9ACC])[0]
ldr = struct.unpack('<I', f[0xA9ADC:0xA9AE0])[0]
imm21 = (((adrp >> 5) & 0x7FFFF) << 2) | ((adrp >> 29) & 0x3)
if imm21 & (1 << 20): imm21 -= (1 << 21)
base = (0xA9AC8 & ~0xFFF) + (imm21 << 12)
mlp = base + ((ldr >> 10) & 0xFFF) * 8
mu.mem_write(mlp, struct.pack('<Q', STUBS['pthread_mutex_lock']))

mu.mem_write(INPUT_BASE, token.encode() + b'\x00')
mu.reg_write(UC_ARM64_REG_X0, INPUT_BASE)
mu.reg_write(UC_ARM64_REG_SP, STACK_BASE + STACK_SIZE - 0x1000)
mu.reg_write(UC_ARM64_REG_X30, RET_MAGIC)
mu.reg_write(UC_ARM64_REG_TPIDR_EL0, TLS_BASE + 0x100)
mu.mem_write(TLS_BASE + 0x100 + 0x28, struct.pack('<Q', 0xDEADBEEFCAFEBABE))

steps = [0]
def code_hook(uc, addr, size, data):
steps[0] += 1
if addr == RET_MAGIC: uc.emu_stop(); return
if STUB_BASE <= addr < STUB_BASE + STUB_SIZE:
stub_handler(uc, addr & ~3); return
mu.hook_add(UC_HOOK_CODE, code_hook)
try:
mu.emu_start(SUB, RET_MAGIC, timeout=0, count=0)
except UcError as e:
print(f'[emu] err: {e}')

if current_inst is not None:
instructions.append(current_inst)

print(f'\ntotal instructions traced: {len(instructions)}')
print(f'first {max_inst}:\n')
for i, ins in enumerate(instructions[:max_inst]):
op = ins['op_bytes']
# Compute likely instruction length: distance to next inst's pc
if i+1 < len(instructions):
ilen = instructions[i+1]['pc'] - ins['pc']
else:
ilen = 16
actual_op = op[:max(1, min(ilen, 16))]
print(f' [{i:>3}] PC=0x{ins["pc"]:04x} ({ilen:2}B) opcode_bytes={actual_op.hex(" ")}'
f' memops={len(ins["memops"])}')

if __name__ == '__main__':
token = sys.argv[1] if len(sys.argv) > 1 else 'Trigger4'
n = int(sys.argv[2]) if len(sys.argv) > 2 else 50
main(token, n)

image-20260418234657628

跑一次 token=12345678 的 sub_A9A7C 模拟,输出15919 条指令,662 个独特 PC。

上图只输出了前50

主循环识别:28 轮 × 557 条指令

按 PC 分组统计指令:

1
2
3
4
5
6
PC=0x0000  (81 字节)   ← 函数 header
PC=0x0051 (542 字节) ← 第二个函数(可能是init)
PC=0x026F (11 字节) ← 主循环开始
PC=0x027A (+11 字节)
PC=0x0285 (+11 字节)
... 重复 28 次 ... ← 28 轮主循环

主循环每轮 557 条指令。28 × 557 + 一些 setup/teardown ≈ 15919。

28 轮这个数字非常关键, 不是常见 AES 的 10或14 轮,可能不是常见密码,有可能是自定义的

观察一轮内的 memcpy 写入目标,每条指令写到栈上 16 个固定槽位之一(每个槽位 4 字节)。每轮重置,所以:

1
2
3
4
5
6
7
Round r:
s[0] = 某个表达式
s[1] = 某个表达式(可能依赖 s[0])
s[2] = ...
...
s[15] = ...
Chain to next round.

类似 SSA(静态单赋值)形式。每轮 16 个 slot,每个 slot 算一次。问题简化为:每个 slot 的公式是什么?

跑两次 sub_A9A7C,输入只差 1 字节:

1
2
3
trace_a = run("12345678")
trace_b = run("12345679")
diff = compare(trace_a, trace_b)

输出2000 条指令的 memop 不一样,剩下 13919 条完全相同。

因此只有那 2000 条指令是输入相关的算术,其他都是 dispatcher 控制流。忽略 13919 条,集中分析剩下 2000 条。

完整代码

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
#!/usr/bin/env python3
"""Capture every memop value during execution, per PC-sequenced step."""
import struct, sys, os, pickle
sys.path.insert(0, os.path.dirname(__file__))
from unicorn import Uc, UcError, UC_ARCH_ARM64, UC_MODE_ARM, UC_HOOK_CODE, UC_HOOK_MEM_WRITE, UC_HOOK_MEM_READ
from unicorn.arm64_const import *
from pathlib import Path

LIB = Path("/home/matriy/RE/game/tencent_ctf/2026_end/final/lib/arm64-v8a/libsec2026.so")
SUB = 0xA9A7C
CODE_BASE=0; CODE_SIZE=0x200000
STACK_BASE=0x10000000; STACK_SIZE=0x100000
HEAP_BASE=0x20000000; HEAP_SIZE=0x400000
TLS_BASE=0x30000000; TLS_SIZE=0x1000
STUB_BASE=0x40000000; STUB_SIZE=0x1000
INPUT_BASE=0x50000000; INPUT_SIZE=0x1000
RET_MAGIC=0x7FFF0000

STUBS = {}; HEAP_PTR = HEAP_BASE + 0x1000; BC_HEAP = None
BC_LEN = 5414

# Current instruction window
instructions = [] # list of {pc, memops: [(op_type, ...)]}
current_inst = None

def stub_handler(mu, addr):
global HEAP_PTR, BC_HEAP, current_inst
name = next((k for k,v in STUBS.items() if v == addr), f'unk')
if name in ('pthread_mutex_lock','pthread_mutex_unlock','free','pthread_mutex_destroy','__cxa_atexit','__cxa_finalize'):
mu.reg_write(UC_ARM64_REG_X0, 0)
elif name in ('memset','__memset_chk'):
dst=mu.reg_read(UC_ARM64_REG_X0); val=mu.reg_read(UC_ARM64_REG_X1)&0xFF; sz=mu.reg_read(UC_ARM64_REG_X2)
try: mu.mem_write(dst, bytes([val])*sz)
except Exception: pass
mu.reg_write(UC_ARM64_REG_X0, dst)
if current_inst is not None:
current_inst['memops'].append(('memset', dst, sz, val))
elif name in ('memcpy','__memcpy_chk','memmove','__memmove_chk'):
dst=mu.reg_read(UC_ARM64_REG_X0); src=mu.reg_read(UC_ARM64_REG_X1); sz=mu.reg_read(UC_ARM64_REG_X2)
try:
data = bytes(mu.mem_read(src, sz))
mu.mem_write(dst, data)
if sz == BC_LEN and src == 0x63DA0: globals()['BC_HEAP'] = dst
# Detect bytecode prefetch
if BC_HEAP is not None and BC_HEAP <= src < BC_HEAP + BC_LEN and sz == 32:
pc_in_bc = src - BC_HEAP
if current_inst is not None:
instructions.append(current_inst)
current_inst = {'pc': pc_in_bc, 'memops': []}
elif current_inst is not None:
# Track as data memcpy: preserve destination, source, size, and actual data
# (truncate to 16 bytes for space)
current_inst['memops'].append(('memcpy', dst, sz, data[:16]))
except Exception: pass
mu.reg_write(UC_ARM64_REG_X0, dst)
elif name in ('malloc','calloc','realloc'):
sz = mu.reg_read(UC_ARM64_REG_X1) if name == 'calloc' else mu.reg_read(UC_ARM64_REG_X0)
p = HEAP_PTR; HEAP_PTR = (HEAP_PTR + sz + 0xF) & ~0xF
if name == 'calloc': mu.mem_write(p, b'\x00'*sz)
mu.reg_write(UC_ARM64_REG_X0, p)
elif name == 'strlen':
addr = mu.reg_read(UC_ARM64_REG_X0); n = 0
while True:
b = bytes(mu.mem_read(addr+n, 1))[0]
if b == 0: break
n += 1
if n > 0x1000: break
mu.reg_write(UC_ARM64_REG_X0, n)
elif name == 'getpid': mu.reg_write(UC_ARM64_REG_X0, 1000)
elif name == '__errno': mu.reg_write(UC_ARM64_REG_X0, HEAP_BASE+0x10)
else:
mu.reg_write(UC_ARM64_REG_X0, 0)

def run(token):
global HEAP_PTR, BC_HEAP, current_inst, instructions
HEAP_PTR = HEAP_BASE + 0x1000; BC_HEAP = None; current_inst = None
instructions = []
f = LIB.read_bytes()
mu = Uc(UC_ARCH_ARM64, UC_MODE_ARM)
for base, size in [(CODE_BASE, CODE_SIZE), (STACK_BASE, STACK_SIZE), (HEAP_BASE, HEAP_SIZE),
(TLS_BASE, TLS_SIZE), (STUB_BASE, STUB_SIZE), (INPUT_BASE, INPUT_SIZE),
(RET_MAGIC & ~0xFFF, 0x1000)]:
mu.mem_map(base, size)
e_phoff = struct.unpack('<Q', f[0x20:0x28])[0]
e_phnum = struct.unpack('<H', f[0x38:0x3a])[0]
e_phentsize = struct.unpack('<H', f[0x36:0x38])[0]
for i in range(e_phnum):
ph = f[e_phoff + i*e_phentsize : e_phoff + (i+1)*e_phentsize]
if struct.unpack('<I', ph[:4])[0] != 1: continue
p_off = struct.unpack('<Q', ph[8:16])[0]
p_va = struct.unpack('<Q', ph[16:24])[0]
p_fs = struct.unpack('<Q', ph[32:40])[0]
p_ms = struct.unpack('<Q', ph[40:48])[0]
if p_va + p_ms > CODE_SIZE: continue
mu.mem_write(p_va, f[p_off:p_off+p_fs])
mu.mem_write(HEAP_BASE, b'\x00'*0x40)
mu.mem_write(0x1668D0, struct.pack('<Q', HEAP_BASE))
mu.mem_write(0x1668D8, struct.pack('<Q', 0x183498))
e_shoff = struct.unpack('<Q', f[0x28:0x30])[0]
e_shentsize = struct.unpack('<H', f[0x3a:0x3c])[0]
e_shnum = struct.unpack('<H', f[0x3c:0x3e])[0]
e_shstrndx = struct.unpack('<H', f[0x3e:0x40])[0]
strtab_hdr = f[e_shoff + e_shstrndx * e_shentsize : e_shoff + (e_shstrndx+1) * e_shentsize]
strtab_off = struct.unpack('<Q', strtab_hdr[0x18:0x20])[0]
def cstr(off):
end = off
while end < len(f) and f[end] != 0: end += 1
return f[off:end].decode()
dynsym_off = dynstr_off = 0
for i in range(e_shnum):
sh = f[e_shoff + i*e_shentsize : e_shoff + (i+1)*e_shentsize]
name = cstr(strtab_off + struct.unpack('<I', sh[:4])[0])
if name == '.dynsym': dynsym_off = struct.unpack('<Q', sh[0x18:0x20])[0]
elif name == '.dynstr': dynstr_off = struct.unpack('<Q', sh[0x18:0x20])[0]
all_names = set()
for i in range(500):
s = f[dynsym_off + i*24 : dynsym_off + (i+1)*24]
if len(s) < 24: break
st_name = struct.unpack('<I', s[:4])[0]
if st_name == 0 or dynstr_off + st_name >= len(f): continue
try: all_names.add(cstr(dynstr_off + st_name))
except Exception: continue
for i, name in enumerate(sorted(all_names)):
stub_addr = STUB_BASE + 0x100 + i * 4
mu.mem_write(stub_addr, struct.pack('<I', 0xD65F03C0))
STUBS[name] = stub_addr
for i in range(e_shnum):
sh = f[e_shoff + i*e_shentsize : e_shoff + (i+1)*e_shentsize]
sh_type = struct.unpack('<I', sh[4:8])[0]
sh_off = struct.unpack('<Q', sh[0x18:0x20])[0]
sh_size = struct.unpack('<Q', sh[0x20:0x28])[0]
sh_entsize = struct.unpack('<Q', sh[0x38:0x40])[0]
if sh_type == 4 and sh_entsize == 24:
for j in range(sh_size // 24):
ro, ri, ra = struct.unpack('<QQq', f[sh_off + j*24 : sh_off + (j+1)*24])
rt = ri & 0xffffffff; rs = ri >> 32
if rt == 1027:
try: mu.mem_write(ro, struct.pack('<Q', ra & 0xFFFFFFFFFFFFFFFF))
except Exception: pass
elif rt in (1025, 1026):
sym = f[dynsym_off + rs*24 : dynsym_off + (rs+1)*24]
st_name = struct.unpack('<I', sym[:4])[0]
try:
sn = cstr(dynstr_off + st_name)
if sn in STUBS: mu.mem_write(ro, struct.pack('<Q', STUBS[sn]))
except Exception: pass
adrp = struct.unpack('<I', f[0xA9AC8:0xA9ACC])[0]
ldr = struct.unpack('<I', f[0xA9ADC:0xA9AE0])[0]
imm21 = (((adrp >> 5) & 0x7FFFF) << 2) | ((adrp >> 29) & 0x3)
if imm21 & (1 << 20): imm21 -= (1 << 21)
base = (0xA9AC8 & ~0xFFF) + (imm21 << 12)
mlp = base + ((ldr >> 10) & 0xFFF) * 8
mu.mem_write(mlp, struct.pack('<Q', STUBS['pthread_mutex_lock']))
mu.mem_write(INPUT_BASE, token.encode() + b'\x00')
mu.reg_write(UC_ARM64_REG_X0, INPUT_BASE)
mu.reg_write(UC_ARM64_REG_SP, STACK_BASE + STACK_SIZE - 0x1000)
mu.reg_write(UC_ARM64_REG_X30, RET_MAGIC)
mu.reg_write(UC_ARM64_REG_TPIDR_EL0, TLS_BASE + 0x100)
mu.mem_write(TLS_BASE + 0x100 + 0x28, struct.pack('<Q', 0xDEADBEEFCAFEBABE))

def code_hook(uc, addr, size, data):
if addr == RET_MAGIC: uc.emu_stop(); return
if STUB_BASE <= addr < STUB_BASE + STUB_SIZE:
stub_handler(uc, addr & ~3); return
mu.hook_add(UC_HOOK_CODE, code_hook)
try:
mu.emu_start(SUB, RET_MAGIC, timeout=0, count=0)
except UcError as e:
print(f'[emu] err: {e}')
if current_inst is not None:
instructions.append(current_inst)
return instructions

if __name__ == '__main__':
token1 = sys.argv[1] if len(sys.argv) > 1 else 'Trigger4'
token2 = sys.argv[2] if len(sys.argv) > 2 else 'Trigger1'
out_pickle = sys.argv[3] if len(sys.argv) > 3 else '/tmp/memop_trace.pkl'

print(f'[run1] token={token1}')
insts1 = run(token1)
print(f' {len(insts1)} instructions captured')
data = {'token1': token1, 'insts1': insts1}
with open(out_pickle, 'wb') as f:
pickle.dump(data, f)
print(f'[run2] token={token2}')
insts2 = run(token2)
print(f' {len(insts2)} instructions captured')
data['token2'] = token2
data['insts2'] = insts2
with open(out_pickle, 'wb') as f:
pickle.dump(data, f)
print(f'saved to {out_pickle}')

# Diff analysis: show instructions where memops DIFFER
diffs = 0
first_diff_idx = None
for i in range(min(len(insts1), len(insts2))):
a = insts1[i]; b = insts2[i]
if a['pc'] != b['pc']:
print(f' PC DIFFER at inst {i}: run1={a["pc"]:x} run2={b["pc"]:x}')
break
if a['memops'] != b['memops']:
diffs += 1
if first_diff_idx is None:
first_diff_idx = i
print(f'\ntotal instructions with differing memops: {diffs}')
print(f'first divergent instruction: idx={first_diff_idx}')
if first_diff_idx is not None:
a = insts1[first_diff_idx]; b = insts2[first_diff_idx]
print(f' PC=0x{a["pc"]:04x}')
print(f' run1 memops: {a["memops"]}')
print(f' run2 memops: {b["memops"]}')

每条 VM 指令带着它写了哪些内存地址、每次写了多少字节、写了什么

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402

total instructions with differing memops: 2000
PC sequence identical across runs → constant-time algorithm ✓

=== divergent PCs (by hit count) ===
count pc first_idxs
28 0x08b0 315,872,1429,1986...+24 more
28 0x08fb 331,888,1445,2002...+24 more
28 0x094d 344,901,1458,2015...+24 more
28 0x0998 360,917,1474,2031...+24 more
28 0x09cf 368,925,1482,2039...+24 more
28 0x09f5 374,931,1488,2045...+24 more
28 0x0a0c 377,934,1491,2048...+24 more
28 0x0a4b 391,948,1505,2062...+24 more
28 0x0a82 399,956,1513,2070...+24 more
28 0x0aa8 405,962,1519,2076...+24 more
28 0x0af0 421,978,1535,2092...+24 more
28 0x0b27 429,986,1543,2100...+24 more
28 0x0b4d 435,992,1549,2106...+24 more
28 0x0b64 438,995,1552,2109...+24 more
28 0x0b77 440,997,1554,2111...+24 more
28 0x0bab 453,1010,1567,2124...+24 more
28 0x0be2 461,1018,1575,2132...+24 more
28 0x0c08 467,1024,1581,2138...+24 more
28 0x0c53 483,1040,1597,2154...+24 more
28 0x0c8a 491,1048,1605,2162...+24 more
28 0x0cb0 497,1054,1611,2168...+24 more
28 0x0cc7 500,1057,1614,2171...+24 more
28 0x0d06 514,1071,1628,2185...+24 more
28 0x0d3d 522,1079,1636,2193...+24 more
28 0x0d63 528,1085,1642,2199...+24 more
28 0x0d7a 531,1088,1645,2202...+24 more
28 0x0d8d 533,1090,1647,2204...+24 more
28 0x0dc1 546,1103,1660,2217...+24 more
28 0x0df8 554,1111,1668,2225...+24 more
28 0x0e1e 560,1117,1674,2231...+24 more
28 0x0e48 565,1122,1679,2236...+24 more
28 0x0e7c 578,1135,1692,2249...+24 more
28 0x0eb3 586,1143,1700,2257...+24 more
28 0x0ed9 592,1149,1706,2263...+24 more
28 0x0ef0 595,1152,1709,2266...+24 more
28 0x0f2f 609,1166,1723,2280...+24 more
28 0x0f66 617,1174,1731,2288...+24 more
28 0x0f8c 623,1180,1737,2294...+24 more
28 0x0fa3 626,1183,1740,2297...+24 more
28 0x0fe2 640,1197,1754,2311...+24 more
28 0x1019 648,1205,1762,2319...+24 more
28 0x103f 654,1211,1768,2325...+24 more
28 0x1056 657,1214,1771,2328...+24 more
28 0x1092 671,1228,1785,2342...+24 more
28 0x10c9 679,1236,1793,2350...+24 more
28 0x10ef 685,1242,1799,2356...+24 more
28 0x1106 688,1245,1802,2359...+24 more
28 0x1119 690,1247,1804,2361...+24 more
28 0x114d 703,1260,1817,2374...+24 more
28 0x1184 711,1268,1825,2382...+24 more
28 0x11aa 717,1274,1831,2388...+24 more
28 0x11c1 720,1277,1834,2391...+24 more
28 0x1200 734,1291,1848,2405...+24 more
28 0x1237 742,1299,1856,2413...+24 more
28 0x125d 748,1305,1862,2419...+24 more
28 0x1274 751,1308,1865,2422...+24 more
28 0x12b3 765,1322,1879,2436...+24 more
28 0x12ea 773,1330,1887,2444...+24 more
28 0x1310 779,1336,1893,2450...+24 more
28 0x1327 782,1339,1896,2453...+24 more
28 0x133a 784,1341,1898,2455...+24 more
28 0x136e 797,1354,1911,2468...+24 more
28 0x13a5 805,1362,1919,2476...+24 more
28 0x13cb 811,1368,1925,2482...+24 more
28 0x13ea 815,1372,1929,2486...+24 more
28 0x141e 828,1385,1942,2499...+24 more
28 0x1455 836,1393,1950,2507...+24 more
28 0x1478 840,1397,1954,2511...+24 more
28 0x1493 843,1400,1957,2514...+24 more
28 0x14a6 845,1402,1959,2516...+24 more
27 0x0e35 1120,1677,2234,2791...+23 more
1 0x0123 146
1 0x0159 154
1 0x01f8 178
1 0x0235 187
1 0x0248 189
1 0x03c0 45
1 0x070b 207
1 0x075e 286
1 0x14d7 15909
1 0x14e5 15911
1 0x14fe 15914
1 0x150c 15916
1 0x1514 15917

=== main-loop instructions (hit count == 28): 70 distinct PCs ===
PC=0x08b0 round#0 at inst[315]:
dst=0x203386e0 sz=8
run1 data: 6765723400000000
run2 data: 6765723100000000
PC=0x08fb round#0 at inst[331]:
dst=0x100feb70 sz=8
run1 data: 6765723400000000
run2 data: 6765723100000000
PC=0x094d round#0 at inst[344]:
dst=0x203386e0 sz=8
run1 data: 6765723400000000
run2 data: 6765723100000000
PC=0x0998 round#0 at inst[360]:
dst=0x100feb70 sz=8
run1 data: 6765723400000000
run2 data: 6765723100000000
PC=0x09cf round#0 at inst[368]:
dst=0x2032f710 sz=8
run1 data: 7056264700000000
run2 data: 7056261700000000
PC=0x09f5 round#0 at inst[374]:
dst=0x203386e0 sz=8
run1 data: 6765723400000000
run2 data: 6765723100000000
PC=0x0a0c round#0 at inst[377]:
dst=0x100feb70 sz=8
run1 data: 7056264700000000
run2 data: 7056261700000000
PC=0x0a4b round#0 at inst[391]:
dst=0x100feb70 sz=8
run1 data: 6765723400000000
run2 data: 6765723100000000
PC=0x0a82 round#0 at inst[399]:
dst=0x2032f718 sz=8
run1 data: babc834000000000
run2 data: babc831000000000
PC=0x0aa8 round#0 at inst[405]:
dst=0x203386e0 sz=8
run1 data: 6765723400000000
run2 data: 6765723100000000
PC=0x0af0 round#0 at inst[421]:
dst=0x100feb70 sz=8
run1 data: 6765723400000000
run2 data: 6765723100000000
PC=0x0b27 round#0 at inst[429]:
dst=0x2032f720 sz=8
run1 data: 0602585e00000000
run2 data: 0602585b00000000
PC=0x0b4d round#0 at inst[435]:
dst=0x203386e0 sz=8
run1 data: 6765723400000000
run2 data: 6765723100000000
PC=0x0b64 round#0 at inst[438]:
dst=0x100feb70 sz=8
run1 data: babc834000000000
run2 data: babc831000000000
PC=0x0b77 round#0 at inst[440]:
dst=0x100feb70 sz=8
run1 data: 0602585e00000000
run2 data: 0602585b00000000
PC=0x0bab round#0 at inst[453]:
dst=0x100feb70 sz=8
run1 data: 6765723400000000
run2 data: 6765723100000000
PC=0x0be2 round#0 at inst[461]:
dst=0x2032f728 sz=8
run1 data: bcbedb1e00000000
run2 data: bcbedb4b00000000
PC=0x0c08 round#0 at inst[467]:
dst=0x203386e0 sz=8
run1 data: 6765723400000000
run2 data: 6765723100000000
PC=0x0c53 round#0 at inst[483]:
dst=0x100feb70 sz=8
run1 data: 6765723400000000
run2 data: 6765723100000000
PC=0x0c8a round#0 at inst[491]:
dst=0x2032f730 sz=8
run1 data: cae4680000000000
run2 data: cae4620000000000
PC=0x0cb0 round#0 at inst[497]:
dst=0x203386e0 sz=8
run1 data: 6765723400000000
run2 data: 6765723100000000
PC=0x0cc7 round#0 at inst[500]:
dst=0x100feb70 sz=8
run1 data: cae4680000000000
run2 data: cae4620000000000
PC=0x0d06 round#0 at inst[514]:
dst=0x100feb70 sz=8
run1 data: 6765723400000000
run2 data: 6765723100000000
PC=0x0d3d round#0 at inst[522]:
dst=0x2032f738 sz=8
run1 data: 161b131300000000
run2 data: 161b0d1300000000
PC=0x0d63 round#0 at inst[528]:
dst=0x203386e0 sz=8
run1 data: 6765723400000000
run2 data: 6765723100000000
PC=0x0d7a round#0 at inst[531]:
dst=0x100feb70 sz=8
run1 data: bcbedb1e00000000
run2 data: bcbedb4b00000000
PC=0x0d8d round#0 at inst[533]:
dst=0x100feb70 sz=8
run1 data: 161b131300000000
run2 data: 161b0d1300000000
PC=0x0dc1 round#0 at inst[546]:
dst=0x100feb70 sz=8
run1 data: 6765723400000000
run2 data: 6765723100000000
PC=0x0df8 round#0 at inst[554]:
dst=0x2032f740 sz=8
run1 data: aaa5c80d00000000
run2 data: aaa5d65800000000
PC=0x0e1e round#0 at inst[560]:
dst=0x203386e0 sz=8
run1 data: 6765723400000000
run2 data: 6765723100000000
PC=0x0e48 round#0 at inst[565]:
dst=0x100feb70 sz=8
run1 data: aaa5c80d00000000
run2 data: aaa5d65800000000
PC=0x0e7c round#0 at inst[578]:
dst=0x100feb70 sz=8
run1 data: 6765723400000000
run2 data: 6765723100000000
PC=0x0eb3 round#0 at inst[586]:
dst=0x2032f748 sz=8
run1 data: fe17327500000000
run2 data: fe1740c000000000
PC=0x0ed9 round#0 at inst[592]:
dst=0x203386e0 sz=8
run1 data: 6765723400000000
run2 data: 6765723100000000
PC=0x0ef0 round#0 at inst[595]:
dst=0x100feb70 sz=8
run1 data: fe17327500000000
run2 data: fe1740c000000000
PC=0x0f2f round#0 at inst[609]:
dst=0x100feb70 sz=8
run1 data: 6765723400000000
run2 data: 6765723100000000
PC=0x0f66 round#0 at inst[617]:
dst=0x2032f750 sz=8
run1 data: 80ff854c00000000
run2 data: 80ff051000000000
PC=0x0f8c round#0 at inst[623]:
dst=0x203386e0 sz=8
run1 data: 6765723400000000
run2 data: 6765723100000000
PC=0x0fa3 round#0 at inst[626]:
dst=0x100feb70 sz=8
run1 data: 80ff854c00000000
run2 data: 80ff051000000000
PC=0x0fe2 round#0 at inst[640]:
dst=0x100feb70 sz=8
run1 data: 6765723400000000
run2 data: 6765723100000000
PC=0x1019 round#0 at inst[648]:
dst=0x2032f758 sz=8
run1 data: 6e3c338000000000
run2 data: 6e3cb34300000000
PC=0x103f round#0 at inst[654]:
dst=0x203386e0 sz=8
run1 data: 6765723400000000
run2 data: 6765723100000000
PC=0x1056 round#0 at inst[657]:
dst=0x100feb70 sz=8
run1 data: fe17327500000000
run2 data: fe1740c000000000
PC=0x1092 round#0 at inst[671]:
dst=0x100feb70 sz=8
run1 data: 6765723400000000
run2 data: 6765723100000000
PC=0x10c9 round#0 at inst[679]:
dst=0x2032f760 sz=8
run1 data: 9db4179f00000000
run2 data: 9db425ea00000000
PC=0x10ef round#0 at inst[685]:
dst=0x203386e0 sz=8
run1 data: 6765723400000000
run2 data: 6765723100000000
PC=0x1106 round#0 at inst[688]:
dst=0x100feb70 sz=8
run1 data: 6e3c338000000000
run2 data: 6e3cb34300000000
PC=0x1119 round#0 at inst[690]:
dst=0x100feb70 sz=8
run1 data: 9db4179f00000000
run2 data: 9db425ea00000000
PC=0x114d round#0 at inst[703]:
dst=0x100feb70 sz=8
run1 data: 6765723400000000
run2 data: 6765723100000000
PC=0x1184 round#0 at inst[711]:
dst=0x2032f768 sz=8
run1 data: f388241f00000000
run2 data: f38896a900000000
PC=0x11aa round#0 at inst[717]:
dst=0x203386e0 sz=8
run1 data: 6765723400000000
run2 data: 6765723100000000
PC=0x11c1 round#0 at inst[720]:
dst=0x100feb70 sz=8
run1 data: fe17327500000000
run2 data: fe1740c000000000
PC=0x1200 round#0 at inst[734]:
dst=0x100feb70 sz=8
run1 data: 6765723400000000
run2 data: 6765723100000000
PC=0x1237 round#0 at inst[742]:
dst=0x2032f770 sz=8
run1 data: bf90a90300000000
run2 data: bf00020600000000
PC=0x125d round#0 at inst[748]:
dst=0x203386e0 sz=8
run1 data: 6765723400000000
run2 data: 6765723100000000
PC=0x1274 round#0 at inst[751]:
dst=0x100feb70 sz=8
run1 data: bf90a90300000000
run2 data: bf00020600000000
PC=0x12b3 round#0 at inst[765]:
dst=0x100feb70 sz=8
run1 data: 6765723400000000
run2 data: 6765723100000000
PC=0x12ea round#0 at inst[773]:
dst=0x2032f778 sz=8
run1 data: 9c5d65ae00000000
run2 data: 9ccdbdb000000000
PC=0x1310 round#0 at inst[779]:
dst=0x203386e0 sz=8
run1 data: 6765723400000000
run2 data: 6765723100000000
PC=0x1327 round#0 at inst[782]:
dst=0x100feb70 sz=8
run1 data: f388241f00000000
run2 data: f38896a900000000
PC=0x133a round#0 at inst[784]:
dst=0x100feb70 sz=8
run1 data: 9c5d65ae00000000
run2 data: 9ccdbdb000000000
PC=0x136e round#0 at inst[797]:
dst=0x100feb70 sz=8
run1 data: 6765723400000000
run2 data: 6765723100000000
PC=0x13a5 round#0 at inst[805]:
dst=0x2032f780 sz=8
run1 data: 6fd541b100000000
run2 data: 6f452b1900000000
PC=0x13cb round#0 at inst[811]:
dst=0x203386e0 sz=8
run1 data: 6765723400000000
run2 data: 6765723100000000
PC=0x13ea round#0 at inst[815]:
dst=0x100feb70 sz=8
run1 data: 6fd541b100000000
run2 data: 6f452b1900000000
PC=0x141e round#0 at inst[828]:
dst=0x100feb70 sz=8
run1 data: 6765723400000000
run2 data: 6765723100000000
PC=0x1455 round#0 at inst[836]:
dst=0x2032f788 sz=8
run1 data: d63ab4e500000000
run2 data: d6aa9d4a00000000
PC=0x1478 round#0 at inst[840]:
dst=0x100feb70 sz=8
run1 data: d63ab4e500000000
run2 data: d6aa9d4a00000000
PC=0x1493 round#0 at inst[843]:
dst=0x100feb70 sz=8
run1 data: fe17327500000000
run2 data: fe1740c000000000
PC=0x14a6 round#0 at inst[845]:
dst=0x2032f790 sz=8
run1 data: fe17327500000000
run2 data: fe1740c000000000

=== 28-round trace for PC=0x08b0 ===
r= 1 inst[ 315] dst=0x203386e0 run1=6765723400000000 run2=6765723100000000
r= 2 inst[ 872] dst=0x203386e0 run1=d63ab4e500000000 run2=d6aa9d4a00000000
r= 3 inst[ 1429] dst=0x203386e0 run1=d3a65e0100000000 run2=8e057d7a00000000
r= 4 inst[ 1986] dst=0x203386e0 run1=dbb9d0c700000000 run2=5e4b8d1700000000
r= 5 inst[ 2543] dst=0x203386e0 run1=1787362100000000 run2=761b1a2a00000000
r= 6 inst[ 3100] dst=0x203386e0 run1=1ac86bca00000000 run2=098691d800000000
r= 7 inst[ 3657] dst=0x203386e0 run1=e97fcd6600000000 run2=0a28e98200000000
r= 8 inst[ 4214] dst=0x203386e0 run1=f5ef75ec00000000 run2=0e1d8a4b00000000
r= 9 inst[ 4771] dst=0x203386e0 run1=dd7add7700000000 run2=ea8d7bf800000000
r=10 inst[ 5328] dst=0x203386e0 run1=fedc877600000000 run2=a4a1815500000000
r=11 inst[ 5885] dst=0x203386e0 run1=74a464c000000000 run2=4181f31d00000000
r=12 inst[ 6442] dst=0x203386e0 run1=d10093d000000000 run2=2b7d5e4800000000
r=13 inst[ 6999] dst=0x203386e0 run1=d133a87200000000 run2=bc7ca3d400000000
r=14 inst[ 7556] dst=0x203386e0 run1=f614780d00000000 run2=72be1cd300000000
r=15 inst[ 8113] dst=0x203386e0 run1=7c7ea6dd00000000 run2=3f28115700000000
r=16 inst[ 8670] dst=0x203386e0 run1=859a35f700000000 run2=29b471f000000000
r=17 inst[ 9227] dst=0x203386e0 run1=9655d65d00000000 run2=2def8eb400000000
r=18 inst[ 9784] dst=0x203386e0 run1=e233f96200000000 run2=da73d98300000000
r=19 inst[10341] dst=0x203386e0 run1=a846823c00000000 run2=491d9feb00000000
r=20 inst[10898] dst=0x203386e0 run1=cfc0a46900000000 run2=7ce95eaa00000000
r=21 inst[11455] dst=0x203386e0 run1=d31d7c0e00000000 run2=d25e82f700000000
r=22 inst[12012] dst=0x203386e0 run1=9232fc5000000000 run2=f76d4f9800000000
r=23 inst[12569] dst=0x203386e0 run1=a64e756400000000 run2=905ba4eb00000000
r=24 inst[13126] dst=0x203386e0 run1=0bcb941800000000 run2=c5fbd6e000000000
r=25 inst[13683] dst=0x203386e0 run1=47082ca200000000 run2=b92bf61c00000000
r=26 inst[14240] dst=0x203386e0 run1=79c7e3b500000000 run2=b6020ad500000000
r=27 inst[14797] dst=0x203386e0 run1=028af25400000000 run2=337dd20a00000000
r=28 inst[15353] dst=0x203386e0 run1=7630472900000000 run2=6fb38c9e00000000

70 个主循环 PC、每个 PC 恰好命中 28 次

1
2
3
4
5
count  pc
28 0x08b0
28 0x08fb
28 0x094d
... (70 条)

70 个 PC × 28 轮 = 1960 条 diff,再加 27+1×13 = 40 条 init finalize,总和 2000 条,这 70 个 PC 就是主循环内每轮都执行且写结果跟 token 相关的 VM 指令。

两次 trace 的 PC 序列一模一样,说明算法是线性的

三类槽位反复出现

1
2
3
dst = 0x100feb70    一个"scratch / accumulator"槽
dst = 0x203386e0 "scratch" 槽
dst = 0x2032f710, 0x2032f718, ... 0x2032f790 每轮各写 1 次,共 16 个连续地址(+8 字节一组)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
0x2032f710  ← slot 0 (最终槽)
0x2032f718 ← slot 1
0x2032f720 ← slot 2
0x2032f728 ← slot 3
0x2032f730 ← slot 4
0x2032f738 ← slot 5
0x2032f740 ← slot 6
0x2032f748 ← slot 7
0x2032f750 ← slot 8
0x2032f758 ← slot 9
0x2032f760 ← slot 10
0x2032f768 ← slot 11
0x2032f770 ← slot 12
0x2032f778 ← slot 13
0x2032f780 ← slot 14
0x2032f788 ← slot 15
0x2032f790

连续 16 个地址 + 每个地址出现的 PC 只 1 个,这 16 个 PC 就是 s[0]..s[15] 的赋值点。其他 54 个 PC 是取数据到临时槽、执行算术、写回临时槽这些中间步骤。

从 round#0 数据能看出的关系

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
slot  pc      dst        run1 data                    run2 data
s[0] 0x09cf 0x2032f710 7056264700000000 7056261700000000
s[1] 0x0a82 0x2032f718 babc834000000000 babc831000000000
s[2] 0x0b27 0x2032f720 0602585e00000000 0602585b00000000
s[3] 0x0be2 0x2032f728 bcbedb1e00000000 bcbedb4b00000000
s[4] 0x0c8a 0x2032f730 cae4680000000000 cae4620000000000
s[5] 0x0d3d 0x2032f738 161b131300000000 161b0d1300000000
s[6] 0x0df8 0x2032f740 aaa5c80d00000000 aaa5d65800000000
s[7] 0x0eb3 0x2032f748 fe17327500000000 fe1740c000000000
s[8] 0x0f66 0x2032f750 80ff854c00000000 80ff051000000000
s[9] 0x1019 0x2032f758 6e3c338000000000 6e3cb34300000000
s[10] 0x10c9 0x2032f760 9db4179f00000000 9db425ea00000000
s[11] 0x1184 0x2032f768 f388241f00000000 f38896a900000000
s[12] 0x1237 0x2032f770 bf90a90300000000 bf00020600000000
s[13] 0x12ea 0x2032f778 9c5d65ae00000000 9ccdbdb000000000
s[14] 0x13a5 0x2032f780 6fd541b100000000 6f452b1900000000
s[15] 0x1455 0x2032f788 d63ab4e500000000 d6aa9d4a00000000
0x14a6 0x2032f790 fe17327500000000 fe1740c000000000 第 17 个写入

每个 8 字节只用了低 4 字节,第 17 个地址 0x2032f790 跟 slot 7 的数据一致 = fe17327500000000 = 0xFE173275。可能是把 s[7] 复制到 chain 链传给下一轮

核心流程是 constraint-satisfaction:
1. 每个 slot 收集 28 轮的 (输入变量, 输出) 对(输入 = state, prev_s7, r,输出 = slot 值)
2. 枚举可能的公式模板
3. 每个模板在全部 28 轮都成立才接受

其实就是用 28 组 (输入, 输出) 数据反解公式。肉眼看 hex 能直接看出移位关系;减法/XOR 跟恒定常数有关;乘法用模逆解;两变量组合暴力扫。用数据本身的约束把可能的公式缩小到 1 个

从 pickle 抽出每轮每个 slot 的值

1
2
3
4
5
6
rounds1 = [
(s0_r1, s1_r1, s2_r1, ..., s15_r1), # round 1 的 16 个 slot
(s0_r2, s1_r2, s2_r2, ..., s15_r2), # round 2
...
(s0_r28, ...) # round 28
]

28 × 16 = 448 个 u32 数字(每个 token 一套,两个 token 就是 896 个)。这就是数据,拟合任务变成给 896 个数字,解出公式,因为之前已经证明了都是线性的

建立状态链,从前面还得知每轮的 state 和 prev_s7 不是内存里直接读的,是从上一轮 chain 过来的

1
2
3
4
state[0]   = LE_u32(token[4:8])       # 初始,直接从 token 取
state[r+1] = rounds[r][15] # 下一轮的 state = 上一轮的 s[15]
prev_s7[0] = LE_u32(token[0:4])
prev_s7[r+1] = rounds[r][7] # 下一轮的 prev_s7 = 上一轮的 s[7]

对每个 slot 按优先级试 4 种模板

对 slot N,可用输入源 = {state, prev_s7, r, s[0], s[1], …, s[N-1]}

按顺序试:

   1. 移位: src << k, src >> k  (k = 1..31)
   2. 轮号乘: src + r * K
   3. 加法常数: src + K
   4. XOR 常数: src ^ K
   5. 双变量组合: src1 ^ src2, src1 + src2

第一个在全 28 轮 × 2 个 token 都成立的公式就被接受

关键是那些常数K,不需要枚举 2^32 个 K

1
2
3
4
5
6
7
8
9
# round 1 的约束: actual = src + K
# 所以 K = actual - src
K = (rounds1[0][slot] - get_var(src, 1)) & 0xFFFFFFFF

# 拿这个 K 去验证全 28 轮 × 2 token
ok = all(
(get_var(src, r, token) + K) & 0xFFFFFFFF == rounds[r-1][slot]
for r in range(1, 29) for token in [1, 2]
)

其实很简单,意思就是我们之前跑过一遍,知道对应的所有值,我们可以把这个当成结果,写算法,让它去试那种变化能达到这种结果

1
2
3
4
5
6
7
8
9
10
11
12
第1轮外循环:src = state
尝试 s[3] = state << k(k 从 1 试到 31)
对 k=1:
predicted at r=1: state[0] << 1 = 0x34726567 << 1 = 0x68e4cace = 0x1edb8ebc
actual s[3] at r=1:
不相等就不是 k=1 的左移

对 k=2:
predicted: 0xd1c995cc
actual: 0x1edb8ebc
不等 → 不是 k=2
对 k=3..31 一路试下来,没有一个 k 能让 predicted == actual。所以 try_shift('s[3]', 'state') 返回 None。

因此我们可以写个自动化算法,这里的/tmp/memop_trace.pkl,是我们之前代码执行的产物,是已知的结果

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
import pickle

PKL = '/tmp/memop_trace.pkl'
SLOT_PCS = {0x09cf:0,0x0a82:1,0x0b27:2,0x0be2:3,0x0c8a:4,0x0d3d:5,0x0df8:6,0x0eb3:7,
0x0f66:8,0x1019:9,0x10c9:10,0x1184:11,0x1237:12,0x12ea:13,0x13a5:14,0x1455:15}
SLOT_BASE = 0x2032f710
MASK = 0xFFFFFFFF

# -------- Step 1: extract per-round slot values from memop trace --------
def extract_slots(insts):
rounds, cur, last_slot = [], [None]*16, -1
for inst in insts:
pc = inst['pc']
if pc not in SLOT_PCS: continue
slot = SLOT_PCS[pc]
if slot <= last_slot and any(v is not None for v in cur):
rounds.append(tuple(cur)); cur = [None]*16
for op in inst['memops']:
if op[0]=='memcpy' and op[1] == SLOT_BASE + slot*8:
cur[slot] = int.from_bytes(op[3][:4], 'little'); break
last_slot = slot
if any(v is not None for v in cur): rounds.append(tuple(cur))
return rounds

d = pickle.load(open(PKL,'rb'))
tok1 = d['token1'].encode()
tok2 = d['token2'].encode()
r1 = extract_slots(d['insts1']) # 28 rounds × 16 slots for token1
r2 = extract_slots(d['insts2']) # same for token2

state1 = [int.from_bytes(tok1[4:8], 'little')] + [r1[i][15] for i in range(28)]
state2 = [int.from_bytes(tok2[4:8], 'little')] + [r2[i][15] for i in range(28)]

prev71 = [int.from_bytes(tok1[0:4], 'little')] + [r1[i][7] for i in range(28)]
prev72 = [int.from_bytes(tok2[0:4], 'little')] + [r2[i][7] for i in range(28)]

print(f'token1={tok1!r} state[0]=0x{state1[0]:08x} prev_s7[0]=0x{prev71[0]:08x}')
print(f'token2={tok2!r} state[0]=0x{state2[0]:08x} prev_s7[0]=0x{prev72[0]:08x}')
print(f'captured rounds: {len(r1)}')
print()

# -------- Step 2: helper — get an input variable by name at round r --------
def get_var(name, r, which, slot_vals=None):
"""Return the u32 value for variable `name` at round r (1-indexed).
`which` is 1 or 2 (token1/token2). `slot_vals` is the per-round slot table."""
vals = r1 if which == 1 else r2
state_arr = state1 if which == 1 else state2
prev_arr = prev71 if which == 1 else prev72
if name == 'state': return state_arr[r-1]
if name == 'prev_s7': return prev_arr[r-1]
if name == 'r': return r
if name.startswith('s'): # e.g. 's0', 's1', ...
idx = int(name[1:])
return vals[r-1][idx]
raise KeyError(name)

# -------- Step 3: hypothesis testers --------
NROUNDS = 28

def verify(slot, formula_fn):
"""Check if `formula_fn(round, which)` matches the actual s[slot] for all rounds × 2 tokens."""
for which in (1, 2):
vals = r1 if which == 1 else r2
for r in range(1, NROUNDS+1):
predicted = formula_fn(r, which) & MASK
if predicted != vals[r-1][slot]: return False
return True

def try_shift(slot, src):
"""s[slot] = src << k 或 src >> k"""
for k in range(1, 32):
if verify(slot, lambda r, w, k=k, src=src: get_var(src, r, w) << k):
return ('<<', src, k)
for k in range(1, 32):
if verify(slot, lambda r, w, k=k, src=src: get_var(src, r, w) >> k):
return ('>>', src, k)
return None

def try_add_const(slot, src):
"""s[slot] = src + K — 用第 1 轮直接算 K,再验证其他轮"""
# K = actual - input at round 1
K1_candidate = (r1[0][slot] - get_var(src, 1, 1)) & MASK
if verify(slot, lambda r, w, K=K1_candidate, src=src: get_var(src, r, w) + K):
return ('+K', src, K1_candidate)
return None

def try_xor_const(slot, src):
"""s[slot] = src ^ K"""
K_candidate = r1[0][slot] ^ get_var(src, 1, 1)
if verify(slot, lambda r, w, K=K_candidate, src=src: get_var(src, r, w) ^ K):
return ('^K', src, K_candidate)
return None

def try_round_mul(slot, src):
"""s[slot] = src + r * K — 用第 1 轮 (r=1 时 r*K = K) 算 K"""
# r=1 时:actual = src + 1*K → K = actual - src
K_from_r1 = (r1[0][slot] - get_var(src, 1, 1)) & MASK
if verify(slot, lambda r, w, K=K_from_r1, src=src: get_var(src, r, w) + r * K):
return ('+r*K', src, K_from_r1)
return None

def try_two_var(slot, srcs):
"""s[slot] = x OP y for x, y ∈ srcs, OP ∈ {^, +, -}"""
for x in srcs:
for y in srcs:
if x == y: continue
for op, fn in [('^', lambda a,b: a^b), ('+', lambda a,b: a+b)]:
if verify(slot, lambda r, w, x=x, y=y, fn=fn: fn(get_var(x,r,w), get_var(y,r,w))):
return (op+'_2var', x, y)
return None

# -------- Step 4: fit each slot in order --------
print('=== per-slot formula derivation ===')
for slot in range(16):
avail_srcs = ['state', 'prev_s7'] + [f's{i}' for i in range(slot)]

winner = None
for src in avail_srcs:
winner = try_shift(slot, src)
if winner: break
winner = try_round_mul(slot, src)
if winner: break
winner = try_add_const(slot, src)
if winner: break
winner = try_xor_const(slot, src)
if winner: break
if not winner:
winner = try_two_var(slot, avail_srcs)

if winner:
op = winner[0]
if op == '<<': formula = f's[{slot:2d}] = {winner[1]} << {winner[2]}'
elif op == '>>': formula = f's[{slot:2d}] = {winner[1]} >> {winner[2]}'
elif op == '+K': formula = f's[{slot:2d}] = {winner[1]} + 0x{winner[2]:08x}'
elif op == '^K': formula = f's[{slot:2d}] = {winner[1]} ^ 0x{winner[2]:08x}'
elif op == '+r*K': formula = f's[{slot:2d}] = {winner[1]} + r * 0x{winner[2]:08x}'
elif op == '^_2var': formula = f's[{slot:2d}] = {winner[1]} ^ {winner[2]}'
elif op == '+_2var': formula = f's[{slot:2d}] = {winner[1]} + {winner[2]}'
print(f' {formula}')
else:
print(f' s[{slot:2d}] = ??? (no pattern fit in {NROUNDS} rounds × 2 tokens)')

对每个 slot 收集所有轮的输入, 输出穷举常见模式:

image-20260419010729875

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
def sbc0_hash(token: str) -> str:
b = token.encode('ascii')[:8].ljust(8, b'\x00')
state = struct.unpack('<I', b[4:8])[0]
prev_s7 = struct.unpack('<I', b[0:4])[0]
MASK = 0xFFFFFFFF
for r in range(1, 29): # 28 rounds, 1-indexed
s = [0] * 16
s[0] = (state << 4) & MASK
s[1] = (s[0] + 0xF95D664A) & MASK
s[2] = (state + r * 0x29E59C9F) & MASK
s[3] = s[1] ^ s[2]
s[4] = state >> 7
s[5] = (s[4] + 0x12AA364C) & MASK
s[6] = s[3] ^ s[5]
s[7] = (s[6] + prev_s7) & MASK
s[8] = (s[7] << 6) & MASK
s[9] = (s[8] + 0x33AD3CEE) & MASK
s[10] = (s[7] + r * 0x29E59C9F) & MASK
s[11] = s[9] ^ s[10]
s[12] = s[7] >> 5
s[13] = (s[12] + 0xAABBCCDD) & MASK
s[14] = s[11] ^ s[13]
s[15] = (state + s[14]) & MASK
state, prev_s7 = s[15], s[7]
return f'{state:08x}{prev_s7:08x}'

5 个魔数

  • K1 = 0xF95D664A
  • K5 = 0x12AA364C
  • K9 = 0x33AD3CEE
  • KX = 0xAABBCCDD
  • KR = 0x29E59C9F(轮号乘数)

链式状态state ← s[15]; prev_s7 ← s[7]。每轮把这两个值传到下一轮。

正向测试

之前写的测试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
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
from __future__ import annotations
import struct, sys
from pathlib import Path
from unicorn import Uc, UcError, UC_ARCH_ARM64, UC_MODE_ARM, UC_HOOK_CODE, UC_HOOK_MEM_INVALID
from unicorn.arm64_const import *

LIB = Path("/home/matriy/RE/game/tencent_ctf/2026_end/final/lib/arm64-v8a/libsec2026.so")
SUB = 0xA9A7C
CODE_BASE = 0
CODE_SIZE = 0x200000
STACK_BASE = 0x10000000
STACK_SIZE = 0x100000
HEAP_BASE = 0x20000000
HEAP_SIZE = 0x400000 # 4MB (code may memset 1MB chunks)
TLS_BASE = 0x30000000
TLS_SIZE = 0x1000
STUB_BASE = 0x40000000
STUB_SIZE = 0x1000
INPUT_BASE = 0x50000000
INPUT_SIZE = 0x1000
RET_MAGIC = 0x7FFF0000

def elf_parse(f):
e_shoff = struct.unpack('<Q', f[0x28:0x30])[0]
e_shentsize = struct.unpack('<H', f[0x3a:0x3c])[0]
e_shnum = struct.unpack('<H', f[0x3c:0x3e])[0]
sections = []
for i in range(e_shnum):
sh = f[e_shoff + i*e_shentsize : e_shoff + (i+1)*e_shentsize]
sh_type = struct.unpack('<I', sh[4:8])[0]
sh_off = struct.unpack('<Q', sh[0x18:0x20])[0]
sh_size = struct.unpack('<Q', sh[0x20:0x28])[0]
sh_entsize = struct.unpack('<Q', sh[0x38:0x40])[0]
sections.append((sh_type, sh_off, sh_size, sh_entsize))
return sections

def apply_rela_dyn(mu, f, sections, dynsym_off, dynstr_off, glob_stub_map):
# Apply R_AARCH64_RELATIVE (1027) + R_AARCH64_GLOB_DAT (1025)
def cstr(off):
end = off
while f[end] != 0: end += 1
return f[off:end].decode()
count = 0
for sh_type, sh_off, sh_size, sh_entsize in sections:
if sh_type == 4 and sh_entsize == 24:
for i in range(sh_size // 24):
r_offset, r_info, r_addend = struct.unpack('<QQq', f[sh_off + i*24 : sh_off + (i+1)*24])
r_type = r_info & 0xffffffff
r_sym = r_info >> 32
if r_type == 1027:
try:
mu.mem_write(r_offset, struct.pack('<Q', r_addend & 0xFFFFFFFFFFFFFFFF))
count += 1
except UcError:
pass
elif r_type == 1025 or r_type == 1026: # GLOB_DAT or JUMP_SLOT
# Resolve symbol
s = f[dynsym_off + r_sym*24 : dynsym_off + (r_sym+1)*24]
st_name = struct.unpack('<I', s[:4])[0]
sym_name = cstr(dynstr_off + st_name)
if sym_name in glob_stub_map:
mu.mem_write(r_offset, struct.pack('<Q', glob_stub_map[sym_name]))
count += 1
print(f'[emu] applied {count} relocations')

STUBS = {} # name → stub_addr
def init_stubs(mu):
names = ['pthread_mutex_lock', 'pthread_mutex_unlock', 'memset', 'memcpy', 'malloc', 'free']
for i, name in enumerate(names):
addr = STUB_BASE + i * 4
mu.mem_write(addr, struct.pack('<I', 0xD65F03C0)) # RET
STUBS[name] = addr

STUB_CALLS = {}
def stub_handler(mu, addr):
matches = [k for k,v in STUBS.items() if v == addr]
name = matches[0] if matches else f'unknown@{hex(addr)}'
STUB_CALLS[name] = STUB_CALLS.get(name, 0) + 1
global HEAP_PTR
if name in ('pthread_mutex_lock', 'pthread_mutex_unlock', 'pthread_mutex_destroy',
'pthread_rwlock_unlock', 'pthread_rwlock_rdlock', 'pthread_rwlock_wrlock',
'pthread_cond_broadcast', 'pthread_cond_wait', 'pthread_key_delete',
'pthread_setspecific', 'pthread_once', 'free', 'abort', 'fclose',
'__cxa_finalize', '__cxa_atexit'):
mu.reg_write(UC_ARM64_REG_X0, 0)
elif name in ('memset', '__memset_chk'):
dst = mu.reg_read(UC_ARM64_REG_X0); val = mu.reg_read(UC_ARM64_REG_X1) & 0xFF; sz = mu.reg_read(UC_ARM64_REG_X2)
try:
mu.mem_write(dst, bytes([val]) * sz)
except Exception as e:
print(f' [{name}] write failed dst=0x{dst:x} size={sz}: {e}')
mu.reg_write(UC_ARM64_REG_X0, dst)
elif name in ('memcpy', '__memcpy_chk', 'memmove', '__memmove_chk'):
dst = mu.reg_read(UC_ARM64_REG_X0); src = mu.reg_read(UC_ARM64_REG_X1); sz = mu.reg_read(UC_ARM64_REG_X2)
data = bytes(mu.mem_read(src, sz))
mu.mem_write(dst, data)
mu.reg_write(UC_ARM64_REG_X0, dst)
elif name == 'malloc' or name == 'calloc' or name == 'realloc':
sz = mu.reg_read(UC_ARM64_REG_X1) if name == 'calloc' else mu.reg_read(UC_ARM64_REG_X0)
p = HEAP_PTR
HEAP_PTR = (HEAP_PTR + sz + 0xF) & ~0xF
if name == 'calloc':
mu.mem_write(p, b'\x00' * sz)
mu.reg_write(UC_ARM64_REG_X0, p)
elif name == 'strlen':
addr = mu.reg_read(UC_ARM64_REG_X0)
n = 0
while True:
b = bytes(mu.mem_read(addr + n, 1))[0]
if b == 0: break
n += 1
if n > 0x1000: break
mu.reg_write(UC_ARM64_REG_X0, n)
elif name == 'getpid':
mu.reg_write(UC_ARM64_REG_X0, 1000)
elif name == '__errno':
mu.reg_write(UC_ARM64_REG_X0, HEAP_BASE + 0x10) # dummy errno location
elif name == 'syscall':
mu.reg_write(UC_ARM64_REG_X0, 0)
elif name == '__stack_chk_fail':
print('[!] stack_chk_fail')
mu.emu_stop()
elif name in ('__vsprintf_chk', '__vsnprintf_chk', 'snprintf', 'vsnprintf', 'vsprintf', 'sprintf'):
x0 = mu.reg_read(UC_ARM64_REG_X0)
x3 = mu.reg_read(UC_ARM64_REG_X3)
x4 = mu.reg_read(UC_ARM64_REG_X4)
# Parse va_list on ARM64: struct {stack, gr_top, vr_top, gr_offs, vr_offs}
try:
va_stack = struct.unpack('<Q', bytes(mu.mem_read(x4, 8)))[0]
va_gr_top = struct.unpack('<Q', bytes(mu.mem_read(x4+8, 8)))[0]
va_gr_offs = struct.unpack('<i', bytes(mu.mem_read(x4+24, 4)))[0]
# Read format
fmt = b''
for i in range(128):
b = bytes(mu.mem_read(x3 + i, 1))
if b == b'\x00': break
fmt += b
fmt = fmt.decode()
print(f' [{name}] dest=0x{x0:x} fmt={fmt!r} va_stack=0x{va_stack:x} gr_top=0x{va_gr_top:x} gr_offs={va_gr_offs}')
# Implement the %08x parsing
out = ''
i = 0
offs = va_gr_offs
while i < len(fmt):
c = fmt[i]
if c == '%' and i+3 < len(fmt) and fmt[i+1:i+4] == '08x':
# Read uint32 arg from va_list
if offs < 0:
val = struct.unpack('<I', bytes(mu.mem_read(va_gr_top + offs, 4)))[0]
offs += 8 # next slot
else:
val = struct.unpack('<I', bytes(mu.mem_read(va_stack, 4)))[0]
va_stack += 8
out += f'{val:08x}'
i += 4
elif c == '%':
i += 2 # skip unknown format
else:
out += c
i += 1
print(f' [{name}] formatted: {out!r}')
mu.mem_write(x0, out.encode() + b'\x00')
mu.reg_write(UC_ARM64_REG_X0, len(out))
except Exception as e:
print(f' [{name}] fmt err: {e}')
mu.reg_write(UC_ARM64_REG_X0, 0)
else:
mu.reg_write(UC_ARM64_REG_X0, 0)

def init_globals(mu):
# Allocate fake mutex at HEAP start
mutex_addr = HEAP_BASE
mu.mem_write(mutex_addr, b'\x00' * 0x40)
# off_1668D0 points to mutex_addr
mu.mem_write(0x1668D0, struct.pack('<Q', mutex_addr))
# off_1668D8 points to qword_183498 location — that addr is 0x183498 in libsec2026
mu.mem_write(0x1668D8, struct.pack('<Q', 0x183498))
# qword_183498 gets some value (the anti-debug maps scanner writes this; default 0)
mu.mem_write(0x183498, struct.pack('<Q', 0))


HEAP_PTR = HEAP_BASE + 0x1000 # skip mutex

def run(token: str) -> str:
global HEAP_PTR
HEAP_PTR = HEAP_BASE + 0x1000

f = LIB.read_bytes()
mu = Uc(UC_ARCH_ARM64, UC_MODE_ARM)
mu.mem_map(CODE_BASE, CODE_SIZE)
mu.mem_map(STACK_BASE, STACK_SIZE)
mu.mem_map(HEAP_BASE, HEAP_SIZE)
mu.mem_map(TLS_BASE, TLS_SIZE)
mu.mem_map(STUB_BASE, STUB_SIZE)
mu.mem_map(INPUT_BASE, INPUT_SIZE)
mu.mem_map(RET_MAGIC & ~0xFFF, 0x1000)

# Load per PT_LOAD program headers (VA != file_offset in general)
e_phoff = struct.unpack('<Q', f[0x20:0x28])[0]
e_phentsize = struct.unpack('<H', f[0x36:0x38])[0]
e_phnum = struct.unpack('<H', f[0x38:0x3a])[0]
for i in range(e_phnum):
ph = f[e_phoff + i*e_phentsize : e_phoff + (i+1)*e_phentsize]
p_type = struct.unpack('<I', ph[:4])[0]
if p_type != 1: continue # PT_LOAD
p_offset = struct.unpack('<Q', ph[8:16])[0]
p_vaddr = struct.unpack('<Q', ph[16:24])[0]
p_filesz = struct.unpack('<Q', ph[32:40])[0]
p_memsz = struct.unpack('<Q', ph[40:48])[0]
if p_vaddr + p_memsz > CODE_SIZE: continue # out of our code region
mu.mem_write(p_vaddr, f[p_offset:p_offset + p_filesz])
# zero-init BSS part
if p_memsz > p_filesz:
try:
mu.mem_write(p_vaddr + p_filesz, b'\x00' * (p_memsz - p_filesz))
except Exception: pass
print(f'[emu] loaded PT_LOAD va=0x{p_vaddr:x} off=0x{p_offset:x} filesz=0x{p_filesz:x} memsz=0x{p_memsz:x}')

# Init stubs + globals
init_stubs(mu)
init_globals(mu)

# Find dynsym/dynstr section offsets
e_shoff = struct.unpack('<Q', f[0x28:0x30])[0]
e_shentsize = struct.unpack('<H', f[0x3a:0x3c])[0]
e_shnum = struct.unpack('<H', f[0x3c:0x3e])[0]
e_shstrndx = struct.unpack('<H', f[0x3e:0x40])[0]
strtab_hdr = f[e_shoff + e_shstrndx * e_shentsize : e_shoff + (e_shstrndx+1) * e_shentsize]
strtab_off = struct.unpack('<Q', strtab_hdr[0x18:0x20])[0]
def cstr(off):
end = off
while f[end] != 0: end += 1
return f[off:end].decode()
dynsym_off = dynstr_off = 0
for i in range(e_shnum):
sh = f[e_shoff + i*e_shentsize : e_shoff + (i+1)*e_shentsize]
sh_name_i = struct.unpack('<I', sh[:4])[0]
sh_off = struct.unpack('<Q', sh[0x18:0x20])[0]
name = cstr(strtab_off + sh_name_i)
if name == '.dynsym': dynsym_off = sh_off
elif name == '.dynstr': dynstr_off = sh_off

adrp = struct.unpack('<I', f[0xA9AC8:0xA9ACC])[0]
ldr = struct.unpack('<I', f[0xA9ADC:0xA9AE0])[0]
# ADRP: imm21 = (immhi<<2) | immlo; result = (PC & ~0xfff) + (imm21<<12)
imm_lo = (adrp >> 29) & 0x3
imm_hi = (adrp >> 5) & 0x7FFFF
imm21 = (imm_hi << 2) | imm_lo
if imm21 & (1 << 20): imm21 -= (1 << 21)
adrp_base = (0xA9AC8 & ~0xFFF) + (imm21 << 12)
# LDR (unsigned offset): imm12 * 8
imm12 = (ldr >> 10) & 0xFFF
mutex_lock_ptr_addr = adrp_base + imm12 * 8
print(f'[emu] pthread_mutex_lock_ptr @ 0x{mutex_lock_ptr_addr:x}')
mu.mem_write(mutex_lock_ptr_addr, struct.pack('<Q', STUBS['pthread_mutex_lock']))

# Prepare input buffer
token_bytes = token.encode() + b'\x00'
mu.mem_write(INPUT_BASE, token_bytes)

# Setup registers
mu.reg_write(UC_ARM64_REG_X0, INPUT_BASE)
mu.reg_write(UC_ARM64_REG_SP, STACK_BASE + STACK_SIZE - 0x1000)
mu.reg_write(UC_ARM64_REG_X30, RET_MAGIC) # LR = return magic
mu.reg_write(UC_ARM64_REG_TPIDR_EL0, TLS_BASE + 0x100)
# TPIDR_EL0+0x28 = stack guard cookie
mu.mem_write(TLS_BASE + 0x100 + 0x28, struct.pack('<Q', 0xDEADBEEFCAFEBABE))

all_libc_names = set()
# add PLT-resolved symbols
for i in range(500):
try:
s = f[dynsym_off + i*24 : dynsym_off + (i+1)*24]
if len(s) < 24: break
st_name = struct.unpack('<I', s[:4])[0]
if st_name == 0: continue
name = cstr(dynstr_off + st_name)
all_libc_names.add(name)
except Exception:
break
for i, name in enumerate(sorted(all_libc_names)):
stub_addr = STUB_BASE + 0x100 + i * 4
mu.mem_write(stub_addr, struct.pack('<I', 0xD65F03C0)) # RET
STUBS[name] = stub_addr

sections = elf_parse(f)
apply_rela_dyn(mu, f, sections, dynsym_off, dynstr_off, STUBS)

# Hooks (minimal for speed)
steps = [0]
calls = []
last_pcs = []
MAX_STEPS = 50000000
def code_hook(uc, addr, size, data):
steps[0] += 1
if steps[0] > MAX_STEPS:
uc.emu_stop()
return
if addr == RET_MAGIC:
uc.emu_stop()
return
if STUB_BASE <= addr < STUB_BASE + STUB_SIZE:
stub_handler(uc, addr & ~3)
return

def mem_invalid_hook(uc, access, addr, size, val, data):
pc = uc.reg_read(UC_ARM64_REG_PC)
print(f'[MEM_INVALID] access={access} addr=0x{addr:x} pc=0x{pc:x}')
print('last calls:')
for c in calls[-20:]: print(f' {c}')
return False

mu.hook_add(UC_HOOK_CODE, code_hook)
mu.hook_add(UC_HOOK_MEM_INVALID, mem_invalid_hook)

try:
mu.emu_start(SUB, RET_MAGIC, timeout=0, count=0)
except UcError as e:
pc = mu.reg_read(UC_ARM64_REG_PC)
lr = mu.reg_read(UC_ARM64_REG_X30)
print(f'[emu] UcError at PC=0x{pc:x} LR=0x{lr:x}: {e}')
# Dump last 5 PCs
print(f'last_pcs_history_len={len(last_pcs)}')
for p in last_pcs[-20:]: print(f' PC=0x{p:x}')
print('last 30 calls:')
for c in calls[-30:]: print(f' {c}')
print('heap first 256 bytes:')
print(bytes(mu.mem_read(HEAP_BASE+0x1000, 256)).hex())

ret = mu.reg_read(UC_ARM64_REG_X0)
print(f'[emu] steps={steps[0]} ret=0x{ret:x}')
print('[emu] stub call counts:')
for k, v in sorted(STUB_CALLS.items(), key=lambda x: -x[1]):
print(f' {k}: {v}')
import re
regions_to_search = [
(0x1836C0, 0x2000),
(HEAP_BASE, HEAP_SIZE),
(STACK_BASE + STACK_SIZE - 0x20000, 0x20000),
(0x0, 0x200000), # full code+data range
]
# For Trigger4, expected raw = bytes.fromhex('9b9d024363cedcbd'); also ASCII hex
target_raw = bytes.fromhex('9b9d024363cedcbd')
target_ascii = b'9b9d024363cedcbd'
hex_re = re.compile(rb'[0-9a-f]{16}')
for base, size in regions_to_search:
try:
mem = bytes(mu.mem_read(base, size))
if target_raw in mem:
pos = mem.find(target_raw)
print(f' RAW HASH FOUND @0x{base+pos:x}!')
if target_ascii in mem:
pos = mem.find(target_ascii)
print(f' ASCII HASH FOUND @0x{base+pos:x}!')
for m in hex_re.finditer(mem):
s = m.group().decode()
if base+m.start() < 0x64000 or base+m.start() > 0x70000: # skip bytecode noise
print(f' HEX @0x{base+m.start():x}: {s}')
except Exception as e:
pass
if ret:
try:
out = bytes(mu.mem_read(ret, 64)).split(b'\x00', 1)[0]
return out.decode(errors='replace')
except UcError as e:
print(f'[emu] cannot read ret buffer: {e}')
return None

if __name__ == '__main__':
token = sys.argv[1] if len(sys.argv) > 1 else 'Trigger4'
print(f'>>> sub_A9A7C({token!r})')
out = run(token)
print(f'>>> output: {out!r}')
if out:
print(f'>>> flag: flag{{sec2026_PART3_{out}}}')

image-20260419011947450

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

def sbc0_hash(token: str) -> str:
b = token.encode('ascii')[:8].ljust(8, b'\x00')
state = struct.unpack('<I', b[4:8])[0]
prev_s7 = struct.unpack('<I', b[0:4])[0]

MASK = 0xFFFFFFFF
K1, K5, K9, KX, KR = 0xF95D664A, 0x12AA364C, 0x33AD3CEE, 0xAABBCCDD, 0x29E59C9F

for r in range(1, 29):
s = [0]*16
s[0] = (state << 4) & MASK
s[1] = (s[0] + K1) & MASK
s[2] = (state + r * KR) & MASK
s[3] = s[1] ^ s[2]
s[4] = state >> 7
s[5] = (s[4] + K5) & MASK
s[6] = s[3] ^ s[5]
s[7] = (s[6] + prev_s7) & MASK
s[8] = (s[7] << 6) & MASK
s[9] = (s[8] + K9) & MASK
s[10] = (s[7] + r * KR) & MASK
s[11] = s[9] ^ s[10]
s[12] = s[7] >> 5
s[13] = (s[12] + KX) & MASK
s[14] = s[11] ^ s[13]
s[15] = (state + s[14]) & MASK
state, prev_s7 = s[15], s[7]

return f'{state:08x}{prev_s7:08x}'


if __name__ == '__main__':
if len(sys.argv) == 2:
token = sys.argv[1]
else:
token = input('Token (8 chars): ').strip()

h = sbc0_hash(token)
print(f'token: {token}')
print(f'hash : {h}')
print(f'flag : flag{{sec2026_PART3_{h}}}')

image-20260419012341876

逆向分析

PART3 是 hash 但完全可逆,因为每轮是双射:

关键观察

  • s14 的计算只依赖 s7和 r(不依赖 state)因此给定 new_s7 能直接算 s14
  • s6 的计算只依赖 state和 r(不依赖 prev_s7)因此给定 state能算 s6
  • 所以 state = new_state - s14 可解,prev_s7 = new_s7 - s6 也可解
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
def s14_of(s7, r):
s8 = (s7 << 6) & MASK
s9 = (s8 + 0x33AD3CEE) & MASK
s10 = (s7 + r * 0x29E59C9F) & MASK
s11 = s9 ^ s10
s12 = s7 >> 5
s13 = (s12 + 0xAABBCCDD) & MASK
return s11 ^ s13

def s6_of(state, r):
s0 = (state << 4) & MASK
s1 = (s0 + 0xF95D664A) & MASK
s2 = (state + r * 0x29E59C9F) & MASK
s3 = s1 ^ s2
s4 = state >> 7
s5 = (s4 + 0x12AA364C) & MASK
return s3 ^ s5

def sbc0_invert(hash_hex: str) -> bytes:
state = int(hash_hex[:8], 16)
prev_s7 = int(hash_hex[8:16], 16)
for r in range(28, 0, -1): # reverse rounds
s14 = s14_of(prev_s7, r)
state = (state - s14) & MASK
s6 = s6_of(state, r)
prev_s7 = (prev_s7 - s6) & MASK
return struct.pack('<I', prev_s7) + struct.pack('<I', state)
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
import struct, sys

MASK = 0xFFFFFFFF
K1, K5, K9, KX, KR = 0xF95D664A, 0x12AA364C, 0x33AD3CEE, 0xAABBCCDD, 0x29E59C9F

def s14_of(s7, r):
"""Compute s14 from s7 and round number (purely a function of s7, r)."""
s8 = (s7 << 6) & MASK
s9 = (s8 + K9) & MASK
s10 = (s7 + r * KR) & MASK
s11 = s9 ^ s10
s12 = s7 >> 5
s13 = (s12 + KX) & MASK
return s11 ^ s13

def s6_of(state, r):
"""Compute s6 from state and round number."""
s0 = (state << 4) & MASK
s1 = (s0 + K1) & MASK
s2 = (state + r * KR) & MASK
s3 = s1 ^ s2
s4 = state >> 7
s5 = (s4 + K5) & MASK
return s3 ^ s5

def inverse_round(new_state, new_s7, r):
"""Reverse one round: from (state[r+1], s7[r]) recover (state[r], s7[r-1])."""
s14 = s14_of(new_s7, r)
state = (new_state - s14) & MASK
s6 = s6_of(state, r)
prev_s7 = (new_s7 - s6) & MASK
return state, prev_s7

def sbc0_invert(hash_hex: str) -> bytes:
"""Recover the 8-byte input token from the 16-char hash output."""
assert len(hash_hex) == 16, "hash must be 16 hex chars"
state = int(hash_hex[:8], 16)
prev_s7 = int(hash_hex[8:16], 16)
# Reverse 28 rounds
for r in range(28, 0, -1):
state, prev_s7 = inverse_round(state, prev_s7, r)
# Now state = LE_u32(input[4..8]), prev_s7 = LE_u32(input[0..4])
return struct.pack('<I', prev_s7) + struct.pack('<I', state)

if __name__ == '__main__':
if len(sys.argv) == 2:
arg = sys.argv[1]
else:
arg = input('flag or 16-hex hash: ').strip()

# Strip "flag{sec2026_PART3_" prefix/"}" suffix if present
prefix = 'flag{sec2026_PART3_'
if arg.startswith(prefix) and arg.endswith('}'):
h = arg[len(prefix):-1]
else:
h = arg

recovered = sbc0_invert(h)
print(f'hash : {h}')
print(f'bytes: {recovered.hex()}')
try:
print(f'ASCII: {recovered.decode("ascii")!r}')
except UnicodeDecodeError:
print(f'ASCII: (not valid ASCII)')

image-20260419012745836

ollvm去混淆 [解法二]

时间有点来不及了,写的有点潦草

用前面提到的逆向思想去混淆

代码:

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
import ida_bytes
import ida_funcs
import ida_auto
import ida_hexrays

COND_EQ = 0
COND_CC = 3

def encode_b(src, target):
off = (target - src) >> 2
if off < -(1 << 25) or off >= (1 << 25):
raise ValueError(f"B 跳转超界: 0x{src:x} -> 0x{target:x}")
val = 0x14000000 | (off & 0x03FFFFFF)
return val.to_bytes(4, 'little')


def encode_bcond(src, target, cond):
off = (target - src) >> 2
if off < -(1 << 18) or off >= (1 << 18):
raise ValueError(f"B.cond 跳转超界: 0x{src:x} -> 0x{target:x}")
val = 0x54000000 | ((off & 0x7FFFF) << 5) | (cond & 0xF)
return val.to_bytes(4, 'little')


# ===== sub_A9A7C patches (16 跳转) =====
SUB_A9A7C_PATCHES = [
(0xA9B28, encode_b(0xA9B28, 0xAA144), 'sub_A9A7C init: → 0xAA144 (state 0x4085CF24)'),

# 简单 entry tail (单一 next_state via mutation)
(0xAA030, encode_b(0xAA030, 0xAA078), '0xAA034 → 0xAA078 (state 0x3FBB3CD1)'),
(0xAA074, encode_b(0xAA074, 0xAA078), '0xAA078 alt path → 0xAA078'),
(0xAA1C8, encode_b(0xAA1C8, 0xA9F38), '0xAA1A0 → 0xA9F38 (state 0xF3B9DF0B)'),
(0xAA2FC, encode_b(0xAA2FC, 0xAA38C), '0xAA2CC → 0xAA38C (state 0x8026FFFA)'),
(0xAA524, encode_b(0xAA524, 0xAA528), '0xAA478 → 0xAA528 (state 0xEDF54793)'),
(0xAA668, encode_b(0xAA668, 0xA9E78), '0xAA66C → 0xA9E78 (state 0xA9584791)'),

# 用 0xA9B58 store_W8_then_mutate reentry 的 entry tail
(0xA9E80, encode_b(0xA9E80, 0xA9B50), '0xA9E54 entry → 0xA9B50 (state 0xAE8F4B31)'),
(0xA9F10, encode_b(0xA9F10, 0xAA214), '0xA9EC0 entry → 0xAA214 (state 0x1B1A3A62)'),
(0xA9FB8, encode_b(0xA9FB8, 0xA9E54), '0xA9F74 entry → 0xA9E54 (state 0x7E93B664)'),
(0xAA140, encode_b(0xAA140, 0xAA38C), '0xAA130 entry → 0xAA38C (state 0x8026FFFA)'),
(0xAA19C, encode_b(0xAA19C, 0xA9E78), '0xAA170 entry → 0xA9E78 (state 0xA9584791)'),
(0xAA210, encode_b(0xAA210, 0xA9B2C), '0xAA1CC entry → 0xA9B2C (state 0x447272E6)'),
(0xAA388, encode_b(0xAA388, 0xA9F14), '0xAA344 entry → 0xA9F14 (state 0xA0958E8A)'),
(0xAA430, encode_b(0xAA430, 0xAA170), '0xAA3EC entry → 0xAA170 (state 0xAFD42D17)'),
]


#VMEntry (sub_A6BEC) =====
VMENTRY_PATCHES = [
(0xA6CA8, encode_b(0xA6CA8, 0xA6E60), 'VMEntry init: → 0xA6E60 (state 0x7ACBF177)'),

# entry 0xA6E60 CSEL 条件 (var_4C 即参数 a4)
(0xA6E84, encode_bcond(0xA6E84, 0xA6EC4, COND_EQ), '0xA6E60: a4==0 → 0xA6EC4 (clean exit)'),
(0xA6E88, encode_b(0xA6E88, 0xA6CAC), '0xA6E60: a4!=0 → 0xA6CAC (run VM)'),

# entry 0xA6E8C CSEL 条件 (W20 < 8, 字节码循环边界)
(0xA6EA4, encode_bcond(0xA6EA4, 0xA6DD8, COND_CC), '0xA6E8C: i<8 → 0xA6DD8 (read byte)'),
(0xA6EA8, encode_b(0xA6EA8, 0xA6EAC), '0xA6E8C: i>=8 → 0xA6EAC (loop tail)'),

# entry 0xA6EAC tail (byte read 完, 回 0xA6DD8 dispatch opcode)
(0xA6EC0, encode_b(0xA6EC0, 0xA6DD8), '0xA6EAC → 0xA6DD8 (W26 mutated → state 0xFD3358AA)'),

# entry 0xA6EC4 tail (clean exit 路径, 回 RET)
(0xA6ED0, encode_b(0xA6ED0, 0xA6ED4), '0xA6EC4 → 0xA6ED4 (RET / epilogue)'),

# 中段未识别 entry tail → 0xA6DC0 (主循环入口)
(0xA6E5C, encode_b(0xA6E5C, 0xA6DC0), 'entry tail → 0xA6DC0 (state 0x402F7245 main loop)'),

# 跳过 mutation+dispatch, 直接进 0xA6DC0
(0xA6D1C, encode_b(0xA6D1C, 0xA6DC0), 'sub_1385BC 后 → 0xA6DC0 (skip dispatcher)'),
]


#sub_AA758 (Godot method 102) =====
SUB_AA758_PATCHES = [
# 跳过 dispatcher, 直接进 CSEL 块
(0xAA7E4, encode_b(0xAA7E4, 0xAA7E8), 'sub_AA758 init: skip dispatcher → CSEL'),
# CSEL → B.cond
(0xAA7FC, encode_bcond(0xAA7FC, 0xAA96C, COND_EQ), 'sub_AA758: flag==0 → 0xAA96C (clean exit)'),
(0xAA800, encode_b(0xAA800, 0xAA8EC), 'sub_AA758: flag!=0 → 0xAA8EC (run VM)'),
]


def main():
# === sub_A9A7C ===
print(f"\n[1] 修复 sub_A9A7C 边界 (0xA9A7C-0xAA6A8)")
f = ida_funcs.get_func(0xA9A7C)
if f: ida_funcs.del_func(f.start_ea)
for ea in range(0xA9A7C, 0xAA6A8, 4):
f = ida_funcs.get_func(ea)
if f and f.start_ea != 0xA9A7C: ida_funcs.del_func(f.start_ea)
ok = ida_funcs.add_func(0xA9A7C, 0xAA6A8)
print(f" add_func -> {ok}")
ida_funcs.add_func(0xAA6AC) # vsprintf wrapper

print(f"\n[2] 应用 sub_A9A7C {len(SUB_A9A7C_PATCHES)} 个 patch")
for src, b, note in SUB_A9A7C_PATCHES:
ida_bytes.patch_bytes(src, b)
print(f" 0x{src:x}: {note}")

# === VMEntry ===
print(f"\n[3] 修复 VMEntry 边界 (0xA6BEC-0xA6F0C)")
f = ida_funcs.get_func(0xA6BEC)
if f: ida_funcs.del_func(f.start_ea)
for ea in range(0xA6BEC, 0xA6F0C, 4):
f = ida_funcs.get_func(ea)
if f and f.start_ea != 0xA6BEC: ida_funcs.del_func(f.start_ea)
ok = ida_funcs.add_func(0xA6BEC, 0xA6F0C)
print(f" add_func -> {ok}")

print(f"\n[4] 应用 VMEntry {len(VMENTRY_PATCHES)} 个 patch")
for src, b, note in VMENTRY_PATCHES:
ida_bytes.patch_bytes(src, b)
print(f" 0x{src:x}: {note}")

# === sub_AA758 ===
print(f"\n[5] 修复 sub_AA758 边界 (0xAA758-0xAA9AC)")
f = ida_funcs.get_func(0xAA758)
if f: ida_funcs.del_func(f.start_ea)
for ea in range(0xAA758, 0xAA9AC, 4):
f = ida_funcs.get_func(ea)
if f and f.start_ea != 0xAA758: ida_funcs.del_func(f.start_ea)
ok = ida_funcs.add_func(0xAA758, 0xAA9AC)
print(f" add_func -> {ok}")

print(f"\n[6] 应用 sub_AA758 {len(SUB_AA758_PATCHES)} 个 patch")
for src, b, note in SUB_AA758_PATCHES:
ida_bytes.patch_bytes(src, b)
print(f" 0x{src:x}: {note}")

print(f"\n[7] auto-analysis + Hex-Rays cache flush")
ida_auto.auto_wait()
ida_hexrays.mark_cfunc_dirty(0xA9A7C)
ida_hexrays.mark_cfunc_dirty(0xA6BEC)
ida_hexrays.mark_cfunc_dirty(0xAA758)


if __name__ == "__main__":
main()

image-20260419132326856

image-20260419132715303

这里就能得到字节码了

下一步要拿 PART3 真实算法,逆向 sub_13879C 的 opcode 表或逆向 sub_A9A7C 看它怎么从 input 编码 bytecode

image-20260419222204460

但是一些函数还是没有被去混淆

实际上从 0x137000 到 0x138B00 的一坨函数,36 个 handler 互相通过 BR X8 链调用,最终都收敛到 handler 35 0x137A98

这里又是一个dispatcher

可以写个IDA脚本,把 36 个 handler 都定义成有名字的独立函数,当然中间也去一个个看这些块的实际意义了

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
import ida_funcs, ida_bytes, ida_auto, idaapi, ida_name

HANDLERS = [
(0, 0x1384d0, 'h0_init_unused'),
(1, 0x1381e4, 'h1_init_t1_op9'),
(2, 0x138ab8, 'h2_init_t1_op0'),
(3, 0x1388c4, 'h3_var_access'),
(4, 0x138770, 'h4_check_x23'),
(5, 0x13801c, 'h5_var_call_op38'),
(6, 0x1375c0, 'h6_init_t5_op1_36'),
(7, 0x13732c, 'h7_compare_op3'),
(8, 0x1381ac, 'h8_init_t5_op11'),
(9, 0x138654, 'h9_var_func_op6_37'),
(10, 0x1387f4, 'h10_check_x21'),
# h11 shares addr with h10
(12, 0x137af0, 'h12_init_t2'),
(13, 0x138114, 'h13_check_x23_op8'),
(14, 0x138978, 'h14_complex'),
(15, 0x137bc8, 'h15_call_vptr_2010'),
(16, 0x13727c, 'h16_multi_branch'),
(17, 0x138178, 'h17_check_byte'),
(18, 0x137ffc, 'h18_check_x0'),
# h19 shares addr with h5
(20, 0x1373b8, 'h20_pc_advance'),
(21, 0x1388f0, 'h21_var_call'),
(22, 0x1388c4, 'h22_var_access_op5'),
(23, 0x13787c, 'h23_load_pc'),
(24, 0x13894c, 'h24_check_x22_op7'),
(25, 0x137dac, 'h25_dispatch_x27'),
(26, 0x1378c4, 'h26_var_op'),
(27, 0x13831c, 'h27_check_x19'),
(28, 0x13798c, 'h28_init_t1_op10'),
(29, 0x137ea8, 'h29_var_op'),
(30, 0x138684, 'h30_load_dispatch'),
(31, 0x137500, 'h31_compare_x21'),
(32, 0x137844, 'h32_check_x20_op2'),
(33, 0x1375f8, 'h33_var_op'),
(34, 0x138894, 'h34_call_vptr'),
(35, 0x137a98, 'h35_RETURN_op4'), # ★ the actual epilogue
]

print('=== defining 36 VM handlers as functions ===')
ok_count = 0
for idx, addr, name in HANDLERS:
# Try to define a function. If addr is already inside another function, skip.
f = ida_funcs.get_func(addr)
if f and f.start_ea == addr:
# Already a function — just rename
ida_name.set_name(addr, name, ida_name.SN_FORCE)
print(f' [{idx:2d}] @ 0x{addr:x} EXISTS → renamed to {name}')
ok_count += 1
continue
# If inside another function, undefine that
if f:
ida_funcs.del_func(f.start_ea)
ok = idaapi.add_func(addr)
if ok:
ida_name.set_name(addr, name, ida_name.SN_FORCE)
print(f' [{idx:2d}] @ 0x{addr:x} ADDED → {name}')
ok_count += 1
else:
print(f' [{idx:2d}] @ 0x{addr:x} FAILED')
ida_auto.auto_wait()

# Special: h35 is sub_13879C's actual epilogue. Mark it explicitly.
print(f'\n★ h35 @ 0x137A98 is the REAL function epilogue (LDP+RET).')
print(f'★ Each handler ends with BR X8 (chained dispatch) — F5 will show prep code only.')
print(f'\nDONE: {ok_count}/36 handlers defined.')
print('Now F5 individual handlers (h2_init_t1_op0, h35_RETURN_op4, etc.) to read code.')

image-20260419222535067

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, sys
from pathlib import Path

LIB = Path('final/lib/arm64-v8a/libsec2026.so')
BC_FILE_OFF = 0x63D9C
BC_LEN = 0x1526

raw = LIB.read_bytes()
bc = raw[BC_FILE_OFF:BC_FILE_OFF + BC_LEN]

OP_NAMES = {
0x00: 'ADD', # additive — matches s[X] = a + b uses
0x01: 'SUB',
0x02: 'MUL', # used in r*KR
0x03: 'DIV',
0x04: 'XOR', # bitwise xor (s[3] = s[1] ^ s[2])
0x05: 'POW',
0x06: 'CONST', # 06 03 = load constant
0x07: 'JUMP', # control flow
0x08: 'LSL', # shift left (s[0] = state << 4)
0x09: 'LSR', # shift right (s[4] = state >> 7)
0x0A: 'AND',
0x0B: 'OR',
0x0C: 'BXOR',
0x0D: 'BNOT',
0x0E: '?',
0x0F: '?',
0x14: 'TYPE_INT', # type tag, not opcode
}

def fmt_arg(t, val_bytes):
if t == 0x01: # slot ref
return f'r{val_bytes[0]:#x}'
if t == 0x04: # u32 literal
v = struct.unpack('<I', val_bytes[:4])[0]
return f'#0x{v:08x}'
if t == 0x14: # ?
return f'<type{t} {val_bytes.hex()}>'
return f'<t{t:02x} {val_bytes.hex()}>'

def decode_arg(buf, pos):
"""Decode one argument starting at pos. Returns (length, str_repr)."""
if pos >= len(buf): return None
tag = buf[pos]
if tag == 0x01: # slot ref: 01 SS
if pos + 2 > len(buf): return None
return (2, f'r{buf[pos+1]:#x}')
if tag == 0x04: # u32 literal: 04 BB BB BB BB
if pos + 5 > len(buf): return None
v = struct.unpack('<I', buf[pos+1:pos+5])[0]
return (5, f'#0x{v:08x}')
# Unknown tag — 1 byte fallback
return None

def decode_instr(buf, pc):
"""Format: [OP] [TYPE] [00] [NARGS] [arg1] [arg2] ...
Returns (length, mnemonic, args_str) or None on failure."""
if pc + 4 > len(buf): return None
op = buf[pc]
typ = buf[pc+1]
flag = buf[pc+2]
nargs = buf[pc+3]

# Sanity: flag must be 0, nargs reasonable
if flag != 0 or nargs == 0 or nargs > 10:
# Not a valid header — treat as 1-byte garbage
return (1, '???', f'op=0x{op:02x}')

args = []
pos = pc + 4
for _ in range(nargs):
a = decode_arg(buf, pos)
if a is None:
return (1, '???', f'op=0x{op:02x} bad arg')
ln, s = a
args.append(s)
pos += ln

total = pos - pc
opname = OP_NAMES.get(op, f'OP{op:02x}')

# Pretty-print for known shapes
if op == 0x06 and nargs == 2 and len(args) == 2:
return (total, 'CONST', f'{args[0]} = {args[1]}')
if nargs == 3 and len(args) == 3:
return (total, opname, f'{args[0]} = {args[1]} {opname} {args[2]}')
if nargs == 2 and op == 0x00 and len(args) == 2:
return (total, 'MOV', f'{args[0]} = {args[1]}')
if nargs == 2 and len(args) == 2:
return (total, opname + '_2', f'{args[0]} = {opname}({args[1]})')
if nargs == 1 and len(args) == 1:
nm = {0x03:'INIT', 0x04:'CLR', 0x07:'JMP'}.get(op, opname)
return (total, nm, args[0])
return (total, opname, ', '.join(args))

def disasm(start=0, end=None, mark={}):
pc = start
end = end or len(bc)
while pc < end:
result = decode_instr(bc, pc)
if not result: break
sz, mnem, args = result
bs = ' '.join(f'{b:02x}' for b in bc[pc:pc+sz])
marker = mark.get(pc, '')
print(f' {pc:04x}: {bs:36} {mnem:8} {args} {marker}')
pc += sz

if __name__ == '__main__':
# Mark known constants
MARKERS = {
0x8c8: 'KR (round multiplier)',
0xa18: 'K1 (slot1 add)',
0xcd3: 'K5 (slot5 add)',
0xfaf: 'K9 (slot9 add)',
0x1280: 'KX (slot13 add)',
}

if len(sys.argv) > 1 and sys.argv[1] == '--all':
print(f'=== full bytecode ({len(bc)} bytes) ===')
disasm(0, mark=MARKERS)
else:
# Show region around K1 (slot1 add formula in round body)
print(f'=== region around K1 (slot 1 = slot 0 + K1) ===')
disasm(0xa00, 0xa50, mark=MARKERS)
print()
print(f'=== region around KR (slot 2 = state + r * KR) ===')
disasm(0x8b0, 0x900, mark=MARKERS)
print()
print(f'=== region around K5 ===')
disasm(0xcc0, 0xd10, mark=MARKERS)
print()
print(f'=== region around K9 ===')
disasm(0xf90, 0xfe0, mark=MARKERS)
print()
print(f'=== region around KX ===')
disasm(0x1270, 0x12c0, mark=MARKERS)
print()
print(f'=== round body start (PC 0x26F per user trace) ===')
disasm(0x26f, 0x2c0, mark=MARKERS)

image-20260419222725690

image-20260419222859785

unk_17C240 (opcode→idx) + off_17C120 (36-handler 表) 两层间接

VM 解释器是 sub_13879C + 36-handler 表 + bytecode opcode 编码。

instruction 的统一格式是 OP TYPE 00 NARGS [args]

image-20260419223233911

后面的流程其实就跟解法一一样了

用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
import struct, sys
from pathlib import Path

LIB = Path('final/lib/arm64-v8a/libsec2026.so')
BC_FILE_OFF = 0x63DA0
BC_LEN = 0x1526
raw = LIB.read_bytes()
bc = raw[BC_FILE_OFF:BC_FILE_OFF + BC_LEN]

KNOWN_MAGICS = {0x29E59C9F:'KR', 0xF95D664A:'K1', 0x12AA364C:'K5',
0x33AD3CEE:'K9', 0xAABBCCDD:'KX'}

def slot_name(addr):
"""Map slot pointer constant to slot name."""
if 0x14000 <= addr <= 0x14080 and (addr & 7) == 0:
return f's[{(addr - 0x14000)//8}]'
return None

def decode(buf, pc):
if pc + 4 > len(buf): return None
op = buf[pc]; typ = buf[pc+1]; flag = buf[pc+2]; nargs = buf[pc+3]
if flag != 0 or nargs == 0 or nargs > 10: return None
pos = pc + 4; args = []
for _ in range(nargs):
if pos >= len(buf): return None
tag = buf[pos]
if tag == 0x01:
if pos + 2 > len(buf): return None
args.append(('s', buf[pos+1])); pos += 2
elif tag == 0x04:
if pos + 5 > len(buf): return None
args.append(('#', struct.unpack('<I', buf[pos+1:pos+5])[0])); pos += 5
else: return None
return (pos - pc, op, typ, nargs, args)

def fmt_ssa(op, nargs, args):
"""Pretty-print as SSA-like expression."""
def fmt(arg):
t, v = arg
if t == 's': return f'r{v:#x}'
else:
if v in KNOWN_MAGICS: return f'#{KNOWN_MAGICS[v]}'
sn = slot_name(v)
if sn: return f'&{sn}'
return f'#0x{v:x}'
a = [fmt(x) for x in args]
# 3-arg: dst = src1 OP src2
if nargs == 3:
names = {0:'+',1:'-',2:'*',4:'^',9:'>>',8:'<<'}
opname = names.get(op, f'op{op:02x}')
return f'{a[0]} = {a[1]} {opname} {a[2]}'
if nargs == 2:
if op == 0x00: return f'{a[0]} = {a[1]}' # MOV
if op == 0x01: return f'{a[0]} = LOAD({a[1]})' # *(ptr)
if op == 0x02: return f'STORE({a[1]}) = {a[0]}' # *(ptr) = r
if op == 0x06: return f'{a[0]} = {a[1]}' # CONST
if op == 0x07: return f'JMP {a[0]}{a[1]}' # JMP
return f'op{op:02x}_2 {a[0]}, {a[1]}'
if nargs == 1:
nm = {0x03:'INIT', 0x04:'CLR'}.get(op, f'op{op:02x}_1')
return f'{nm} {a[0]}'
return ', '.join(a)

# Walk bytecode
pc = 0
insns = []
while pc < len(bc):
r = decode(bc, pc)
if r is None: pc += 1; continue
sz, op, typ, nargs, args = r
insns.append((pc, sz, op, typ, nargs, args))
pc += sz

# Filter to "interesting" ops (LOAD/STORE/CONST/arith), skip INIT/CLR/JMP
def interesting(op, nargs):
if nargs == 1 and op in (0x03, 0x04): return False # skip INIT/CLR
if op == 0x07: return False # skip JMP
return True

if __name__ == '__main__':
region = sys.argv[1] if len(sys.argv) > 1 else 'round'
if region == 'round':
start, end = 0x800, 0x1500
title = 'round body (PC 0x800..0x1500)'
elif region == 'init':
start, end = 0, 0x800
title = 'init/header (PC 0..0x800)'
elif region == 'all':
start, end = 0, len(bc)
title = 'full bytecode'
else:
try: start, end = (int(x, 0) for x in region.split(':'))
except: start, end = 0, len(bc)
title = f'PC 0x{start:x}..0x{end:x}'

print(f'## {title}\n')
for pc, sz, op, typ, nargs, args in insns:
if pc < start or pc > end: continue
if not interesting(op, nargs): continue
text = fmt_ssa(op, nargs, args)
marker = ''
for tag, val in args:
if tag == '#' and val in KNOWN_MAGICS:
marker = f' ★ {KNOWN_MAGICS[val]}'
print(f' {pc:04x}: {text}{marker}')

image-20260419225310857

分析这些可以得到所有的slot公式

每条 slot 公式在 bytecode 里都精确定位:

slot 用户 Unicorn 公式 bytecode STORE PC 关键操作 PC
s[0] state << 4 0x09CF LSL @ 0x096C (r0xc << #4)
s[1] s[0] + K1 0x0A82 ADD @ 0x0A1F (after CONST K1 @ 0x0A14)
s[2] state + r*KR 0x0B27 ADD @ 0x0AC4 (state + r0xd where r0xd = r*KR precomputed)
s[3] s[1] ^ s[2] 0x0BE2 XOR @ 0x0B7F (LOAD s[1] XOR LOAD s[2])
s[4] state >> 7 0x0C8A LSR @ 0x0C27 (r0xc >> #7)
s[5] s[4] + K5 0x0D3D ADD @ 0x0CDA (after CONST K5 @ 0x0CCF)
s[6] s[3] ^ s[5] 0x0DF8 XOR @ 0x0D95 (LOAD s[3] XOR LOAD s[5])
s[7] s[6] + prev_s7 0x0EB3 ADD @ 0x0E50 (LOAD s[16] + LOAD s[6])
s[8] s[7] << 6 0x0F66 LSL @ 0x0F03 (LOAD s[7] << #6)
s[9] s[8] + K9 0x1019 ADD @ 0x0FB6 (after CONST K9 @ 0x0FAB)
s[10] s[7] + r*KR 0x10C9 ADD @ 0x1066 (LOAD s[7] + r0xd)
s[11] s[9] ^ s[10] 0x1184 XOR @ 0x1121 (LOAD s[9] XOR LOAD s[10])
s[12] s[7] >> 5 0x1237 LSR @ 0x11D4 (LOAD s[7] >> #5)
s[13] s[12] + KX 0x12EA ADD @ 0x1287 (after CONST KX @ 0x127C)
s[14] s[11] ^ s[13] 0x13A5 XOR @ 0x1342 (LOAD s[11] XOR LOAD s[13])
s[15] state + s[14] 0x1455 ADD @ 0x13F2 (r0xc + LOAD s[14])
s[16] state ← s[15]; prev_s7 ← s[7] 0x14A6 (round chain update)

时间有点来不及了,写的有点潦草

flag生成算法及逆算法 C

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>

/* ---------- algorithm constants ---------- */

#define K1 0xF95D664Au /* slot 1 add */
#define K5 0x12AA364Cu /* slot 5 add */
#define K9 0x33AD3CEEu /* slot 9 add */
#define KX 0xAABBCCDDu /* slot 13 add */
#define KR 0x29E59C9Fu /* round counter multiplier */

#define ROUNDS 28

#define FLAG_PREFIX "flag{sec2026_PART3_"
#define FLAG_SUFFIX "}"

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

static int hex_to_bytes(const char *hex, uint8_t *out, size_t n) {
for (size_t i = 0; i < n; i++) {
int hi = hex_nibble(hex[2 * i]);
int lo = hex_nibble(hex[2 * i + 1]);
if (hi < 0 || lo < 0) return -1;
out[i] = (uint8_t)((hi << 4) | lo);
}
return 0;
}

static inline uint32_t load_le32(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_le32(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);
}

/* s14 only depends on s7 (= new_s7 in inverse view) and r. */
static inline uint32_t s14_of(uint32_t s7, int r) {
uint32_t s8 = s7 << 6;
uint32_t s9 = s8 + K9;
uint32_t s10 = s7 + (uint32_t)r * KR;
uint32_t s11 = s9 ^ s10;
uint32_t s12 = s7 >> 5;
uint32_t s13 = s12 + KX;
return s11 ^ s13;
}

/* s6 only depends on state and r. */
static inline uint32_t s6_of(uint32_t state, int r) {
uint32_t s0 = state << 4;
uint32_t s1 = s0 + K1;
uint32_t s2 = state + (uint32_t)r * KR;
uint32_t s3 = s1 ^ s2;
uint32_t s4 = state >> 7;
uint32_t s5 = s4 + K5;
return s3 ^ s5;
}

static void part3_encrypt(const uint8_t token[8], char hex_out[17]) {
uint32_t state = load_le32(token + 4);
uint32_t prev_s7 = load_le32(token + 0);

for (int r = 1; r <= ROUNDS; r++) {
uint32_t s6 = s6_of(state, r);
uint32_t s7 = s6 + prev_s7;
uint32_t s14 = s14_of(s7, r);
uint32_t s15 = state + s14;
state = s15;
prev_s7 = s7;
}
snprintf(hex_out, 17, "%08x%08x", state, prev_s7);
}

static int part3_decrypt(const char hex_in[16], uint8_t token_out[8]) {
uint8_t state_b[4], prev_b[4];
if (hex_to_bytes(hex_in, state_b, 4) < 0) return -1;
if (hex_to_bytes(hex_in + 8, prev_b, 4) < 0) return -1;
/* Note: hash output is "%08x%08x" of (state, prev_s7), which on a
* little-endian byte-grouping is BIG-ENDIAN bytes per word. Convert. */
uint32_t state =
((uint32_t)state_b[0] << 24) | ((uint32_t)state_b[1] << 16) |
((uint32_t)state_b[2] << 8) | (uint32_t)state_b[3];
uint32_t prev_s7 =
((uint32_t)prev_b[0] << 24) | ((uint32_t)prev_b[1] << 16) |
((uint32_t)prev_b[2] << 8) | (uint32_t)prev_b[3];

for (int r = ROUNDS; r >= 1; r--) {
uint32_t s14 = s14_of(prev_s7, r);
state = state - s14;
uint32_t s6 = s6_of(state, r);
prev_s7 = prev_s7 - s6;
}
/* state = LE_u32(token[4..8]), prev_s7 = LE_u32(token[0..4]) */
store_le32(token_out + 0, prev_s7);
store_le32(token_out + 4, state);
return 0;
}

/* token → flag */
static int make_flag(const char *token, char *flag_out) {
if (strlen(token) != 8) return -1;
char hex[17];
part3_encrypt((const uint8_t*)token, hex);
snprintf(flag_out, 64, FLAG_PREFIX "%s" FLAG_SUFFIX, hex);
return 0;
}

/* "flag → token */
static int parse_input_to_token(const char *input, char *token_out) {
const char *hex = input;
size_t pl = sizeof(FLAG_PREFIX) - 1;
size_t sl = sizeof(FLAG_SUFFIX) - 1;
size_t in_len = strlen(input);

if (in_len == pl + 16 + sl
&& memcmp(input, FLAG_PREFIX, pl) == 0
&& memcmp(input + pl + 16, FLAG_SUFFIX, sl) == 0) {
hex = input + pl;
} else if (in_len != 16) {
return -1;
}

uint8_t token[8];
if (part3_decrypt(hex, token) < 0) return -1;
memcpy(token_out, token, 8);
token_out[8] = '\0';
return 0;
}

int main(int argc, char **argv) {
if (argc == 3 && strcmp(argv[1], "enc") == 0) {
char flag[64];
if (make_flag(argv[2], flag) != 0) {
fprintf(stderr, "enc: bad token (need 8 ASCII chars)\n");
return 1;
}
printf("%s\n", flag);
return 0;
}
if (argc == 3 && strcmp(argv[1], "dec") == 0) {
char tok[9];
if (parse_input_to_token(argv[2], tok) != 0) {
fprintf(stderr, "dec: bad input (need 16-hex cipher or full flag string)\n");
return 1;
}
printf("%s\n", tok);
return 0;
}
return 1;
}

image-20260418152945662