不是因为这个平台有多优秀,是因为平台的 rtmp 串流是经过某种方式加密的,没法直接观看。 因为 apk 加了个梆梆加固,首先脱壳。
脱壳 关于app 从地址发布页 中选个地址,更换为安卓的UA之后就能下载了。
反射大师/DexInjector 免费版的梆梆加固可以使用反射大师脱壳,更多详细资料参看 https://bbs.binmt.cc/。 安装VMOS ➕ Android5.1极客版 ➕ Xposed ➕ 反射大师
,这里使用的反射大师是VMOS
用户分享的版本。 反射大师中长按写出DEX
就能一次性生成数个DEX
了。 通过尝试发现反射大师十个很好用的工具,对于梆梆加固和360加固的免费版,都能起到比较好的脱壳。 那如果用了企业版怎么办?举报! 最开始用的是frida
的一些脚本,发现只要一启动脚本,该壳就会闪退,遂放弃。
refselect.png refstart.png refdex.png refdexdump.png
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.com
和 down.tsfsb.com
。 前者更多的是处理一些信息,后者则是一些头像,动图之类的资源,后者是明文传输,前者是 key
,data
同时传输的加密格式。
VMOS 如何在虚拟机中抓包呢?在母机中安装 Postern
,设置代理到 192.168.137.1
,然后启动 VPN
,就可以在VMOS中抓包了。
CustomGson 抓到的包 apicap.png
如何用key
去解密data
是关键的。
api请求解密 首先在 AndroidManifest.xml
中找到关于 tomato
/live
的 Activity
。 出现了一个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 public void updateLiveRoomInfo () { TomatoLiveSDK.getSingleton().onAllLiveListUpdate(bindToLifecycle(), new ResultCallBack <List<LiveEntity>>() { @Override 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; } 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 ) { }).onErrorResumeNext(new HttpResultFunction ()).subscribeOn(Schedulers.io()).observeOn(Schedulers.io()); if (lifecycleTransformer != null ) { observeOn.compose(lifecycleTransformer); } observeOn.subscribe(new Consumer () { @Override public final void accept (Object obj) { TomatoLiveSDK.lambda$onAllLiveListUpdate$1 (ResultCallBack.this , (HttpResultPageModel) obj); } }); } } static 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(); } } 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; } }
这里需要注意:addConverterFactory
是Retrofit
中用来处理返回数据的一个方法。 所以在这里无疑是非常重要的了! 下面先看下别的,应该可以猜到,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 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, DES3from Crypto.PublicKey import RSAfrom Crypto.Hash import SHAfrom Crypto import Randomfrom base64 import b64decode, b64encodewith 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()
这里有一点需要注意,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的几乎所有内容全都是加密的。
clubapicap.png
往上翻,看看最开始请求有什么线索,找到该api请求的第一条,抓到的包如下,是一条json
,但内容也是加密的。
clubapijson.png
不过很显然,这条请求用于获取各类域名。 本来想从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 b64decodefrom Crypto.Cipher import PKCS1_v1_5, AES, DES3from Crypto.PublicKey import RSAfrom Crypto.Hash import SHA1from Crypto import Randomclass 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' )
从两天前着手的时候就对这部分充满兴趣,最开始我以为可能只是修改了一下.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>() { 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); } } }
这个类与IjkVideoView
和IjkMediaPlayer
紧密的关系在一起,是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 jsonfrom Appdec import Appdecimport requestsimport timefrom datetime import datetimeimport randomimport stringimport hashlibimport pytzSIGN_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 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' ]} 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 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: 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); } } 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) { p->reference_dts = s->dts - av_rescale(s->dts_ref_dts_delta, num, den); } else if (p->reference_dts != AV_NOPTS_VALUE) { 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; } } *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, modesflv = open ('orig.flv' , 'rb' ) dst = open ('dest.flv' , 'wb' ) flvheader = flv.read(13 ) if flvheader[:3 ] != b'FLV' : raise TypeError dst.write(flvheader) 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' ) tagbody = flv.read(datasize + 4 ) pretagsize = tagbody[-4 :] tagheader = tagbody[:5 ] 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 nalu = tagbody[5 :-4 ] if nalu[4 :12 ] == b'\x00\x0F\x0E\x0D\x0F\x0F\x0D\x0E' : cipher = Cipher(algorithms.AES(b'1234567890qwerty' ), modes.CFB(b'0123456789abcdef' )) decryptor = cipher.decryptor() nalu = nalu[:4 ] + decryptor.update(nalu[12 :]) 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) 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 argparseimport osimport loggingfrom hexdump import hexdumpfrom cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modesparser = 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..." ) 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' ) tagbody = flv.read(datasize + 4 ) pretagsize = tagbody[-4 :] tagheader = tagbody[:5 ] 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' ) nalu = tagbody[5 :-4 ] if nalu[4 :12 ] == b'\x00\x0F\x0E\x0D\x0F\x0F\x0D\x0E' : cipher = Cipher(algorithms.AES(b'1234567890qwerty' ), modes.CFB(b'0123456789abcdef' )) decryptor = cipher.decryptor() nalu = nalu[:4 ] + decryptor.update(nalu[12 :]) datasize -= 8 flvtagdst = flvtag[:1 ] + datasize.to_bytes(3 , 'big' ) + flvtag[4 :] 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) 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) flvtag = flv.read(11 ) flv.close() dst.close() logging.info('DONE!' )
主要是加了参数和日志。 接下来就是打包的问题了,在网上搜了很多,最终决定使用 Cython。
1 2 3 4 5 set PROJECT_NAME=mainset 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
但是解密部分太难了,找了非常多函数都没找到,实在不行就一个一个找。总有找到的时候。
hevc-dontpanic.png
把关键位置全都找完了,还是没发现,哭了。没办法,以后再说吧。
关键位置 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 (); })