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

不是因为这个平台有多优秀,是因为平台的 rtmp 串流是经过某种方式加密的,没法直接观看。
因为 apk 加了个梆梆加固,首先脱壳。

脱壳

关于app

地址发布页中选个地址,更换为安卓的UA之后就能下载了。

反射大师/DexInjector

免费版的梆梆加固可以使用反射大师脱壳,更多详细资料参看 https://bbs.binmt.cc/。
安装VMOS ➕ Android5.1极客版 ➕ Xposed ➕ 反射大师,这里使用的反射大师是VMOS用户分享的版本。
反射大师中长按写出DEX就能一次性生成数个DEX了。
通过尝试发现反射大师十个很好用的工具,对于梆梆加固和360加固的免费版,都能起到比较好的脱壳。
那如果用了企业版怎么办?举报!
最开始用的是frida的一些脚本,发现只要一启动脚本,该壳就会闪退,遂放弃。

AndroidManifest

加固一般情况下也会导致 AndroidManifest.xml 不可读。
AXMLPrinter2.jar 这个工具会解决这个问题。

1
java -jar AXMLPrinter2.jar 001_A007_3.4.2.1\AndroidManifest.xml > tomato\AndroidManifest.xml

抓包分析

分别使用 Fiddler 和 Burp Suite 抓包。从结果上来看,tmt 的服务与 xdj 比起来显得有点不易懂。
最明显的是充斥着满屏的错误,各种不可访问资源请求,Error/404 等等。
从这一堆垃圾中,有两个地址很显著:api.zhengjianyj.comdown.tsfsb.com
前者更多的是处理一些信息,后者则是一些头像,动图之类的资源,后者是明文传输,前者是 key,data 同时传输的加密格式。

VMOS

如何在虚拟机中抓包呢?在母机中安装 Postern,设置代理到 192.168.137.1,然后启动 VPN,就可以在VMOS中抓包了。

CustomGson

抓到的包

如何用key去解密data是关键的。

api请求解密

