抱歉,您的浏览器无法访问本站
本页面需要浏览器支持(启用)JavaScript
了解详情 >

xdj 之后 QC 是当之无愧的行业领头羊,20 年仅有几十个套壳 App,21 年底就有 150+ 的套壳 app,到目前已经有 200+ 套壳,这些套壳都是使用 QC 的主播资源,然后上自家游戏和 df。

误入歧途

最开始看到 libapp.so 和 libflutter.so 决定 hook BoringSSL 中的 handshake.cc 内的 ssl_verify_peer_cert 函数。但 hook 后并没有发现有效的数据包。

easy 壳

既然 flutter 内部没有数据包,我找到群主交流了一下,一致推测很可能在 Java 层。另外注意到 libkiwi.so 这是阿里的游戏盾。

这里的 Java 层有一个很简单的壳,是使用三段式存储两个 dex 到一个文件中,结构如下:

  1. shell.dex: 壳本体,负责解密,得到两个 dex
  2. 两个 dex 的加密数据: 这一段直接拼接到了 shell.dex 的文件末尾,占的空间最大
  3. index: 这里索引两个 dex 的文件名和尺寸

虽然 shell.dex 经过了混淆,但很轻松就能够获取其中的解密逻辑。

ByteString

这里拿到两个 dex 之后像静态分析了一下,使用到了很多 flutter plugin,这可能就是请求逻辑在 Java 层的原因,通过 frida hook okio 成功获取到了数据。

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
function hook(){
Java.perform(function(){
var ByteString = Java.use('okio.ByteString')
var BSString = ByteString.string.overload('java.nio.charset.Charset')

BSString.implementation = function(arg1){
var ret_value = this.string(arg1)
var ret = JSON.parse(ret_value)
console.log(ret)
return(ret_value)
}

let FlutterYunCengKiwiPlugin = Java.use("com.example.flutter_yun_ceng_kiwi.FlutterYunCengKiwiPlugin");
let MethodCall = Java.use("io.flutter.plugin.common.MethodCall");
FlutterYunCengKiwiPlugin["onMethodCall"].implementation = function(methodCall, result){
let ret = this.onMethodCall(methodCall, result);
let mc = Java.cast(methodCall, MethodCall);
if (mc.method.value == 'initEx') {
var key = 'cd/jsWS6Noql+UlpgSadL1wMHQ/gO6lZqZ+slgt3MtAJS3WoVON5c9v1zWteoqImeZeuh377bVOcowhCUD5TNN0wA5uT0CxgrURzMPv7O2EMXMXEG9EK8I6rvRjJ08TgnA3tnKmzbCuXyJyHknNVMkWBoVxDnaLmGIu3Zkw0M1LMn3wm+e7OceZd0RbXJGenSxDcyIMaRkDQVy6J2NeVI26MRF+uIaeT0UuAaHJ3bVmNEJr/6DxiQhMyeEIFFczE4dAZVbUHjv5I/T4nvyeY+ibeAPEdaAdUu2yVbk/x9NOBfaqi/iVyyEPf2bXtR3qk0udk6kSRBp4K4vTDppcGMzQtOpYUqPZ/hI6/1sIuJGUZ84Vc3IBLtOqtDBlO0hyYb16VqWf51u2VnjwYj0pexoBjFKTypT3fhbvS4OS/rmNCaO1WMKkkEDNXlWhnfa+2DXk/A1vkE3zl1Ev+II1O/Askt9p566LyHFyGsfJzKoO5f8ZTzLOdAH8khSxLZSRMR+KBPejdFqzYBxQEOM1nXId+fW6jzEjBhqlce+mJga8Iu502YtAvDK/DsGgCQ1MSQ08N/hu/ONiw4JZ0CU2z5P0ByEJRCzVOOPTAcyoVHKXAPr7a4b6aAb8Xc3CUxNOt1LBHY88UuaqYISFgPfP2xBZNlOMErdS/kjMvbnk6IYIJ2C9gZFFvefroqvpcPeifTvyaUo0jgH+ZQJ4ALb8Cpku8i2V37LTGtpoa7/rkBGXUr5AFoXt7bG7Lkqg2+lcLEjdF2L99lS73I8KKI2nRCgwbaVHT//KzDIp2bhObyNdVf6wXDzu/1ofuvsRXXD/QFXt4HGg9TTpfJd7UIfIE47EMY+q7WVbxRTSYsY5cLWR3BNf1EBecn4YSmmJ1TsHGo9WmS5vWoQGJMCbDwHBecmkZosxTs5KqXo6eaxH5ObeqL9fHhyKGGbAWPFPmprU6hKIv9NH3JXIKs/vxRIIbuyZOUUBndIaEZTncTtidlkxJgxjkmiGId1euCJsw79RCTkStnIU/xNi1jI6SlPKBylVY6M6uimw4ahUb74pziKFjJEYFOSC3m74AOFpTTHn3qsv1frFNfONHiMbG+X5f8BWYvhG++HJkkCvYcK2qI1vzG5WllAvx0BvVuoUAhxK/7UI+8BqvLg6m5u+OdFa91V2lOhAcErbwRGVWWbqmrRfh1oOs4jyy1a6go5UyXMtMUKYN1tfXkf06ZcP0Q5lqVH71aJc8VTdms0Yufv+m4pPbREtB3PJ3zUQZDRXaifEQOtWC7Eqj3XFe8LU9ahClmBhHO6+831AhNNLqTBgWtSNODGm3601QCNppDCHhXBcvsx7gE1mhS1oXCTtaX91/EHwmSMruPPlEeRkspJcOtwLBZGcQ4KVQazbHRLBI+rWdiITHBmLbU+XMCMKN3Y0teeoUHKy+jR7lfcQNc1kDL4VwHwr8X7B8GHhvOt6SKizCind2a2NfSg2tzYqJq99Kem4ay+eGVv5/reB+iIOc+TpvjxQrP64ZF4qNl5/mICyP/F4HdgRCEdldziv9SOfryDGj4noulTAP2KIS5hUYut5jmTa8aMtEhJW4xuflMq+obQ8JmidsjpIqaTwiFIriDJmVJI0SdxKWwQbDWSqN8Lnn6wfr9b3oq7uG75Iha1CMGyIgJJVahhlaCDm0AKKCfN31iMJM8GC5IE1e+24NZfsHPyDNfIluUaNueW5ygQoHzzvq9WNWviW9eBRRQlx3uaq86VIfuPWp0Qc6N8BP7muzCbWiC4sECcx6t7yHQEERgDfR/ZoaAfT2cCFJHIIFDp27pqA2VBslRrpprrwswmY='
if(mc.argument("appKey") != key){
console.log('appKey: ' + mc.argument("appKey"))
console.log('token : ' + mc.argument("token"))
}else{
console.log('appKey: Same' + '; token : ' + mc.argument("token"));
}
}
else{
console.log('token: ' + mc.argument("token") +
'; ddomain: ' + mc.argument('ddomain') +
'; dport: ' + mc.argument('dport') +
'; group_name: ' + mc.argument('group_name'))
}
return ret;
};

let Kiwi = Java.use("com.kiwi.sdk.Kiwi");
Kiwi["init_appname"].implementation = function(str){
let ret = this.init_appname(str);
console.log('init_appname param value is ' + str);
console.log('init_appname ret value is ' + ret);
return ret;
};

Kiwi["server_to_local"].implementation = function(str){
let ret = this.server_to_local(str);
console.log('server_to_local ret value is ' + ret);
return ret;
};


let SuperNetworkKitPlugin = Java.use("com.example.super_network_kit.SuperNetworkKitPlugin");
SuperNetworkKitPlugin["onMethodCall"].implementation = function(methodCall, result){
let ret = this.onMethodCall(methodCall, result);
let mc = Java.cast(methodCall, MethodCall);
let mn = mc.method.value;
if (mn == 'sendString') {
let connID = mc.argument("connID");
let data = mc.argument("data").toString();
let dj = JSON.parse(data);
console.log('send -> random: ' + dj['random'] + '; connID: ' + connID + '; data: ' + JSON.stringify(dj));
}else if (mn == 'initConnection'){
let url = mc.argument("url");
let salt = mc.argument("salt");
let enableZip = mc.argument("enableZip");
console.log('initConnection: ' + `${url}, ${salt}, ${enableZip}`);
}else{
let connID = mc.argument("connID");
console.log('connID: ' + connID + '; method: ' + mn + '; Now: ' + new Date().getTime());
}
return ret;
};
});
}

