跳至主要内容

2026数字中国数字安全赛道网络安全团队赛WP

2026数字中国数字安全赛道网络安全团体赛决赛WP

Reverse

  • Re手因身体原因未到现场,但因现场题就Re较为简单,其他方向题目比较难,导致团队未能获奖,Re手表示非常抱歉未能到现场。

FlagVault

  • 这题题目说的很明确了,加密在Native层
    截图 202606220922

  • 定位到关键函数

  • 发现整体逻辑较为简单,且有用函数为getFlag

  • 用Bandizip解压apk(这里主要是我没到现场,队员说问ai用apktool解压,但她没有,所以有点小可惜)
    202606220922 1

  • 定位到getflag

  • 发现加密很简单
    202606220922 2

SystemMonitor

  • 这道题题目明确说明是个恶意软件

  • 且丢到IDA发现没有明显flag相关函数

  • 可以合理怀疑是把敏感信息换为了flag,然后传到恶意网站或服务器
    202606220922 4

  • 大概看了下主要函数

  • 这两个很可疑

  • 审阅后可以发现 sub_1400015FE()为关键函数

  • 这个函数行数较多,可以丢给本地ai进行分析,也完全能跑出来流程
    截图 202606220923

  • 然后就可以大胆猜测是把flag传出去了,打个断点动态调试即可
    202606220923 1

  • 以上两道题就是线下赛解出的Re题了,我也是在模拟离线环境下做出来的,非常可惜,如果拿到这两个题的解最少也是个铜奖银奖了(悲)

ChatA

  • 这道题题目说了要分析通讯

  • 把apk丢到jadx中发现有好多base64编码

  • 主要是StringFog中的解密

  • 看了一下就是两个base64异或

  • 然后就没什么头绪了
    202606220923 2

  • 只找到了个flag的关键字

  • 大概率也是要去翻native层
    202606220923 3

  • 丢给GPT进行分析

  • 加密方式为AES-256-ECB
    202606220923 4

  • 加个flag头就行了应该

  • 不知道正常解是不是这么解,对于ai是怎么分析出来这块我还是很疑惑怎么分析出来的,奈何现在GPT也被限制了,希望有师傅可以解惑

  • 还是说我是做题经验太少,这是经验问题
    202606220923 5


后记,2026.06.22

  • 继上文分析
    截图 202606221451
  • 这里可以看到初始段有两个解密函数
  • 肯定是后面要用到的
  • 现在看JNI_OnLoad函数
  • 发现是ollvm的混淆,完全看不懂在干什么
  • 在ai辅助下找到sub_1AA4C80函数(我知道线下赛用不了ai,但我是在复现并学习,我为了绕开更多弯路所必须要做的,没人手把手教我,只有ai,参考wp也找不到)
    截图 202606221507
  • 可以看到这里有很多有趣的函数
    截图 202606221513
  • 模仿初始化阶段来写解密脚本,解出部分字符串
1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>
int main(){
unsigned char byte_2D2D2C1[13] = {
0x64, 0x6F, 0x78, 0x6F, 0x21, 0x67, 0x61, 0x21, 0x48, 0x67, 0x62, 0x6B, 0x0E
};
for (int i19 = 0; i19 != 13; ++i19 )
byte_2D2D2C1[i19] = byte_2D2D2C1[i19] & 0xF1 | ~byte_2D2D2C1[i19] & 0xE;
for(int i = 0;i != 13;i++){
printf("%c",byte_2D2D2C1[i]);
}
return 0;
}
  • 这是模板,小改一下就可以解字符串了
  • 可以看出这个assets/index.js很重要,但是打开却发现根本看不懂
  • 就可以知道这被加密了
  • 继续分析发现sub_1AA3C30函数很关键
  • 虽然也是很乱,看不出来是什么东西,但可以发现sub_1AAFEE0函数内有aes字样
    202606221518 1
  • 就可以合理推断出这个js脚本是被aes-256-ecb加密的
  • 密钥的话大概率是这四段
    截图 202606221519
  • 并且可以发现初始化阶段它们的解密是独立的
    截图 202606221520
  • 就可以顺利解出密钥
    截图 202606221523
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
  #include <stdio.h>

int main(){
unsigned char qword_2D2D400[32] = {
0x1E, 0x52, 0x34, 0x1E, 0x41, 0x4D, 0x41, 0x14, 0x34, 0x16, 0x0E, 0x27, 0x34, 0x0A, 0x0F, 0x3E,
0x42, 0x5E, 0x0C, 0x10, 0x34, 0x4D, 0x21, 0x2F, 0x30, 0x34, 0x2E, 0x26, 0x57, 0x12, 0x3E, 0x28
};
for (int i = 0; i != 32; ){
qword_2D2D400[i] = (qword_2D2D400[i] & 0xC3 | ~qword_2D2D400[i] & 0x3C) ^ 0x5B;
++i;
}
for(int i = 0;i != 32;i++){
printf("%c",qword_2D2D400[i]);
}
return 0;

}
  • 然后解密环节就和之前一样了

