小红书shield算法逆向破解

小红书shield算法逆向破解

前言

小红书,年轻人经常种草的app,内容还是比较丰富的,但是web端能够浏览的数据非常的少,app端上的内容比较全面,但是app端上面需要登录之后才能进入,游客无法查看内容,内容管的还是挺严的。在尝试抓取app端数据的过程中,发现其数据接口存在两个校验参数,sign和shield,笔者本着学术交流的目的研究了一下这两个参数的生成算法。最终成功破解了这两个参数的生成算法,sign主要是java层的逆向,shield主要是c层的逆向,更加熟练的使用frida进行动态分析。最后注意笔者破解的app版本较低,c层没有遇到加密和混淆的问题,本文也就仅仅相当于c层算法破解的入门探索,可供参考。

前辈的工作

依然首先在网络上找寻相关资料,目前比较靠谱的文章如下:逆向某电商社区app浅谈安卓逆向协议(四)- ida pro – 小红书,感谢前辈,向前辈学习。从以上文章中能够得出,sign比较简单,第二篇文章已经把python实现源码放出来了;但是shield则比较复杂,笔者主要是参考着第一篇文章进行分析的。

样本

逆向或者说分析时,样本很重要,同一个app,不同的版本差别也可能很大,而且随着版本的升级,安全防御措施往往会越来越强,本文用的是小红书android:versionName=”5.22.0″,经过笔者测试,该版本是目前能正常使用的最低版本,可以从该链接下载样本以供后续分析。其实最开始笔者分析的是5.17.0版本,但是后来这个版本不能用了,才找到5.22.0版本,比较舒服的是shield部分的代码基本没什么差别。

逆向

Java部分

该部分的逆向分析方法可以参考笔者之前写的文章:抖音火山版设备注册生成device_id与iid。方法论基本都是相同的,对于小红书,搜索的关键字符串是sign,sign算法是在Java部分实现的,在此就不在赘述,可以直接去看上面推荐的前辈的工作中第二篇文章。

C/C++部分

该部分的逆向主要参考前辈的工作中第一篇文章,不过分析的app版本不同,多少会有点差异。

native开发知识

Android JNI 静态注册与动态注册代码备忘录JNI学习笔记:JNIEnv、jobject与jclass详解Android Studio NDK开发-JNI调用Java方法简单的使用jni调用java方法——多参数JNI 提示。这几篇文档分别介绍了native开发时的基础知识,可以先大致看下。

静态分析

C部分的静态分析工具不像Java部分,反编译软件只有一个首选,大杀器IDA,笔者使用的是ida7.0,这个软件网上找找应该能下载到适合自己操作系统的版本的。废话不多说,下面就开始吧。

Android native开发会产生动态链接库,也就是各个so文件,在app打包时,会放在apk这个压缩文件中的lib文件夹下,根据运行时的Android系统cpu选择合适的abi文件夹,比较基础的abi是armeabi,本文中笔者就是选择的armeabi文件夹中的libshield.so文件进行分析,至于为什么是这个so文件,前辈的意思是它非常扎眼。

ida打开libshield.so文件,ida能自动识别出其是arm指令集的文件,也不用设置什么,直接OK进入。ida会进行反编译分析,稍等一会儿即可完成。接着就可以人工分析该文件了,在前辈的工作中第一步要先进行代码动态解密,这部分应该是高版本才加的功能,对于5.22.0版本的app,很多反逆向的安全措施还并没有出现,因此分析起来非常顺手。分析的第一步依然是字符串搜索,寻找“shield”字符串,View->Open subviews->Strings(快捷键:shift+F12),从打开的字符串窗口中寻找shield字符串,肉眼看不大好看,直接选中该窗口,然后键盘敲击shield即可快速定位,如下图所示:Alt pic
双击进入查看相关代码,能够发现两处引用,继续查看这两处引用所在的代码,其实最后都是指向同一处代码。分析相关代码时,默认是arm的汇编语句,但是ida有一个强大的功能,能够继续反编译为类c的伪代码,View->Open subviews->Generate pseudocode(快捷键:F5),如下图所示:Alt pic
能够从图中看出调用了很多函数,但是大部分都是通过偏移地址来调用的,同时ida并没有自动的识别出JNIEnv结构,这就需要我们人工手动改一下,把某些已知的数据类型给转换一下,比如常见的JNIEnv*结构,详细了解可参考IDA 还原JNI函数方法名 的三种方法。根据_JNIEnv::CallObjectMethod方法的定义,我们知道第一个参数v9的数据类型就是JNIEnv*,因此更改其数据类型,如下图所示:Alt pic
更改之后就能看出其中很多jni函数就被识别出来了,如下图所示:Alt pic
另外还可以在这些识别出来的jni函数上 右键鼠标->Force call type,如下图所示:Alt pic
这样会显得函数更简洁,不过这块影响不大,不弄也无所谓。另外在ida中分析时,为了更好的分析,经常对变量进行重命名,比如把v9重命名为jniEnv,尽量让代码看起来更易读一些。

