WMCTF2025 Want2BecomeMagicalGirl

最近在总结flutter逆向 又重新看了一遍这道题

WMCTF2025 Want2BecomeMagicalGirl - Britney

java层

Java 层其实很短,真正有用的只有一条 Flutter 通道和一个 XXTEA变种

image-20260309101717812

入口MainActivity onCreate() 只做了几件事:

  • 注册 Flutter 通道:P0.b.c(aVarJ)。
  • 弹一个 120 秒倒计时对话框,文本来自 story 资源。

真正流程关键在 P0/b.java:48:

  • 它向 Flutter 注册了一个 MethodChannel(“to”)。
  • handler 取的是 hVar.f7172a,也就是 MethodCall.method,不是 arguments。
  • 收到字符串后走 f(str)。

f(str) 的逻辑在 P0/b.java:79:

  • 第一次调用时启动一个后台线程。
  • 把种子重置为固定值 114514。
  • 用 Java Random 同款 LCG 公式生成一个 16-bit 数。
  • 这个数每次都一样,算出来是 16929。
  • 然后调用 c.a(str, “16929”)。

c.a() 在 P0/c.java:10Base64( Magic.encrypt(str_utf8, “16929”_utf8) )

加密算法Magic里确实是 XXTEA/BTEA 加密变种:

  • delta = 0x9E3779B9
  • 轮数 q = 52 / n + 6
  • fixKey() 把 key 截断/补零到 16 字节,再按小端拆成 4 个 int
  • 明文也按小端每 4 字节打包成 int[]

可以把它简化成:

Flutter 调 MethodChannel(“to”).invokeMethod(plaintext, anything) -> Java 用固定 key “16929” 对 method 名做 XXTEA/BTEA 变种加密-> Base64 返回给 Dart

dart层

在 Dart 和 Flutter 的架构中,层级设计是实现高效、模块化开发的核心。Dart 层级通常分为UI 层和数据层,并通过清晰的职责划分和组件化设计,确保代码的可维护性和扩展性。

通过blutter看到的,在 /asm/magical_girl/里有editview.dart等关键组件:

调用路径可以还原成这样:

1
2
3
4
5
6
7
8
9
main()
-> runApp(MyApp())
-> MyApp.build()
-> MaterialApp(home: MyEditText())
-> MyEditText.createState()
-> MyEditTextState.build()
-> TextField(onSubmitted: (text) { setState(() { check(text); controller.text = ""; }); })
-> 用户按回车
-> check(text)

asm:

  • main() 里 runApp(MyApp()) 在 main.dart:48
  • MyApp.build() 里先创建 MyEditText(),再创建 MaterialApp(),并把 MyEditText 塞进去,在 main.dart:507
  • MyEditText 是个 StatefulWidget,createState() 返回 MyEditTextState,在 EditView.dart:1329
  • MyEditTextState.build() 里创建了 TextField,并给它挂了一个 closure (闭包)回调,在 EditView.dart:1 和 EditView.dart:120
  • 这个回调再 setState(…),在 setState 里面真正调用 check(text),在 EditView.dart:388 和 EditView.dart:449

以后读 blutter,可以按这个顺序找:

  1. main -> runApp
  2. 根 widget 的 build
  3. 有没有 StatefulWidget
  4. createState
  5. State.build
  6. UI 控件上的 closure
  7. closure 里有没有 setState / onPressed / onSubmitted
  8. 最后才到业务函数,比如 check

SetupParameters:先看回调签名,知道它收什么参数

AllocateContext + StoreField:看 closure 捕获了什么变量

AllocateClosure:看又套了几层 closure

setState():很多 Flutter 业务逻辑其实包在这里面

LoadField:调用前到底把哪些 capture 取出来了

bl xxx:最后才是实际调用点

check函数逻辑

  • 先通过 FFI 从 libnative_add.so 取两份 native 数据:
    • getKey() 返回 24 字节 Uint8List:native_add.dart:9
    • getSym() 返回 8 个 Uint16 的 ExternalUint16Array:native_add.dart:117
  • 然后读取 getSym().buffer.asUint8List() 的某个字节做分支判断:EditView.dart:643 EditView.dart:679
  • 分支两边都会把 getKey() 和一小段固定字节拼成 AES 用的 key material:EditView.dart:682 EditView.dart:726
  • 另外还构造了一个固定 16 字节序列 6,8,10,…,32 给 AES:EditView.dart:769
  • 用户输入先走自定义 padding,不是标准 PKCS#7,而是补若干个字符值 padLen*2:EditView.dart:1002
  • 然后 UTF-8 编码,AES 加密:EditView.dart:872 EditView.dart:881 EditView.dart:892
  • AES 密文再被转成 hex 字符串:EditView.dart:962
  • 这个 hex 字符串就是传给 Java invokeMethod 的内容:EditView.dart:926 null_sub0.dart:34

image-20260309103259240

调用了send()发送给java层的invokemethod 然后

image-20260309103400050

密文比较

1
8sAFX45zT7uc0vSUyFNNly1h/d5zTt89tV3kcVr5P5n7lRKPyYtxg31zYNB2lPV0c5nf/x2/IK94XV9Ufs9XfaDG5IXxMlZy+Z2nE+ZZRFBSpMoKzQXfUq2TSjJJfQxV

关于java层和dart层的分工,虽然之前已经提过,这里再解释下

可以把它理解成三层:

  1. Java/Kotlin 负责 Android 容器和生命周期
  2. Dart 负责 Flutter 页面、状态和大部分业务逻辑
  3. Flutter Engine(C++) 负责 真正把 Dart 描述的界面画到屏幕上

这个题里,不是 Dart 触发了 Java 的 onCreate()。顺序反过来:

  1. Android 系统启动应用
  2. 系统创建 MainActivity
  3. 调用 MainActivity.java:48 的 onCreate()
  4. super.onCreate() 里面会把 Flutter 引擎/视图准备好
  5. Dart 入口 main.dart:6 的 main() 才开始执行
  6. Dart 里 runApp() 后,Flutter 页面才真正建立起来

所以结论是:先有 Android 的 onCreate,后有 Dart main()。Dart 不负责启动 Activity,它是被 Flutter 引擎拉起来运行的。

页面到底谁负责显示?这题里两边都参与了,但分工不同:

  • Java 负责显示那个原生弹窗。你在 MainActivity.java:57 能看到它直接创建并 show() 了一个 Android 对话框。
  • Dart 负责真正的 Flutter 页面,也就是输入框、文案、检查逻辑这些。在 EditView.dart:1 和 main.dart:124 能看到 UI 都在 Dart 里。

更准确地说:

  • Activity/窗口 是 Java 的
  • Flutter 页面结构 是 Dart 的
  • 像素绘制 是 Flutter engine 的,不是 JVM 自己画的

Dart 在 Flutter 里大致起什么作用?Dart 不是辅助脚本,它基本就是 Flutter app 的主代码层。通常负责:

  • 页面怎么长
  • 按钮点了做什么
  • 状态怎么变化
  • 网络/校验/业务逻辑怎么跑
  • 什么时候调用原生能力

在这题里,Dart 的作用:

  • 搭 UI:输入框和结果文案在 Dart 里
  • 处理输入:check() 在 EditView.dart:505
  • 做一层本地 AES
  • 通过 MethodChannel 调 Java:见 null_sub0.dart:26
  • 再拿 Java 返回值去比较,决定显示成功还是失败

所以对这题来说可以简单记成:

  • Java:Android 壳、生命周期、原生弹窗、MethodChannel 接口、XXTEA 那层
  • Dart:Flutter 页面、输入处理、AES、调用 Java、最后比对结果

所以现在是

1
硬编码 Base64-> Base64 解码-> XXTEA 解密-> 得到 AES 密文(hex)-> hex 转 bytes-> AES 解密-> 明文
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
def rc4_crypt(key: bytes, data: bytes) -> bytes:
s = list(range(256))
j = 0

# KSA
for i in range(256):
j = (j + s[i] + key[i % len(key)]) & 0xFF
s[i], s[j] = s[j], s[i]

# PRGA
out = bytearray(data)
i = 0
j = 0
for n in range(len(out)):
i = (i + 1) & 0xFF
j = (j + s[i]) & 0xFF
s[i], s[j] = s[j], s[i]
out[n] ^= s[(s[i] + s[j]) & 0xFF]

return bytes(out)


def main():
key = b"magical"
hex_input = "646f5577616e744d61676963"
data = bytes.fromhex(hex_input)
out = rc4_crypt(key, data)

print(out.hex())

if __name__ == "__main__":
main()

7a25c524c6334c30f362afac

这里的key还要追加几个常量

image-20260310140847355

真正追加发生在这两处 addAll()

  • 如果 getSym().buffer.asUint8List()[7] == 0xd6,走 EditView.dart:682,然后 EditView.dart:692 的 addAll() 把 fp-0x20 那组尾巴拼到 getKey() 后面。

    当前设备/环境里,libart.so 的 ArtMethod::PrettyMethod(bool) 这段机器码的某个字节是不是 0xd6。

    这通常是:

    • Android 版本差异
    • ART 实现差异
    • 指令布局差异
    • 有时也能顺带做一点反分析/环境区分

    也就是说,这更像一个运行环境分支开关,决定后面 AES key 用哪组尾巴

  • 否则走 EditView.dart:726,然后 EditView.dart:736 的 addAll() 把 fp-0x18 那组尾巴拼到 getKey() 后面。

所以会有两个完整 key:

  • 7a25c524c6334c30f362afac3f2303d5
  • 7a25c524c6334c30f362afac01010405

Native层

AES 魔改分析

image-20260309123608650

aes处魔改

它里面的调用顺序很明确:

  • _addRoundKey_291204
  • _subBytes_2901b8
  • _shiftRows_28ffb4
  • _addRoundKey_291204
  • _mixColumns_28d8b0

标准 AES 中间轮应该是:SubBytes -> ShiftRows -> MixColumns -> AddRoundKey

而它这里是:SubBytes -> ShiftRows -> AddRoundKey -> MixColumns