Bridge

  • 这是一个鸿蒙系统下的程序

  • 需要用到abc-decompiler工具
    202606220923 6

  • 找到了Flag的关键函数

  • 第一步就是获取用户的token
    202606220923 7

  • 在com.hmos.exam5.pkg/entry/ets/model/DatabaseUtil中也给了admin账户和密码

  • 也给了盐,生成token使用了sha256
    202606220923 8

  • 回到解密,第一步,再次使用sha256
    202606220923 9

  • 取前32个hex,51a8f50d751a48550f90b3c49439afbe,作为key

  • 下一步是解sm4

  • 返回到com.hmos.exam5.pkg/entry/ets/model/FlagUtil

  • 看了一下调取的函数#*#readLocalFlagCipher就是5a52339fbdb9b11b330ed4ffd6f06b453e0d3fb96a624a71b16124065c2d9e3362fa1d5941b0995e559002b759cf91a9
    截图 202606220924

  • sm4的相关信息也给了
    202606220924 1

  • 就可以得出flag了!!!

  • 其实这里面还可以进行sql注入
    202606220924 2

  • 但没什么鸟用

  • 也可以在解压的文件夹中看到
    202606220924 3

Dedebug

  • 这道题做的我好痛苦

  • 怎么弄也绕不过这个检测

  • 用了apktool把apk改成了这样子
    202606220924 4

  • 还是绕不过去

  • 实在没招,问了ai,说初始化段也有反调试
    202606220924 5

  • 不听GPT,改来改去,改不动了

  • 还是看看GPT给的静态分析吧

  • GPT应该是分析的x86_64下的so库
    202606220924 6

202606220924 7

截图 202606220925

  • 这是GPT给我的一把梭脚本
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
from pathlib import Path
import base64
import subprocess
import tempfile
import os

so = Path("libdebugme.so").read_bytes()

# 这个 ELF 中 .rodata 的 VMA 和文件偏移一致,直接按虚拟地址取即可
base = 0x4840

def u8(addr):
return so[addr]

# 1. 还原 AES key
key = bytearray()
for rax in range(0, 0x21, 3):
for j in range(3):
i = rax + j
key.append(u8(base + 0x2F6 + (i & 0xF)) ^ u8(base + 0x306 + i))

key = bytes(key).rstrip(b"\x00")
print("[+] AES key:", key.decode())
print("[+] AES key hex:", key.hex())

# 2. 还原自定义 Base64 alphabet
chunk1 = bytes(
u8(0x485E + i) ^ u8(0x485E + i + 0x1F)
for i in range(0x15)
).rstrip(b"\x00")

chunk2 = bytes(
u8(0x48AD + i) ^ u8(0x48AD + i + 0x1E)
for i in range(0x15)
).rstrip(b"\x00")

chunk3 = bytearray(25)
rax = 0
while True:
esi = rax & 0xE
chunk3[rax] = u8(base + 0x2B6 + esi) ^ u8(base + 0x2C6 + rax)

if rax == 0x18:
break

esi = (rax + 1) & 0xF
chunk3[rax + 1] = u8(base + 0x2B6 + esi) ^ u8(base + 0x2C7 + rax)
rax += 2

chunk3 = bytes(chunk3).rstrip(b"\x00")

custom_b64 = chunk1 + chunk2 + chunk3
std_b64 = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"

print("[+] custom b64:", custom_b64.decode())

# 3. 还原目标 custom-base64 字符串
def high64_mul(a, b):
return (a * b) >> 64

target = bytearray(45)
r9 = 0x842108421084211
rsi = 0
rdi = 1

while True:
rdx = high64_mul(rsi, r9)
tmp = rsi - rdx
tmp >>= 1
tmp += rdx
tmp >>= 4
tmp -= tmp << 5
target[rsi] = u8(base + tmp + rsi + 0x33C) ^ u8(base + rsi + 0x35B)

if rsi == 0x2C:
break

rcx = high64_mul(rdi, r9)
tmp = rdi - rcx
tmp >>= 1
tmp += rcx
tmp >>= 4
tmp -= tmp << 5
target[rsi + 1] = u8(base + tmp + rsi + 0x33D) ^ u8(base + rsi + 0x35C)

rsi += 2
rdi += 2

target = bytes(target).rstrip(b"\x00")
print("[+] target custom b64:", target.decode())

# 4. custom base64 -> standard base64 -> ciphertext
trans = bytes.maketrans(custom_b64, std_b64)
standard_target = target.translate(trans)