接着看这部分代码,能发现requestBuilderHeaderMethod函数,通过查看该方法的交叉引用,能够找到这个函数其实来自于java方法,如下图所示:Alt pic
也就知道了该方法就是okhttp3/Request$Builder中的header(Ljava/lang/String;Ljava/lang/String;)Lokhttp3/Request$Builder;方法,我们能够从反编译的java代码中找到这个方法,它是属于okhttp3这个第三方库的一个方法,如下图所示:Alt pic
能够看出该方法需要传入两个参数,对应请求头中的key与value,这样我们再回到shield处相关的代码,就能知道它下面的方法调用就是进行请求头中shield字段的设置了,另外需要注意的是ida对于_JNIEnv::CallObjectMethod方法无法识别正确的参数个数,我们可以手动的设置函数的参数个数,依然是在函数上 右键鼠标->Set item type,如下图所示:Alt pic
这样就一目了然,sign函数的结果就是shield的值。sign函数有三个参数,第一个参数是jnienv,但是第二和第三个参数都不知道是什么,唯一能知道的就是这两个参数都是传进来的,那就先找一下这两个参数的来历吧。通过查看函数的交叉引用,发现了在java端声明的native方法:process函数,如下图所示:Alt pic
Alt pic
以上两图分别对应java端和native端,native端被我手动处理了一下。能够看出这个jni函数并不是静态注册的,而是动态注册的,是在JNI_Onload中进行动态注册的,本文不再赘述。

从_process函数中能够看出,其在请求头中设置了多个字段,同时也用sign函数计算了shield,而且我们能知道,sign函数的另外两个参数是来自于java方法,分别是urlBytesMethod和deviceIdMethod,同样我们也能在java中找到对应的方法,这样基本上就找到了这两个参数,看它们的命名就能知道一个是url相关数据,一个是deviceId相关数据,具体是什么暂时先不管,想看的话可以开发个xposed模块对这两个java方法进行hook查看。

那么现在我们回到sign函数,看看它是怎么实现的,经过笔者的手动修改和部分猜测,得到较为易读的sign函数,如下图所示:Alt pic
大体过程是把urlBytes和deviceId做一些转换,同时对固定字符串进行转换,调用S函数,得到shield字节数据,再转换数据格式就得到了shield字符串数据。那么接着看S函数,经过笔者猜测与修改,如下图所示:Alt pic
大体过程是D0函数计算出算法字符串v_s_str,从而选择S1-S4中的某一个函数进行计算。继续看D0函数,如下图所示:Alt pic
能够看出,利用deviceId和固定字符串,先生成密钥,然后进行des解密得到v_s_str。这时候我们能够想到,deviceId和固定字符串一直都是固定不变的,那么最后生成的v_s_str一定也是不变的,所以S1-S4这四个函数,程序运行时应该只会一直调用某一个函数。同时,我们大概浏览一下这四个函数,发现它们很像,都是 生成密钥->des对称加密->hash计算,只不过具体算法有所差异。

笔者不想再静态分析每一个S1-S4函数了,既然程序运行时只会调用其中一个函数,那么就看看运行时究竟调用的是哪个函数,然后再对其进行分析。动态分析呼之欲出!

动态分析