首先在 AndroidManifest.xml 中找到关于 tomato/liveActivity
出现了一个com.tomatolive.library.ui.activity.live.TomatoLiveActivity

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class TomatoLiveActivity extends BaseActivity<BasePresenter> implements TomatoLiveFragment.OnFragmentInteractionListener {
@Override // com.tomatolive.library.ui.activity.live.TomatoLiveFragment.OnFragmentInteractionListener
public void updateLiveRoomInfo() {
TomatoLiveSDK.getSingleton().onAllLiveListUpdate(bindToLifecycle(), new ResultCallBack<List<LiveEntity>>() {
/* class com.tomatolive.library.ui.activity.live.TomatoLiveActivity.AnonymousClass3 */

@Override // com.tomatolive.library.http.ResultCallBack
public void onError(int i, String str) {
}

public void onSuccess(List<LiveEntity> list) {
TomatoLiveActivity.this.mLiveList = new ArrayList(list);
TomatoLiveActivity.this.mPagerAdapter.notifyDataSetChanged();
}
});
}

之所以这一段引人注目而不是其他的,我想应该是看它有个完整的请求,获取了的结果进行订阅,那其中一定应该有解码。
那下面接着看 TomatoLiveSDK

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
public class TomatoLiveSDK {
public static class SingletonHolder {
private static final TomatoLiveSDK INSTANCE = new TomatoLiveSDK();
}

private TomatoLiveSDK() {
this.ENCRYPT_API_KEY = ConstantUtils.ENCRYPT_FILE_KEY;
// ConstantUtils.ENCRYPT_FILE_KEY 就是RSA的私钥
}

public void onAllLiveListUpdate(LifecycleTransformer lifecycleTransformer) {
onAllLiveListUpdate(lifecycleTransformer, null);
}

public void onAllLiveListUpdate(LifecycleTransformer lifecycleTransformer, ResultCallBack<List<LiveEntity>> resultCallBack) {
if (AppUtils.isApiService()) {
Observable<R> observeOn = ApiRetrofit.getInstance().getApiService().getAllListService(new RequestParams().getPageListParams(1, 40)).map(new ServerResultFunction<HttpResultPageModel<LiveEntity>>(this) {
/* class com.tomatolive.library.TomatoLiveSDK.AnonymousClass35 */
}).onErrorResumeNext(new HttpResultFunction()).subscribeOn(Schedulers.io()).observeOn(Schedulers.io());
if (lifecycleTransformer != null) {
observeOn.compose(lifecycleTransformer);
}
observeOn.subscribe(new Consumer() {
/* class com.tomatolive.library.$$Lambda$TomatoLiveSDK$DatQA3QcolUJn_L_jJkT0gUuuug */

@Override // io.reactivex.functions.Consumer
public final void accept(Object obj) {
TomatoLiveSDK.lambda$onAllLiveListUpdate$1(ResultCallBack.this, (HttpResultPageModel) obj);
}
});
}
}

static /* synthetic */ void lambda$onAllLiveListUpdate$1(ResultCallBack resultCallBack, HttpResultPageModel httpResultPageModel) throws Exception {
if (httpResultPageModel != null) {
LiveManagerUtils.getInstance().setLiveList(httpResultPageModel.dataList);
if (resultCallBack != null) {
resultCallBack.onSuccess(httpResultPageModel.dataList);
}
}
}
}

这其中最关键标黄的一行,因为以后的数据就可以被正常处理了。
下面看一下ApiRetrofit

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
public class ApiRetrofit {
private ApiService mApiService;

private ApiRetrofit() {
this.mApiService = null;
try {
HttpsUtils.SSLParams sslSocketFactory = HttpsUtils.getSslSocketFactory(null, null, null);
OkHttpClient.Builder builder = new OkHttpClient.Builder();
builder.addInterceptor(new BaseUrlManagerInterceptor());
builder.addInterceptor(new AddHeaderInterceptor());
builder.addInterceptor(new SignRequestInterceptor());
builder.connectTimeout(6, TimeUnit.SECONDS);
builder.readTimeout(30, TimeUnit.SECONDS);
builder.writeTimeout(30, TimeUnit.SECONDS);
builder.sslSocketFactory(sslSocketFactory.sSLSocketFactory, sslSocketFactory.trustManager);
builder.hostnameVerifier($$Lambda$ApiRetrofit$iu5P2JclTluLIXZx0dZfIDluCk.INSTANCE);
OkHttpClient build = builder.build();
Retrofit.Builder builder2 = new Retrofit.Builder();
builder2.baseUrl(AppUtils.getApiURl());
builder2.addConverterFactory(CustomGsonConverterFactory.create());
builder2.addCallAdapterFactory(RxJava2CallAdapterFactory.create());
builder2.client(build);
this.mApiService = (ApiService) builder2.build().create(ApiService.class);
} catch (Exception e) {
e.printStackTrace();
}
}

/* access modifiers changed from: private */
public static class SingletonHolder {
private static final ApiRetrofit INSTANCE = new ApiRetrofit();

private SingletonHolder() {
}
}

public static ApiRetrofit getInstance() {
return SingletonHolder.INSTANCE;
}

public ApiService getApiService() {
return this.mApiService;
}
}

这里需要注意:addConverterFactoryRetrofit中用来处理返回数据的一个方法。
所以在这里无疑是非常重要的了!
下面先看下别的,应该可以猜到,com.tomatolive.library.http.ApiService.java应该是一个集合了各种请求的类。
想要查看各种请求或者查看 site map,直接到该类中。

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
public interface ApiService {
public static final String BASE_START_LIVE_NOTIFY_URL = "tl/live/startLiveNotify/";
public static final String BASE_TL_ACTIVITY_URL = "tl/activity/";
public static final String BASE_TL_ANCHOR_INCOME_SERVER_URL = "tl/statistic/mobile/anchor/income";
public static final String BASE_TL_ANCHOR_PK_LM_URL = "tl/anchorPk/lianmai/";
public static final String BASE_TL_ANCHOR_PK_URL = "tl/anchorPk/";
public static final String BASE_TL_AUDIO_URL = "tl/audio/";
public static final String BASE_TL_AUDIO_VIDEO_ROOM_ANCHOR_URL = "tl/audio/videoRoom/anchor/";
public static final String BASE_TL_AUDIO_VIDEO_ROOM_URL = "tl/audio/videoRoom/";
public static final String BASE_TL_AUDIO_VIDEO_ROOM_USER_URL = "tl/audio/videoRoom/user/";
public static final String BASE_TL_AUDIO_VOICE_ROOM_ANCHOR_URL = "tl/audio/voiceRoom/anchor/";
public static final String BASE_TL_AUDIO_VOICE_ROOM_URL = "tl/audio/voiceRoom/";
public static final String BASE_TL_AUDIO_VOICE_ROOM_USER_URL = "tl/audio/voiceRoom/user/";
public static final String BASE_TL_AUTH_URL = "tl/auth/";
public static final String BASE_TL_AUTH_USER_SERVICE_INDEX_URL = "tl/auth/userService/mobile/index/";
public static final String BASE_TL_CLAN_URL = "tl/clan/clanAdmin/";
public static final String BASE_TL_EXP_ROOM_ACTION = "tl/exp/room/action";
public static final String BASE_TL_FOLLOW_SERVER_URL = "tl/follow/mobile/follow/";
public static final String BASE_TL_GAME_URL = "tl/shop/game/";
public static final String BASE_TL_GIFT_ANCHOR_INCOME_SERVER_URL = "tl/gift/mobile/anchor/income/";
public static final String BASE_TL_GIFT_ANCHOR_SERVER_URL = "tl/gift/anchor/";
public static final String BASE_TL_GIFT_SERVER_URL = "tl/gift/";
public static final String BASE_TL_GUARD_ANCHOR_INCOME_SERVER_URL = "tl/guard/mobile/anchor/income/";
public static final String BASE_TL_GUARD_SERVER_URL = "tl/guard/";
public static final String BASE_TL_GUARD_V1_SERVER_URL = "tl/guard/v1/guard/";
public static final String BASE_TL_IMPRESSION_URL = "tl/user/impression/";
public static final String BASE_TL_INDEX_ADV_URL = "tl/index/adv/";
public static final String BASE_TL_INDEX_CACHE_URL = "tl/index/cache/";
public static final String BASE_TL_INDEX_LIVE_URL = "tl/index/live/";
public static final String BASE_TL_INDEX_MOBILE_URL = "tl/index/mobile/";
public static final String BASE_TL_INDEX_SEARCH_URL = "tl/index/search/";
public static final String BASE_TL_INDEX_URL = "tl/index/";
public static final String BASE_TL_INTIMATE_ANCHOR_URL = "tl/activity/intimate/anchor/";
public static final String BASE_TL_INTIMATE_URL = "tl/activity/intimate/";
public static final String BASE_TL_INTIMATE_USER_URL = "tl/activity/intimate/user/";
public static final String BASE_TL_ITEM_GIFT_BOX_SERVER_URL = "tl/item/giftBox/";
public static final String BASE_TL_ITEM_MOBILE_SERVER_URL = "tl/item/mobile/";
public static final String BASE_TL_ITEM_PROPS_SERVER_URL = "tl/item/mobile/props/";
public static final String BASE_TL_ITEM_SERVER_URL = "tl/item/";
public static final String BASE_TL_ITEM_TASK_BOX_SERVER_URL = "tl/item/taskBox/";
public static final String BASE_TL_ITEM_USER_PROPS_SERVER_URL = "tl/item/mobile/userProps/";
public static final String BASE_TL_LIVE_CORE_MOBILE_LIVE_URL = "tl/liveCore/mobile/live/";
public static final String BASE_TL_LIVE_CORE_URL = "tl/liveCore/";
public static final String BASE_TL_LIVE_DRAW_URL = "tl/activity/liveDraw/";
public static final String BASE_TL_LIVE_HISTORY_URL = "tl/liveHistory/";
public static final String BASE_TL_LIVE_MANGE_MOBILE_MY_LIVE_URL = "tl/liveManage/mobile/myLive/";
public static final String BASE_TL_LIVE_MANGE_URL = "tl/liveManage/";
public static final String BASE_TL_LIVE_MOBILE_LIVE_URL = "tl/live/mobile/live/";
public static final String BASE_TL_LIVE_MOBILE_MY_LIVE_URL = "tl/live/mobile/myLive/";
public static final String BASE_TL_LIVE_MOBILE_URL = "tl/live/mobile/";
public static final String BASE_TL_LIVE_TICKET_ROOM_URL = "tl/live/ticketRoom/";
public static final String BASE_TL_LIVE_URL = "tl/live/";
public static final String BASE_TL_MESSAGE_URL = "tl/activity/messageBox/";
public static final String BASE_TL_MOBILE_STATISTICS_SERVER_URL = "tl/statistic/mobile/statistics/";
public static final String BASE_TL_MOBILE_STATISTIC_SERVER_URL = "tl/statistic/mobile/";
public static final String BASE_TL_NOBILITY_SERVER_URL = "tl/nobility/";
public static final String BASE_TL_POPULARITY_CARD = "tl/shop/myPopularityCard/";
public static final String BASE_TL_REPORT_SERVER_URL = "tl/offence/offenceContent/";
public static final String BASE_TL_SHOP_CAR_SERVER_URL = "tl/shop/car/";
public static final String BASE_TL_SHOP_SERVER_URL = "tl/shop/";
public static final String BASE_TL_STATISTIC_SERVER_URL = "tl/statistic/";
public static final String BASE_TL_STATISTIC_USER_EXPENSE_SERVER_URL = "tl/statistic/user/expense/";
public static final String BASE_TL_STORE_ANCHOR_PRODUCT_ITEM_URL = "tl/store/anchor/productItem/";
public static final String BASE_TL_STORE_ANCHOR_PRODUCT_URL = "tl/store/anchor/product/";
public static final String BASE_TL_STORE_ANCHOR_URL = "tl/store/anchor/";
public static final String BASE_TL_STORE_PRODUCT_URL = "tl/store/product/";
public static final String BASE_TL_STORE_URL = "tl/store/";
public static final String BASE_TL_STORE_USER_PRODUCT_URL = "tl/store/user/product/";
public static final String BASE_TL_STORE_USER_URL = "tl/store/user/";
public static final String BASE_TL_STREAM_SERVER_URL = "tl/streams/stream/";
public static final String BASE_TL_TURNTABLE_SERVER_URL = "tl/turntable/";
public static final String BASE_TL_URL = "tl/";
public static final String BASE_TL_USER_ANCHOR_URL = "tl/user/anchor/";
public static final String BASE_TL_USER_EXPENSE_SERVER_URL = "tl/statistic/mobile/user/expense";
public static final String BASE_TL_USER_MOBILE_SERVER_URL = "tl/user/userService/mobile/";
public static final String BASE_TL_USER_MOBILE_URL = "tl/user/mobile/";
public static final String BASE_TL_USER_URL = "tl/user/";
public static final String BASE_USER_CHAT_URL = "tl/chat/userMark/";
public static final String BASE_USER_MARK_URL = "tl/user/userMark/";

@POST("tl/index/live/list")
Observable<HttpResultModel<HttpResultPageModel<LiveEntity>>> getAllListService(@Body Map<String, Object> map);

最后一行就是了,传入一个 map 返回一个 Observable,然后就可以对这个 Observable 在观察了。
这就和最开始在TomatoLiveSDK中看到对上了。
下面再看最关键的CustomGsonConverterFactory.java

1
2
3
4
5
6
7
8
9
10
public class CustomGsonConverterFactory extends Converter.Factory {
@Override // retrofit2.Converter.Factory
public Converter<ResponseBody, ?> responseBodyConverter(Type type, Annotation[] annotationArr, Retrofit retrofit) {
try {
return new CustomGsonResponseBodyConverter(this.gson, this.gson.getAdapter(TypeToken.get(type)));
} catch (Exception unused) {
return null;
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class CustomGsonResponseBodyConverter<T> implements Converter<ResponseBody, T> {
CustomGsonResponseBodyConverter(Gson gson2, TypeAdapter<T> typeAdapter) {
this.gson = gson2;
this.adapter = typeAdapter;
}

public T convert(@NonNull ResponseBody responseBody) throws IOException {
String string = responseBody.string();
JsonParser jsonParser = new JsonParser();
if (isEncrypt(jsonParser, string)) {
EncryptHttpResultModel encryptHttpResultModel = (EncryptHttpResultModel) this.gson.fromJson(string, (Class) EncryptHttpResultModel.class);
ResultMode resultMode = new ResultMode();
resultMode.code = encryptHttpResultModel.getCode();
resultMode.msg = encryptHttpResultModel.getMessage();
String jsonData = encryptHttpResultModel.getJsonData();
if (jsonParser.parse(jsonData).isJsonArray()) {
resultMode.data = new JsonParser().parse(jsonData).getAsJsonArray();
} else {
resultMode.data = new JsonParser().parse(jsonData).getAsJsonObject();
}
string = this.gson.toJson(resultMode);
}
try {
ResultMode resultMode2 = (ResultMode) this.gson.fromJson(string, (Class) ResultMode.class);
if (resultMode2 != null) {
if (AppUtils.isTokenInvalidErrorCode(resultMode2.getCode())) {
responseBody.close();
throw new ServerException(resultMode2.getCode(), resultMode2.getMsg());
}
}
return this.adapter.fromJson(string);
} finally {
responseBody.close();
}
}
1
2
3
4
5
6
7
8
9
10
public class CustomGsonResponseBodyConverter<T> implements Converter<ResponseBody, T> {
public static class EncryptMode implements Serializable {
public String data;
public String key;

public String getJsonData() throws Exception {
return EncryptUtil.DESDecrypt(EncryptUtil.RSADecrypt(TomatoLiveSDK.getSingleton().ENCRYPT_API_KEY, this.key), this.data);
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class EncryptUtil {
public static final String CHARSET = "utf-8";
private static final String iv = "01234567";

private EncryptUtil() {
}

public static String DESDecrypt(String str, String str2) throws Exception {
return decode(str, str2);
}

public static String RSADecrypt(String str, String str2) throws Exception {
return new String(decrypt(decode(str2), getPraivateKey(str), 2048, 11, "RSA/ECB/PKCS1Padding"), CHARSET);
}

private static String decode(String str, String str2) throws Exception {
SecretKey generateSecret = SecretKeyFactory.getInstance("desede").generateSecret(new DESedeKeySpec(str.getBytes()));
Cipher instance = Cipher.getInstance("desede/CBC/PKCS5Padding");
instance.init(2, generateSecret, new IvParameterSpec(iv.getBytes()));
return new String(instance.doFinal(decode(str2)), CHARSET);
}

private static byte[] decode(String str) throws Exception {
return Base64Util.decode(str, "GBK");
}
}

Example

在来一段 python 的实现,这段代码写了好几个小时,因为 DES3 的坑。
Java的desede是不会对key检测的,直接暴力截断;python中需要检测16/24位,否则报错ValueError: Not a valid TDES key

  • str(bytes) 这是不标准的用法,打印出来看的很清楚,前面有 b 字符串也被单引号引起来了
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
from Crypto.Cipher import PKCS1_v1_5, AES, DES3
from Crypto.PublicKey import RSA
from Crypto.Hash import SHA
from Crypto import Random
from base64 import b64decode, b64encode

with open('pri.pem', 'r') as f:
pri = f.read()

def decrypt(pri, ciphertext):
key = RSA.importKey(pri)
dsize = SHA.digest_size
input_text = ciphertext[:256]
ret = ''
while input_text:
sentinel = Random.new().read(15 + dsize)
cipher = PKCS1_v1_5.new(key)
_message = cipher.decrypt(input_text, sentinel)
ret += str(_message)
ciphertext = ciphertext[256:]
input_text = ciphertext[:256]
return ret

msg = b64decode(b'')
msg = decrypt(pri, msg)
print(msg)

ct = b64decode(b'')

cipher = DES3.new(msg[2:26], DES3.MODE_CBC, iv=b'01234567')
plain = cipher.decrypt(ct).decode('utf-8')

plain = plain[:plain.rfind('}')+1]

f = open('decode.txt', 'w', encoding='utf8')
f.write(plain)
f.close()

PEM header

这里有一点需要注意,RSA 私钥的 pem header 问题,会导致部分网页版无法工作

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
#define PEM_STRING_X509_OLD "X509 CERTIFICATE"
#define PEM_STRING_X509 "CERTIFICATE"
#define PEM_STRING_X509_PAIR "CERTIFICATE PAIR"
#define PEM_STRING_X509_TRUSTED "TRUSTED CERTIFICATE"
#define PEM_STRING_X509_REQ_OLD "NEW CERTIFICATE REQUEST"
#define PEM_STRING_X509_REQ "CERTIFICATE REQUEST"
#define PEM_STRING_X509_CRL "X509 CRL"
#define PEM_STRING_EVP_PKEY "ANY PRIVATE KEY"
#define PEM_STRING_PUBLIC "PUBLIC KEY"
#define PEM_STRING_RSA "RSA PRIVATE KEY"
#define PEM_STRING_RSA_PUBLIC "RSA PUBLIC KEY"
#define PEM_STRING_DSA "DSA PRIVATE KEY"
#define PEM_STRING_DSA_PUBLIC "DSA PUBLIC KEY"
#define PEM_STRING_PKCS7 "PKCS7"
#define PEM_STRING_PKCS7_SIGNED "PKCS #7 SIGNED DATA"
#define PEM_STRING_PKCS8 "ENCRYPTED PRIVATE KEY"
#define PEM_STRING_PKCS8INF "PRIVATE KEY"
#define PEM_STRING_DHPARAMS "DH PARAMETERS"
#define PEM_STRING_DHXPARAMS "X9.42 DH PARAMETERS"
#define PEM_STRING_SSL_SESSION "SSL SESSION PARAMETERS"
#define PEM_STRING_DSAPARAMS "DSA PARAMETERS"
#define PEM_STRING_ECDSA_PUBLIC "ECDSA PUBLIC KEY"
#define PEM_STRING_ECPARAMETERS "EC PARAMETERS"
#define PEM_STRING_ECPRIVATEKEY "EC PRIVATE KEY"
#define PEM_STRING_PARAMETERS "PARAMETERS"
#define PEM_STRING_CMS "CMS"

参考:https://git.openssl.org/?p=openssl.git;a=blob;f=include/openssl/pem.h;hb=HEAD#l30

AppSecretUtil

昨天已经分析了直播的api加密,都在com.tomatolive这个类中,用到的加密是RSA和DES3。
今天准备先干掉flv的加密来着,但没找到头绪,决定先干首页/游戏/papa的各种 api。
通过抓包,发现关于该api的几乎所有内容全都是加密的。

往上翻,看看最开始请求有什么线索,找到该api请求的第一条,抓到的包如下,是一条json,但内容也是加密的。

不过很显然,这条请求用于获取各类域名。
本来想从MainTabActivity一步步启动到如何做出请求,返回的数据如何解密,但实在看不懂流程应该是怎样的。
用个土办法吧。搜索关键词.do,直接就定位到了AppSecretUtil.java,这是个关键的解密函数。

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
public class AppSecretUtil {
public static final String[] apiPaths = {"/order", "/pay", "/query"};

public static String decodeResponse(String str) {
char[] charArray = str.toCharArray();
for (int i = 0; i < charArray.length; i++) {
charArray[i] = (char) (charArray[i] ^ '\u2efd');
}
return String.valueOf(charArray);
}

public static boolean severApiFilter(String str) {
if (TextUtils.isEmpty(str)) {
return false;
}
for (String str2 : apiPaths) {
if (str.equals(str2)) {
return true;
}
}
return false;
}

public static void decodeS3Image(File file, File file2) {
Throwable th;
FileOutputStream fileOutputStream;
FileInputStream fileInputStream;
Exception e;
FileInputStream fileInputStream2 = null;
fileInputStream = new FileInputStream(file);
fileOutputStream = new FileOutputStream(file2);
byte[] bArr = new byte[1];
byte b = -1;
while (true) {
int read = fileInputStream.read(bArr);
if (read == -1) {
break;
}
if (b == -1) {
b = bArr[0];
bArr = new byte[0x1000];
} else {
byte[] bArr2 = new byte[read];
for (int i = 0; i < read; i++) {
bArr2[i] = (byte) (bArr[i] ^ b);
}
fileOutputStream.write(bArr2);
}
}
fileInputStream.close();
fileOutputStream.flush();
fileOutputStream.close();
}

public static String encodePath(String str) {
try {
String str2 = System.currentTimeMillis() + RandomUtil.getRandom(3);
return new String(Base64Util.base64EncodeStr((AESUtil.encryptAES(str + (char) 1 + RandomUtil.getRandom(1), str2) + (char) 1 + Base64Util.base64EncodeStr(str2, 8)).replace("\n", ""), 8)).replace("\n", "") + ".do";
} catch (Exception e) {
e.printStackTrace();
return "";
}
}
}

根据方法名一眼就看出这里面的三个操作:解码服务器的响应,解密s3类型的图片,请求路径加密。

请求路径加密

虽然从方法名字就能看出具体操作,但因为反编译器和垃圾项目的一些问题,现在还不敢确定。
同时对照着JEB反汇编出来的垃圾结果看看,没大有作用,不过不至于一叶障目。
那就首先来看一下encodePath都是谁调用的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@HttpRequest(builder = TomatoParamsBuilder.class, host = "http://www.fanqie345.com", path = "xxx")
public class TomatoParams extends RequestParams {
private String path;

public TomatoParams(String str, String str2, int i) {
super(str + AppSecretUtil.encodePath(str2));
this.path = str2;
int i2 = i * 1000;
setReadTimeout(i2);
setConnectTimeout(i2);
}

public <T> Callback.Cancelable post(TomatoCallback tomatoCallback) {
try {
init();
} catch (Throwable th) {
th.printStackTrace();
}
return x.http().post(this, tomatoCallback);
}
}

再来看一下TomatoParams都被谁使用了,通过搜索发现有很多的view或者fragement,基本可以确定,这就是路径的编码算法。

1
2
3
4
5
6
7
8
9
10
11
private void shield(int i, int i2) {
TomatoParams tomatoParams = new TomatoParams(DomainServer.getInstance().getServerUrl(), "/app/record/shield/add");
tomatoParams.addParameter("createMemberId", Integer.valueOf(DBUtil.getMemberId()));
tomatoParams.addParameter("type", Integer.valueOf(i));
if (i == 1) {
tomatoParams.addParameter("memberId", Integer.valueOf(this.postList.getMemberId()));
} else if (i == 2) {
tomatoParams.addParameter("articleId", Integer.valueOf(this.postList.getId()));
}
tomatoParams.post(new TomatoCallback((ResponseObserver) this, 1, (Class) null, i, i2));
}

大部分调用的方式都是类似的,最开始是编码请求路径,然后自定义的添加一些头部的参数,然后用post发出请求。
这里注意:TomatoCallback这个方法是用来处理返回的数据的。这很重要。
下面来看一下encodePath的加密过程吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class AESUtil {
@NonNull
public static String encryptAES(String str, String str2) throws InvalidKeyException, NoSuchAlgorithmException, NoSuchPaddingException, UnsupportedEncodingException, InvalidAlgorithmParameterException, IllegalBlockSizeException, BadPaddingException {
if (TextUtils.isEmpty(str)) {
return "";
}
byte[] bytes = str.getBytes(Base64Coder.CHARSET_UTF8);
SecretKeySpec secretKeySpec = new SecretKeySpec(str2.getBytes(), "AES");
IvParameterSpec ivParameterSpec = new IvParameterSpec("16-Bytes--String".getBytes());
Cipher instance = Cipher.getInstance("AES/CBC/PKCS5Padding");
instance.init(1, secretKeySpec, ivParameterSpec);
return Base64Util.base64EncodeData(instance.doFinal(bytes));
}

再看encodePath方法,发现其实是可以自解密的,信息完备。

DomainRequest

通过刚才调查是谁调用了TomatoParams发现了一个很有意思的方法:DomainServer.getInstance().getServerUrl()
根据字面意思,这应该是获取服务器地址的方法。来看一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class DomainServer {
private static DomainServer domainServer;

public synchronized String getCurrentServerUrl(String str) {
String domainUrlByType = DomainRequest.getInstance().getDomainUrlByType(str);
if (!domainUrlByType.endsWith("/")) {
domainUrlByType = domainUrlByType + "/";
}
if (!"ttViewPicture".equals(str) && !"ttViewVideoNew".equals(str) && !"jcAgent".equals(str) && !"h5Domain".equals(str) && !"spreadDomain".equals(str) && !"website".equals(str) && !"websiteYule".equals(str) && !"shareDomain".equals(str)) {
return domainUrlByType;
}
return subStringUrlByLine(domainUrlByType);
}
}

啥事没干,又调用了DomainRequest,接着看:

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
public class DomainRequest {
private synchronized ArrayList<DomainUrl> getDomainListByType(String str) {
String sDCardDomainDir = getSDCardDomainDir();
String str2 = this.fileNameMap.get(str);
File file = new File(FileUtil.getDomainDir(sDCardDomainDir), str2);
String str3 = "";
if (file.exists()) {
str3 = FileUtil.readSDCardData(file.getPath());
}
if (TextUtils.isEmpty(str3)) {
StringBuffer stringBuffer = new StringBuffer();
stringBuffer.append(sDCardDomainDir);
stringBuffer.append(File.separator);
stringBuffer.append(str2);
str3 = FileUtil.readAssetFile(stringBuffer.toString());
FileUtil.writeSDCardData(file.getPath(), str3);
}
if (TextUtils.isEmpty(str3)) {
LogUtil.e("getDomainListByType ==>> 没有获取到加密数据");
return null;
}
ArrayList<DomainUrl> arrayList = new ArrayList<>();
if (!decrypt(str, str3, arrayList)) {
return null;
}
this.urlMap.put(str, arrayList);
return arrayList;
}

public boolean decrypt(String str, String str2, ArrayList<DomainUrl> arrayList) {
try {
String decryptAES = AESUtil.decryptAES(str2, "WTSecret81234512");
if (TextUtils.isEmpty(decryptAES)) {
LogUtil.e("解密:domainType = " + str + ", 没有获取到加密数据");
return false;
}
try {
String[] split = decryptAES.split("\\|");
String str3 = split[0];
String str4 = split[1];
String[] split2 = str3.split(",");
String[] split3 = str4.split(",");
for (int i = 0; i < split2.length; i++) {
arrayList.add(new DomainUrl(RSAUtil.RSAdecrypt("RSAPRIVATEKEY", split2[i]), Integer.parseInt(RSAUtil.RSAdecrypt("RSAPRIVATEKEY", split3[i]))));
}
LogUtil.i("解密:domainType = " + str + ",domainUrlList = " + arrayList.toString());
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
} catch (Exception e2) {
e2.printStackTrace();
LogUtil.e("解密:domainType = " + str + ", 解密报异常");
return false;
}
}
}

好了,终于知道最开始第一条请求的数据该如何解密了,但后面还带有一个整数,这个数代表该服务器的权重。

TomatoCallback

既然存在对路径的编码,那自然调用它的应该是请求的方法,有了请求的方法,在沿线估计就可以找到解密服务器响应的方法。
就是上面这种思路帮助我在这篇代码中找到需要的加密和解密方式。
再来看TomatoParams.post()这个方法的调用,这个方法带一个参数,用于回调。
回调方法的用途便是处理服务器返回的数据,在众多TomatoParams的调用中,一个回调类被频繁使用。
来看一下TomatoCallback

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class TomatoCallback<T> implements Callback.CommonCallback<String>, RequestInterceptListener {
public void onSuccess(String str) {
String str2;
String str3;
String str4;
int i;
if (AppSecretUtil.severApiFilter(this.filterPath) || (i = this.msg.arg1) == 111111 || i == 222222) {
String tag = getTAG();
LogUtil.i(tag, "无需解密的:" + str);
} else {
str = AppSecretUtil.decodeResponse(str);
String tag2 = getTAG();
LogUtil.i(tag2, "解密后的:" + str);
}
// 省略众多行
}
}

到这里终于证实了AppSecretUtil的重要性了。
下面把这些加密的数据用python实现解密。

Example

下面用python把里面用到的加密都解密一遍,这个过程走了很多弯路,尤其是**decoderesponce()**搞了4小时!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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
from base64 import b64decode
from Crypto.Cipher import PKCS1_v1_5, AES, DES3
from Crypto.PublicKey import RSA
from Crypto.Hash import SHA1
from Crypto import Random


class Appdec:

@staticmethod
def unpad5(s):
return s[0:-s[-1]]

def decodepath(self, encp):
dp = b64decode(bytes(encp, 'utf8'))
al = dp.split(b'')
key = b64decode(al[1])
iv = b'16-Bytes--String'
cipher = AES.new(key, AES.MODE_CBC, iv)
ud = cipher.decrypt(b64decode(al[0]))
decrypted = self.unpad5(ud)[:-2]
return decrypted.decode('utf8')

@staticmethod
def decodes3(encf, decf):
s3 = open(encf, 'rb')
ba = bytearray()
b = s3.read(1)

while True:
ch = s3.read(1)
if not ch:
break
ba.append(ord(ch) ^ ord(b))
d3 = open(decf, 'wb')
d3.write(ba)
s3.close()
d3.close()

@staticmethod
def decrsa(pri, ciphertext):
key = RSA.importKey(pri)
dsize = SHA1.digest_size
input_text = ciphertext[:256]
ret = b''
while input_text:
sentinel = Random.new().read(15 + dsize)
cipher = PKCS1_v1_5.new(key)
_message = cipher.decrypt(input_text, sentinel)
ret += _message
ciphertext = ciphertext[256:]
input_text = ciphertext[:256]
return ret

def decodelive(self, encdata, enckey):
bencdata = bytes(encdata, 'utf8')
benckey = bytes(enckey, 'utf8')
with open('prilive.pem', 'r') as f:
pri = f.read()
deckey = self.decrsa(pri, b64decode(benckey))
ct = b64decode(bencdata)
cipher = DES3.new(deckey[:24], DES3.MODE_CBC, iv=b'01234567')
plain = cipher.decrypt(ct).decode('utf8', errors='ignore')
plain = plain[:plain.rfind('}') + 1]
f.close()
return plain

def decodedomain(self, encdata):
key = b'WTSecret81234512'
iv = b'16-Bytes--String'
cipher = AES.new(key, AES.MODE_CBC, iv)
bencdata = b64decode(bytes(encdata, 'utf8'))
rsadata = cipher.decrypt(bencdata)
with open('pridomain.pem', 'r') as f:
pri = f.read()
rsadomain = rsadata.split(b'|')
rsadomainlist = []
for i in range(len(rsadomain)):
rsadomainlist += rsadomain[i].split(b',')
domainlist = []
for encdom in rsadomainlist:
encditem = b64decode(encdom.strip(b'\n'))
domainlist.append(self.decrsa(pri, encditem).decode('utf8'))
f.close()
return domainlist

@staticmethod
def decoderesponce(responcebytes):
encresponce = responcebytes.decode('utf8')
responce = b''
for e in list(encresponce):
responce += chr(ord(e) ^ 12029).encode('utf8', errors='ignore')
return responce.decode('utf8')

IjkMediaPlayer

从两天前着手的时候就对这部分充满兴趣,最开始我以为可能只是修改了一下.flv的参数,导致视频解析出了问题,
没想到结果出乎意料啊!竟然用到了NDK需要反编译.so文件。暂时还不能掌握这门技术,慢慢搞吧。
下面先说一下,定位代码位置的过程吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class MainTabActivity extends BaseActivity implements ResponseObserver {
public void selectTab(int i) {
if (i == 2) {
Fragment fragment = this.liveTabFragment;
if (fragment == null) {
this.liveTabFragment = LiveHomeFragment.newInstance();
beginTransaction.add(2131296852, this.liveTabFragment);
TomatoLiveSDKUtils.getSingleton().updateServerUrl();
TomatoLiveSDKUtils.getSingleton().initAnim();
TomatoLiveSDKUtils.getSingleton().loadOperationActivityDialog(this);
} else {
beginTransaction.show(fragment);
}
}
}
}

这里调用了LiveHomeFragment,并且看起来是用于交互了,看一下这个类。
这个类非常长,里面用众多方法,因为mvp导致的,仔细观察!发现很多个关于play的方法!
感觉其中一定有点搞头,观察其中一个方法,应该是在从rtmp换到flv的时候使用的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class LiveHomeFragment extends BaseFragment<HomePresenter> implements IHomeView {
private void switchStream() {
if (!isLiving()) {
return;
}
if (this.isRTCStream) {
if (isVoiceRoomType()) {
stopRTC();
initSeatData();
updateYYLinkIconView(null);
}
showLiveLoadingView(4);
return;
}
startPullTimeOut();
if (isVideoRoomType()) {
showLoadingAnim();
}
PlayManager playManager2 = this.playManager;
if (playManager2 != null) {
playManager2.switchStream(this.pullStreamUrl);
}
}
}

其他的名字带有play的方法很类似地都提到了一个PlayManager的类,这应该是控制整个播放流程的关键类。

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
public class PlayManager {
private void initIjkVideoView() {
Observable.just(true).observeOn(Schedulers.io()).subscribeOn(Schedulers.io()).subscribe(new Consumer<Boolean>() {
/* class com.tomatolive.library.utils.live.PlayManager.AnonymousClass1 */

public void accept(Boolean bool) throws Exception {
try {
IjkMediaPlayer.loadLibrariesOnce(null);
IjkMediaPlayer.native_profileBegin("libfqplayer.so");
} catch (Exception e) {
e.printStackTrace();
}
}
});
this.mIjkplayerView = new IjkVideoView(this.mContext);
if (!this.isFromRecyclerView) {
this.mIjkplayerView.setMute(false);
}
}

private void checkPlayUrl(String str) {
if (str.startsWith("rtmp://")) {
this.mPlayType = 0;
} else if ((str.startsWith("http://") || str.startsWith("https://")) && str.contains(".flv")) {
this.mPlayType = 1;
} else if ((str.startsWith("http://") || str.startsWith("https://")) && str.contains(".m3u8")) {
this.mPlayType = 3;
}
}

public void startPlayWithListener(String str) {
checkPlayUrl(str);
if (this.isEnableVideoStreamEncode) {
IjkVideoView ijkVideoView = this.mIjkplayerView;
if (ijkVideoView != null) {
ijkVideoView.setOnPlayStateListener(this.onPlayStateListener);
this.mIjkplayerView.setVideoPath(str);
this.mIjkplayerView.start();
return;
}
return;
}
TXLivePlayer tXLivePlayer = this.mLivePlayer;
if (tXLivePlayer != null) {
tXLivePlayer.setPlayListener(this.playListener);
this.mLivePlayer.startPlay(str, this.mPlayType);
}
}
}

这个类与IjkVideoViewIjkMediaPlayer紧密的关系在一起,是Ijk播放器的控制类。
下面观察一下这两个Ijk播放器的类,在这里面将.so导入,使用native直接调用等操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
public class IjkVideoView extends FrameLayout implements MediaController.MediaPlayerControl {
@TargetApi(23)
private void openVideo() {
if (this.mUri != null && this.mSurfaceHolder != null) {
release(false);
((AudioManager) this.mAppContext.getSystemService("audio")).requestAudioFocus(null, 3, 1);
try {
this.mMediaPlayer = createPlayer();
getContext();
this.mMediaPlayer.setOnPreparedListener(this.mPreparedListener);
this.mMediaPlayer.setOnVideoSizeChangedListener(this.mSizeChangedListener);
this.mMediaPlayer.setOnCompletionListener(this.mCompletionListener);
this.mMediaPlayer.setOnErrorListener(this.mErrorListener);
this.mMediaPlayer.setOnInfoListener(this.mInfoListener);
this.mMediaPlayer.setOnBufferingUpdateListener(this.mBufferingUpdateListener);
this.mMediaPlayer.setOnSeekCompleteListener(this.mSeekCompleteListener);
this.mMediaPlayer.setOnTimedTextListener(this.mOnTimedTextListener);
this.mCurrentBufferPercentage = 0;
this.mUri.getScheme();
if (Build.VERSION.SDK_INT >= 14) {
this.mMediaPlayer.setDataSource(this.mAppContext, this.mUri, this.mHeaders);
} else {
this.mMediaPlayer.setDataSource(this.mUri.toString());
}
bindSurfaceHolder(this.mMediaPlayer, this.mSurfaceHolder);
this.mMediaPlayer.setAudioStreamType(3);
this.mMediaPlayer.setScreenOnWhilePlaying(true);
this.mPrepareStartTime = System.currentTimeMillis();
this.mMediaPlayer.prepareAsync();
setMute(this.mMuted);
this.mCurrentState = 1;
attachMediaController();
} catch (IOException e) {
String str = this.TAG;
Log.w(str, "Unable to open content: " + this.mUri, e);
this.mCurrentState = -1;
this.mTargetState = -1;
this.mErrorListener.onError(this.mMediaPlayer, 1, 0);
} catch (IllegalArgumentException e2) {
String str2 = this.TAG;
Log.w(str2, "Unable to open content: " + this.mUri, e2);
this.mCurrentState = -1;
this.mTargetState = -1;
this.mErrorListener.onError(this.mMediaPlayer, 1, 0);
}
}
}
}
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
public final class IjkMediaPlayer extends AbstractMediaPlayer{
public static void loadLibrariesOnce(IjkLibLoader ijkLibLoader) {
synchronized (IjkMediaPlayer.class) {
if (!mIsLibLoaded) {
if (ijkLibLoader == null) {
ijkLibLoader = sLocalLibLoader;
}
ijkLibLoader.loadLibrary("fqffmpeg");
ijkLibLoader.loadLibrary("fqsdl");
ijkLibLoader.loadLibrary("fqplayer");
mIsLibLoaded = true;
}
}
}

private void initPlayer(IjkLibLoader ijkLibLoader) {
loadLibrariesOnce(ijkLibLoader);
initNativeOnce();
Looper myLooper = Looper.myLooper();
if (myLooper != null) {
this.mEventHandler = new EventHandler(this, myLooper);
} else {
Looper mainLooper = Looper.getMainLooper();
if (mainLooper != null) {
this.mEventHandler = new EventHandler(this, mainLooper);
} else {
this.mEventHandler = null;
}
}
native_setup(new WeakReference(this));
}

public int getVideoDecoder() {
return (int) _getPropertyLong(20003, 0);
}
}

好了,目前只能到此为止了,最后这三个重要的类以及三个.so就是目前的所有线索。稍微准备点预备知识再追。

请求参数

这是一块比较枯燥的内容,搞过一次,大概费了一天的时间,后来让我给忘记了,妹的,以后如果能搞定 .so 一定回来补上这一节。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public static String signRequest(Request request, TreeMap<String, Object> treeMap, String str) {
TreeMap treeMap2 = new TreeMap($$Lambda$TEfSBt3hRUlBSSARfPEHsJesTtE.INSTANCE);
treeMap2.putAll(treeMap);
treeMap2.put(AddHeaderInterceptor.TIME_STAMP_STR, request.header(AddHeaderInterceptor.TIME_STAMP_STR));
treeMap2.put(AddHeaderInterceptor.RANDOM_STR, request.header(AddHeaderInterceptor.RANDOM_STR));
treeMap2.put("deviceId", request.header("deviceId"));
StringBuilder sb = new StringBuilder();
for (Map.Entry entry : treeMap2.entrySet()) {
String str2 = (String) entry.getKey();
Object value = entry.getValue();
if (!TextUtils.equals(RequestParams.PAGE_SIZE, str2) && !TextUtils.equals(RequestParams.PAGE_NUMBER, str2) && value != null) {
sb.append(str2);
sb.append(SimpleComparison.EQUAL_TO_OPERATION);
sb.append(value);
sb.append("&");
}
}
if (sb.indexOf("&") != -1) {
sb.deleteCharAt(sb.lastIndexOf("&"));
}
return MD5Utils.hash(TomatoLiveSDK.getSingleton().SIGN_API_KEY + "_" + sb.toString().toUpperCase());
}

其中传入的参数 treeMap 是请求体, SIGN_API_KEY 是一个固定的字符串 8zy8nbs9lyddx02slcz8ypmwcr2tlu72

请求实现

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
import json
from Appdec import Appdec
import requests
import time
from datetime import datetime
import random
import string
import hashlib
import pytz

SIGN_API_KEY = '8zy8nbs9lyddx02slcz8ypmwcr2tlu72'
deviceId = ''.join(random.choices(string.hexdigits + string.digits, k=16)).lower()
user = {}
openId = ''


def getsign(postdata=None):
if postdata is None:
postdata = {}
randomStr = ''.join(random.choices(string.ascii_letters + string.digits, k=16))
sdict = {'deviceId': deviceId, 'randomStr': randomStr, 'timeStampStr': str(int(time.time()))}
sdict_ = dict(sorted({**sdict, **postdata}.items()))
sd = SIGN_API_KEY + '_'
for k, v in sdict_.items():
sd = sd + k.upper() + '=' + v.upper() + '&'
sd = sd[:-1]
sign = hashlib.md5(sd.encode('utf8')).hexdigest()
return sdict, sign


def getheader():
header = {'deviceType': 'android', 'appId': '1', 'appKey': '016c6dd0-0531-4c7f-a531-52b76f0d366c',
'osVersion': '10.0', 'sdkVersion': '3.4.1', 'deviceName': 'iphone11pm', 'Connection': 'Keep-Alive',
'Content-Type': 'application/json; charset=UTF-8', 'User-Agent': 'okhttp/3.11.0', 'openId': openId}
return header


# https://api.hndwsm.com/tl/auth/userService/mobile/index/syncUserInfo2LiveModuleAndGetToken
def sync():
global openId, user
header = {
'apkChannelId': '001',
'appId': '1',
'deviceNo': deviceId * 2,
'netStatus': 'WIFI',
'versionNo': '342001201',
'Connection': 'Keep-Alive'}
luser = requests.post('https://api.lck03.xyz/eStBMUNFTjZXWGxDcGswNnNIcnlkdjNpUExQTTZDWWQzd0Q0Nld'
'XZ0xnST0BTVRZek5UYzNNRFV4TnpjM016TXhOdz09.do', data='', headers=header)
luserj = luser.json()
openId = str(luserj['data']['id'])
puserj = {"isRisk": "0", "ntoken": luserj['data']['liveToken'], "userId": openId,
"avatar": "https://www.google.com/logos/doodles/2021/halloween-2021-6753651837109122.2-2xa.gif",
"name": luserj['data']['inviteCode'], "isLogin": "1", "sex": "1"}
header = getheader()
sdict, sign = getsign(puserj)
header = {**header, 'sign': sign, **sdict, 'token': '', 'userId': ''}
ruser = requests.post('https://api.hndwsm.com/tl/auth/userService/mobile/index/syncUserInfo2LiveModuleAndGetToken',
json.dumps(puserj), headers=header)
ruserj = ruser.json()
ad = Appdec()
u = ad.decodelive(ruserj['data']['data'], ruserj['data']['key'])
uj = json.loads(u)
print(uj)
user = {'userId': uj['userId'], 'token': uj['token']}


# https://api.hndwsm.com/tl/index/live/list
def livelist(pageNumber=1):
header = getheader()
sdict, sign = getsign()
header = {**header, **sdict, **user, 'sign': sign, 'openId': openId}
postdata = {"pageNumber": pageNumber, "pageSize": 40}
r = requests.post('https://api.hndwsm.com/tl/index/live/list',
postdata.__str__(), headers=header)
rj = r.json()
ad = Appdec()
llist = ad.decodelive(rj['data']['data'], rj['data']['key'])
lj = json.loads(llist)
return lj


# http://down.tsfsb.com/
def process(alllist):
data = alllist['dataList']
data.sort(key=lambda x: x["onlineUserCount"], reverse=True)
onlineUserCount = 0
freeList = []
chargeList = []
for j in data:
if 'coverIdentityUrl' in j and not j['coverIdentityUrl'].startswith('http'):
j['coverIdentityUrl'] = 'http://down.tsfsb.com/' + j['coverIdentityUrl']
if 'liveCoverUrl' in j and not j['liveCoverUrl'].startswith('http'):
j['liveCoverUrl'] = 'http://down.tsfsb.com/' + j['liveCoverUrl']
j['pullStreamUrl'] = j['pullStreamUrl'].split(',')
j['lowDefinitionPullStreamUrl'] = j['lowDefinitionPullStreamUrl'].split(',')
j['lowDefinitionPullStreamUrlH265'] = j['lowDefinitionPullStreamUrlH265'].split(',')
j['pullStreamUrlH265'] = j['pullStreamUrlH265'].split(',')
starttime = datetime.fromtimestamp(j['startTime'], pytz.timezone('Asia/Shanghai'))
j['startTime'] = starttime.strftime('%d/%m %H:%M:%S')
onlineUserCount += j['onlineUserCount']
if j['chargeType'] == 0:
freeList.append(j)
else:
chargeList.append(j)
alllist['pageSize'] = alllist['totalRowsCount']
alllist['totalPageCount'] = 1
alllist['onlineUserCount'] = onlineUserCount
alllist.pop('dataList', None)
alllist['freeList'] = freeList
alllist['chargeList'] = chargeList
return alllist


def datalist():
sync()
first = livelist()
totalPageCount = first['totalPageCount']
for i in range(2, totalPageCount+1):
curlist = livelist(i)
first['dataList'] += curlist['dataList']
print(len(first['dataList']))
first = process(first)
return first


f = open('dec.json', 'w', encoding='utf8')
json.dump(datalist(), f, indent=2, ensure_ascii=False, sort_keys=True)

libfqffmpeg

在搞完 aliplayer 之后,对基础的音视频处理算是有了一个小经验,结合上次的经验,有了现在的进展。
用 IDA PRO 打开 libfqffmpeg.so 之后,查看 exports 一栏,从头看到尾,有一个的命名非常可疑,叫做 decrypt。
进去一看,是用 openssl 解密的函数,查看 xref,发现只有一个函数 sub_B8228 调用了这个解密函数。
这个函数究竟做什么事情的呢?根据关键词搜索,发现是 ffmpeg 中的一个函数:

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
int __fastcall sub_B8228(_DWORD *AVCodecParserContext_s, _DWORD *avctx, _DWORD *poutbuf, _DWORD *poutbuf_size, _BYTE *buf, int buf_size)
{
// 一些参数

v6 = *AVCodecParserContext_s;
if ( !*(_DWORD *)(*AVCodecParserContext_s + 1640) )
{
v9 = avctx[26];
*(_DWORD *)(v6 + 1640) = 1;
if ( v9 )
ff_h264_decode_extradata(avctx[25], v9, v6 + 40, v6 + 1632, v6 + 1636, avctx[172], avctx);
}
if ( (AVCodecParserContext_s[44] & 1) != 0 )
{
next = buf_size;
}
else
{
next = h264_find_frame_end(v6, (int)buf, buf_size, (int)avctx);
if ( ff_combine_frame(v6, next, &buf, &buf_size) < 0 )
{
result = buf_size;
*poutbuf = 0;
*poutbuf_size = 0;
return result;
}
if ( next < 0 && next != -100 )
h264_find_frame_end(v6, *(_DWORD *)(v6 + 8) + next + *(_DWORD *)v6, -next, (int)avctx);
}
nal_length_size = *(_DWORD *)(*AVCodecParserContext_s + 1636);
for ( i = 0; i < custom_head_size; ++i )
{
if ( (unsigned __int8)buf[nal_length_size + i] != custom_header[i] )
goto parse_nal_units;
}
for ( j = 0; j < nal_length_size; ++j )
{
v14 = buf[j];
v15 = &buf[j];
v15[custom_head_size] = v14;
}
buf += custom_head_size;
v17 = buf + 4;
buf_size -= custom_head_size;
v16 = buf_size;
v18 = EVP_aes_128_cfb128();
decrypt((int)v18, (int)v17, v16 - 4, (int)"1234567890qwerty", (int)"0123456789abcdef", (int)v17);
parse_nal_units: // // parse_nal_units
// parse_nal_units 函数调用
LABEL_165:
if ( avctx[220] )
{
v83 = avctx[29];
v104 = 1;
v103 = v83;
av_mul_q(s, avctx[220], avctx[221]);
avctx[27] = s[1];
avctx[28] = s[0];
}
v84 = *(_DWORD *)(v6 + 1404);
v85 = v84 < 0;
if ( v84 < 0 )
v84 = 0x80000000;
else
v23 = *(_DWORD *)(v6 + 1432);
if ( v85 )
{
AVCodecParserContext_s[60] = v84;
AVCodecParserContext_s[61] = v84;
}
else
{
AVCodecParserContext_s[61] = v84;
v84 = *(_DWORD *)(v6 + 1400);
}
AVCodecParserContext_s[62] = v84;
v86 = AVCodecParserContext_s[44];
if ( !v85 )
AVCodecParserContext_s[60] = v23;
if ( (v86 & 2) != 0 )
AVCodecParserContext_s[44] = v86 & 1;
v99 = AVCodecParserContext_s[60];
if ( v99 >= 0 )
{
v87 = (int)avctx[28] * (__int64)(int)avctx[223];
if ( v87 >= 1 )
{
v88 = *((_QWORD *)AVCodecParserContext_s + 6);
v89 = HIDWORD(v88) == 0x80000000;
if ( HIDWORD(v88) == 0x80000000 )
v89 = (_DWORD)v88 == 0;
v90 = (int)avctx[27] * (__int64)(int)avctx[224];
if ( v89 )
{
v91 = *(_QWORD *)(v6 + 1664);
v92 = HIDWORD(v91) == 0x80000000;
if ( HIDWORD(v91) == 0x80000000 )
v92 = (_DWORD)v91 == 0;
if ( !v92 )
*((_QWORD *)AVCodecParserContext_s + 6) = av_rescale(
AVCodecParserContext_s[61],
(int)AVCodecParserContext_s[61] >> 31,
v90,
HIDWORD(v90),
v87,
HIDWORD(v87))
+ v91;
}
else
{
*(_QWORD *)(v6 + 1664) = v88
- av_rescale(
AVCodecParserContext_s[61],
(int)AVCodecParserContext_s[61] >> 31,
v90,
HIDWORD(v90),
v87,
HIDWORD(v87));
}
v93 = *(_QWORD *)(v6 + 1664);
v94 = HIDWORD(v93) == 0x80000000;
if ( HIDWORD(v93) == 0x80000000 )
v94 = (_DWORD)v93 == 0;
if ( !v94 )
{
v95 = *((_QWORD *)AVCodecParserContext_s + 5);
v96 = HIDWORD(v95) == 0x80000000;
if ( HIDWORD(v95) == 0x80000000 )
v96 = (_DWORD)v95 == 0;
if ( v96 )
*((_QWORD *)AVCodecParserContext_s + 5) = av_rescale(
AVCodecParserContext_s[62],
(int)AVCodecParserContext_s[62] >> 31,
v90,
HIDWORD(v90),
v87,
HIDWORD(v87))
+ *((_QWORD *)AVCodecParserContext_s + 6);
}
if ( v99 )
*(_QWORD *)(v6 + 1664) = *((_QWORD *)AVCodecParserContext_s + 6);
}
}
result = next;
*poutbuf = buf;
*poutbuf_size = buf_size;
return result;
}

上面代码中的黄色部分,就是较源码中添加的部分,下面看一下源码:

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
static int h264_parse(AVCodecParserContext *s,
AVCodecContext *avctx,
const uint8_t **poutbuf, int *poutbuf_size,
const uint8_t *buf, int buf_size)
{
H264ParseContext *p = s->priv_data;
ParseContext *pc = &p->pc;
int next;

if (!p->got_first) {
p->got_first = 1;
if (avctx->extradata_size) {
ff_h264_decode_extradata(avctx->extradata, avctx->extradata_size,
&p->ps, &p->is_avc, &p->nal_length_size,
avctx->err_recognition, avctx);
}
}

if (s->flags & PARSER_FLAG_COMPLETE_FRAMES) {
next = buf_size;
} else {
next = h264_find_frame_end(p, buf, buf_size, avctx);

if (ff_combine_frame(pc, next, &buf, &buf_size) < 0) {
*poutbuf = NULL;
*poutbuf_size = 0;
return buf_size;
}

if (next < 0 && next != END_NOT_FOUND) {
av_assert1(pc->last_index + next >= 0);
h264_find_frame_end(p, &pc->buffer[pc->last_index + next], -next, avctx); // update state
}
}

parse_nal_units(s, avctx, buf, buf_size);

if (avctx->framerate.num)
avctx->time_base = av_inv_q(av_mul_q(avctx->framerate, (AVRational){avctx->ticks_per_frame, 1}));
if (p->sei.picture_timing.cpb_removal_delay >= 0) {
s->dts_sync_point = p->sei.buffering_period.present;
s->dts_ref_dts_delta = p->sei.picture_timing.cpb_removal_delay;
s->pts_dts_delta = p->sei.picture_timing.dpb_output_delay;
} else {
s->dts_sync_point = INT_MIN;
s->dts_ref_dts_delta = INT_MIN;
s->pts_dts_delta = INT_MIN;
}

if (s->flags & PARSER_FLAG_ONCE) {
s->flags &= PARSER_FLAG_COMPLETE_FRAMES;
}

if (s->dts_sync_point >= 0) {
int64_t den = avctx->time_base.den * (int64_t)avctx->pkt_timebase.num;
if (den > 0) {
int64_t num = avctx->time_base.num * (int64_t)avctx->pkt_timebase.den;
if (s->dts != AV_NOPTS_VALUE) {
// got DTS from the stream, update reference timestamp
p->reference_dts = s->dts - av_rescale(s->dts_ref_dts_delta, num, den);
} else if (p->reference_dts != AV_NOPTS_VALUE) {
// compute DTS based on reference timestamp
s->dts = p->reference_dts + av_rescale(s->dts_ref_dts_delta, num, den);
}

if (p->reference_dts != AV_NOPTS_VALUE && s->pts == AV_NOPTS_VALUE)
s->pts = s->dts + av_rescale(s->pts_dts_delta, num, den);

if (s->dts_sync_point > 0)
p->reference_dts = s->dts; // new reference
}
}

*poutbuf = buf;
*poutbuf_size = buf_size;
return next;
}

解密算法

根据 FLV 的语法描述,加密的部分是 NALu,写出解密算法:

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
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes


flv = open('orig.flv', 'rb')
dst = open('dest.flv', 'wb')

flvheader = flv.read(13)
if flvheader[:3] != b'FLV':
raise TypeError
dst.write(flvheader)

# get FLV TAG first 11 Bytes that including some information
flvtag = flv.read(11)
while flvtag:
tagtype = flvtag[0] & 0x1F
datasize = int.from_bytes(flvtag[1:4], 'big')
timestamp = int.from_bytes(flvtag[4:7], 'big')

# Tag and Size of previous tag(4Bytes)
tagbody = flv.read(datasize + 4)
# get the size of tag on progress
pretagsize = tagbody[-4:]
# get video tag header
tagheader = tagbody[:5]
# decrypt video NALu
if tagtype == 9:
frametype = (tagheader[0] & 0xF0) >> 4
codecid = tagheader[0] & 0x0F
if codecid == 7:
avcpackettype = tagheader[1]
compositiontime = int.from_bytes(tagheader[2:], 'big')
else:
print(codecid)
break
# get NALu
nalu = tagbody[5:-4]
if nalu[4:12] == b'\x00\x0F\x0E\x0D\x0F\x0F\x0D\x0E':
# DO NOT USE Crypto, USE cryptography!
cipher = Cipher(algorithms.AES(b'1234567890qwerty'), modes.CFB(b'0123456789abcdef'))
decryptor = cipher.decryptor()
nalu = nalu[:4] + decryptor.update(nalu[12:])
# change datasize and previous tag size
datasize -= 8
flvtag = flvtag[:1] + datasize.to_bytes(3, 'big') + flvtag[4:]
pretagsize = (int.from_bytes(pretagsize, 'big') - 8).to_bytes(4, 'big')
dst.write(flvtag + tagheader + nalu + pretagsize)
else:
dst.write(flvtag + tagbody)
# process next tag
flvtag = flv.read(11)


flv.close()
dst.close()

解密工具

想使用 rtmpdump
在下载时顺便解密,但因为读取网络流后直接写入文件以及和需要解密的块不一样大,需要改动的地方太多,所以放弃了。
如果想要打包,首先要修改下源文件,以支持输入参数,修改结果:

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
import argparse
import os
import logging

from hexdump import hexdump
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes

parser = argparse.ArgumentParser(description='Dectypt FLV file that encrypted by libfqffmpeg.so, author tgMrZ')
parser.add_argument('-d', '--debug', action='store_true', help='show debug info')
parser.add_argument('-l', '--log', type=str, help='the log file path, by default log will output to console')
parser.add_argument('-i', '--input', type=str, help='the encrypted flv file path', required=True)
parser.add_argument('-o', '--output', type=str, help='the decrypted output flv file path', required=True)
args = parser.parse_args()
flv = open(args.input, 'rb')
dst = open(args.output, 'wb')

if args.debug:
loglevel = logging.DEBUG
else:
loglevel = logging.INFO
if args.log:
logging.basicConfig(filename=f"{args.log}.log",
format='%(asctime)s - %(levelname)s - %(message)s', level=loglevel)
else:
logging.basicConfig(format='%(asctime)s - %(levelname)s - %(message)s', level=loglevel)
logging.info(f'Encrypted FLV file path: {os.path.realpath(flv.name)}')
logging.info(f'Decrypted FLV file path: {os.path.realpath(dst.name)}')

flvheader = flv.read(13)
if flvheader[:3] != b'FLV':
logging.error('FLV HEADER ERROR!')
raise TypeError
dst.write(flvheader)

if args.debug:
logging.debug('DEBUG INFO:')
else:
logging.info("Waiting for decryption...This may be take a long time...Don't panic...")
# get FLV TAG first 11 Bytes that including some information
flvtag = flv.read(11)
serial = 0
while flvtag:
tagtype = flvtag[0] & 0x1F
datasize = int.from_bytes(flvtag[1:4], 'big')
timestamp = int.from_bytes(flvtag[4:7], 'big')
# Tag and Size of previous tag(4Bytes)
tagbody = flv.read(datasize + 4)
# get the size of tag on progress
pretagsize = tagbody[-4:]
# get video tag header
tagheader = tagbody[:5]
# decrypt video NALu
if tagtype == 9:
frametype = (tagheader[0] & 0xF0) >> 4
codecid = tagheader[0] & 0x0F
# AVC
if codecid == 7:
avcpackettype = tagheader[1]
compositiontime = int.from_bytes(tagheader[2:], 'big')
# get NALu
nalu = tagbody[5:-4]
if nalu[4:12] == b'\x00\x0F\x0E\x0D\x0F\x0F\x0D\x0E':
# DO NOT USE Crypto, USE cryptography!
cipher = Cipher(algorithms.AES(b'1234567890qwerty'), modes.CFB(b'0123456789abcdef'))
decryptor = cipher.decryptor()
nalu = nalu[:4] + decryptor.update(nalu[12:])
# change datasize and previous tag size
datasize -= 8
flvtagdst = flvtag[:1] + datasize.to_bytes(3, 'big') + flvtag[4:]
# datasize equals to length of the tag - 11
pretagsize = (datasize + 11).to_bytes(4, 'big')
nalusize = int.from_bytes(nalu[:4], 'big')
if datasize - nalusize != 9:
logging.info(f'Tag {serial}: \nDecrypt Tag Data: \n'
f'{hexdump(flvtagdst + tagheader + nalu + pretagsize, result="return")}'
f'\n{"-" * 76}\nEncrypt Tag Data: \n'
f'{hexdump(flvtag + tagbody, result="return")}')
raise TypeError
else:
flvtag = flvtagdst
dst.write(flvtag + tagheader + nalu + pretagsize)
# HEVC
elif codecid == 12:
logging.error("请使用 雷电3模拟器 抓包下载!")
break
else:
logging.error(f'UNKNOWN CODECID: {codecid} at {serial}th packet\n{hexdump(tagbody, result="return")}')
break
if args.debug:
logging.debug(f'Packet Serial Number: {serial: >{8}} , Previous Tag Size: {pretagsize.hex()}')
serial += 1
else:
serial += 1
dst.write(flvtag + tagbody)
# process next tag
flvtag = flv.read(11)


flv.close()
dst.close()
logging.info('DONE!')

主要是加了参数和日志。
接下来就是打包的问题了,在网上搜了很多,最终决定使用 Cython。

1
2
3
4
5
set PROJECT_NAME=main
set PYTHON_DIR=C:\Users\Administrator\AppData\Local\Programs\Python\Python38
%PYTHON_DIR%\python -m cython --embed -o %PROJECT_NAME%.c %PROJECT_NAME%.py
:gcc -Os -I %PYTHON_DIR%\include -o %PROJECT_NAME%.exe %PROJECT_NAME%.c -lpython38 -lm -L %PYTHON_DIR%\libs
cl.exe /nologo /Ox /MD /W3 /GS- /DNDEBUG -I%PYTHON_DIR%\include /Tc%PROJECT_NAME%.c /link /OUT:%PROJECT_NAME%.exe /SUBSYSTEM:CONSOLE /MACHINE:X64 /LIBPATH:%PYTHON_DIR%\libs

开始想使用 gcc 编译,但总是失败,看到打包扩展的时候 Cython 不推荐使用 Mingw64,参考
后来决定放弃,当时使用的参数为,在 msys2 上:

cython --embed -o main.c main.py
gcc -shared -pthread -fPIC -fwrapv -O2 -Wall -fno-strict-aliasing $(python3-config --cflags --embed) $(python3-config --embed --ldflags) -o main.exe main.c

更多的 gcc 参数:https://gcc.gnu.org/onlinedocs/gcc/Option-Summary.html
python3-config 获取参数:参考

构造 Stand-Alone

To allow you executable file to run with python you will first need to
download an embedded zip file from the python website. If you are
running a “hello world” example, only the python.zip folder,
python._pth, and python.dll files are required in the folder with
the executable file. However, to produce tools that have more practical
benefits other libraries need to be included. In that case, a Lib folder
should be created in the embedded zip folder and the appropriate
libraries from the installed python in the \Lib\site-packages folder
should be copied – in this example, the numpy, pyqt5, and pyqtgraph
libraries were copied. The /Lib folder path should also be added to
the python._pth file.
参考:https://cnocanalysis.com/2020/07/04/compiling-cython-c-code-with-msvc-compiler-to-create-a-stand-alone-executable

HEVC

这货竟然还实现了 FLV 对 HEVC 的支持,我他妈的也是醉了!RNM! 完成了对 ffmpeg 的魔改,增加了对 FLV+HEVC 的支持。
播放是可以播放了,但现在全屏马赛克,并报错:

The cu_qp_delta x is outside the valid range [-26, 25]
CABAC_MAX_BIN : 7

但是解密部分太难了,找了非常多函数都没找到,实在不行就一个一个找。总有找到的时候。

把关键位置全都找完了,还是没发现,哭了。没办法,以后再说吧。

关键位置
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
function hookSo1() {
var EVP_DecryptInit_ex = Module.findExportByName('libfqffmpeg.so', 'EVP_CipherInit_ex')
var EVP_DecryptUpdate = Module.findExportByName('libfqffmpeg.so', 'EVP_DecryptUpdate')

var a1;
var a4;
Interceptor.attach(EVP_DecryptInit_ex, {
onEnter: function (args) {
console.log('EVP_DecryptInit_ex key & iv: ------------------------- \n',
hexdump(args[3], {length: 16, header: false}), '\n',
hexdump(args[4], {length: 16, header: false}));
},
onLeave: function (retValue) {
}
})
Interceptor.attach(EVP_DecryptUpdate, {
onEnter: function (args) {
a1 = args[1]
a4 = args[4].toInt32()
console.log('IN : ------------------------------------------------- \n',
hexdump(args[3], {length: args[4].toInt32(), header: false}))
},
onLeave: function (retValue) {
console.log('OUT : ------------------------------------------------ \n',
hexdump(a1, {length: a4, header: false}))
}
})
}

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

评论