一个色播APP逆向——初窥千万灰色直播产业

当你凝视深渊的时候, 深渊也在凝视你 —— 尼采.

从大约13年开始, 各大直播平台就如雨后春笋般冒出来, 犹记得当初还经常在午休时看DOTA的直播下饭. 后来不知道什么时候开始不再只是游戏直播, 而是一度刮起了女主播打擦边球的歪风邪气, 再后来严厉整顿, 各个平台也才逐渐收敛野蛮的发展, 逐渐步入正轨. 然而, 在阳光照不到的地方, 依然暗流涌动…

前言

发现这一迹象的契机, 是在总部位于卢森堡的某联盟网站发现开始有大量中国人拍摄的视频, 这在国际网站中是比较少见的. 而这些视频有个共同特征, 都打有某某直播的水印. 当时并没有在意, 加上年底也比较忙没空深究. 直到今天闲下来, 才想起来一探究竟.

首先按照水印的logo去搜索, 发现一个网站:

webpage

主页倒是中规中矩, 一副文艺社交的样子, 但是看标题就知道不一般啊. SM是什么东西, 熟悉二次元的朋友应该都不会陌生. 尽管我也有所了解, 但随着深入探究还是被震惊到, 所以建议未成年的小兄弟三思而百度.

说到百度, 来看看百度对这个站的收录是如何的:

baidu

竟然没有收录! 果然藏得很深. 什么叫暗网? 不是洋葱才叫暗网, 这也是一种暗网. 回过头来, 这个网站显然只是一个下载站, 也没有web访问界面, 因此我们要深入的话需要下载其手机APP. 网站提供了安卓和iOS版本, 我们先选择前者来进行分析.

分析APP的第一步当然是使用了. 以防万一, 这种APP还是先放在沙盒里运行, 保不准有什么顽固木马. 意料之中的是不支持x86的模拟器, 换到arm环境中正常启动. 贵圈的画风是怎么样的的? 截了一张图大家感受下:

shot

里面大致分为直播和回放, 当然天下没有免费的午餐, 每个视频或者直播都只能免费播放8秒, 8秒之后自动弹出购买窗口, 平均购买价格为288-988金币不等, 金币价格如下, 支持微信支付哦:)

price

探索

大致玩了一遍这个APP, 基本功能都了解了, 接下来就从技术角度去探索下里面的实现. 一般来说逆向都要事先定下目标目标, 但是我们这次没有很明确的目标, 只是随便逛逛, 顺便复习下逆向技能.

首先在刚才的首页下载安卓apk, 用JADX看看大致的结构:

shell

看样子是使用了360的安全加固, 正好之前写了个脱壳脚本, 一键脱掉:

unpack

之后每个dex就可以正常使用jadx-gui来打开看了, 不过这里分享一个我比较喜欢的操作, 即用jadx命令行分别反编译各个dex, 然后用rsync合并到一处.

mkdir t380.jadx
for i in {1..4};do
    JAVA_OPTS="-Xmx8G" jadx -j 1 -r -d t380_${i}.jadx unpacked_classes${i}.dex
    rsync -a t380_${i}.jadx t380.jadx
done

为什么要这么做? 因为使用jadx-gui分别打开每个dex时候不论是查找函数还是查找引用都不太方便, 合并到一处可以直接用find/grep来查找. 这对于上百兆的巨型APK逆向还是很实用的.

虽然这个APK只有50多M, 但代码量也不小, 直接看有点无从下手, 就从收费的地方开始吧! 毕竟这是整个环境最核心的部分. 我们打开一个直播间, 等8秒后弹出付费窗口, 定位这个地方:

ui

当然, 还有个比较直接的方法, 直接使用adb来查看当前置顶的activity:

$ adb shell dumpsys window windows | grep Focus
  mCurrentFocus=Window{be2e8cd u0 com.zhuoyigou.dese/com.zhuoyigou.dese.ui.live.activity.PlayerLiveActivtiy}
  mFocusedApp=AppWindowToken{73e5eb8 token=Token{bfded1b ActivityRecord{2d0fb2a u0 com.zhuoyigou.dese/.ui.live.activity.PlayerLiveActivtiy t50}}}

这个直播软件有个独特的地方是里面除了直播, 还有大部分是视频回放, 观看视频回放同样是需要收费. 定位方法也是同样, 回放的activity为com.zhuoyigou.dese.ui.live.activity.PlayBackActivity.

这里再分享个小技巧, 逆向过程中经常会需要用编辑器打开某个文件, 定义个shell函数可以加速该流程:

function jopen() {
    p=`echo -n $1 | tr . /`
    file=$p.java
    gvim -R $file
}

这里以PlayBackActivity为例, 在jadx代码目录下, 可以直接这样打开文件:

jopen com.zhuoyigou.dese.ui.live.activity.PlayBackActivity