ct = base64.b64decode(standard_target)
print("[+] ciphertext:", ct.hex())

# 5. AES-256-ECB decrypt
with tempfile.NamedTemporaryFile(delete=False) as f:
f.write(ct)
ct_path = f.name

try:
pt = subprocess.check_output([
"openssl", "enc", "-d", "-aes-256-ecb",
"-K", key.hex(),
"-nopad",
"-in", ct_path,
])
finally:
os.unlink(ct_path)

body = pt.decode()
print("[+] flag body:", body)
print("[+] flag: flag{%s}" % body)
  • 当然,根据这道题的题意肯定是让选手绕过调试解出flag的

  • 用GPT分析了下需要patch的点,但让那个用apktool修改也是需要做得(以x86_64为例)

Text
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
# ============================================================
# target: libdebugme(12).so
# arch : x86_64
# rule : 本 so 的 .text 段 VA == file offset,所以这些地址可直接 dd patch
# goal : 保留初始化逻辑,只干掉反调试命中后的 kill / syscall kill / 非法地址崩溃
# ============================================================


# ------------------------------------------------------------
# [1] 初始化阶段 Root / Magisk 检测
# ------------------------------------------------------------

# 0x4690: su 路径检测函数
# 原因:
# .init_array 入口 sub_3FD0 会先调用它。
# 它会检查 /data/local/su、/system/xbin/su、/system/bin/su 等路径。
# 如果返回 1,后续会进入异常处置逻辑。
# patch:
# xor eax, eax
# ret
# 效果:
# 永远返回 0,表示没有检测到 su / root。
0x4690 -> 31 C0 C3


# 0x4560: Magisk / mounts 检测函数
# 原因:
# .init_array 入口 sub_3FD0 也会调用它。
# 它会读 /proc/self/mounts,搜索 magisk、core/mirror、core/img 等特征。
# 如果返回 1,后续进入异常处置逻辑。
# patch:
# xor eax, eax
# ret
# 效果:
# 永远返回 0,表示没有检测到 Magisk / 可疑挂载。
0x4560 -> 31 C0 C3


# ------------------------------------------------------------
# [2] 反调试失败后的通用崩溃 helper
# ------------------------------------------------------------

# 0x1B40: 通用非法地址调用 helper
# 原因:
# .init_array 中 Root/Magisk 检测命中后,会通过这个函数进行异常处置。
# 原逻辑大概是:
# lea rax, [bad_base + offset]
# call rax
# mov eax, [bad_base + offset]
# 这是故意调用/读取非法地址,让进程 SIGSEGV。
# patch:
# xor eax, eax
# ret
# 效果:
# 即使有分支走到这个 helper,也不会崩溃。
0x1B40 -> 31 C0 C3


# ------------------------------------------------------------
# [3] .init_array 中 pthread_create 失败后的 libc kill
# ------------------------------------------------------------

# 0x40D7: 第一次 pthread_create 失败后的 kill(getpid(), 9)
# 原因:
# 0x40B2 创建检测线程 sub_2170。
# 如果 pthread_create 返回非 0,就执行:
# getpid()
# kill(pid, 9)
# patch:
# nop
# nop
# 注意:
# 原指令是 call rbx,长度 2 字节。
# 只能用 90 90,不要改成 ret。
0x40D7 -> 90 90


# 0x4157: 第二次 pthread_create 失败后的 kill(getpid(), 9)
# 原因:
# 0x4132 创建检测线程 sub_1B60。
# 创建失败后会 kill 自己。
# patch:
# nop
# nop
0x4157 -> 90 90


# 0x419F: 第三次 pthread_create 失败后的 kill(getpid(), 9)
# 原因:
# 0x417A 创建检测线程 sub_EE0。
# 创建失败后会 kill 自己。
# patch:
# nop
# nop
0x419F -> 90 90


# ------------------------------------------------------------
# [4] 直接 syscall kill + 非法 call 崩溃
# ------------------------------------------------------------

# 0x1AC0: 线程检测命中后的 syscall kill 分支入口
# 原因:
# 这里不是 libc kill,而是直接 syscall:
# syscall nanosleep
# syscall getpid
# syscall kill(pid, 9)
# call rax ; 后面还会非法 call 崩溃
#
# 所以只 hook / patch libc kill 没用。
# 只 NOP syscall 也不够,因为后面还有 call rax 非法崩溃。
#
# patch:
# jmp 0xF80
# 机器码:
# E9 BB F4 FF FF
# 效果:
# 直接跳回正常循环,绕过 syscall kill 和后续非法 call。
0x1AC0 -> E9 BB F4 FF FF


# ------------------------------------------------------------
# [5] pagemap / mem 检测后的非法地址崩溃点
# ------------------------------------------------------------