native层的动态分析,xposed就失去了用武之地,取而代之的是另一个成熟的hook框架——Frida。frida的具体原理这里也不再赘述,官方文档的说明已经比较详细了。笔者之前对这个框架的使用也不太熟练,虽然官方文档对JavaScript API的说明很详细,但是对native层hook的实例却很少,在实际使用时往往不知如何下手,笔者在学习的过程中主要参考非虫大佬的Android 软件安全权威指南中关于frida的native hook部分,以及网上大佬的一些资料,比如frida_hook_libart。学习frida部分大家可以从网上找到各种资料,多多实践操作一下就熟悉了。另外,看雪上有个帖子不错,后面有空了笔者也打算学习学习:[原创]FRIDA 使用经验交流分享

从上一节中我们知道,现在的分析目的是想要知道v_s_str的内容究竟是什么,可选的范围是:S1、S2、S3、S4这四种,那么我们就在D0函数调用结束之后查看v_decrypted_s_str的值是什么,进行hook。下面直接给出frida相应的js代码:

function hookD0() {
    // 函数的导出符号
    var addrD0 = Module.findExportByName("libshield.so", "_Z2D0PKhPhS1_ii");
    console.log("_Z2D0PKhPhS1_ii address:" + addrD0);
    // 指定地址进行挂钩
    Interceptor.attach(addrD0, {
        onEnter: function (args) {
            console.log("D0 onEnter");
            this.decrypted_s = args[1];

        },
        onLeave: function (retval) {
            var s_str_len = retval;
            var decrypted_s_str = Memory.readCString(this.decrypted_s);

            console.log("D0 onLeave, s_str_len=" + s_str_len + ", decrypted_s_str=" + decrypted_s_str);
        }
    });
}

从以上代码中能够看出,首先找到要hook的导出函数,然后在hook时,在进入该函数时保存下想要查看的参数地址,利用this对象,在退出该函数时打印出该参数。其中用到的js api都能在官方文档中找到详细说明。下面看看运行时的效果,如下图所示:Alt pic
从图中能够看出,v_decrypted_s_str就是S1字符串,而且经过笔者多次测试,不论访问app上的什么页面,打出来的日志都是S1字符串,那么就验证了我们之前的猜想,v_s_str果然是固定不变的,固定为S1!

既然知道了是S1,那么我们就去S1函数中去继续分析吧。经过笔者手动猜测与修改,S1函数,如下图所示:Alt pic
从图中能够看出,大概是先用r_generate_key生成对称密钥,然后用des加密,最后把结果进行md5,从而生成最后的v_shield_bytes。进入r_generate_key函数能够看到调用了md5,然后取结果的[4:20],正如前辈的文章中分析的那样,实现过程可以直接看arm汇编,ida反编译的c不太清晰,关键是要理解一个字节8位,转成16进制之后就变成了0xff这种样子。接着继续看比较关键的des加密函数,也就是上图中的r_des_enc函数,经过笔者手动修改,如下图所示:Alt pic
从图中能够看出,这就是一个基本的des加密过程,先设置padding模式,然后用密钥进行加密。话不多说,继续hook这两个函数看看效果。代码如下:

function hook_r_generate_key() {
    // 函数的导出符号
    var addr_r_generate_key = Module.findExportByName("libshield.so", "_Z14r_generate_keyPKhPhi");
    console.log("_Z14r_generate_keyPKhPhi address:" + addr_r_generate_key);
    // 指定地址进行挂钩
    Interceptor.attach(addr_r_generate_key, {
        onEnter: function (args) {
            var urlBytes_len_in = args[2].toInt32();
            var urlBytes_array_in = Memory.readCString(args[0], urlBytes_len_in);
            this.urlBytes_key = args[1];

            console.log("r_generate_key onEnter, urlBytes_array_in=" + urlBytes_array_in + ", urlBytes_len_in=" + urlBytes_len_in);
        },
        onLeave: function (retval) {
            var urlBytes_key_hex = hexdump(Memory.readByteArray(this.urlBytes_key, 8));

            console.log("r_generate_key onLeave, urlBytes_key_hex=\n" + urlBytes_key_hex);
        }
    });
}