哈, 扯远了, 来看看弹窗付费的地方是如何实现的吧, 该类的部分代码风格如下:

code

做了一点对抗, 但是却没有完全混淆, 看来是不太了解木桶原理啊. 根据里面的方法名称, 发现一个比较有趣的函数:

private void showTimeOutDialog() {
    if (isMainActivityTop(GiftActivity.class)) {
        reflashGiftActivity("1", null);
    }
    if (!isFinishing()) {
        this.mLiveTimeOutDialog = new LiveTimeOutDialog(this, 2131493319);
        this.mLiveTimeOutDialog.show();
        this.mLiveTimeOutDialog.setGold(this.mPlayBackBean.getLiveRoom().getPayMoney());
        this.mLiveTimeOutDialog.setTitle("replay");
        this.mLiveTimeOutDialog.setCancelable(false);
        this.mLiveTimeOutDialog.setBuyOnClick(new 12(this));
        this.mLiveTimeOutDialog.setNoBuyOnClick(new 13(this));
    }
}

直接把这个dialog禁掉试试. 测试可以用一些hook框架如xposed或者frida, 这里选择后者, 因为可能会需要做native的分析(有没有发现这个Activity的onCreate函数是native的?). frida更新速度飞快, 而且很多用法和特性都没有写到文档里, 多看看别人写的脚本也许会有很多灵感.

劫持函数是常规操作了:

const PlayBackActivity = Java.use("com.zhuoyigou.dese.ui.live.activity.PlayBackActivity");
PlayBackActivity.showTimeOutDialog.implementation = function() {
    log("skip showTimeOutDialog()");
}

重新附加进程, 打开直播间, 发现确实不弹窗了, 但是过十几秒后视频窗口仍然会自己返回. 了解安卓开发的应该都知道, Activity返回一般是调用finish函数, 不过搜了一圈发现调用的地方太多了, 懒癌发作不想一个个检查, 于是祭出frida大法, 在finish函数中查看堆栈:

[+] skip showTimeOutDialog()
[+] skip finish()
[+] java.lang.Exception: printStackTrace here:
	at android.app.Activity.finish(Native Method)
	at com.zhuoyigou.dese.ui.live.activity.PlayBackActivity$11.handleMessage(PlayBackActivity.java:561)
	at android.os.Handler.dispatchMessage(Handler.java:102)
	at android.os.Looper.loop(Looper.java:148)
	at android.app.ActivityThread.main(ActivityThread.java:5539)
	at java.lang.reflect.Method.invoke(Native Method)
	at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:726)
	at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:616)
	at de.robv.android.xposed.XposedBridge.main(XposedBridge.java:107)

根据finish的堆栈查看com.zhuoyigou.dese.ui.live.activity.PlayBackActivity$11类中的handleMessage函数来进行验证, 确实是有这个逻辑:

void handleMessage(Message msg) {
    switch(msg.what) {
        // ...
        case 1002:
            this.this$0.mPointProgressBar.setVisibility(8);
            PlayBackActivity.access$1400(this.this$0).onPause();
            if (PlayBackActivity.access$400(this.this$0)) {
                PlayBackActivity.access$1500(this.this$0);
            } else {
                PlayBackActivity.access$1600(this.this$0);
            }
            PlayBackActivity.access$600(this.this$0).sendEmptyMessageDelayed(AidConstants.EVENT_NETWORK_ERROR, 11000);
            return;
        case AidConstants.EVENT_NETWORK_ERROR /*1003*/:
            if (PlayBackActivity.access$400(this.this$0) && PlayBackActivity.access$900(this.this$0).isShowing()) {
                PlayBackActivity.access$900(this.this$0).dismiss();
            }
            this.this$0.finish();
            return;
        // ...
    }
}

在11秒后触发事件, 直接退出窗口. 在8秒的试看后还给你3秒选择是否购买哦:) 作为验证, 我们直接对finish函数下手, 发现禁用dialog的同时禁用finish函数, 就可以绕过金币购买的限制了, 想看多久看多久.

虽然现在可以无限制看片, 但正事还是要做的. 下一步看看是哪家对象存储提供了服务, 说不定还能找到硬编码的key. 在代码中瞎逛, 又(?)发现一个有趣的函数:

