一个QQ音乐源无损音质下载软件逆向浅要分析

查看 172|回复 12
作者:QiuChenly   
逆向一个QMD QQ音乐源下载软件
这个Apk主要是用来下载QQ音乐的无损数字音频文件,我为了把我iMac上的mp3音质音乐替换为flac或者HiRes无损,一个个去网上找文件。偶然间在网上发现了这个app,有些好奇怎么实现的,于是做本篇分析文章。
样本APK:QMD 1.7.2.apk
https://wwb.lanzoub.com/iX5Lr08oc3be
写在前面 - Bugs修复 - 2022.08.12
[ol]
  • 已修复写入config文件时Windows提示GBK编码无法解码UTF-8编码的错误
  • 第一次使用时缓存路径自动初始化为执行当前代码所在的路径,直接删除config.json会重新初始化
    3. 修复了更换下载目录时目录后缀不带‘/’导致目录访问报错的bug
    4. 如果有其他问题可以github提issues。因为代码我本地测试并不严谨,本地测试使用正常但是可能在其他机器上会发生一些奇怪的现象,请回帖或github提issues。
    [/ol]
    第一步 反射大师脱壳
    对于类似于这种神奇的软件作者总会加个壳加加固保护一下源代码,我有种直觉这玩意应该也是加了固的,果然打开zip文件一看:


    16589910239841.png (155.55 KB, 下载次数: 1)
    下载附件
    2022-7-30 13:58 上传

    libjiagu.so赫然在目。。。得,直接打开反射大师先来扒一层皮看看能不能看到里面。
    反射大师脱壳过程不再赘述,直接导出内存dex即可。
    第二步 JEB静态分析
    可喜可贺,2021年初还只有Jeb 3.24,坐了一年牢出来发现竟然有4.x的版本更新了,好,很有精神!
    首先我们打开app开始下载高解析度音频,看看加密如何。


    16589915329732.jpg (274.21 KB, 下载次数: 0)
    下载附件
    2022-7-30 13:58 上传

    我们关注一下上图的/api/Download请求。
    这个包是用来获取QQ音乐的文件实际下载地址,我们来看看这个请求:
    一个迷之数据,一个朴实无华的http请求,再无其他。
    只有下面的一个http://ws.stream.qqmusic.qq.com/RS01003zLSuX07Z0LB.flac
    接口关联他。那么为了得到这个qqmusic的源数据,我们要批量下载这些音乐就需要逆向出这个迷之数据到底是什么东西,我们如何生成它。
    接口表
    获取音乐的高解析度下载地址
    /api/MusicLink/link
    那么话不多说,jeb直接打开导出的dex看函数,搜索这个字符串。


    16589918835312.jpg (336.43 KB, 下载次数: 0)
    下载附件
    2022-7-30 13:58 上传

    直接可以看到这个搜索结果了,很好,看来不需要再去找其他的dex文件了。
    tab一下看看。


    16589919873070.jpg (258.07 KB, 下载次数: 0)
    下载附件
    2022-7-30 13:58 上传

    关注以下函数:
    public String getMusicLink(String arg4) {
        String v4 = EncryptAndDecrypt.encryptText(arg4);
        String v4_1 = new HttpManager("http://8.136.185.193/api/MusicLink/link").postDataWithResult("\"" + v4 + "\"");
        Logger.e(v4_1, new Object[0]);
        return v4_1;
    }
    v4=arg4,arg4则是一个String不足为惧,先看看这个写的非常漂亮的Encryption函数:
        public static String encryptText(String arg1) {
            return EncryptAndDecrypt.encryptText(arg1, Cookie.getQQ());
        }
        public static String encryptText(String arg4, String arg5) {
            if(!TextUtils.isEmpty(arg4) && !TextUtils.isEmpty(arg5)) {
                int v1 = 0;
                StringBuilder v5 = new StringBuilder(EncryptAndDecrypt.encryptDES(arg4, "QMD" + arg5.substring(0, 8)));
                Random v4 = new Random(((long)Calendar.getInstance().get(5)));
                int v0 = v4.nextInt(4) + 1;
                while(v1
    他调用了encryptText(String arg4, String arg5)函数,那么我们就可知道arg5是作为密码而存在的,arg4则是String的原文,那么Cookie.getQQ()这个代码则就相当的可疑。
    跳转一下看看:
    public class Cookie {
        private static String Mkey;
        private static String QQ;
        public static String getMkey() { return Cookie.Mkey; }
        public static String getQQ() { return Cookie.QQ; }
        public static void setCookie(String arg0, String arg1) {
            Cookie.Mkey = arg0;
            Cookie.QQ = arg1;
        }
    }
    一个静态实体类,直接看setCookie的交叉引用看看是从decryptAndSetCookie函数设置密码的:
        public static boolean decryptAndSetCookie(String arg5) {
            String v5 = arg5.replace("-", "").replace("|", "");
            if(v5.length() >= 10 && (v5.contains("%"))) {
                String[] v5_1 = v5.split("%");
                String v0 = v5_1[0];
                String v5_2 = EncryptAndDecrypt.decryptDES(v5_1[1], v0.substring(0, 8));
                if(v5_2.length()
    继续跟踪交叉引用:
        public boolean getCookie() {
            String v0 = new HttpManager("http://8.136.185.193/api/Cookies").postDataWithResult(new Gson().toJson(SystemInfoUtil.getDeviceInfo()));
            return TextUtils.isEmpty(v0) ? false : EncryptAndDecrypt.decryptAndSetCookie(v0);
        }
    找到了,看来是从这个接口获取的数据,但是这个接口居然是POST提交,那么我们就有必要看看这个提交的数据SystemInfoUtil.getDeviceInfo()到底是什么东西:
        public static final DeviceInfo getDeviceInfo() {
            DeviceInfo v10 = new DeviceInfo(SystemInfoUtil.getUID(), SystemInfoUtil.getSystemModel(), SystemInfoUtil.getDeviceBrand(), SystemInfoUtil.getAppVersionName(), SystemInfoUtil.getSystemVersion(), SystemInfoUtil.getAppVersionCode() + "", null, 0x40, null);
            v10.setIp(EncryptAndDecrypt.encryptText(v10.getUid() + v10.getDeviceModel() + v10.getDeviceBrand() + v10.getSystemVersion() + v10.getAppVersion() + v10.getVersionCode(), "F*ckYou!"));//密码是F*ckYou!,emmmm....
            return v10;
        }
    获取了设备的一些信息,然后调用了一个setIp函数,这个加密看起来像是一个接口签名防止被抓包调用接口,提高逆向成本。


    16589927417533.jpg (42.32 KB, 下载次数: 0)
    下载附件
    2022-7-30 13:58 上传

    直接抓包看数据,可以看出来其实就是几个字符串appand一起后加上密码des,下面我们就开始先用python实现一下。
    不过在此之前我们还要看一下EncryptAndDecrypt.encryptText函数,里面是如何处理的:
        public static String encryptText(String arg4, String arg5) {
            if(!TextUtils.isEmpty(arg4) && !TextUtils.isEmpty(arg5)) {
                int v1 = 0;
                StringBuilder v5 = new StringBuilder(EncryptAndDecrypt.encryptDES(arg4, ("QMD" + arg5).substring(0, 8)));
                Random v4 = new Random(((long)Calendar.getInstance().get(5)));
                int v0 = v4.nextInt(4) + 1;
                while(v1
    清晰地看到又调用了EncryptAndDecrypt.encryptDES函数,还加上了“QMD”作为密码前置字符串,我们继续跟踪:
        public static String encryptDES(String arg5, String arg6) {
            if(arg5 != null && arg6 != null) {
                try {
                    Cipher v0 = Cipher.getInstance("DES/CBC/PKCS5Padding");
                    v0.init(1, new SecretKeySpec(arg6.getBytes(), "DES"), new IvParameterSpec(arg6.getBytes()));
                    return Base64.encodeToString(v0.doFinal(arg5.getBytes()), 0).trim();
                }
                catch(Exception v5) {
                    return v5.getMessage();
                }
            }
            return null;
        }
    到这里我们已经很清晰了,密码作为iv,加密方式为des/cbc/pkcs5padding方式填充结果,那么我们用python实现一下这个加密函数:


    16591253982291.jpg (327.21 KB, 下载次数: 0)
    下载附件
    2022-7-30 13:58 上传



    16591257371138.jpg (319.21 KB, 下载次数: 0)
    下载附件
    2022-7-30 13:58 上传

    到这里我们就用python写出了加密算法,接下来就可以用这个算法生成数据去请求http数据了。
    第三步 测试接口访问


    16591588597095.jpg (338.54 KB, 下载次数: 0)
    下载附件
    2022-7-30 13:58 上传



    16591604097922.jpg (358.13 KB, 下载次数: 0)
    下载附件
    2022-7-30 13:58 上传

    写在最后 - 总结
    于是为了下载flac,我直接写了一个python脚本。
    项目地址:https://github.com/QiuChenly/QQFlacMusicDownloader

    下载次数, 函数

  • kickbirds   

    貌似只看最后一句就可以了、
    小白网络com   

    ==== Welcome to QQMusic Digit High Quality Music Download Center ====
    Traceback (most recent call last):
      File "/workspace/python_down_jaychou-main/main.py", line 509, in
        _main(searchKey)
      File "/workspace/python_down_jaychou-main/main.py", line 371, in _main
        os.mkdir(f"{download_home}")
    FileNotFoundError: [Errno 2] No such file or directory: '/Volumes/data/music/'
    wsp2267   

    学习使我快乐
    小白网络com   

    Traceback (most recent call last):
      File "/workspace/python_down_jaychou-main/main.py", line 3, in
        import requests
    ModuleNotFoundError: No module named 'requests'
    soloeco   

    这些垃圾公司搞得我对听歌都没兴趣了
    lmk021   


    侃遍天下无二人 发表于 2022-8-2 12:25
    这些垃圾公司搞得我对听歌都没兴趣了

    你这个跟帖图有点年代了吧
    MuSTAR   

    有些想下到车载里,就挺不方便的
    emptynullnill   

    支持大佬!
    Avalon0021   


    kickbirds 发表于 2022-8-2 12:23
    貌似只看最后一句就可以了、

    哈哈哈,早应该看到你这句话的。我从头看到尾才突然发现后面的github地址
    您需要登录后才可以回帖 登录 | 立即注册

    返回顶部