1
__int64 __fastcall magical_girl_aes_crypt_null_safe__Aes::_mixColumns_28d8b0(__int64 a1, __int64 a2)

发现一大串疑似sbox的东西

image-20250925194329359

image-20250925194538093

image-20250925194606638

其中这些函数

1
__int64 magical_girl_EditView_MyEditTextState::check_28bea8()

最重要

image-20250925195223779

包括setkey和mode

image-20250925195246855

加密的数据通过 aesEncrypt_28c6a8 和其他相关函数进行处理,这表明该函数处理的是加密或解密任务。

显然这里肯定会传一个类似实例的东西,借助blutter自带的frida脚本

给了一个blutter_frida.js hook脚本

image-20250925200045853

hook试试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function onLibappLoaded() {
const fn_addr = 0x28c6a8;
Interceptor.attach(libapp.add(fn_addr), {
onEnter: function () {
init(this.context);
let objPtr = getArg(this.context, 1);
const [tptr, cls, values] = getTaggedObjectValue(objPtr);
console.log(
`${cls.name}@${tptr.toString().slice(2)} =`,
JSON.stringify(values, null, 2)
);
},
});
}

或者hook这个

image-20251019195744279

getArg(this.context, 0) 更改第二个参数控制传参打印位置

1
2
3
4
5
6
7
8
9
10
11
function onLibappLoaded() {
const fn_addr = 0x2915a4;
Interceptor.attach(libapp.add(fn_addr), {
onEnter: function () {
init(this.context);
let objPtr = getArg(this.context, 0);
const [tptr, cls, values] = getTaggedObjectValue(objPtr);
console.log(`${cls.name}@${tptr.toString().slice(2)} =`, JSON.stringify(values, null, 2));
}
});
}

image-20251019200651511

hook得到

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
AesCrypt@6f00854529 = {
"off_8!_Aes@6f00854539": {
"off_8!Smi": 4,
"off_c!Smi": 10,
"off_10!_Uint32List@6f00854f49": [
2049295652,
3325250608,
4083330988,
16843781,
6783327,
3327446383,
892756675,
876046022,
3267769349,
76740970,
832871337,
93584751,
2526363713,
2449979691,
2745377410,
2788220909,
3825754614,
1980750045,
3584883295,
1939613106,
2595761419,
3971478998,
958074761,
1250372155,
576656933,
3471369203,
4159958138,
3178546753,
3672808602,
335708009,
3824259859,
1585796434,
1333110389,
1534581020,
3095808527,
3858964317,
4090255858,
2830860526,
272485089,
4131313084,
1712018262,
3467695032,
3733789017,
682872037
],
"off_14!List@6f00854569": [
{
"key": [
0,
0,
0,
0
]
},
{
"key": [
0,
0,
0,
0
]
},
{
"key": [
0,
0,
0,
0
]
},
{
"key": [
0,
0,
0,
0
]
}
],
"off_18!AesMode@6f0045a1b1": {
"parent!_Enum": {
"off_8": "0",
"off_10!String@6f000f7a61": "ecb"
}
},
"off_1c!_Uint8List@6f00854ee9": [
122,
37,
197,
36,
198,
51,
76,
48,
243,
98,
175,
172,
1,
1,
4,
5
],
"off_20!_Uint8List@6f00854f19": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16
]
},
"off_c!Map@6f00854719": "Unhandle class id: 85, Map"
}

aesEncrypt_28c6a8 都第一个参数是输入的flag

image-20251019200938343

16字节的那个有点像key

1
frida -U -f work.pangbai.magic.magical_girl -l blutter_frida.js

反正现在S盒子S盒逆key ecb都有了可以写代码解密了

sbox 我们将这些值均除以2

flutter的整数貌似都是*2存储的,因为虽然 Dart 中一切皆为 Object,但是实际 Dart 在也是有底层优化的。在 Dart 中最最常见的就是 int 类型。

int 分两种类型:

  1. smi: 对于小数值的数字,Dart直接将原数值左移一位。

  2. bigint: 对于大数值,Dart构建对象,其数值在对象偏移+7处。

Flutter 函数调用约定: 参数从右往左入栈,最后入栈对象的this指针

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
#!/usr/bin/env python3
import base64


CMP_BASE64 = (
"8sAFX45zT7uc0vSUyFNNly1h/d5zTt89tV3kcVr5P5n7lRKPyYtxg31zYNB2lPV0"
"c5nf/x2/IK94XV9Ufs9XfaDG5IXxMlZy+Z2nE+ZZRFBSpMoKzQXfUq2TSjJJfQxV"
)
XXTEA_KEY = b"16929"
GENKEY = bytes.fromhex("7a25c524c6334c30f362afac")
BRANCH_SUFFIXES = {
"other": bytes([63, 35, 3, 213]),
"sym_0xd6": bytes([1, 1, 4, 5]),
}

SBOX = list(
bytes.fromhex(
"207b18a74244d74acd32d1ecf381a5890e914bf0e95d8df546fc3136b6ac9bb9"
"2609e640d4b0514f9c3ee7793088b13c7a5cd3145aab56c00429d03b1ff9a357"
"008a8416f41aea64a6d62ebe2f17c4e01e023a228f9fcba82c673425d5ffeff6"
"e2aad972fecea17885962a77cac13774a25e6cfdb84d7d70b3ddcf717361f819"
"48e363333d15ae98e580bdbc82c69401e4de065095df47f7908b459a6e07ad1c"
"358368036f5bb7fb1dc5107cd86acc698e244c39b4a00b52e8a9b28c0abf2886"
"6dafda41fa75b543c360622b55f29e2d12230ddb6bc7387f5f9708ede1bbee9d"
"d292493fdc5887c2ba99c94ef121eb136559760cc805a454931b661127537e0f"
)
)
ATABLE = list(
bytes.fromhex(
"01e54cb5fb9ffc120334d4c416ba1f36055c67573ad5215a0fe4a9f94e6463ee"
"1137e010d2aca52933593b306deff47b55eb4d50b72a078dff26d7f0c27e098c"
"1a6a620b5d821b8f2ebea61de79d2d8a72d9f12732bc77859670086956df9994"
"a19018bbfa7ab0a7f8ab28d6158ecbf213e678613f89460d353188a34180ca17"
"5f5383fec39b4539e1f59e195eb6cf4b3804b92be2c14add480cd07d3d58de7c"
"d8146b8747e87984733cbd92c9238b979544dcad406586a2a4cc7fecc0af91fd"
"f74f812f5beaa81c02d19871ed25e3240668b3932c6f3e6c0ab8ceae74b142b4"
"1ed349e99cc8c6c7226edb20bf43515266b27660dac5f3f6aacd9aa075540e01"
)
)
LTABLE = list(
bytes.fromhex(
"00ffc8089110d0365a3ed8439977fe1823200770a16c0c7f628b4046c74be00e"
"eb16e8adcfcd39536a273593d44e48c32b79542809780f219087142aa99cd674"
"b47cdeedb18676a498e2968f02321cc133eeef81fd305c139d2917c411448c80"
"f373421e1db5f012d15b41a2d72ce9d559cb50a8dcfcf25672a6652f9f9b3dba"
"7dc24582a757b6a37a754fae3f376d4761beabd35fb058afca5efa85e44d8a05"
"fb60b77bb8264a67c61af86925b3dbbd66ddf1d2df038d34d9920d6355aa49ec"
"bc953c840bf5e6e7e5ac7e6eb9f9da8e9ac924e10a156b3aa051f4eab2979e5d"
"228894ce1901714ca5e3c531bbcc1f2d3b526ff62e89f7c0681b640406bf8338"
)
)
RCON = [
0x00000000,
0x01000000,
0x02000000,
0x04000000,
0x08000000,
0x10000000,
0x20000000,
0x40000000,
0x80000000,
0x1B000000,
0x36000000,
0x6C000000,
0xD8000000,
0xAB000000,
0x4D000000,
0x9A000000,
0x2F000000,
]


def u32(x: int) -> int:
return x & 0xFFFFFFFF