# 0x20F0: 检测异常后 call rax + 非法读
# 原因:
# 原逻辑:
# lea rax, [rbx + 0x9863]
# call rax
# mov eax, [rbx + 0x9863]
# 这是故意崩溃,不是正常业务逻辑。
# patch:
# 从 0x20F0 开始 NOP 8 字节
# 效果:
# 跳过 call rax 和后面的非法读。
0x20F0 -> 90 90 90 90 90 90 90 90


# 0x210D: 另一处检测异常后的 call rax + 非法读
# 原因:
# 原逻辑:
# lea rax, [rbx + 0x1234]
# call rax
# mov eax, [rbx + 0x1234]
# patch:
# 从 0x210D 开始 NOP 8 字节
0x210D -> 90 90 90 90 90 90 90 90


# ------------------------------------------------------------
# [6] Frida/Gum 线程名检测后的非法地址崩溃点
# ------------------------------------------------------------

# 0x26DE: gum-js-loop / gmain 检测命中后的非法 call
# 原因:
# 附近会比较线程名,命中后走:
# mov ebx, 0xDEEE77C8
# call rbx
# mov eax, [rbx]
# 0xDEEE77C8 是明显非法地址。
# 如果你挂 Frida,容易触发这里。
# patch:
# 从 0x26DE 开始 NOP 4 字节
# 效果:
# 跳过 call rbx 和后面的非法读。
0x26DE -> 90 90 90 90


# ------------------------------------------------------------
# [7] linjector / fd 检测后的非法地址崩溃点
# ------------------------------------------------------------

# 0x29D5: /proc/self/fd 检测命中后的非法 call
# 原因:
# 附近会遍历 /proc/self/fd/%s,检测 linjector 等注入痕迹。
# 命中后走:
# mov ebx, 0xDEEE77C8
# lea rax, [rbx + 0x1EA]
# call rax
# mov eax, [rbx + 0x1EA]
# patch:
# 从 0x29D5 开始 NOP 8 字节
# 效果:
# 跳过非法 call 和非法读。
0x29D5 -> 90 90 90 90 90 90 90 90
  • Frida脚本
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
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
'use strict';

const LIB_NAME = "libdebugme.so";

// x86_64 offsets
const OFF_CORE_CHECK = 0x3DA0;
const OFF_AES_KEY = 0x7200;
const OFF_B64_TABLE = 0x70F0;
const OFF_TARGET = 0x7222;

let solved = false;
let nativeHooked = false;

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

function findModuleCompat(name) {
try {
if (typeof Process.findModuleByName === "function") {
const m = Process.findModuleByName(name);
if (m !== null) return m;
}
} catch (e) {}

try {
const mods = Process.enumerateModules();
for (let i = 0; i < mods.length; i++) {
const m = mods[i];

if (m.name === name) {
return m;
}

if (m.path && m.path.indexOf(name) !== -1) {
return m;
}
}
} catch (e) {
log("[-] enumerateModules failed: " + e);
}

return null;
}

function findExportCompat(moduleName, symbolName) {
try {
if (typeof Module.findExportByName === "function") {
const p = Module.findExportByName(moduleName, symbolName);
if (p !== null) return p;
}
} catch (e) {}

try {
if (typeof Module.getExportByName === "function") {
return Module.getExportByName(moduleName, symbolName);
}
} catch (e) {}

try {
const m = findModuleCompat(moduleName);
if (m !== null && typeof m.enumerateExports === "function") {
const exports = m.enumerateExports();
for (let i = 0; i < exports.length; i++) {
if (exports[i].name === symbolName) {
return exports[i].address;
}
}
}
} catch (e) {}

try {
if (typeof Module.enumerateExports === "function") {
const exports = Module.enumerateExports(moduleName);
for (let i = 0; i < exports.length; i++) {
if (exports[i].name === symbolName) {
return exports[i].address;
}
}
}
} catch (e) {}

return null;
}

function readCStringFixed(ptr, maxLen) {
try {
if (ptr === null || ptr.isNull()) return null;
return ptr.readCString();
} catch (e) {}

try {
let s = "";
for (let i = 0; i < maxLen; i++) {
const c = ptr.add(i).readU8();
if (c === 0) break;
s += String.fromCharCode(c);
}
return s;
} catch (e) {
return null;
}
}

function jsStringToJavaByteArray(s) {
const arr = [];

for (let i = 0; i < s.length; i++) {
let v = s.charCodeAt(i) & 0xff;

if (v > 127) {
v = v - 256;
}

arr.push(v);
}

return Java.array("byte", arr);
}

function javaByteArrayToString(jbytes) {
let out = "";

for (let i = 0; i < jbytes.length; i++) {
out += String.fromCharCode(jbytes[i] & 0xff);
}

return out;
}

