近日,发现了一款视频APP,里面的内容极其不堪入目,且需要收费才能观看,否则每天(不确定,也可能是几小时)只有1次免费观看机会
于是对其进行了暴力破解(不知道怎么回事,自从破了之后就没怎么打开过这玩意儿了...)
大概前两天的时候,这个APP的大版本号升级到了5.0,幸运的是,破解方法照样有效,趁此机会记录一下Android逆向小白的心路历程

在打开APP的时候就会根据手机特征码帮你自动注册一个账号,名字和头像都是随机的,好像也没法退出和切换账号?感觉换台设备你充的钱就没了

该APP中有2种视频,一种是可以使用免费观看次数观看的免费视频,一种是收费的,收费视频意味着不仅要开通VIP,还得额外交钱才能看
名字下面有个免费观看次数,随便点开一个免费视频后就从1变成0了,不知道过了多久,它又会变成1,若免费观看次数为0,则点开视频会提示次数用尽无法播放
对APP进行测试,发现使用免费观看次数观看过的视频可以重复观看,关闭应用再重启也能观看,但是清除数据后再次打开APP,虽然账号还是那个账号,免费观看次数依旧为0,但刚刚的视频却不能观看了,由此推断某些数据应该是存在本地的,并且有代码对该部分进行检验,从而决定能否播放视频
第一时间想到的是看看能不能直接用MT管理器修改一下dex绕过这个判断,结果一看,某加固,不好搞,先另辟蹊径试试

首先在没有观看视频的情况下打开/data/data/com.tencent.mm.oneff/shared_prefs目录,可以看到一个SharedPreferencesData_tbr.xml文件

然后随便点开一个视频,再查看该文件的内容