def bytes_to_u32_le(data: bytes) -> list[int]:
out = [0] * ((len(data) + 3) // 4)
for i, b in enumerate(data):
out[i >> 2] |= b << ((i & 3) << 3)
return out


def u32_to_bytes_le(words: list[int]) -> bytes:
out = bytearray(len(words) * 4)
for i in range(len(out)):
out[i] = (words[i >> 2] >> ((i & 3) << 3)) & 0xFF
return bytes(out)


def fix_key(key: bytes) -> list[int]:
return bytes_to_u32_le(key[:16].ljust(16, b"\x00"))


def mx(z: int, y: int, total: int, p: int, e: int, k: list[int]) -> int:
part1 = u32((z >> 5) ^ u32(y << 2))
part2 = u32((y >> 3) ^ u32(z << 4))
part3 = u32(part1 + part2)
part4 = u32((y ^ total) + (z ^ k[(p & 3) ^ e]))
return u32(part3 ^ part4)


def xxtea_decrypt(data: bytes, key: bytes) -> bytes:
if not data:
return data

v = bytes_to_u32_le(data)
n = len(v)
if n < 2:
return u32_to_bytes_le(v)

k = fix_key(key)
delta = 0x9E3779B9
rounds = 6 + (52 // n)
total = u32(rounds * delta)

while total:
e = (total >> 2) & 3
y = v[0]
for p in range(n - 1, 0, -1):
z = v[p - 1]
y = v[p] = u32(v[p] - mx(z, y, total, p, e, k))
z = v[n - 1]
y = v[0] = u32(v[0] - mx(z, y, total, 0, e, k))
total = u32(total - delta)

return u32_to_bytes_le(v)


def rot_word(word: int) -> int:
return u32((word << 8) | (word >> 24))


def sub_word(word: int) -> int:
out = 0
for _ in range(4):
out = ((out << 8) | SBOX[(word >> 24) & 0xFF]) & 0xFFFFFFFF
word = u32(word << 8)
return out


def expand_key(key: bytes) -> tuple[list[int], int]:
nk = len(key) // 4
nr = nk + 6
words = [0] * (4 * (nr + 1))
for i in range(nk):
words[i] = int.from_bytes(key[i * 4 : i * 4 + 4], "big")

i = nk
while i < len(words):
temp = words[i - 1]
if i % nk == 0:
temp = sub_word(rot_word(temp)) ^ RCON[i // nk]
elif nk > 6 and i % nk == 4:
temp = sub_word(temp)
words[i] = u32(words[i - nk] ^ temp)
i += 1

return words, nr


def bytes_to_state(block: bytes) -> list[list[int]]:
state = [[0] * 4 for _ in range(4)]
for i, b in enumerate(block):
state[i & 3][i >> 2] = b
return state


def state_to_bytes(state: list[list[int]]) -> bytes:
out = bytearray(16)
for i in range(16):
out[i] = state[i & 3][i >> 2]
return bytes(out)


def add_round_key(state: list[list[int]], words: list[int], round_idx: int) -> None:
base = 4 * round_idx
for row in range(4):
shift = 8 * (3 - row)
for col in range(4):
state[row][col] ^= (words[base + col] >> shift) & 0xFF


def inv_shift_rows(state: list[list[int]]) -> None:
for row in range(1, 4):
state[row] = state[row][-row:] + state[row][:-row]


def inv_sub_bytes(state: list[list[int]], inv_sbox: list[int]) -> None:
for row in range(4):
for col in range(4):
state[row][col] = inv_sbox[state[row][col]]


def gf_mul(a: int, b: int) -> int:
if a == 0 or b == 0:
return 0
return ATABLE[(LTABLE[a] + LTABLE[b]) % 255]


def inv_mix_columns(state: list[list[int]]) -> None:
for col in range(4):
a0, a1, a2, a3 = (state[row][col] for row in range(4))
state[0][col] = gf_mul(14, a0) ^ gf_mul(11, a1) ^ gf_mul(13, a2) ^ gf_mul(9, a3)
state[1][col] = gf_mul(9, a0) ^ gf_mul(14, a1) ^ gf_mul(11, a2) ^ gf_mul(13, a3)
state[2][col] = gf_mul(13, a0) ^ gf_mul(9, a1) ^ gf_mul(14, a2) ^ gf_mul(11, a3)
state[3][col] = gf_mul(11, a0) ^ gf_mul(13, a1) ^ gf_mul(9, a2) ^ gf_mul(14, a3)


def custom_aes_decrypt_ecb(data: bytes, key: bytes) -> bytes:
if len(data) % 16 != 0:
raise ValueError(f"AES input length is not 16-byte aligned: {len(data)}")

inv_sbox = [0] * 256
for idx, value in enumerate(SBOX):
inv_sbox[value] = idx

words, nr = expand_key(key)
out = bytearray()

for off in range(0, len(data), 16):
state = bytes_to_state(data[off : off + 16])
add_round_key(state, words, nr)
inv_shift_rows(state)
inv_sub_bytes(state, inv_sbox)
for round_idx in range(nr - 1, 0, -1):
inv_mix_columns(state)
add_round_key(state, words, round_idx)
inv_shift_rows(state)
inv_sub_bytes(state, inv_sbox)
add_round_key(state, words, 0)
out.extend(state_to_bytes(state))

return bytes(out)


def try_strip_custom_padding(data: bytes) -> bytes | None:
if not data:
return data
last = data[-1]
if last == 0 or (last & 1):
return None
pad_len = last >> 1
if not (1 <= pad_len <= 16):
return None
if len(data) < pad_len:
return None
if data[-pad_len:] != bytes([last]) * pad_len:
return None
return data[:-pad_len]


def choose_aes_cipher(xxtea_out: bytes) -> tuple[bytes, str]:
stripped = xxtea_out.rstrip(b"\x00")
if stripped and len(stripped) % 2 == 0:
try:
text = stripped.decode("ascii")
if all(ch in "0123456789abcdefABCDEF" for ch in text):
return bytes.fromhex(text), "hex-ascii"
except UnicodeDecodeError:
pass
return stripped, "raw-bytes"


def main() :
decoded = base64.b64decode(CMP_BASE64)
decrypted = xxtea_decrypt(decoded, XXTEA_KEY)
print(decrypted.hex())
print(decrypted)

aes_cipher, mode = choose_aes_cipher(decrypted)
print(f"[aes input mode] {mode}")
print(f"[aes input hex ] {aes_cipher.hex()}")

for name, suffix in BRANCH_SUFFIXES.items():
full_key = GENKEY + suffix
aes_plain = custom_aes_decrypt_ecb(aes_cipher, full_key)
unpadded = try_strip_custom_padding(aes_plain)
print(f"[branch {name}] key={full_key.hex()}")
print(f"[branch {name}] raw={aes_plain.hex()}")
print(f"[branch {name}] bytes={aes_plain}")
if unpadded is None:
print(f"[branch {name}] unpadded=<fail>")
else:
print(f"[branch {name}] unpadded={unpadded.hex()}")
print(f"[branch {name}] unpadded_bytes={unpadded}")


if __name__ == "__main__":
main()

解不出来 是乱码

init_proc

标准的xxtea,不过出不来T.T,出题人提示init_proc,猜测可能是被改掉了 ,而且字符串均被加密了

在libnative_add.so里

image-20251008214918515

这里init_proc() 的行为非常典型

  • 先解析 libart.so 里的目标函数地址

  • mmap 一块可执行内存

  • 把 shellcode_start_ 那段 trampoline/shellcode 拷进去

  • 把 handler 地址写到 shellcode 里

  • mprotect 把目标函数所在页改成可写可执行

  • 直接覆盖目标函数开头 16 字节

  • 把原始前 16 字节另存起来,后面还能跳回去

  • 第一组:

    • 入口 handler: sub_1A51C
    • 收尾/返回 handler: sub_1A6E0
  • 第二组:

    • 入口 handler: sub_1A710
    • 收尾/返回 handler: sub_1A76C

init_proc() 和 .init_array 是什么

init_proc() 在这里不是 Java 的构造函数,而是 ELF/so 层的初始化函数。

init_array 是 ELF 里的一个段,里面放的是函数指针数组。动态链接器在加载这个 .so 时,会把这些函数按顺序调用一遍。

在 Android 上,System.loadLibrary() / dlopen() 把 so 加载进来后,链接器会先跑这类初始化代码,通常早于 JNI_OnLoad。

所以 .init_array 里的东西常被拿来做:

  • 全局状态初始化
  • 反调试
  • 自修改代码
  • 提前 hook

字符串解密代码:

veorq_s8 是 ARM NEON 的一个 intrinsic,意思是对两个 128-bit 向量做按位异或(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
72
import ida_bytes
import ida_name
import idaapi

def read16(name: str) -> bytes:
ea = ida_name.get_name_ea(0, name)
if ea == idaapi.BADADDR:
raise RuntimeError(f"symbol not found: {name}")
data = ida_bytes.get_bytes(ea, 16)
if not data or len(data) != 16:
raise RuntimeError(f"failed to read 16 bytes from {name} @ {hex(ea)}")
return data


TBL_T = read16("t")
TBL_L = read16("l")


def decode_chunks(names: list[str]) -> bytes:
out = bytearray()
for name in names:
src = read16(name)
perm = bytes(src[idx] for idx in TBL_T)
out.extend(((TBL_L[i] ^ TBL_T[i] ^ perm[i]) & 0xFF) for i in range(16))
return bytes(out).split(b"\x00")[0]


TARGETS = {
"libart": ["xmmword_10250"],
"target1": [
"xmmword_10150",
"xmmword_10270",
"xmmword_10100",
"xmmword_10180",
"xmmword_101D0",
"xmmword_10130",
"xmmword_10190",
"xmmword_10260",
],
"target1_fallback": [
"xmmword_10150",
"xmmword_10270",
"xmmword_10120",
"xmmword_101E0",
"xmmword_101A0",
"xmmword_10230",
"xmmword_10200",
"xmmword_101B0",
],
"target2": [
"xmmword_10110",
"xmmword_10210",
"xmmword_10160",
],
"target3": [
"xmmword_101C0",
"xmmword_10140",
"xmmword_10170",
],
"needle1": ["xmmword_101F0"],
"needle2": ["xmmword_10240"],
}


for label, names in TARGETS.items():
data = decode_chunks(names)
print(f"{label}: {data!r}")
try:
print(f"{label}_str: {data.decode('utf-8')}")
except UnicodeDecodeError:
print(f"{label}_hex: {data.hex()}")

1
2
3
4
5
6
7
8
9
10
11
12
13
14
>libart: b'libart.so'
>libart_str: libart.so
>target1: b'_ZN3art11interpreter6DoCallILb0EEEbPNS_9ArtMethodEPNS_6ThreadERNS_11ShadowFrameEPKNS_11InstructionEtbPNS_6JValueE'
>target1_str: _ZN3art11interpreter6DoCallILb0EEEbPNS_9ArtMethodEPNS_6ThreadERNS_11ShadowFrameEPKNS_11InstructionEtbPNS_6JValueE
>target1_fallback: b'_ZN3art11interpreter6DoCallILb0ELb0EEEbPNS_9ArtMethodEPNS_6ThreadERNS_11ShadowFrameEPKNS_11InstructionEtPNS_6JValueE'
>target1_fallback_str: _ZN3art11interpreter6DoCallILb0ELb0EEEbPNS_9ArtMethodEPNS_6ThreadERNS_11ShadowFrameEPKNS_11InstructionEtPNS_6JValueE
>target2: b'_ZN3art9ArtMethod12PrettyMethodEb'
>target2_str: _ZN3art9ArtMethod12PrettyMethodEb
>target3: b'_ZNK3art9OatHeader12IsDebuggableEv'
>target3_str: _ZNK3art9OatHeader12IsDebuggableEv
>needle1: b'gic.fi'
>needle1_str: gic.fi
>needle2: b'gic.toB'
>needle2_str: gic.toB

也就是说它不是在 hook 自己的函数,也不是在 hook libc,而是在 hook libart.so 里的运行时函数。

ART = Android Runtime。它是 Android 现在的运行时环境,负责执行 app 的 Dex/OAT 代码。

大致上它管这些:

  • 类加载
  • 方法调用
  • 解释执行
  • JIT/AOT
  • 垃圾回收
  • JNI 交互

可以把它理解成:Android 上 Java/Kotlin 代码真正跑起来时,底下那套运行时系统。早年是 Dalvik,后来换成了 ART。

推断这两个片段大概率就是在匹配 Magic.fixKey 和 Magic.toByteArray。

而 0x1a51c 的行为是:

  • 命中 gic.fi 时,保存并修改 DoCall 前 160 字节(后面证实不是这个功能)
  • 命中 gic.toB 时,再把那 160 字节恢复回去

会影响前面那条 XXTEA。

hook 了:

  • ART 解释器的 DoCall
  • ArtMethod::PrettyMethod(bool)
  • OatHeader::IsDebuggable()

更关键的是,第一个 hook handler 0x1a51c 会先调用 PrettyMethod 取当前方法名,然后按方法名片段触发动态 patch。它解出来的两个触发子串是:

  • gic.fi
  • gic.toB

但是这里hook不太好处理

dlopen函数调用完之后.init_xxxx函数已经执行完了,这个时候不容易使用frida进行hook,hook linker的call_function并不容易,这里面涉及到linker的自举。所以这里有一个思路:在.init_proc函数中找一个调用了外部函数的位置,时机越早越好

init_proc@0x1a09c 不是在改 ART 的通用解释器分支逻辑,而是 hook art::interpreter::DoCall,把它当触发器去热补丁当前正在解释执行的 DEX code window。真正被改的是字节码语义,不是DoCall 本体。

在sub_1A51C方法中,可以看到

image-20260310175252173

  • 0xb3 -> 0xb2:div-int/2addr -> mul-int/2addr
  • 0xe0 -> 0xe2:shl-int/lit8 -> ushr-int/lit8

所以=可以直接概括成:

  • 借 Magic.fixKey 进入点触发 patch
  • 在后续一小段 Java/Dex 执行窗口里,把除法改成乘法,把左移改成无符号右移
  • 到 Magic.toByteArray 再恢复

0xb3 -> 0xb2、0xe0 -> 0xe2正好能对上 DEX opcode,不像 ARM64 机器码 patch

https://android.googlesource.com/platform/dalvik/+/refs/heads/main/opcode-gen/bytecode.txt

这里可以看到dex的opcode,可以静态分析

解密脚本

给出exp:

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
import base64


CMP_BASE64 = (
"8sAFX45zT7uc0vSUyFNNly1h/d5zTt89tV3kcVr5P5n7lRKPyYtxg31zYNB2lPV0"
"c5nf/x2/IK94XV9Ufs9XfaDG5IXxMlZy+Z2nE+ZZRFBSpMoKzQXfUq2TSjJJfQxV"
)
XXTEA_KEY = b"16929"
GENKEY = bytes.fromhex("7a25c524c6334c30f362afac")
BRANCH_SUFFIXES = {
"other": bytes([63, 35, 3, 213]),
"sym_0xd6": bytes([1, 1, 4, 5]),
}

SBOX = list(
bytes.fromhex(
"207b18a74244d74acd32d1ecf381a5890e914bf0e95d8df546fc3136b6ac9bb9"
"2609e640d4b0514f9c3ee7793088b13c7a5cd3145aab56c00429d03b1ff9a357"
"008a8416f41aea64a6d62ebe2f17c4e01e023a228f9fcba82c673425d5ffeff6"
"e2aad972fecea17885962a77cac13774a25e6cfdb84d7d70b3ddcf717361f819"
"48e363333d15ae98e580bdbc82c69401e4de065095df47f7908b459a6e07ad1c"
"358368036f5bb7fb1dc5107cd86acc698e244c39b4a00b52e8a9b28c0abf2886"
"6dafda41fa75b543c360622b55f29e2d12230ddb6bc7387f5f9708ede1bbee9d"
"d292493fdc5887c2ba99c94ef121eb136559760cc805a454931b661127537e0f"
)
)
ATABLE = list(
bytes.fromhex(
"01e54cb5fb9ffc120334d4c416ba1f36055c67573ad5215a0fe4a9f94e6463ee"
"1137e010d2aca52933593b306deff47b55eb4d50b72a078dff26d7f0c27e098c"
"1a6a620b5d821b8f2ebea61de79d2d8a72d9f12732bc77859670086956df9994"
"a19018bbfa7ab0a7f8ab28d6158ecbf213e678613f89460d353188a34180ca17"
"5f5383fec39b4539e1f59e195eb6cf4b3804b92be2c14add480cd07d3d58de7c"
"d8146b8747e87984733cbd92c9238b979544dcad406586a2a4cc7fecc0af91fd"
"f74f812f5beaa81c02d19871ed25e3240668b3932c6f3e6c0ab8ceae74b142b4"
"1ed349e99cc8c6c7226edb20bf43515266b27660dac5f3f6aacd9aa075540e01"
)
)
LTABLE = list(
bytes.fromhex(
"00ffc8089110d0365a3ed8439977fe1823200770a16c0c7f628b4046c74be00e"
"eb16e8adcfcd39536a273593d44e48c32b79542809780f219087142aa99cd674"
"b47cdeedb18676a498e2968f02321cc133eeef81fd305c139d2917c411448c80"
"f373421e1db5f012d15b41a2d72ce9d559cb50a8dcfcf25672a6652f9f9b3dba"
"7dc24582a757b6a37a754fae3f376d4761beabd35fb058afca5efa85e44d8a05"
"fb60b77bb8264a67c61af86925b3dbbd66ddf1d2df038d34d9920d6355aa49ec"
"bc953c840bf5e6e7e5ac7e6eb9f9da8e9ac924e10a156b3aa051f4eab2979e5d"
"228894ce1901714ca5e3c531bbcc1f2d3b526ff62e89f7c0681b640406bf8338"
)
)
RCON = [
0x00000000,
0x01000000,
0x02000000,
0x04000000,
0x08000000,
0x10000000,
0x20000000,
0x40000000,
0x80000000,
0x1B000000,
0x36000000,
0x6C000000,
0xD8000000,
0xAB000000,
0x4D000000,
0x9A000000,
0x2F000000,
]


def u32(x: int) -> int:
return x & 0xFFFFFFFF


def bytes_to_u32_le(data: bytes) -> list[int]:
out = [0] * ((len(data) + 3) // 4)
for i, b in enumerate(data):
out[i >> 2] |= b << ((i & 3) << 3)
return out


def u32_to_bytes_le(words: list[int]) -> bytes:
out = bytearray(len(words) * 4)
for i in range(len(out)):
out[i] = (words[i >> 2] >> ((i & 3) << 3)) & 0xFF
return bytes(out)


def fix_key(key: bytes) -> list[int]:
return bytes_to_u32_le(key[:16].ljust(16, b"\x00"))


def mx(z: int, y: int, total: int, p: int, e: int, k: list[int]) -> int:
part1 = u32((z >> 5) ^ u32(y << 2))
part2 = u32((y >> 3) ^ u32(z << 4))
part3 = u32(part1 + part2)
part4 = u32((y ^ total) + (z ^ k[(p & 3) ^ e]))
return u32(part3 ^ part4)


def magic_runtime_mx(z: int, y: int, total: int, p: int, e: int, k: list[int]) -> int:
# init_proc patches Magic.encrypt's DEX opcodes from:
# div-int/2addr -> mul-int/2addr
# shl-int/lit8 -> ushr-int/lit8
part1 = u32((z >> 5) ^ (y >> 2))
part2 = u32((y >> 3) ^ (z >> 4))
part3 = u32(part1 + part2)
part4 = u32((y ^ total) + (z ^ k[(p & 3) ^ e]))
return u32(part3 ^ part4)


def xxtea_decrypt(data: bytes, key: bytes, *, patched: bool = False) -> bytes:
if not data:
return data

v = bytes_to_u32_le(data)
n = len(v)
if n < 2:
return u32_to_bytes_le(v)

k = fix_key(key)
delta = 0x9E3779B9
rounds = 6 + (52 * n if patched else 52 // n)
total = u32(rounds * delta)
mix = magic_runtime_mx if patched else mx

while total:
e = (total >> 2) & 3
y = v[0]
for p in range(n - 1, 0, -1):
z = v[p - 1]
y = v[p] = u32(v[p] - mix(z, y, total, p, e, k))
z = v[n - 1]
y = v[0] = u32(v[0] - mix(z, y, total, 0, e, k))
total = u32(total - delta)

return u32_to_bytes_le(v)


def rot_word(word: int) -> int:
return u32((word << 8) | (word >> 24))


def sub_word(word: int) -> int:
out = 0
for _ in range(4):
out = ((out << 8) | SBOX[(word >> 24) & 0xFF]) & 0xFFFFFFFF
word = u32(word << 8)
return out


def expand_key(key: bytes) -> tuple[list[int], int]:
nk = len(key) // 4
nr = nk + 6
words = [0] * (4 * (nr + 1))
for i in range(nk):
words[i] = int.from_bytes(key[i * 4 : i * 4 + 4], "big")

i = nk
while i < len(words):
temp = words[i - 1]
if i % nk == 0:
temp = sub_word(rot_word(temp)) ^ RCON[i // nk]
elif nk > 6 and i % nk == 4:
temp = sub_word(temp)
words[i] = u32(words[i - nk] ^ temp)
i += 1

return words, nr


def bytes_to_state(block: bytes) -> list[list[int]]:
state = [[0] * 4 for _ in range(4)]
for i, b in enumerate(block):
state[i & 3][i >> 2] = b
return state


def state_to_bytes(state: list[list[int]]) -> bytes:
out = bytearray(16)
for i in range(16):
out[i] = state[i & 3][i >> 2]
return bytes(out)


def add_round_key(state: list[list[int]], words: list[int], round_idx: int) -> None:
base = 4 * round_idx
for row in range(4):
shift = 8 * (3 - row)
for col in range(4):
state[row][col] ^= (words[base + col] >> shift) & 0xFF


def inv_shift_rows(state: list[list[int]]) -> None:
for row in range(1, 4):
state[row] = state[row][-row:] + state[row][:-row]


def inv_sub_bytes(state: list[list[int]], inv_sbox: list[int]) -> None:
for row in range(4):
for col in range(4):
state[row][col] = inv_sbox[state[row][col]]


def gf_mul(a: int, b: int) -> int:
if a == 0 or b == 0:
return 0
return ATABLE[(LTABLE[a] + LTABLE[b]) % 255]


def inv_mix_columns(state: list[list[int]]) -> None:
for col in range(4):
a0, a1, a2, a3 = (state[row][col] for row in range(4))
state[0][col] = gf_mul(14, a0) ^ gf_mul(11, a1) ^ gf_mul(13, a2) ^ gf_mul(9, a3)
state[1][col] = gf_mul(9, a0) ^ gf_mul(14, a1) ^ gf_mul(11, a2) ^ gf_mul(13, a3)
state[2][col] = gf_mul(13, a0) ^ gf_mul(9, a1) ^ gf_mul(14, a2) ^ gf_mul(11, a3)
state[3][col] = gf_mul(11, a0) ^ gf_mul(13, a1) ^ gf_mul(9, a2) ^ gf_mul(14, a3)


def custom_aes_decrypt_ecb(data: bytes, key: bytes) -> bytes:
if len(data) % 16 != 0:
raise ValueError(f"AES input length is not 16-byte aligned: {len(data)}")

inv_sbox = [0] * 256
for idx, value in enumerate(SBOX):
inv_sbox[value] = idx

words, nr = expand_key(key)
out = bytearray()

for off in range(0, len(data), 16):
state = bytes_to_state(data[off : off + 16])
add_round_key(state, words, nr)
inv_shift_rows(state)
inv_sub_bytes(state, inv_sbox)
for round_idx in range(nr - 1, 0, -1):
inv_mix_columns(state)
add_round_key(state, words, round_idx)
inv_shift_rows(state)
inv_sub_bytes(state, inv_sbox)
add_round_key(state, words, 0)
out.extend(state_to_bytes(state))

return bytes(out)


def try_strip_custom_padding(data: bytes) -> bytes | None:
if not data:
return data
last = data[-1]
# This branch matches the actual observed plaintext candidate.
if 1 <= last <= 16 and len(data) >= last and data[-last:] == bytes([last]) * last:
return data[:-last]
if last == 0 or (last & 1):
return None
pad_len = last >> 1
if not (1 <= pad_len <= 16):
return None
if len(data) < pad_len:
return None
if data[-pad_len:] != bytes([last]) * pad_len:
return None
return data[:-pad_len]


def choose_aes_cipher(xxtea_out: bytes) -> tuple[bytes, str]:
stripped = xxtea_out.rstrip(b"\x00")
if stripped and len(stripped) % 2 == 0:
try:
text = stripped.decode("ascii")
if all(ch in "0123456789abcdefABCDEF" for ch in text):
return bytes.fromhex(text), "hex-ascii"
except UnicodeDecodeError:
pass
return stripped, "raw-bytes"


def run_candidate(name: str, xxtea_out: bytes) -> None:
print(f"[xxtea {name}] raw={xxtea_out.hex()}")
print(f"[xxtea {name}] bytes={xxtea_out}")

aes_cipher, mode = choose_aes_cipher(xxtea_out)
print(f"[xxtea {name}] aes input mode={mode}")
print(f"[xxtea {name}] aes input hex ={aes_cipher.hex()}")

for branch_name, suffix in BRANCH_SUFFIXES.items():
full_key = GENKEY + suffix
try:
aes_plain = custom_aes_decrypt_ecb(aes_cipher, full_key)
except Exception as exc:
print(f"[{name}/{branch_name}] aes_error={exc}")
continue
unpadded = try_strip_custom_padding(aes_plain)
print(f"[{name}/{branch_name}] key={full_key.hex()}")
print(f"[{name}/{branch_name}] raw={aes_plain.hex()}")
print(f"[{name}/{branch_name}] bytes={aes_plain}")
if unpadded is None:
print(f"[{name}/{branch_name}] unpadded=<fail>")
else:
print(f"[{name}/{branch_name}] unpadded={unpadded.hex()}")
print(f"[{name}/{branch_name}] unpadded_bytes={unpadded}")


def main() :
decoded = base64.b64decode(CMP_BASE64)
run_candidate("static_xxtea", xxtea_decrypt(decoded, XXTEA_KEY, patched=False))
run_candidate("patched_xxtea", xxtea_decrypt(decoded, XXTEA_KEY, patched=True))


if __name__ == "__main__":
main()

1
WMCTF{I_R4@11y_w@n7_70 _84c0m4_@_m@gic@1_Gir1}

(●´3`●)やれやれだぜ

“原生库”是指用 C 或 C++ 编写并编译为 .so 文件(Shared Object,共享库) 的代码。

它不是 Java 层代码,而是直接运行在 Linux 层 的二进制代码。

在 Android 应用中,如果你用 JNI(Java Native Interface) 调用本地函数,例如:

1
System.loadLibrary("mylib");

系统库是 Android 平台本身提供的 .so 文件,位于系统分区,比如:

1
2
3
4
5
/system/lib/ 或 /system/lib64/
libc.so
libm.so
liblog.so
libandroid_runtime.so

所有的应用库都是原生库,但不是所有的原生库都是应用库。

名称 含义 由谁编写 存放位置 运行层级
原生库 指用 C/C++ 编写、编译成 .so(共享库)的底层二进制库。包括系统的和应用自己的。 Android 系统或应用开发者 /system/lib//vendor/lib//data/app/.../lib/ Linux 层(Native 层)
应用库 应用自己带的原生库(一个子集),随 APK 打包,用于 JNI 调用。 应用开发者 /data/app/包名/lib/ 应用进程的 Native 层

Android 7.0 的变化:引入“命名空间隔离”

在 Android 7.0(Nougat)之前,应用加载原生库时,它能看到系统 /system/lib/ 目录下几乎所有 .so 文件。

这带来了严重问题:

  • 有些应用会“偷偷”调用 Android 内部未公开的系统库函数(非 SDK 接口);
  • 这些接口未被官方支持,系统更新后容易崩溃;
  • 同名库(系统库 vs 应用自带库)容易“串库”或冲突。

Android 7.0 起,每个进程(尤其是 app 进程)都有独立的 linker namespace

这意味着:

  • 系统库和应用库在“命名空间”上是隔离的;
  • 应用默认只能访问经过允许的系统库(例如 libc.so, libm.so, liblog.so 等);
  • 其他系统内部库(如 libandroid_runtime.so, libbinder.so)对应用是不可见的
  • 如果应用想加载这些“私有系统库”,会报错

原生库的命名空间 | Android Open Source Project

Android 7.0 为原生库引入了命名空间,以限制内部 API 的可见性,听起来很高大上,具体解释就是安卓上的动态链接库使用被约束了,普通开发者不管是编译时链接还是使用 dlsym 都不能获取到被限制的 lib 的符号地址, native 开发能调用的 api 被严重限制,同理 Java 也有类似的约束 hiddenapi 用来限制反射的功能。

ssrlive/fake-dlfcn: dlopen/dlclose/dlsym in Android

本题目使用 fake-dlfcn 来查找调用 libart.so 的符号,同时修改了一些代码来适配高版本安卓。这个项目原理比较简单,扫描 proc/self/maps 得到原生库的基址,并通过解析原生库的 elf 符号表得到偏移,再进行计算得到符号地址。

libart.so是什么?

libart.so 的全称就是 Android Runtime Library

它是 Android 系统中实现 ART(Android Runtime)虚拟机 的核心共享库。

通俗地说:libart.so 就是 Android 上运行 Java/Kotlin 应用的底层“虚拟机引擎”。

fake-dlfcn 是一个“自实现的 dlopen/dlsym”小工具/库,它绕过 Android(尤其是 Nougat 以后的)对 dlopen/dlsym 访问的限制,允许在受限环境下加载系统 .so(比如 libart.so)或直接解析 ELF 符号,从而找到/取到那些符号在运行时的地址。

Android 7+ 有命名空间限制,应用不能任意 dlopen("/system/lib/libart.so"),系统会把很多内部库对应用隐藏;因此普通 dlsym/dlopen 不一定生效。

fake-dlfcn 提供了替代实现(或直接解析 ELF)来读取/解析系统库的符号表并返回函数地址,或者通过把库映射到进程地址空间再解析符号,从而找到调用 libart.so 的函数名/地址,便于你在运行时定位与 hook。

解密代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
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
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#define DELTA 1640531527
#define MX (((z >> 5 ^ y >> 2) + (y >> 3 ^ z >> 4)) ^ ((sum ^ y) + (key[(p & 3) ^ e] ^ z)))

void btea(uint32_t *v, int n, uint32_t const key[4])
{
uint32_t y, z, sum;
unsigned p, rounds, e;
if (n > 1) /* Coding Part */
{
rounds = 6 + 52 * n;
sum = 0;
z = v[n - 1];
do
{
sum -= DELTA;
e = (sum >> 2) & 3;
for (p = 0; p < n - 1; p++)
{
y = v[p + 1];
z = v[p] += MX;
}
y = v[0];
z = v[n - 1] += MX;
} while (--rounds);
}
else if (n < -1) /* Decoding Part */
{
n = -n;
rounds = 6 + 52 * n;
sum = rounds * (~DELTA + 1);
y = v[0];
do
{
e = (sum >> 2) & 3;
for (p = n - 1; p > 0; p--)
{
z = v[p - 1];
y = v[p] -= MX;
}
z = v[n - 1];
y = v[0] -= MX;
sum += DELTA;
} while (--rounds);
}
}

typedef struct
{
uint32_t eK[44], dK[44]; // encKey, decKey
int Nr; // 10 rounds
} AesKey;

#define BLOCKSIZE 16 // AES-128分组长度为16字节

// uint8_t y[4] -> uint32_t x
#define LOAD32H(x, y) \
do \
{ \
(x) = ((uint32_t)((y)[0] & 0xff) << 24) | ((uint32_t)((y)[1] & 0xff) << 16) | \
((uint32_t)((y)[2] & 0xff) << 8) | ((uint32_t)((y)[3] & 0xff)); \
} while (0)

// uint32_t x -> uint8_t y[4]
#define STORE32H(x, y) \
do \
{ \
(y)[0] = (uint8_t)(((x) >> 24) & 0xff); \
(y)[1] = (uint8_t)(((x) >> 16) & 0xff); \
(y)[2] = (uint8_t)(((x) >> 8) & 0xff); \
(y)[3] = (uint8_t)((x) & 0xff); \
} while (0)

// 从uint32_t x中提取从低位开始的第n个字节
#define BYTE(x, n) (((x) >> (8 * (n))) & 0xff)

// 密钥扩展中的SubWord(RotWord(temp),字节替换然后循环左移1位
#define MIX(x) (((S[BYTE(x, 2)] << 24) & 0xff000000) ^ ((S[BYTE(x, 1)] << 16) & 0xff0000) ^ \
((S[BYTE(x, 0)] << 8) & 0xff00) ^ (S[BYTE(x, 3)] & 0xff))

// uint32_t x循环左移n位
#define ROF32(x, n) (((x) << (n)) | ((x) >> (32 - (n))))
// uint32_t x循环右移n位
#define ROR32(x, n) (((x) >> (n)) | ((x) << (32 - (n))))

// AES-128轮常量,无符号长整型
static const uint32_t rcon[10] = {
0x01000000UL, 0x02000000UL, 0x04000000UL, 0x08000000UL, 0x10000000UL,
0x20000000UL, 0x40000000UL, 0x80000000UL, 0x1B000000UL, 0x36000000UL};
// S盒
unsigned char S[256] = {32, 123, 24, 167, 66, 68, 215, 74, 205, 50, 209, 236, 243, 129, 165, 137, 14, 145, 75, 240, 233, 93, 141, 245, 70, 252, 49, 54, 182, 172, 155, 185, 38, 9, 230, 64, 212, 176, 81, 79, 156, 62, 231, 121, 48, 136, 177, 60, 122, 92, 211, 20, 90, 171, 86, 192, 4, 41, 208, 59, 31, 249, 163, 87, 0, 138, 132, 22, 244, 26, 234, 100, 166, 214, 46, 190, 47, 23, 196, 224, 30, 2, 58, 34, 143, 159, 203, 168, 44, 103, 52, 37, 213, 255, 239, 246, 226, 170, 217, 114, 254, 206, 161, 120, 133, 150, 42, 119, 202, 193, 55, 116, 162, 94, 108, 253, 184, 77, 125, 112, 179, 221, 207, 113, 115, 97, 248, 25, 72, 227, 99, 51, 61, 21, 174, 152, 229, 128, 189, 188, 130, 198, 148, 1, 228, 222, 6, 80, 149, 223, 71, 247, 144, 139, 69, 154, 110, 7, 173, 28, 53, 131, 104, 3, 111, 91, 183, 251, 29, 197, 16, 124, 216, 106, 204, 105, 142, 36, 76, 57, 180, 160, 11, 82, 232, 169, 178, 140, 10, 191, 40, 134, 109, 175, 218, 65, 250, 117, 181, 67, 195, 96, 98, 43, 85, 242, 158, 45, 18, 35, 13, 219, 107, 199, 56, 127, 95, 151, 8, 237, 225, 187, 238, 157, 210, 146, 73, 63, 220, 88, 135, 194, 186, 153, 201, 78, 241, 33, 235, 19, 101, 89, 118, 12, 200, 5, 164, 84, 147, 27, 102, 17, 39, 83, 126, 15};

// 逆S盒
unsigned char inv_S[256] = {64, 143, 81, 163, 56, 245, 146, 157, 218, 33, 188, 182, 243, 210, 16, 255, 170, 251, 208, 239, 51, 133, 67, 77, 2, 127, 69, 249, 159, 168, 80, 60, 0, 237, 83, 209, 177, 91, 32, 252, 190, 57, 106, 203, 88, 207, 74, 76, 44, 26, 9, 131, 90, 160, 27, 110, 214, 179, 82, 59, 47, 132, 41, 227, 35, 195, 4, 199, 5, 154, 24, 150, 128, 226, 7, 18, 178, 117, 235, 39, 147, 38, 183, 253, 247, 204, 54, 63, 229, 241, 52, 165, 49, 21, 113, 216, 201, 125, 202, 130, 71, 240, 250, 89, 162, 175, 173, 212, 114, 192, 156, 164, 119, 123, 99, 124, 111, 197, 242, 107, 103, 43, 48, 1, 171, 118, 254, 215, 137, 13, 140, 161, 66, 104, 191, 230, 45, 15, 65, 153, 187, 22, 176, 84, 152, 17, 225, 248, 142, 148, 105, 217, 135, 233, 155, 30, 40, 223, 206, 85, 181, 102, 112, 62, 246, 14, 72, 3, 87, 185, 97, 53, 29, 158, 134, 193, 37, 46, 186, 120, 180, 198, 28, 166, 116, 31, 232, 221, 139, 138, 75, 189, 55, 109, 231, 200, 78, 169, 141, 213, 244, 234, 108, 86, 174, 8, 101, 122, 58, 10, 224, 50, 36, 92, 73, 6, 172, 98, 194, 211, 228, 121, 145, 149, 79, 220, 96, 129, 144, 136, 34, 42, 184, 20, 70, 238, 11, 219, 222, 94, 19, 236, 205, 12, 68, 23, 95, 151, 126, 61, 196, 167, 25, 115, 100, 93};

/* copy in[16] to state[4][4] */
int loadStateArray(uint8_t (*state)[4], const uint8_t *in)
{
for (int i = 0; i < 4; ++i)
{
for (int j = 0; j < 4; ++j)
{
state[j][i] = *in++;
}
}
return 0;
}

/* copy state[4][4] to out[16] */
int storeStateArray(uint8_t (*state)[4], uint8_t *out)
{
for (int i = 0; i < 4; ++i)
{
for (int j = 0; j < 4; ++j)
{
*out++ = state[j][i];
}
}
return 0;
}

// 密钥扩展,只接受16字初始密钥
int keyExpansion(const uint8_t *key, uint32_t keyLen, AesKey *aesKey)
{

if (NULL == key || NULL == aesKey)
{
printf("keyExpansion param is NULL\n");
return -1;
}

if (keyLen != 16)
{
printf("keyExpansion keyLen = %d, Not support.\n", keyLen);
return -1;
}

uint32_t *w = aesKey->eK; // 加密密钥
uint32_t *v = aesKey->dK; // 解密密钥

// 扩展密钥长度44=4*(10+1)个字,原始密钥128位,4个32位字,Nb*(Nr+1)

/* W[0-3],前4个字为原始密钥 */
for (int i = 0; i < 4; ++i)
{
LOAD32H(w[i], key + 4 * i);
}

/* W[4-43] */
// temp=w[i-1];tmp=SubWord(RotWord(temp))xor Rcon[i/4] xor w[i-Nk]
for (int i = 0; i < 10; ++i)
{
w[4] = w[0] ^ MIX(w[3]) ^ rcon[i];
w[5] = w[1] ^ w[4];
w[6] = w[2] ^ w[5];
w[7] = w[3] ^ w[6];
w += 4;
}

w = aesKey->eK + 44 - 4;
// 解密密钥矩阵为加密密钥矩阵的倒序,方便使用,把ek的11个矩阵倒序排列分配给dk作为解密密钥
// 即dk[0-3]=ek[41-44], dk[4-7]=ek[37-40]... dk[41-44]=ek[0-3]
for (int j = 0; j < 11; ++j)
{
for (int i = 0; i < 4; ++i)
{
v[i] = w[i];
}
w -= 4;
v += 4;
}

return 0;
}

// 轮密钥加
int addRoundKey(uint8_t (*state)[4], const uint32_t *key)
{
uint8_t k[4][4];

/* i: row, j: col */
for (int i = 0; i < 4; ++i)
{
for (int j = 0; j < 4; ++j)
{
k[i][j] = (uint8_t)BYTE(key[j], 3 - i); /* 把 uint32 key[4] 先转换为矩阵 uint8 k[4][4] */
state[i][j] ^= k[i][j];
}
}

return 0;
}

// 字节替换
int subBytes(uint8_t (*state)[4])
{
/* i: row, j: col */
for (int i = 0; i < 4; ++i)
{
for (int j = 0; j < 4; ++j)
{
state[i][j] = S[state[i][j]]; // 直接使用原始字节作为S盒数据下标
}
}

return 0;
}

// 逆字节替换
int invSubBytes(uint8_t (*state)[4])
{
/* i: row, j: col */
for (int i = 0; i < 4; ++i)
{
for (int j = 0; j < 4; ++j)
{
state[i][j] = inv_S[state[i][j]];
}
}
return 0;
}

// 行移位
int shiftRows(uint8_t (*state)[4])
{
uint32_t block[4] = {0};

/* i: row */
for (int i = 0; i < 4; ++i)
{
// 便于行循环移位,先把一行4字节拼成uint_32结构,移位后再转成独立的4个字节uint8_t
LOAD32H(block[i], state[i]);
block[i] = ROF32(block[i], 8 * i); // block[i]循环左移8*i位,如第0行左移0位
STORE32H(block[i], state[i]);
}
return 0;
}

// 逆行移位
int invShiftRows(uint8_t (*state)[4])
{
uint32_t block[4] = {0};

/* i: row */
for (int i = 0; i < 4; ++i)
{
LOAD32H(block[i], state[i]);
block[i] = ROR32(block[i], 8 * i);
STORE32H(block[i], state[i]);
}

return 0;
}

/* Galois Field (256) Multiplication of two Bytes */
// 两字节的伽罗华域乘法运算
uint8_t GMul(uint8_t u, uint8_t v)
{
uint8_t p = 0;

for (int i = 0; i < 8; ++i)
{
if (u & 0x01)
{
p ^= v;
}

int flag = (v & 0x80);
v <<= 1;
if (flag)
{
v ^= 0x1B;
}

u >>= 1;
}

return p;
}

// 列混合
int mixColumns(uint8_t (*state)[4])
{
uint8_t tmp[4][4];
uint8_t M[4][4] = {{0x02, 0x03, 0x01, 0x01},
{0x01, 0x02, 0x03, 0x01},
{0x01, 0x01, 0x02, 0x03},
{0x03, 0x01, 0x01, 0x02}};

/* copy state[4][4] to tmp[4][4] */
for (int i = 0; i < 4; ++i)
{
for (int j = 0; j < 4; ++j)
{
tmp[i][j] = state[i][j];
}
}

for (int i = 0; i < 4; ++i)
{
for (int j = 0; j < 4; ++j)
{ // 伽罗华域加法和乘法
state[i][j] = GMul(M[i][0], tmp[0][j]) ^ GMul(M[i][1], tmp[1][j]) ^ GMul(M[i][2], tmp[2][j]) ^ GMul(M[i][3], tmp[3][j]);
}
}

return 0;
}

// 逆列混合
int invMixColumns(uint8_t (*state)[4])
{
uint8_t tmp[4][4];
uint8_t M[4][4] = {{0x0E, 0x0B, 0x0D, 0x09},
{0x09, 0x0E, 0x0B, 0x0D},
{0x0D, 0x09, 0x0E, 0x0B},
{0x0B, 0x0D, 0x09, 0x0E}}; // 使用列混合矩阵的逆矩阵

/* copy state[4][4] to tmp[4][4] */
for (int i = 0; i < 4; ++i)
{
for (int j = 0; j < 4; ++j)
{
tmp[i][j] = state[i][j];
}
}

for (int i = 0; i < 4; ++i)
{
for (int j = 0; j < 4; ++j)
{
state[i][j] = GMul(M[i][0], tmp[0][j]) ^ GMul(M[i][1], tmp[1][j]) ^ GMul(M[i][2], tmp[2][j]) ^ GMul(M[i][3], tmp[3][j]);
}
}

return 0;
}

// AES-128加密接口,输入key应为16字节长度,输入长度应该是16字节整倍数,
// 这样输出长度与输入长度相同,函数调用外部为输出数据分配内存
int aesEncrypt(const uint8_t *key, uint32_t keyLen, const uint8_t *pt, uint8_t *ct, uint32_t len)
{
AesKey aesKey;
uint8_t *pos = ct;
const uint32_t *rk = aesKey.eK; // 加密密钥指针
uint8_t out[BLOCKSIZE] = {0};
uint8_t actualKey[16] = {0};
uint8_t state[4][4] = {0};

if (NULL == key || NULL == pt || NULL == ct)
{
printf("param err.\n");
return -1;
}

if (keyLen > 16)
{
printf("keyLen must be 16.\n");
return -1;
}

if (len % BLOCKSIZE)
{
printf("inLen is invalid.\n");
return -1;
}

memcpy(actualKey, key, keyLen);
keyExpansion(actualKey, 16, &aesKey); // 密钥扩展

// 使用ECB模式循环加密多个分组长度的数据
for (int i = 0; i < len; i += BLOCKSIZE)
{
// 把16字节的明文转换为4x4状态矩阵来进行处理
loadStateArray(state, pt);
// 轮密钥加
addRoundKey(state, rk);

for (int j = 1; j < 10; ++j)
{
rk += 4;
subBytes(state); // 字节替换
shiftRows(state); // 行移位
addRoundKey(state, rk); // 轮密钥加
mixColumns(state); // 列混合
}

subBytes(state); // 字节替换
shiftRows(state); // 行移位
// 此处不进行列混合
addRoundKey(state, rk + 4); // 轮密钥加

// 把4x4状态矩阵转换为uint8_t一维数组输出保存
storeStateArray(state, pos);

pos += BLOCKSIZE; // 加密数据内存指针移动到下一个分组
pt += BLOCKSIZE; // 明文数据指针移动到下一个分组
rk = aesKey.eK; // 恢复rk指针到秘钥初始位置
}
return 0;
}

// AES128解密, 参数要求同加密
int aesDecrypt(const uint8_t *key, uint32_t keyLen, const uint8_t *ct, uint8_t *pt, uint32_t len)
{
AesKey aesKey;
uint8_t *pos = pt;
const uint32_t *rk = aesKey.dK; // 解密密钥指针
uint8_t out[BLOCKSIZE] = {0};
uint8_t actualKey[16] = {0};
uint8_t state[4][4] = {0};

if (NULL == key || NULL == ct || NULL == pt)
{
printf("param err.\n");
return -1;
}

if (keyLen > 16)
{
printf("keyLen must be 16.\n");
return -1;
}

if (len % BLOCKSIZE)
{
printf("inLen is invalid.\n");
return -1;
}

memcpy(actualKey, key, keyLen);
keyExpansion(actualKey, 16, &aesKey); // 密钥扩展,同加密

for (int i = 0; i < len; i += BLOCKSIZE)
{
// 把16字节的密文转换为4x4状态矩阵来进行处理
loadStateArray(state, ct);
// 轮密钥加,同加密
addRoundKey(state, rk);

for (int j = 1; j < 10; ++j)
{
rk += 4;
invShiftRows(state); // 逆行移位
invSubBytes(state); // 逆字节替换,这两步顺序可以颠倒
invMixColumns(state); // 逆列混合
addRoundKey(state, rk); // 轮密钥加,同加密
}

invShiftRows(state); // 逆行移位
invSubBytes(state); // 逆字节替换
// 此处没有逆列混合
addRoundKey(state, rk + 4); // 轮密钥加,同加密

storeStateArray(state, pos); // 保存明文数据
pos += BLOCKSIZE; // 输出数据内存指针移位分组长度
ct += BLOCKSIZE; // 输入数据内存指针移位分组长度
rk = aesKey.dK; // 恢复rk指针到秘钥初始位置
}
return 0;
}

int main()
{

unsigned char enc[] = {0xf2, 0xc0, 0x05, 0x5f, 0x8e, 0x73, 0x4f, 0xbb, 0x9c, 0xd2, 0xf4, 0x94, 0xc8, 0x53, 0x4d, 0x97, 0x2d, 0x61, 0xfd, 0xde, 0x73, 0x4e, 0xdf, 0x3d, 0xb5, 0x5d, 0xe4, 0x71, 0x5a, 0xf9, 0x3f, 0x99, 0xfb, 0x95, 0x12, 0x8f, 0xc9, 0x8b, 0x71, 0x83, 0x7d, 0x73, 0x60, 0xd0, 0x76, 0x94, 0xf5, 0x74, 0x73, 0x99, 0xdf, 0xff, 0x1d, 0xbf, 0x20, 0xaf, 0x78, 0x5d, 0x5f, 0x54, 0x7e, 0xcf, 0x57, 0x7d, 0xa0, 0xc6, 0xe4, 0x85, 0xf1, 0x32, 0x56, 0x72, 0xf9, 0x9d, 0xa7, 0x13, 0xe6, 0x59, 0x44, 0x50, 0x52, 0xa4, 0xca, 0x0a, 0xcd, 0x05, 0xdf, 0x52, 0xad, 0x93, 0x4a, 0x32, 0x49, 0x7d, 0x0c, 0x55, 0x0};
uint32_t v[] = {1650877538, 875968049, 842479969, 1714696806, 862139446, 1667380838, 912679474, 1631019828};
uint32_t k[4] = {842610225, 57, 0, 0};
int n = 24;
n = 24;
btea((uint32_t *)enc, -n, k);
printf("xxtea -> %s\n", enc);
unsigned char encrypted[] = {0xb0, 0xca, 0x9f, 0xa0, 0xa3, 0xee, 0x7e, 0x58, 0x1c, 0x46, 0xc8, 0xca, 0x69, 0x2f, 0xc9, 0xb1, 0x2a, 0xd4, 0x33, 0xf8, 0xa3, 0x23, 0x2c, 0xb6, 0xd7, 0x3b, 0x54, 0xcf, 0x82, 0xe8, 0x0c, 0x71, 0x53, 0xba, 0x9b, 0xb1, 0xb2, 0xda, 0x3e, 0x43, 0xbe, 0x66, 0x7d, 0x65, 0xdc, 0x10, 0xe5, 0xd5, 0x0};
unsigned char key[] = {0x7a, 0x25, 0xc5, 0x24, 0xc6, 0x33, 0x4c, 0x30, 0xf3, 0x62, 0xaf, 0xac, 0x3f, 0x23, 0x03, 0xd5};
unsigned char decrypted[16] = {0};
aesDecrypt(key, 16, encrypted, decrypted, 48);
printf("aes ecb -> %s", decrypted);
return 0;
}
1
WMCTF{I_R4@11y_w@n7_70 _84c0m4_@_m@gic@1_Gir1}

Frida-dexdump

现在AI太猛了,告诉他哪里有问题直接能分析出来

预期正常解是用frida-dexdump或者其他hook或调试方法去做

在 Android 的解释执行模式(Interpreter Mode)下,Java 字节码中的 invoke-*指令最终就是通过 DoCall函数(及其相关函数)来完成方法调用的执行的。

DoCall 可以理解成 ART 解释器里处理方法调用的核心 helper。根据 AOSP

1
2
3
4
5
6
7
8
9
10
// Docall函数定义
template<bool is_range>
NO_STACK_PROTECTOR
bool DoCall(ArtMethod* called_method, //要被调用的方法(目标方法)
Thread* self, // 当前线程对象
ShadowFrame& shadow_frame, //当前解释器的 shadow frame(栈帧)
const Instruction* inst, //当前字节码指令
uint16_t inst_data, //解析指令所需的数据
bool is_string_init, //是否是特殊处理的 String.<init> 构造函数
JValue* result)// 调用结果

它大致做这几件事:

1. 从当前 invoke-* 指令里把参数信息拆出来。
2. 确认被调方法 called_method。
3. 根据被调方法创建新的 ShadowFrame。
4. 把调用者寄存器里的参数拷到被调用者的参数寄存器。
5. 必要时做引用类型可赋值检查。
6. 决定继续走解释器,还是桥接到已编译代码。
7. 执行调用,把返回值写进 result,异常则返回 false。

几个参数的意义:

  • called_method: 已解析出的目标方法
  • self: 当前线程
  • shadow_frame: 当前解释器栈帧
  • inst: 当前这条 DEX invoke-* 指令
  • inst_data: 指令的附带解码数据
  • result: 返回值槽

DoInvoke 最后会调到 DoCall,所以它是每次解释执行方法调用的好观察点。

它同时拿得到:

  • called_method
  • 当前 inst

hook 它有两个好处:

  • 能拿到当前被调用的方法 ArtMethod*
  • 能拿到当前调用点对应的 Instruction*,也就是当前 DEX 指令位置

所以出题人的 so 才会 hook DoCall,再配合 PrettyMethod 判断当前是不是 Magic.fixKey / Magic.toByteArray 附近,然后去改当前解释路径对应的 DEX code window。

也就是说,在这题里 DoCall 的作用不是藏加密算法,而是提供一个稳定的 runtime 截获点。

libnative_add.so 的 init_proc() 在 so 加载时自动执行。它做的是:

1. 解密出 libart.so 和几个 ART 符号名
2. 在 libart.so 里解析到目标函数地址
3. mmap 一块可执行内存,拷自己的 trampoline/shellcode
4. 保存目标函数前 16 字节原始指令
5. mprotect 把目标页改成可写
6. 把目标函数入口改成跳到我的 shellcode

思路有进入 gic.toB 分支前将 dex dump下来,要么去 trace Smali,对比看看他改了什么,当然最简单的思路是作者给出的。

第二次 strstr 后的还原逻辑 NOP

这样,dex 文件就不会被改回去了

image-20260310205331406

windows上操作不区分大小写解包时候会有资源文件发生同名替换的问题

1
2
3
4
5
6
java -jar apktool_3.0.0.jar d Want2BecomeMagicalGirl.apk
D:\AndroidSdk\build-tools\35.0.0\zipalign.exe -p -f 4 Want2BecomeMagicalGirl.apk patched-aligned.apk
rm -f debug.keystore && keytool -genkeypair -v -storetype JKS -keystore debug.keystore -storepass android -alias androiddebugkey -keypass android -keyalg RSA -keysize 2048 -validity 10000 -dname 'CN=Android Debug,O=Android,C=US'
"D:\AndroidSdk\build-tools\35.0.0\apksigner.bat" sign --ks debug.keystore --ks-pass pass:android --key-pass pass:android --ks-key-alias androiddebugkey --out patched-signed.apk patched-aligned.apk
然后验签:
"D:\AndroidSdk\build-tools\35.0.0\apksigner.bat" verify -v patched-signed.apk
1
2
3
./hs -l 0.0.0.0:9999
adb forward tcp:9999 tcp:9999(需要另外开一个终端),前面的是电脑端口
frida-dexdump -H 127.0.0.1:9999 -n magical_girl -o D:\Matriy\Desktop\WMCTF2023\dump

image-20260310214213317

dump前

image-20260310214322320

dump后

image-20260310215628387

其他小知识

这道题不得不说出的非常好

from Want2BecomeMagicalGirl出题笔记-WMCTF2025 - 逆向工程 - CTF | PangBai’s Blog = 𝕺𝖚𝖗 𝕷𝖎𝖋𝖊 = 旁白博客

hook OatHeader::IsDebuggable(),不是为了直接改加密逻辑,而是为了把 Java 执行路径尽量拽回解释器,这样前面 hook 的 DoCall 才有机会触发。

可以分成 3 层理解。

  1. 为什么单 hook DoCall 不够,DoCall 是 art::interpreter::DoCall,名字就说明了它是解释器路径里的函数。但 Release 包正常运行时,很多 Java 代码不会走解释器,而是走AOT或 JIT 后的编译代码

    一旦直接跑编译后的 native 代码,就不会经过解释器里的 DoCall。所以只 hook DoCall,程序很可能根本踩不到

    解释执行就是:ART 不把字节码提前翻译成机器指令,而是一条一条读取 dex bytecode,再逐条解释运行

    比如 Java 里的一个方法调用,解释器执行到 invoke-* 这类字节码时,可能就会进入解释器内部的调用逻辑,例如art::interpreter::DoCall。所以

    • 走解释器 → 有机会经过 DoCall
    • 不走解释器 → 就根本不会到 DoCall

    AOT 是 Ahead-Of-Time compilation,即提前编译

    意思是:在应用安装时、系统启动阶段,或者某些预编译流程里,ART 会把 dex/字节码直接编译成目标平台的机器码。这样程序运行时就可以直接执行机器指令,而不是再逐条解释字节码。

    你可以把它理解成:

    • 原本:运行时看到一条 dex 指令,就解释一条
    • AOT:运行前就把整段方法翻译成 ARM/x86 机器码了,运行时直接跳进去执行

    所以一旦某个 Java 方法已经被 AOT 编译:

    • CPU 直接执行那段机器码
    • 不再由解释器逐条处理 invoke-*
    • 自然也不会经过解释器里的 DoCall

    JIT 是 Just-In-Time compilation,即即时编译

    它和 AOT 的区别主要在“编译发生的时间”:

    • AOT:运行前就编译好
    • JIT:程序运行过程中,ART 发现某些方法很热点、调用很多次,就把它们动态编译成机器码

    也就是说,某个方法一开始可能是解释执行,但跑着跑着,ART 觉得它太常用了,就把它编译掉。之后再调用这个方法时,就直接跑编译后的机器码了。

    于是就出现这种情况:

    • 前几次调用可能经过解释器,能踩到 DoCall
    • 后面变成 JIT 代码后,就不再经过 DoCall

    这也是为什么只 hook DoCall,实际命中率可能很低,甚至完全打不到。

    DoCall 属于解释器处理 method invoke 字节码时的辅助逻辑,而 AOT/JIT 编译后的代码已经不是“解释字节码”了,而是“直接执行机器码”。

    解释器路径大概像这样:

    1
    dex bytecode -> interpreter switch/dispatch -> 遇到 invoke -> DoCall -> 目标方法

    而编译执行路径更像:

    1
    dex bytecode -> 编译成 native code -> 运行时直接跳到机器码入口 -> 执行

    Release 包通常会更偏向性能优化,因此更容易出现:

    • 方法已被 AOT 编译
    • 热点方法被 JIT 编译
    • 内联优化,把方法调用直接展开
    • 一些桥接/调用路径被优化掉
    执行方式 来源 特点
    AOT .oat 安装时编译,直接执行
    Interpreter .dex 逐条解释执行
    JIT .dex 运行时编译热点代码
  2. IsDebuggable() 在这里是干什么的,OatHeader::IsDebuggable() 是 ART 在处理 oat/odex 时会参考的一个标志函数。直观理解就是:这个 oat 是不是按可调试/可插桩/不那么激进优化的方式对待?

    作者的做法是 hook 它,让 ART 在运行时误以为当前这套 oat/执行环境更偏 debuggable。这样带来的实际效果,作者自己也说了,是实验验证出来的:

    所以他的真实意思不是我已经完全证明它精确关闭了哪条 JIT/AOT 分支,而是通过 hook IsDebuggable(),成功把运行时执行策略改到了一个更容易进入解释器/可观测路径的状态。

值得注意的是如果你将 Apk 编译为 Debug 将不会触发 AOT ,但或许会触发 JIT

OatHeader::IsDebuggable() 本质上不是一个直接关掉 JIT/AOT 的总开关,而是 ART 读取 oat/odex 头部元数据时,用来判断这份 oat 是否按 debuggable 语义生成/使用 的一个标志位读取函数。AOSP 里它本身非常简单,就是从 oat header 的 key-value store 里检查 kDebuggableKey 是否启用。

更关键的是,这个标志会影响 ART 是否愿意使用某些优化后的代码路径。例如在 ART 的 instrumentation 代码里,AOSP 明确写着:在 debuggable 模式下,只能对 native 方法使用 AOT code,也就是说对普通 Java 方法,会更倾向于不用那些高度优化的 AOT 执行路径

hook IsDebuggable() 之后,运行时对这份 oat 的判断发生了偏转

这个偏转 改变了 ART 的执行策略,使其更接近 debuggable 场景;

结果上表现为:更容易进入解释执行/可观测路径,或者减少某些优化代码路径的使用

其实并不是不能让 APP 走 Interpreter,而是 Android 设计上不会长期用 Interpreter 运行应用代码

Java 代码:

1
a = b + c

如果是 AOT / JIT

CPU直接执行机器码:

1
2
mov r0, r1
add r0, r2

只需要 几条指令

但如果是 Interpreter

ART需要:

  1. 读取 dex bytecode
  2. decode opcode
  3. 查找 handler
  4. 执行 handler
  5. 更新寄存器状态
  6. 再读取下一条

流程类似:

1
2
3
4
fetch opcode
switch(opcode)
execute handler
update state

一条 dex 指令可能需要 几十条甚至上百条机器指令

其他解法

Smali trace,看到同一个 method 同一个 code offset 实际执行的 opcode,就能直接对比出哪里变了。

记录一些项目以后学习

SeeFlowerX/frida-smali-trace: smali trace[未复现成功]

CE 断点 + Dump 内存。只要恢复之前断下来,内存里就存在改过的 dex。

大致流程是:

  1. 附加到进程。
  2. 在 patch 点下断点。常见下法:
    • 下在 sub_1A51C 里 patch 循环开始/结束处
    • 或下在 restore 分支前
    • 或直接监控那片 DEX 内存的写入
  3. 触发一次输入,让 patch 生效。
  4. 在 restore 前暂停。
  5. 把对应内存区域 dump 出来。
  6. 再按 dex header 修复/切块,得到修改后的 dex。