function customB64ToStd(customStr, alphabet) {
const std = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let out = "";

for (let i = 0; i < customStr.length; i++) {
const ch = customStr.charAt(i);

if (ch === "=") {
out += "=";
continue;
}

const idx = alphabet.indexOf(ch);

if (idx < 0) {
throw new Error("unknown custom b64 char: " + ch);
}

out += std.charAt(idx);
}

return out;
}

function solveWithJavaAes(keyStr, customAlphabet, targetCustom) {
Java.perform(function () {
try {
const Base64 = Java.use("android.util.Base64");
const Cipher = Java.use("javax.crypto.Cipher");
const SecretKeySpec = Java.use("javax.crypto.spec.SecretKeySpec");

const targetStd = customB64ToStd(targetCustom, customAlphabet);

log("[+] standard b64 = " + targetStd);

const ciphertext = Base64.decode(targetStd, 2); // Base64.NO_WRAP = 2
const keyBytes = jsStringToJavaByteArray(keyStr);

const keySpec = SecretKeySpec.$new(keyBytes, "AES");
const cipher = Cipher.getInstance("AES/ECB/NoPadding");

cipher.init(2, keySpec); // Cipher.DECRYPT_MODE = 2

const plain = cipher.doFinal(ciphertext);
const body = javaByteArrayToString(plain);

log("");
log("========================================");
log("[+] FLAG BODY = " + body);
log("[+] FLAG = flag{" + body + "}");
log("========================================");
log("");
} catch (e) {
log("[-] Java AES decrypt failed: " + e);
}
});
}

function dumpAndSolve(base) {
if (solved) return;
solved = true;

try {
const keyPtr = base.add(OFF_AES_KEY);
const b64Ptr = base.add(OFF_B64_TABLE);
const targetPtr = base.add(OFF_TARGET);

const keyStr = readCStringFixed(keyPtr, 64);
const customAlphabet = readCStringFixed(b64Ptr, 80);
const targetCustom = readCStringFixed(targetPtr, 80);

log("");
log("[Dump] AES key = " + keyStr);
log("[Dump] custom alphabet = " + customAlphabet);
log("[Dump] target custom b64= " + targetCustom);

if (keyStr === null || customAlphabet === null || targetCustom === null) {
log("[-] dump failed, offset may be wrong");
return;
}

if (keyStr.length !== 32) {
log("[!] AES key length looks wrong: " + keyStr.length);
}

if (customAlphabet.length !== 64) {
log("[!] custom alphabet length looks wrong: " + customAlphabet.length);
}

solveWithJavaAes(keyStr, customAlphabet, targetCustom);
} catch (e) {
log("[-] dumpAndSolve failed: " + e);
}
}

function hookJavaCheckFlag() {
Java.perform(function () {
try {
const MainActivity = Java.use("com.debugme.MainActivity");
const checkFlag = MainActivity.checkFlag.overload("java.lang.String");

checkFlag.implementation = function (input) {
log("");
log("[Java] checkFlag input = " + input);

const ret = checkFlag.call(this, input);

log("[Java] checkFlag ret = " + ret);
return ret;
};

log("[+] Java checkFlag hooked");
} catch (e) {
log("[-] Java checkFlag hook failed: " + e);
}
});
}

function hookNativeCore(base) {
try {
const core = base.add(OFF_CORE_CHECK);

log("[+] native core sub_3DA0 @ " + core);

Interceptor.attach(core, {
onEnter: function (args) {
try {
const body = readCStringFixed(args[0], 40);

log("");
log("[Native] sub_3DA0 called");
log("[Native] candidate body = " + body);

if (body !== null) {
log("[Native] candidate flag = flag{" + body + "}");
}
} catch (e) {
log("[-] native core onEnter failed: " + e);
}
},

onLeave: function (retval) {
try {
log("[Native] sub_3DA0 ret = " + retval.toInt32());
} catch (e) {}
}
});
} catch (e) {
log("[-] hookNativeCore failed: " + e);
}
}

function hookJniExport() {
const sym = "Java_com_debugme_MainActivity_checkFlag";
const addr = findExportCompat(LIB_NAME, sym);

if (addr === null) {
log("[-] JNI export not found: " + sym);
return;
}

log("[+] JNI export " + sym + " @ " + addr);

Interceptor.attach(addr, {
onEnter: function (args) {
log("");
log("[JNI] Java_com_debugme_MainActivity_checkFlag called");
log("[JNI] jstring ptr = " + args[2]);

/*
* 这里不强行调用 env.getStringUtfChars,
* 因为你当前 Frida 版本可能没有这个 wrapper。
* 输入内容已经能从 Java 层 hook 打印出来。
*/
},

onLeave: function (retval) {
try {
log("[JNI] ret = " + retval.toInt32());
} catch (e) {}
}
});
}