private void initPlay(String videoUrl, PlayBackBean playBackBean) {
    MyLogger.jLog().e("reVideoUrl:" + videoUrl);
    this.mVideoUrl = videoUrl;
    if (TextUtils.isEmpty(videoUrl)) {
        showError(getString(R.string.video_invalid));
        return;
    }
    if (this.mPLVideoTextureView == null) {
        this.mPLVideoTextureView = (PLVideoTextureView) findViewById(2131362384);
    }
    AVOptions options = new AVOptions();
    options.setInteger("timeout", StatusCodes.AUTH_DISABLED);
    options.setInteger("fast-open", 1);
    options.setInteger("mediacodec", 0);
    PLVideoTextureView pLVideoTextureView = this.mPLVideoTextureView;
    PLVideoTextureView pLVideoTextureView2 = this.mPLVideoTextureView;
    pLVideoTextureView.setDisplayAspectRatio(2);
    this.mPLVideoTextureView.setAVOptions(options);
    this.mCustomController = new CustomController(this);
    this.mPLVideoTextureView.setMediaController(this.mCustomController);
    this.mPLVideoTextureView.setVideoPath(videoUrl);
    this.mPLVideoTextureView.start();
    this.mPLVideoTextureView.setOnInfoListener(new 4(this, playBackBean));
    this.mPLVideoTextureView.setOnCompletionListener(new 5(this));
    this.mPLVideoTextureView.setOnErrorListener(new 6(this));
}

videoUrl? 劫持该函数并打印出来, url地址如下(已打码):

http://qiniuvod.xxxxx.com/recordings/z1.xxxxx.qn1546622961492A/0_1546635114.mp4?sign=b6c6587f1794a14c250e143f28e07620&t=5c303623

这个链接使用wget竟然可以直接下载, 下载结果就是mp4视频文件, 也就是播放的回放视频. 除了mp4还有m3u8格式的链接. m3u8链接可以直接用ffmpeg下载:

ffmpeg -i http://xxx.com/vid.m3u8 -c copy vid.mp4

言归正传, 从这里的url地址看到了熟悉的qiniu, 莫非是七牛云? 查看代码发现确实是用了七牛的安卓SDK. 一年前正好写了个七牛云的对象存储管理命令行工具qncli, 所以对七牛云还是有了解的. 七牛云本身有key和secret, 但是是使用token来对bucket进行访问. 对于客户端而言, 建议的安全做法也是使用服务器下发token的方式来操作.

通过查看代码, 发现该url也确实是服务端下发的. 一般来说, 七牛云的私有bucket会通过对超时与资源一起签名来控制对内容的访问, 格式为:

http://qiniu.example.com/resource.txt?e=1546686297&token=xxx

不知为何上述链接的格式却不是传统私有链接, 也许是其他产品也说不定, 有了解的朋友也可以留言同步一下.

除了云存储, 该直播软件与很多小众直播一样, 使用了三方直播SDK减少开发量, 如某拍SDK. 同时集成了多家厂商的地址定位, 猜测是为了做LBS社交, 而主页中出现的“同城”banner也部分印证了我们的想法. 至于同城见面是做什么, 那就自己想象吧.

该APP同时也做了分享赚金币的功能, 使用ShareSDK, 支持QQ、微信、微博、豆瓣等知名社交网站, 这也解释了为什么明明这个低调得都不进入百度收录的APP, 却拥有不少的用户量. 上面也说过, 不论直播还是回放, 都是需要金币付费访问的. 而里面还可以发布非直播的视频, 有偿提供下载, 俨然成长为了一个小视频交易社区.

根据平台提供的统计数据, 充值用户的“土豪榜”如下:

user

另外通过技术手段, 发现该APP的注册用户数在25万左右, 虽然量不多, 但都是实打实的付费用户啊! 按照金币的平均价格以及用户的付费情况估算, 排除部分托儿刷榜, 保守估计该平台在18年收入也是千万级. 这还只是单个APP明面上的数据, 实际上一个公司背后往往关联数十个平台, 拔出萝卜带出泥. 由于该平台本身并没有做什么伤天害理的事情, 就点到为止吧.

小结

本文出于无聊以及猎奇的心理, 加上手痒, 就跟这个灰色直播软件玩了半天. 顺便记录了一下Android逆向的一些常用操作, 有些技术写出来确实印象深刻一点. 俗话说, 猎人要随时保持自己的技能锋利, 这对于安全研究人员也是一样的. 安全技术也是日新月异, 闲时也要把握好各种实战机会.

后记

在使用该直播软件的过程中, 惊讶地发现里面有很部分用户竟还是在校学生, 包括用户和主播. 通过某种手段添加其中一个主播聊天得知, SM行业“水很深”, 除了直播间收礼物外, 主播一般还会将高质量用户引流到其他社交平台(刷“飞机”加微信). 然后私底下会视情况进行现实调教. 用该主播的话说, ”一天2个或者3个, 元旦那天最多, 一天有8个”, 这每一“个”的收费自然也是不低的, 内容大概有XX、XX和XX, 重口味一点的有XX、XX等…

对于该“圈子”的是非且不去谈, 这个公司游走在法律边缘, 还致未成年人的身心健康于无物, 老子动画片(进击的巨人)都看不了, 这种直播竟没有相关部门管束? 对于成年的观众, 大家都有辨别是非的能力, 就不多说了吧. 至于主播, 很多人就是作为工作或者兼职, 赚的也是辛苦钱, 也不多说了吧. 毕竟, 言多必失, 你懂的.