可以看到LOCAL_VD_HI_KEY中保存了刚才点开的视频的信息,推测401979是视频的ID
将401979:{......}改成401979:{},保存后重新打开APP,发现仍然可以观看该视频,将整个map项删掉,则不能观看了,再次恢复map的内容,又能观看了,由此推断内部写法应该是if(xxx.containsKey(ID))这种形式
当时的想法是,给它把0到999999全部填上去,应该就可以了吧?实验证明,填的项目数量超过8万后APP会崩,只填8万个再去测试发现确实可以观看少部分视频,但毕竟区间覆盖不广,这种办法pass
看来只能正面进攻了,上frida!
main.py
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | import sys import frida import scripts def on_message(message, data): if message[ 'type' ] = = 'send' : print (message[ 'payload' ]) else : print (message) app = 'com.tencent.mm.oneff' dev = frida.get_remote_device() pid = dev.spawn(app) proc = dev.attach(pid) script = proc.create_script(scripts.dex_dump % app) script.on( 'message' , on_message) script.load() dev.resume(app) sys.stdin.read() |
scripts.py
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 | # %s: App package name dex_dump = ''' var dex_count = 0 Interceptor.attach( Module.findExportByName( 'libart.so', '_ZN3art7DexFile10OpenMemoryEPKhjRKNSt3__112basic_stringIcNS3_11char_traitsIcEENS3_9allocatorIcEEEEjPNS_6MemMapEPKNS_10OatDexFileEPS9_' ), {
onEnter: function (args) {
var begin = args[1] var address = parseInt(begin, 16) + 0x20 var dex_size = Memory.readInt(ptr(address)) dex_count++ send('Dex' + dex_count + ' Size : ' + dex_size) var file = new File('/data/data/%s/classes' + (dex_count == 1 ? '' : dex_count) + '.dex', 'wb') file.write(Memory.readByteArray(begin, dex_size)) file.flush() file.close() }, onLeave: function (retval) {
} } ); ''' |


用脱壳脚本将dex给dump了下来,但是发现APP会一直卡在启动页面,下掉脱壳脚本照样卡住,推测有某些反制手段
用frida attach APP会瞬间闪退,而直接spawn会一直卡住,即使改frida-server的名字、端口也无效,于是想到了xposed,写了一个测试模块,惊喜的发现可以绕过APP的防护,这下好整了
使用MT管理器的Dex转Jar功能,把几个dex都转成jar,然后解压到电脑上(因为Windows不分大小写,而经过混淆的代码中有许多类似A、a的类文件,所以解压时不要选择覆盖,而是选择重命名),使用IDEA打开

通过字符串定位到com.ss.android.article.uitls.Aa这个类

在这个APP中,很多类都有一个静态方法来获取该类的实例,几乎看到类名.方法名().xxx就知道是访问某个类的实例对象了

Aa这个类中e方法是返回LOCAL_VD_HI_KEY的内容
接着找到了对应的map项的bean类
com.ss.android.article.listplayer.adapter.ListLikeVideoBean.class
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 | public class ListLikeVideoBean extends ListPlayerBaseBean {
public int club_id; public int coins; public int count_comment; public int count_like; public String count_like_str; public int count_pay; public int count_play; public String count_play_str; public String created_at; public String created_date; public String desc; public int duration; public String duration_str; public int id ; public boolean isChecked = false; public boolean isChoice = false; public boolean isEdit = false; public boolean isInit = true; public boolean isLike; public int isTop; public int is_activity; public boolean is_club; public int is_origin; public boolean is_pay; public int isfree; public ListLikeVideoBean.MemberBean member; public int mv_type; public String refresh_at; public String reject_reason; public String source_1080; public String source_240; public String source_480; public String source_720; public int status; public String status_str; public List <String> tags; public String thumb_cover; public int thumb_height; public int thumb_width; public String title; public int v_ext; public ListLikeVideoBean() {
} public int getItemType() {
return super .itemType; } public static class MemberBean implements Serializable {
public boolean auth_status; public int fans_count; public int followed_count; public boolean is_follow; public String nickname; public String phone; public String taggroup_name; public String thumb; public String username; public String uuid; public int videos_count; public MemberBean() {
} } } |
紧接着在com.ss.android.article.listplayer.F这个类中找到了对上述bean的引用

其中读入数据的地方可以很明显的看见


而最底层的判断函数则是下面的f函数

该函数对当前的视频进行判断,is_pay==true表示这是个收费视频并且你已经购买了,e.containsKey(c.id)和我之前猜测的一模一样,isfree==1表示这是个免费视频
所以我们只需要hook该函数并始终返回true即可
题外话:如果hook下图的d函数并返回99999,你就能在个人页面看见你有99999次免费观看次数了

下面开始编写xposed模块
打开Android Studio并创建一个No Activity的项目

我创建的包名是com.titvt.xposed
接着打开AndroidManifest.xml,在Application标签内添加下面的内容
1 2 3 4 5 6 7 8 | <application 省略... > <meta - data android:name = "xposedmodule" android:value = "true" / > <meta - data android:name = "xposeddescription" android:value = "xposed" / > <meta - data android:name = "xposedminversion" android:value = "53" / > < / application> |
这表示你写的是xposed模块,并且能被xposed框架识别到
然后在AndroidManifest.xml的同级目录下新建assets目录,在assets下新建xposed_init文件(类型是文本文件),在文件中写下你的模块要用到的类(每行一个类,比如我只有一个叫Xposed的类,我就写com.titvt.xposed.Xposed)
别忘了在build.gradle(:app)中添加依赖,一定要用compileOnly
1 2 3 4 5 6 | dependencies {
省略... compileOnly 'de.robv.android.xposed:api:82' } |
做好准备工作后,开始正式的编写模块
新建一个名为Xposed的类文件,定义一个名为Xposed的类,实现IXposedHookLoadPackage接口
实现该接口后需要重写handleLoadPackage函数,该函数会在每个package被加载的时候被调用,我们可以通过它的参数来获取以及修改当前package的信息、hook各种method
可以通过packageName来判断当前加载的package是否是我们期望的APP
由于加壳的缘故,直接hook是拿不到target的,因此先要hook壳程序来拿到被壳手动加载的部分

查询资料后发现可以从attachBaseContext函数的Context参数中拿到class loader,进而hook真正的内容,对应到本APP就是com.SecShell.SecShell.AW.attachBaseContext()
剩下的具体的就不细讲了,网上有大量的教程,下面放出完整的代码
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 | package com.titvt.xposed; import android.content.Context; import java.lang.reflect.Method; import de.robv.android.xposed.IXposedHookLoadPackage; import de.robv.android.xposed.XC_MethodHook; import de.robv.android.xposed.XposedBridge; import de.robv.android.xposed.XposedHelpers; import de.robv.android.xposed.callbacks.XC_LoadPackage.LoadPackageParam; public class Xposed implements IXposedHookLoadPackage {
@Override public void handleLoadPackage(LoadPackageParam lpparam) throws Throwable {
XposedBridge.log(lpparam.packageName); if (!lpparam.packageName.equals( "com.tencent.mm.oneff" )) {
return ; } Class<?> AW = null; try {
AW = XposedHelpers.findClass( "com.SecShell.SecShell.AW" , lpparam.classLoader); } catch (Exception ignored) {
} if (AW = = null) {
XposedBridge.log( "Find AW fail" ); return ; } Method attachBaseContext = null; try {
attachBaseContext = XposedHelpers.findMethodExact(AW, "attachBaseContext" , Context. class ); } catch (Exception ignored) {
} if (attachBaseContext = = null) {
XposedBridge.log( "Find attachBaseContext fail" ); return ; } XposedHelpers.findAndHookMethod(AW, "attachBaseContext" , Context. class , new XC_MethodHook() {
@Override protected void afterHookedMethod(MethodHookParam param) throws Throwable {
XposedBridge.log( "Hook attachBaseContext succeed" ); ClassLoader classLoader = ((Context) param.args[ 0 ]).getClassLoader(); Class<?> F = null; try {
F = XposedHelpers.findClass( "com.ss.android.article.listplayer.F" , classLoader); } catch (Exception ignored) {
} if (F = = null) {
XposedBridge.log( "Find F fail" ); return ; } Method f = null; try {
f = XposedHelpers.findMethodExact(F, "f" ); } catch (Exception ignored) {
} if (f = = null) {
XposedBridge.log( "Find f fail" ); return ; } XposedHelpers.findAndHookMethod(F, "f" , new XC_MethodHook() {
@Override protected void afterHookedMethod(MethodHookParam param) throws Throwable {
XposedBridge.log( "Hook f succeed" ); param.setResult(true); } }); } }); } } |
然后编译Release版APK,安装启动一气呵成,现在所有的免费视频都任君随便看啦(收费视频也可以点开,但是视频画面内容是“该版本已停止维护...”,推测是服务器不给播,于是返回回来的视频链接是这种提示画面)
总结一下,该APP防护做得比较到位,加壳、混淆、m3u8视频还配上了sign,甚至还有APP低版本检测,唉,驾照难考啊= =