function hook_r_des_enc() {
    // 函数的导出符号
    var addr_r_des_enc = Module.findExportByName("libshield.so", "_Z9r_des_encPKhPhS1_ii");
    console.log("_Z9r_des_encPKhPhS1_ii address:" + addr_r_des_enc);
    // 指定地址进行挂钩
    Interceptor.attach(addr_r_des_enc, {
        onEnter: function (args) {
            var urlBytes_len_in = args[3].toInt32();
            var urlBytes_array_in = Memory.readCString(args[0], urlBytes_len_in);
            var urlBytes_key_hex = hexdump(Memory.readByteArray(args[1], 8));
            this.encrypt_out = args[2];

            console.log("r_des_enc onEnter, urlBytes_array_in=" + urlBytes_array_in + ", urlBytes_len_in=" + urlBytes_len_in + ", urlBytes_key_hex=\n" + urlBytes_key_hex);
        },
        onLeave: function (retval) {
            var encrypt_len = retval.toInt32();
            var encrypt_out_hex = hexdump(Memory.readByteArray(this.encrypt_out, encrypt_len));

            console.log("r_des_enc onLeave, encrypt_len=" + encrypt_len + ", encrypt_out_hex=\n" + encrypt_out_hex);
        }
    });
}

笔者在编写以上代码时耗时较长,主要还是对frida的js api不熟悉导致的,另外要提的一点是hexdump这个函数简直太好用了,阅读字节不再是问题。下面来看看效果,如下图所示:Alt pic
能够看出,r_generate_key被调用了两次,是因为D0函数中也调用了r_generate_key,能够从调用参数中看出区别,这里我们主要看第二次调用,与紧接着的r_des_enc调用。通过hook,我们拿到了计算时的参数,以及计算完成后的结果,分别对应函数onEnter与onLeave时打印的数据。

有了以上数据,我们就可以对上面分析过的计算过程进行验证,验证r_generate_key函数是否就是md5之后取[4:20],验证r_des_enc是否就是des加密过程。r_generate_key函数比较容易验证,我们可以找到任何一个在线的md5计算网站对参数进行md5计算,然后判断结果的[4:20]是否就是最终结果即可。r_des_enc函数的验证就略微复杂一些,比较好的验证方式是自己找个语言实现一个des加密算法,然后把参数传进去,对结果进行比较校验。笔者使用python进行校验,具体过程笔者不再给出,结论就是ECB模式的des加密,padding模式是自定义的PAD_PKCS5,标准的PKCS#5_and_PKCS#7是填充的每一个字节都是padding的长度,然而r_des_enc中的padding却是只在最后一个字节填充padding的长度,中间字节填充0x00。如下图所示:Alt pic

至此,小红书5.22.0版本的shield计算中,S1函数算法破解完成。

总结

在破解的过程中,笔者除了对frida的使用更加熟练,而且对密码学算法进行了回顾和学习。主要还是得益于旧版本的安全等级较低,而且还没有被下架,笔者也往后看了几个版本的app,后面就增加了代码混淆,分析起来应该更复杂一些。就这样吧。

声明

请勿使用本技术于商用或大量抓取!

若因使用本技术与小红书官方造成不必要的纠纷,本人盖不负责,存粹技术爱好,若侵犯小红书相关公司的权益,请告知!

加入对话

8条评论

    1. 可能的原因有几个吧:手机设备问题,frida使用问题,frida代码写的有问题。可以逐步排查,根据frida的文档学习学习。

  1. 大佬,你好,我这边按照你的教程看了下6.68版本的 在进行到“续查看这两处引用所在的代码,其实最后都是指向同一处代码。分析相关代码时,默认是arm的汇编语句,但是ida有一个强大的功能,能够继续反编译为类c的伪代码”时,确实看到有两个地方引用,但是如何进入同一处代码 双击进入吗? 但是我进入后显示的代码为v67 = (*(__int64 (__fastcall **)(__int64, const char *))(*(_QWORD *)v11 + 1336LL))(v11, “shield”); 和你的截图有些出入,是我定位代码的问题吗

    1. 估计是版本不同的原因,我看的5.22.0版本,你看的6.68版本,新版本估计代码混淆的更厉害。
      双击进入没问题,你给出的这段代码,应该可以继续分析,v11的变量类型改成JNIEnv*,详细了解可参考IDA 还原JNI函数方法名 的三种方法,接着上面文章继续分析看看。

留下评论

您的邮箱地址不会被公开。 必填项已用 * 标注

这个站点使用 Akismet 来减少垃圾评论。了解你的评论数据如何被处理