function tryHookNative() {
if (nativeHooked) return;

const m = findModuleCompat(LIB_NAME);

if (m === null) {
return;
}

nativeHooked = true;

log("[+] " + LIB_NAME + " loaded");
log("[+] base = " + m.base);
log("[+] path = " + m.path);

dumpAndSolve(m.base);
hookJniExport();
hookNativeCore(m.base);
}

function waitForLib() {
const timer = setInterval(function () {
tryHookNative();

if (nativeHooked) {
clearInterval(timer);
}
}, 100);
}

setTimeout(function () {
log("[*] hook_unknown_flag_fixed.js loaded");

Java.perform(function () {
hookJavaCheckFlag();
});

waitForLib();
}, 0);
  • 安卓逆向经验欠佳,试了很久也不能出flag,所以用ai来总结

后记,2026.06.23

  • 还是先把apk放在jadx中分析
    截图 202606230803
  • 很明显,发现有个root检测
  • 使用java -jar .\apktool_3.0.2.jar d -r dedebug.apk -o debugme_src把apk编回smali文件
    截图 202606230810
  • 装回后发现依旧有检测,怀疑是native层面的检测
  • 看了看native层的checkflag,发现无法直接逆向
    截图 202606230813
  • 开始分析反调试检测
  • 来到初始化阶段
    截图 202606230814
  • 发现只有这一个函数,点开看看
  • 审阅后发现是三个kill
    截图 202606230815
  • 也就对应三次检测
  • 改下jmp就可以了
  • 然后继续分析,发现还是有检测,注释一下,然后patch就行了,也就是nop和改一下jmp
    截图 202606230911
  • 后面发现sub_EE0,sub_1B60,sub_2170这三个检测函数也调用了kill
  • 所以直接让它们三个retn就好
  • hook代码还是上面的,怎么写出来的我还得再学习学习

DC

  • 一个core文件

  • 查了一下是Core 文件记录了程序在发生异常那一刻的内存映像、寄存器状态和堆栈信息。

  • 用ai一把梭吧,找不到主函数(前几个题的wp写的有点燃尽了)

0x00 文件识别

拿到题目文件 dc.core,先看一下是什么:

1
2
$ file dc.core
dc.core: ELF 64-bit LSB core file, x86-64, version 1 (SYSV), SVR4-style, from '/.cmd'

是一个 ELF 64-bit core dump 文件。Core dump 是程序运行时内存的快照,通常包含了程序的代码段、数据段以及当时的栈/堆状态。

strings 能看到一些有意思的东西:

Text
1
2
/tmp/ctf_%08x          # 写入 /tmp 目录,疑似成功时输出 flag
g{EdIiz`sU`k}Ga # 带有 {} 括号的字符串,flag 格式特征明显

用 IDA Pro 打开 core dump,设置基址为 0x55C62414E000

0x01 定位主逻辑

Core dump 中有很多 libc 函数,真正的程序代码在 0x55C62414F000 附近。从 survey_binary 可以看到几个关键函数都在这个区域。

函数清单

地址 功能
0x55C62414F2C9 XOR 循环
0x55C62414F31A 初始化函数(解密数据)
0x55C62414F370 RC4 KSA(密钥调度)
0x55C62414F45C RC4 加/解密
0x55C62414F5F8 自定义 Base64 编码
0x55C62414F86C 主验证逻辑
0x55C62414F786 成功回调(写 flag 到 /tmp)

0x02 初始化函数 — XOR 解密

首先吸引注意的是 sub_55C62414F31A,它调用了三次同一个 XOR 函数:

1
2
3
4
5
6
__int64 sub_55C62414F31A()
{
sub_55C62414F2C9(byte_55C624152040, 64, 66); // 字母表 XOR 0x42
sub_55C62414F2C9(&unk_55C6241520A0, 53, 36); // 期望串 XOR 0x24
return sub_55C62414F2C9(&qword_55C624152020, 14, 86); // 密钥 XOR 0x56
}

sub_55C62414F2C9 的逻辑非常简单:

1
2
for (i = 0; i < size; ++i)
buf[i] ^= xor_byte;

这说明程序在编译时将关键数据 XOR 加密存储,运行时通过 init 函数解密。三块数据的 XOR 密钥各不相同:

  • 0x55C624152040(64 字节)← XOR 0x42

  • 0x55C6241520A0(53 字节)← XOR 0x24

  • 0x55C624152020(14 字节)← XOR 0x56

从 core dump 中读取这三段加密数据,用对应密钥 XOR 解密:

Text
1
2
3
4
5
6
7
8
9
10
11
# 字母表 (64 bytes)
raw: 24 1c 2e 3b 78 30 6b 1d 6e 69 66 6a 12 3f 6f 06 ...
xor 0x42 后 → "f^ly:r)_,+$(P}-DVONCI;wSx{B#q.Wv2zHmE9%d[617nJc3MG@sia]ZQoY5t>L0"