setImmediate(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
import org.json.JSONException;
import org.json.JSONObject;
import org.json.JSONArray;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;


public class Main {
public static String main(String[] args) {
String ret_value = "";

JSONObject ret_json;
JSONObject data;
try {
ret_json = new JSONObject(ret_value);
} catch (JSONException ignored) {
return ret_value;
}

// ctl
try {
if (ret_json.has("ctl")) {
String ctl = ret_json.getString("ctl");
if (ctl.contains("notice")) {
ret_json.put("data", new JSONArray());
} else if (ctl.contains("room_msg")) {
JSONArray new_msgs = new JSONArray();
JSONArray room_msg = new JSONArray(ret_json.getString("data"));
for (int i=0; i < room_msg.length(); i++) {
JSONObject msg_i = room_msg.getJSONObject(i);
Integer content_type = msg_i.getInt("content_type");
if (content_type.equals(1)) {
new_msgs.put(msg_i);
}
}
ret_json.put("data", new_msgs.toString());
} else if (ctl.contains("room")) {
data = ret_json.getJSONObject("data");
ret_json.put("data", data.put("live_fee", 0));
}
ret_value = ret_json.toString();
return ret_value;
}
} catch (JSONException ignoed) {
return ret_value;
}

// response
try {
if (ret_json.has("data")) {
data = ret_json.getJSONObject("data");
if (data.has("body")) {
// response of /room_push/join
JSONObject body = new JSONObject((String)data.get("body"));
if(body.has("room")){
JSONObject room = (JSONObject)(body.get("room"));
room.put("live_fee", 0).put("chess_id", -1);
body.put("room", room);
}
if(body.has("podcast")){
JSONObject podcast = (JSONObject)(body.get("podcast"));
// podcast.put("card_price", 0).put("is_pay_podcast", 0);
body.put("podcast", podcast);
}
// response of /room/get_list_by_ids
if(body.has("rooms")){
JSONArray array_room = body.getJSONArray("rooms");
JSONArray new_rooms = new JSONArray();
// game signature
String[] words = new String[]
{"快三", "六合", "带飞", "带你飞", "燊", "大佛", "回血", "赌王",
"赌神", "赌圣", "财神", "暴富", "致富", "公式", "计划", "澳门",
"赛车", "快车", "牛牛", "回归", "实力", "车友", "派彩", "上车",
"大注", "头文字D", "兔鼻", "牛逼哄哄", "单带"};
Integer[] _ids = new Integer[]
{1965425522, 12273040, 1559390190, 1092923344, 1475912101, 904403059};
List<Integer> ids = new ArrayList<>(Arrays.asList(_ids));
// remove signature
for (int i=0; i < array_room.length(); i++){
JSONObject room_i = array_room.getJSONObject(i);
boolean flag = false;
String name = (String)room_i.get("name");
for (String word : words) {
flag = flag || name.contains(word);
}
if ( !(flag || ids.contains(room_i.getInt("id"))) ){
room_i.put("chess_id", -1);
new_rooms.put(room_i);
}
}
body.put("rooms", new_rooms);
}
// response of ad and banner
if (body.has("result")) {
JSONArray result = body.getJSONArray("result");
JSONArray new_result = new JSONArray();
for (int i=0; i < result.length(); i++) {
JSONObject res = result.getJSONObject(i);
if (res.has("ad_pos_name")) {
res.put("url", "");
if (res.getInt("ad_type_id") == 2)
new_result.put(res);
} else if (res.has("notice_type")) {
res.put("url", "");
} else if (res.has("history")) {
Integer content_type = res.getInt("content_type");
if (content_type.equals(1))
new_result.put(res);
} else if (res.has("title")) {
if (!res.getString("title").contains("公告")) {
new_result.put(res);
}
} else {
new_result = result;
}
}
body.put("result", new_result);
}
data.put("body", body);
}
ret_json.put("data", data);
}
// ret
ret_value = ret_json.toString();
} catch (NoSuchMethodError | Exception ignored) {
return ret_value;
}

return ret_value;
}
}

编译之后,替换 okio.ByteString 的 smali 中。

AIO 脚本

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
import os
import zlib
import shutil
import hashlib
import logging

from io import BufferedReader, BufferedRandom
from zipfile import ZipFile

import click
from cryptography.hazmat.primitives import padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes


class QC:
def __init__(self, apk, work_dir, key, out=None) -> None:
"""
param:
apk: apk path to modify
dir: work dir to save files in the process
key: the aes ecb key to de/en-crypt dex
"""
# files paths
self.work_path = self.clean_path(work_dir + '/')
self.unzip_apk_path = self.clean_path(os.path.join(work_dir, os.path.basename(apk)[:-4]))
# The encrypted dex splited from apk
self.original_path = self.clean_path(os.path.join(work_dir, 'original/'))
# The decrypted dex from original encrypted dex
self.decrypted_path = self.clean_path(os.path.join(work_dir, 'decrypted/'))
# The smali files baksmali from decrypted dex
self.baksmali_path = os.path.join(work_dir, 'baksmali/')
# The dex from modified smali files
self.updated_path = os.path.join(work_dir, 'updated/')
# The encrypted dex from updated dex
self.encrypted_path = self.clean_path(os.path.join(work_dir, 'encrypted/'))

# unzip apk
logging.info('Start Unpack apk')
# shutil.unpack_archive(apk, self.unzip_apk_path, 'zip')
self.qcapk = ZipFile(apk, mode='r')
self.qcapk.extract("classes.dex", path=self.unzip_apk_path)
self.classes_path = os.path.join(self.unzip_apk_path, 'classes.dex')
self.dex_dict = {}

# en/de-crypt
self.key = key.encode('utf8')

# output
self.outapk = self.work_path + out + '.apk'
self.outzip = self.work_path + out + '.zip'
shutil.copyfile(apk, self.outzip)

@staticmethod
def clean_path(p):
if os.path.exists(p):
shutil.rmtree(p)
os.mkdir(p)
return p

@staticmethod
def checksum(dexf: BufferedRandom):
"""
Update checksum of dex file

param:
dexf: the file-object of dex file
"""
dexf.seek(8)
sourceData = dexf.read(4)
dexf.seek(12)
checkdata = dexf.read()
checksum = zlib.adler32(checkdata)
checkBytes = (checksum & 0xffffffff).to_bytes(4, byteorder='little')
logging.info("checksum: " + sourceData.hex() + " -> " + checkBytes.hex())
if dexf.writable and sourceData != checkBytes:
dexf.seek(8)
dexf.write(checkBytes)

@staticmethod
def signature(dexf: BufferedRandom):
"""
Update signature of dex file

param:
dexf: the file-object of dex file
"""
dexf.seek(12)
sourceData = dexf.read(20)
dexf.seek(32)
sigdata = dexf.read()
sha1 = hashlib.sha1()
sha1.update(sigdata)
sha2 = sha1.digest()
logging.info("signature: " + sourceData.hex() + " -> " + sha2.hex())
if dexf.writable and sourceData != sha2:
dexf.seek(12)
dexf.write(sha2)

def splitdex(self, dexf: BufferedReader):
"""
Split original classes.dex file

param:
dexf: the file-object of dex file
"""
dexf.seek(0x20)
length = int.from_bytes(dexf.read(4), byteorder='little')
dexf.seek(length - 4)
index_length = int.from_bytes(dexf.read(4), byteorder='big')
dexf.seek(length - 4 - index_length)
_tmp = dexf.read(index_length).decode('utf8')
info = "".join(_tmp[i] for i in range(1, len(_tmp), 2))
for di in info.split('-'):
self.dex_dict[di.split('=')[0]] = int(di.split('=')[1])
# dex_start is the start of inject data
dex_start = length - 4 - index_length
for dex_name in self.dex_dict:
dex_length = self.dex_dict[dex_name]
dex_start = dex_start - dex_length
# get the shell dex
with open(self.original_path + 'shell.dex', 'wb') as sf:
dexf.seek(0)
sf.write(dexf.read(dex_start))
# get resume dex
for dex_name in self.dex_dict:
dex_length = self.dex_dict[dex_name]
with open(self.original_path + dex_name, 'wb') as cf:
dexf.seek(dex_start)
cf.write(dexf.read(dex_length))
dex_start = dex_start + dex_length

def decdex(self, dex_name: str):
"""
Decrypt dex

param:
the dex name to be dec
"""
with open(self.original_path + dex_name, 'rb') as e:
bencdata = e.read()
decryptor = Cipher(algorithms.AES(self.key), modes.ECB()).decryptor()
dexraw = decryptor.update(bencdata) + decryptor.finalize()
unpadder = padding.PKCS7(128).unpadder()
bdecdex = unpadder.update(dexraw) + unpadder.finalize()
with open(self.decrypted_path + dex_name, 'wb') as d:
d.write(bdecdex)

def encdex(self, dex_name: str):
"""
Encrypt dex

param:
the dex name to be enc
"""
with open(self.updated_path + dex_name, 'rb') as e:
bdecdata = e.read()
padder = padding.PKCS7(128).padder()
bpaddata = padder.update(bdecdata) + padder.finalize()
encryptor = Cipher(algorithms.AES(self.key), modes.ECB()).encryptor()
bencdex = encryptor.update(bpaddata) + encryptor.finalize()
with open(self.encrypted_path + dex_name, 'wb') as d:
return d.write(bencdex)

def repdex(self, dex_path, dex_name):
"""
Repair signature and checksum of dex

param:
dex_path: the dex path
dex_name: the dex name
"""
logging.info(f'Start Repair {dex_path + dex_name}')
with open(dex_path + dex_name, 'r+b') as dexf:
self.signature(dexf)
self.checksum(dexf)

def baksmali(self):
"""
apply baksmali.jar, from decrypted_path to baksmali_path
"""
dec_name = self.work_path + 'decrypted'
shutil.make_archive(dec_name, 'zip', self.decrypted_path)
os.system(f'java -jar bin/apktool.jar d {dec_name}.zip -o {self.baksmali_path}')

def smali(self):
"""
apply smali.jar, from baksmali_path to updated_path
"""
bak_path = self.work_path + 'baksmali.zip'
os.system(f'java -jar bin/apktool.jar b {self.baksmali_path} -o {bak_path}')
shutil.unpack_archive(bak_path, self.updated_path, 'zip')

def write_out(self):
"""
merge all classes to one classes use the original encrypt type
"""
with open(self.work_path + 'classes.dex', 'w+b') as dexf:
with open(self.original_path + 'shell.dex', 'rb') as sf:
shell_length = dexf.write(sf.read())
for dex_name in self.dex_dict:
dex_length = self.encdex(dex_name)
self.dex_dict[dex_name] = dex_length
with open(self.encrypted_path + dex_name, 'rb') as df:
dexf.write(df.read())
dex_list = map(lambda x: x + '=' + str(self.dex_dict[x]), self.dex_dict)
dex_index = ("." + ".".join(i for i in '-'.join(list(dex_list)))).encode('utf8')
dexf.write(dex_index)
index_length = len(dex_index).to_bytes(4, byteorder='big')
dexf.write(index_length)
dexf.seek(0x20)
length = shell_length + sum(self.dex_dict.values()) + len(dex_index) + 4
dexf.write(length.to_bytes(4, byteorder='little'))
self.repdex(self.work_path, 'classes.dex')

def pack_sign(self):
"""
Zip and Sign
:return:
"""
shutil.move(self.work_path + 'classes.dex', 'classes.dex')
logging.info(f'Start pack to {self.outapk}')
os.system(f"bin\\7za d {self.outzip} classes.dex > nul")
os.system(f"bin\\7za a {self.outzip} classes.dex > nul")
os.rename(self.outzip, self.outapk)
logging.info(f'Start sign {self.outapk}')
os.system(f'apksigner.bat sign --ks bin/release.jks --ks-pass pass:123456 '
f'--min-sdk-version 22 {self.outapk}')
shutil.move('classes.dex', self.work_path + 'classes.dex')

def break_dex(self):
"""
command d: splitdex + decrypt + baksmali
"""
with open(self.classes_path, 'rb') as cf:
logging.info('Start Split original classes.dex')
self.splitdex(cf)
for dex_name in self.dex_dict:
logging.info(f'Start Decrypt {dex_name}')
self.decdex(dex_name)
logging.info('Start Baksmali dex to smali')
self.baksmali()

def replace(self):
"""
command r: replace ByteString.smali
"""
for dex_name in self.dex_dict:
BS_path = f'{self.baksmali_path}smali_{dex_name[:-4]}/okio/ByteString.smali'
if os.path.exists(BS_path):
logging.info(f'Start Replace ByteString.smali of {dex_name}')
os.remove(BS_path)
shutil.copy('ByteString.smali', BS_path)

def build(self):
"""
command b: smali + encrypt + write + pack + sign
"""
logging.info('Start smali.jar to convert smali to dex')
self.smali()
for dex_name in self.dex_dict:
logging.info(f'Start Decrypt {dex_name}')
self.encdex(dex_name)
logging.info('Start write out to classes.dex')
self.write_out()
logging.info('Start pack and sign the zip file')
self.pack_sign()


# run all
@click.command()
@click.option('-a', '--apk', help='the apk file path')
@click.option('-d', '--dir', default='classes', help='the work directory')
@click.option('-k', '--key', help='the aes ecb key')
@click.option('-o', '--out', help='the output apk name')
def run(apk, dir, key, out):
xl = QC(apk, dir, key, out)
xl.break_dex()
xl.replace()
xl.build()


if __name__ == '__main__':
FORMAT = '%(levelname)s: %(message)s'
logging.basicConfig(format=FORMAT, level=logging.INFO)
run()

评论