# RC4 密钥 (14 bytes)
raw: 15 66 38 10 23 63 3f 66 38 09 1d 65 2f 77
xor 0x56 后 → "C0nFu5i0n_K3y!"

# 期望密文 (53 bytes, 找到 XOR 后为 0x00 的位置即 null 终止符)
raw: 73 1f 15 67 7b 45 64 49 69 7a 60 73 55 60 6b 7d 47 61 01 08 ...
xor 0x24 后 → "W;1C_a@mM^DWqDOYcE%,LtCl9:BOO$S,MO,%;}$5NZ5^;G%I%H>H" (52 chars)

RC4 密钥 C0nFu5i0n_K3y!(Confusion_Key 的 leet 写法)明显是人造的,确认解密方向正确。

0x03 主验证逻辑

sub_55C62414F86C 是核心验证函数:

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
void sub_55C62414F86C()
{
char input[256]; // v1 — 用户输入
char rc4_out[256]; // v2 — RC4 输出
char b64_out[512]; // v3 — Base64 输出

memset(input, 0, 256);
// 1. 读取用户输入
if (!fgets(input, 256, stdin)) return;

size_t len = strlen(input);
// 2. 去除尾部换行符
if (len > 0 && input[len-1] == '\n')
input[--len] = '\0';

if (len == 0) return;

// 3. RC4 加密: input → rc4_out
memset(rc4_out, 0, 256);
sub_55C62414F45C(input, rc4_out, len, &key, 14);

// 4. 自定义 Base64 编码: rc4_out → b64_out
memset(b64_out, 0, 512);
sub_55C62414F5F8(rc4_out, len, b64_out);

// 5. 与期望值比较
if (strcmp(b64_out, &expected) == 0)
sub_55C62414F786(input, len); // 成功
}

程序流程:用户输入 → RC4 加密 → 自定义 Base64 编码 → 与期望密文比较

所以逆向求 flag 的路径:期望密文 → 自定义 Base64 解码 → RC4 解密 → Flag

0x04 修改版 RC4

KSA(密钥调度)

仔细对比标准 RC4,该程序的 KSA 有改动:

1
2
3
4
5
6
7
8
9
10
11
12
// 标准 RC4 KSA:
for (i = 0; i < 256; i++) {
j = (j + S[i] + key[i % keylen]) % 256;
swap(S[i], S[j]);
}

// 本题的修改版 — 多了一个 + i:
for (j = 0; j < 256; j++) {
v7 = (v7 + S[j] + j + key[j % keylen]) % 256;
// 额外加了 ^
swap(S[j], S[v7]);
}

PRGA(伪随机生成)

同样被修改了:

1
2
3
4
5
6
7
8
9
10
11
12
// 标准 RC4 PRGA:
i = (i + 1) % 256;
j = (j + S[i]) % 256;
swap(S[i], S[j]);
k = S[(S[i] + S[j]) % 256]; // 用 S-box 查表
output = input ^ k;

// 本题的修改版:
v8 = (v8 + 1) % 256; // i
v9 = (v6 + v9) % 256; // j
k = (S[i] + S[j]) ^ pos; // XOR 的是输出位置 pos,而非 S-box 查表
output = input ^ k;

两个关键改动:

  1. KSA 中 j = j + S[i] + i + key[...](多了 + i

  2. PRGA 中 k = (S[i] + S[j]) ^ pos(用输出位置 XOR 替代 S-box 索引)

0x05 自定义 Base64

sub_55C62414F5F8 实现了一个带位置相关偏移的 Base64 变体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
v7 = 0;  // 全局输出位置计数器
for (i = 0; i < len; i += 3) {
// 标准 Base64: 取 3 字节 → 4 个 6-bit 值
v11[0] = byte0 >> 2;
v11[1] = (byte0 << 4 & 0x30) | (byte1 >> 4);
v11[2] = (byte1 << 2 & 0x3C) | (byte2 >> 6);
v11[3] = byte2 & 0x3F;

for (j = 0; j < 4; j++) {
// 关键改动: 索引加了位置相关的偏移
idx = (v11[j] + (v7 % 7) + 3) % 64;
output[out_pos++] = alphabet[idx];
v7++;
}
}

与标准 Base64 的区别:

  1. 字母表是乱序的(64 个自定义字符而非标准 A-Za-z0-9+/

  2. **每个位置的编码索引加了偏移量 ****(pos % 7) + 3**

解码时需要逆向这个偏移:

1
2
3
4
for pos, char in enumerate(encoded):
idx = rev_alphabet[char] # 反查字母表得到索引
original = (idx - (pos % 7) - 3) % 64 # 减去位置偏移
# 然后标准 4×6bit → 3×8bit 解包

0x06 解密脚本

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
# 从 core dump 读取的加密数据
raw_alphabet = bytes([0x24,0x1c,0x2e,0x3b,0x78,0x30,0x6b,0x1d,0x6e,0x69,0x66,0x6a,
0x12,0x3f,0x6f,0x06,0x14,0x0d,0x0c,0x01,0x0b,0x79,0x35,0x11,
0x3a,0x39,0x00,0x61,0x33,0x6c,0x15,0x34,0x70,0x38,0x0a,0x2f,
0x07,0x7b,0x67,0x26,0x19,0x74,0x73,0x75,0x2c,0x08,0x21,0x71,
0x0f,0x05,0x02,0x31,0x2b,0x23,0x1f,0x18,0x13,0x2d,0x1b,0x77,
0x36,0x7c,0x0e,0x72])

raw_key = bytes([0x15,0x66,0x38,0x10,0x23,0x63,0x3f,0x66,0x38,0x09,0x1d,0x65,0x2f,0x77])

raw_expected = bytes([0x73,0x1f,0x15,0x67,0x7b,0x45,0x64,0x49,0x69,0x7a,0x60,0x73,
0x55,0x60,0x6b,0x7d,0x47,0x61,0x01,0x08,0x68,0x50,0x67,0x48,
0x1d,0x1e,0x66,0x6b,0x6b,0x00,0x77,0x08,0x69,0x6b,0x08,0x01,
0x1f,0x59,0x00,0x11,0x6a,0x7e,0x11,0x7a,0x1f,0x63,0x01,0x6d,
0x01,0x6c,0x1a,0x6c,0x24])

# XOR 解密
alphabet = bytes([b ^ 0x42 for b in raw_alphabet])
rc4_key = bytes([b ^ 0x56 for b in raw_key])
decrypted_all = bytes([b ^ 0x24 for b in raw_expected])
expected = decrypted_all[:decrypted_all.find(0)] # 截取到 null
# expected = "W;1C_a@mM^DWqDOYcE%,LtCl9:BOO$S,MO,%;}$5NZ5^;G%I%H>H" (52 字节)

# --- 自定义 Base64 解码 ---
rev = {b: i for i, b in enumerate(alphabet)}
indices = [rev[c] for c in expected]
original = [((indices[pos] - (pos % 7) - 3) % 64) for pos in range(len(indices))]

decoded = bytearray()
for i in range(0, len(original), 4):
if i + 3 < len(original):
a, b, c, d = original[i:i+4]
decoded.extend([(a << 2) | (b >> 4),
((b & 0xF) << 4) | (c >> 2),
((c & 0x3) << 6) | d])
elif i + 2 < len(original):
a, b, c = original[i:i+3]
decoded.extend([(a << 2) | (b >> 4),
((b & 0xF) << 4) | (c >> 2)])
elif i + 1 < len(original):
a, b = original[i:i+2]
decoded.append((a << 2) | (b >> 4))

# --- 修改版 RC4 解密 ---
def modified_rc4_ksa(key):
S = list(range(256))
v7 = 0
for j in range(256):
v7 = (v7 + S[j] + j + key[j % len(key)]) & 0xFF # 多了 +j
S[j], S[v7] = S[v7], S[j]
return S

def modified_rc4_crypt(key, data):
S = modified_rc4_ksa(key)
v8 = 0 # i
v9 = 0 # j
result = bytearray()
for pos in range(len(data)):
v8 = (v8 + 1) & 0xFF
v6 = S[v8]
v9 = (v6 + v9) & 0xFF
k = ((v6 + S[v9]) ^ pos) & 0xFF # XOR pos 而非 S-box 查表
S[v8], S[v9] = S[v9], v6
result.append(data[pos] ^ k)
return bytes(result)

flag = modified_rc4_crypt(rc4_key, bytes(decoded))
print(flag.decode()) # flag{Tw34k3d_RC4_n_B64_m4k3_1t_h4rd!!!}

0x07 总结

本题是一个典型的 CTF 逆向题,考察点包括:

  1. Core dump 分析 — 识别 core dump 文件并加载到 IDA 分析

  2. XOR 加密数据识别 — 通过 init 函数发现静态数据在编译时被 XOR 加密

  3. 魔改 RC4 识别 — 对比标准 RC4 发现 KSA 多了 +i 和 PRGA 的 ^pos 改动

  4. 自定义 Base64 — 位置相关的偏移量 (pos % 7) + 3 + 乱序字母表

最终 Flag:

Text
1
flag{Tw34k3d_RC4_n_B64_m4k3_1t_h4rd!!!}

第七题题目和附件全部丢失,如果有师傅有附件并愿意给我的话,请联系我,感谢

关于本文

由 GuQing 撰写,采用 CC BY-NC 4.0 许可协议。