本文仅用于技术学习与安全研究交流,文中涉及的分析方法、还原代码及相关数据均基于公开可获取的APK样本逆向所得,不涉及未授权访问服务器或非法获取用户数据。请勿将本文内容用于任何侵犯用户隐私、破坏系统安全或其他违反法律法规的用途,由此产生的一切后果与作者无关。如相关方认为本文内容存在不当之处,请联系作者,将第一时间配合处理或下架。
最近抽空看了下QIMEI SDK的最新版本,本来是想快速过一遍有没有新变化,结果打开IDA之后就傻眼了——核心函数经过了混淆处理,体积异常庞大,反编译出来的伪代码看着密密麻麻一大坨,IDA分析的时候明显能感觉到卡顿,跳转、查看交叉引用这些基本操作都变得很迟钝。
硬啃肯定是不现实的,与其在这个混淆过的"庞然大物"里死磕,不如换个思路:既然是同一套SDK,早期版本很可能还没上这么重的混淆,核心逻辑说不定更清晰、更容易看懂。于是决定往回翻,找找这个参数最早是从哪个版本开始出现的,先把最原始的实现逻辑看懂——一方面看看这套算法能不能复用,另一方面也想借助分析老版本积累的经验,反过来帮助确定最新版本到底采集了哪些参数。
确定了往老版本回溯的思路后,接下来的问题是:从哪个版本开始翻起,翻到哪个版本为止。
因为这个参数的特点是"一同意隐私协议就触发采集",行为比较明显、好判断,所以找最早出现这个参数的版本反而不算难——只要装上、点同意、抓包看有没有对应请求就行,不需要逐行看代码去确认。
具体操作是去豌豆荚这类应用商店找历史版本安装包,也可以找那种保存了近几年QQ所有历史版本的渠道/频道,把版本包收集齐。然后按大版本往前翻,先定位到9.0.x这个大版本区间,再往前几个版本装上试——发现再往前就没有这个参数了,这样就能确定是9.0.x里的某个具体版本刚加上的这个采集逻辑。
但翻这几个版本的过程中发现,最早加上这个参数的那几个版本,开发上好像还没做完善:App刚装上、一点进去,还没走到用户点同意隐私协议这一步,请求就已经发出去了。这种行为不符合正常预期(正常应该是要等同意之后才采集),拿来做分析起点不太合适。
继续往后翻,找到9.0.20——这个版本的行为就正常了:装上之后不会立刻发请求,需要点击同意隐私协议后才会触发。而且这个版本无论是spawn方式启动hook,还是先attach上再手动点同意,都能正常复现请求,调试起来很方便。所以最终选定9.0.20作为分析的起点。
版本定下来之后,先不急着扒so,第一步是把外部行为和代码入口对上——目的很明确,就是要逆这个采集参数(qimei)。
既然目标明确,直接从包名带qimei的类找起,很快在com.tencent.qimei.*包下面翻到一个URL:snowflake.qq.com。抓包核对了一下,确实能看到请求打到了这个域名,说明找的方向是对的。
但静态看这个URL常量的交叉引用,却没找到有代码在直接调用它——说明构造/发起请求这块逻辑不是简单的Java层字符串拼接调用,可能是走了反射,或者干脆下沉到了native层。这种情况下,静态分析很容易卡在这一步。
于是换成动态手段,用Frida hook相关方法,在请求发出的时机打印调用栈(stack trace)。栈回溯出来之后,能看到这个请求实际上是从com.tencent.qimei.uin.U.r这个方法触发的——而这个方法本身就是一个native方法,对应到so里的某个函数。
这样一来,方向就清楚了:Java层能做的静态分析已经到头了,接下来要真正定位参数是怎么生成的,就得进so里面去看了。
定位到com.tencent.qimei.uin.U.r是个native方法之后,下一步就是进到so里面去看这个函数到底干了什么。
先没有直接上IDA啃伪代码,而是先用Frida在Java层这个native方法上挂了个hook,观察它在一次完整流程(打开App到点同意)里到底被调用了几次、每次调用时的参数长什么样,心里先有个数。
进入so静态看的时候,发现既没有init函数,也没有init_array,那就直接从JNI_OnLoad看起。在RegisterNatives调用之前,有一个函数会执行一次很大内容的memcpy操作,memcpy完之后把这段内存的地址存进了一个全局变量。顺着这个全局变量做交叉引用,找到了使用它的函数,结合调用点的上下文看下来,能确定这就是一个字符串解密函数——那一大段memcpy进来的内容,就是加密后的字符串数据。
这个解密函数调用的时候需要传入两个数字参数,静态去推这两个数字对应哪个字符串、解出来是什么,搞起来比较麻烦。于是换成动态方式,写了个Frida脚本挂在这个解密函数的返回点上,把so运行过程中所有被解密出来的字符串都批量打印出来。
打印出来的这批字符串里,一眼能看出一堆文件路径,还有一些暂时看不出用途的内容。接下来就是顺着这些字符串做交叉引用,看它们分别被传到了哪些函数里使用,通过使用场景来反推这些字符串实际的用途,为后面深入分析加密逻辑做铺垫。
字符串这条线索理清楚之后,下一步自然是要顺藤摸瓜找到加密逻辑在哪——毕竟最终发出去的请求参数,肯定是经过加密处理的。
一开始还是想走Java层这条路,希望能像前面找请求发起点那样,顺着调用链一路跟到加密的地方。但这次没那么顺利:跟下去发现,从Java层能看到的调用链到某一步就断了,再往下已经看不到明显的加密相关调用了——逻辑大概率是整个下沉到native层里完成的,Java层这边只是简单传了个参数进去,具体怎么处理完全看不出来。
这样一来,单纯从Java层往下跟这条路走不通了,得换个思路,直接从native层入手去找加密逻辑的位置。
Java层这条路走不通之后,思路换成了"结果导向":既然不知道加密逻辑具体在哪个函数里,那就不用管调用链,直接在so里找加密算法本身留下的痕迹。
常见的加密算法在实现的时候,通常会用到一些固定的特征常量,比如AES的S-box替换表这类初始化数据,这些常量在二进制文件里基本是不变的,可以直接搜。于是用了IDA的findcrypt插件对so扫了一遍,搜出来命中了MD5和AES相关的常量。
先顺着AES的常量往回找引用,找到一个sub_F200函数,里面有一处在判断某个入参的长度是否等于16。结合AES常见的key/iv长度就是16字节这一点,合理怀疑这里就是在校验传进来的key和iv的长度是否合法——如果这个判断成立,那这个函数附近大概率就是AES加密真正被调用、处理key/iv的地方。
这样一来,加密逻辑的位置基本锁定了,但具体是不是对应抓包看到的那个请求参数,还需要进一步动态验证。
静态分析定位到疑似AES加密调用的位置之后,只能算是"猜测",还不能百分百确定这里就是最终请求参数的加密点。要验证这个猜测,最直接的办法就是动态跑起来,看实际输出的密文是不是和抓包抓到的请求内容对得上。
于是在这个疑似的AES函数上挂了Frida hook,先打印了一下入参内容。看了下发现这个函数其实是加解密复用的——是加密还是解密,是由入参里的某个标志位来控制的,并不是两个独立的函数。确认了这一点之后,把hook脚本完善了一下,把key、iv,以及加解密前后的内容都打印出来,方便后续对照。
跑起来之后发现一个细节:这个AES函数在一次流程里被调用了两次,也就是说请求参数实际上是经过两次AES加密得到的。通过对比两次调用前后的数据变化可以看出:第一次加密的时候,入参还是能看出明显结构的明文;第一次加密的结果出来之后,会在前面拼接上一段固定内容03000001,拼接之后再送进同一个函数做第二次加密,这才是最终发出去的密文。
另外多跑了几次流程反复测试对比,还发现一个规律:这两次加密用到的key和iv并不是完全独立随机的——只有第二次加密的key和iv是每次都会变化的,第一次加密的key和iv则是固定不变的。而且这个固定的key和iv,直接拿去前面打印出来的解密字符串列表里搜索,就能搜到——说明第一次加密用的key和iv本身就是硬编码在so里、以加密字符串的形式存在的,运行时解密出来直接用。
把最终第二次加密的结果做base64编码,和抓包里的请求参数做对比,是能对上的。这样就确认了:前面定位到的这个函数确实是标准的AES加密实现,而且是套了两层,第一层key/iv固定、第二层key/iv每次变化。
接下来要搞清楚的就是两块内容:第一次加密的明文到底是什么、怎么来的;以及第二次加密用到的、每次都会变化的key和iv是怎么生成的。
加密点确认之后,第一件要搞清楚的事就是:第一次AES加密的明文到底是什么结构,又是怎么拼出来的。
PB结构与文件信息
先看明文本身的特征。打印出来的明文数据开头是0a,这个特征在protobuf序列化数据里很常见——protobuf的字段标签(tag)由字段号和wire type组成,很多常见结构第一个字段编码出来就正好是0a开头,所以第一反应就是"这大概率是个PB结构"。直接丢进CyberChef,用protobuf解码功能反序列化了一下,果然能解出结构化的字段内容,内容整体看起来比较常规,字段类型和数值都还算规整,验证了这个猜测。
解出来的字段里,有几个字段的内容格式是这样的:
一看这个格式就很像是stat出文件的时间信息——年月日、时分秒、纳秒级的小数部分,外加时区偏移,拼起来带个分号结尾。单纯看PB内容猜不出这些时间是哪个文件的;但结合前面第4节里,字符串解密打出来的那批字符串里本来就有一堆文件路径,两边一联想,基本可以确定:这些字段就是对应文件的时间信息,说明这个PB结构里打包的核心内容之一,就是设备上某些文件的时间信息。
方向确定之后,就要搞清楚这些文件信息具体是怎么获取到的。做法是回到字符串解密函数的调用点,一个个去看每次解密调用返回的是什么内容——找到了其中解密出文件路径字符串的那次调用,顺着这个调用往下跟它的返回值被传到了哪个函数里,跟着跟着就找到了实际读取文件信息的地方:这里没有用常规的stat之类的系统调用,而是用popen执行了一条命令,再用fgets一行行读取命令输出,把需要的文件信息从输出内容里解析出来。
为了确认这个判断,直接在popen和fgets这两个函数上挂了hook,把调用时传入的命令字符串、以及读取到的输出内容都打印出来,跟PB里解出来的文件时间字段对上号,就完美确认了这条获取文件信息的链路。
<!-- 图:popen+fgets hook打印命令与输出内容的截图 -->
未知参数的计算
文件信息这块理清楚之后,PB结构里还剩一个字段一直没搞明白是什么——数值看起来完全没有规律,暂且把它叫做"未知参数"。这一块也是整个分析过程里花时间最久的部分。
找计算点
思路是从前面已经确认的文件信息入手:既然采集到的文件信息最终会存进一个全局变量,那这个全局变量大概率是为了保证后续多处使用时数据一致才这么做的。顺着这个全局变量的使用,找到一个在做strcat字符串拼接的函数,hook打印了一下,发现是把一系列参数依次拼接到一起。拼接完之后,紧接着调用了另一个函数——在这个函数的入口(onEnter)和出口(onLeave)分别打印入参和返回值,对比前后变化,就能看到"未知参数"是在这次调用里生成的,这样就把计算点精确定位到了这个函数。
IDA反编译出来这个函数因为有一些平坦化(flatten)处理,看着比较乱,懒得一点点还原控制流,索性还是走动态分析:直接在函数内部调用的几个子函数上挂hook,打印输入输出,看它们各自在干什么。这时候发现一个可疑点——同一个子函数被调用了两次,这基本可以确定,前面拼接好的字符串在这里被拆成了两份分别处理。
继续在这两次调用前后打印入参内容,一开始没看出规律。对比了两次调用各自拼接的固定字符串,发现内容分别是pzotrcm869和另一段——IDA里能看到原始形态是plzdontcrackme856c91。看到这个字符串的瞬间就反应过来了:这其实是提示"单双数分组取值"(plzdontcrackme——奇偶间隔着读),对应到逻辑上就是字符串被按奇偶位置拆成了两组,各自处理时前后拼接了不同的固定内容。
<!-- 图:plzdontcrackme856c91字符串在IDA里出现的截图 -->
确认魔改SHA2
分组的问题搞清楚之后,剩下的就是这个子函数内部到底做了什么运算。跟进去看,全是大量的异或、左移右移操作,一看就是哈希类算法的典型写法。搜了一下常量,能在里面找到SHA2标准的那组常量(比如0x428A2F98这类),说明压缩函数本体就是标准SHA2;但是看函数开头取的初始化值,却对不上标准SHA2的8个初始化常量。
<!-- 图:IDA里0x428A2F98等SHA2标准常量出现位置的截图 -->
既然压缩函数的常量表都在,只是初始化值不一样,第一反应就是"是不是只改了初始化常量"。验证方法是写个脚本,在so被dlopen加载的时候把这8个初始化常量patch成标准SHA2的值,再正常走一遍调用;同时把同样的输入丢到常规工具里跑一遍标准SHA2,两边结果做比对——完全一致。这样就确认了:这个哈希函数除了初始化常量之外,跟标准SHA2没有任何区别。
跨组取值
现在手上有两组各自的(魔改)SHA2结果,但直接看还是和最终的"未知参数"对不上。回去看这个哈希函数本身,把入参重命名成input、length、out方便阅读,然后对out做交叉引用,找到了取值的地方:第一组的哈希结果会按字节逐个做&0x1f运算,得到的值作为索引,去第二组的哈希结果里取对应位置的字节。逐个测试比对之后,确认就是这样跨组取值、拼出32位结果的。
到这一步,"未知参数"的来源和算法就完整搞清楚了:文件信息等内容拼接后按奇偶分组、分别加上不同固定字符串、各自做一次魔改SHA2(只改了初始化常量),再用第一组结果的&0x1f索引去第二组结果里取值拼接而成。
举个最终跑出来的结果例子(具体拼接顺序和常量细节详见后续正文):
至此,第一次AES加密的明文——PB结构里的文件信息和这个未知参数——就都理清楚了。接下来要追的是第二次加密用到的、每次都会变化的key和iv是怎么生成的。
前面确认过,第二次AES加密用的key和iv是每次都会变化的(不像第一次那样是固定值)。追这种"每次都不一样"的值,正常顺着调用链去看数据流会比较麻烦——因为可能是某种运算结果,也可能就是简单地取了个随机数,没法一眼看出来是哪种。
先猜了两种可能:一是有时间戳参与计算,二是干脆就是直接生成的随机值。往random相关的函数名去找,并没有发现调用了常见的random系列函数;但在字符串列表里翻到/dev/urandom和/dev/random这两个路径,而且都是在同一个函数里出现的——一个作为主路径,另一个大概率是作为备用。
确认思路和之前一样:直接在这个函数上hook,把入参和返回值都打印出来。打印出来看,入参是要读取的长度,返回值就是读到的随机字节结果。多跑几次,拿这些结果跟实际用到的key、iv数据做比对,完美对得上。这样就确认了:第二次加密的key和iv,就是直接从/dev/urandom(或备用的/dev/random)读出来的随机字节,并不涉及额外的运算。
key和iv本身的来源确认之后,还剩一个问题:抓包看到的请求里,这两个值并不是直接明文传上去的,得看看它们最终是怎么被处理、放进请求参数里的。
一开始注意到一个格式上的差异:前面hook AES函数打印出来的加密结果都是16进制格式,而抓包里请求携带的对应字段却是base64格式。这说明中间必然经过了一次编码转换,于是去搜base64相关的encode函数,根据编码表特征反向确认了对应的decode函数位置。
在encode函数上hook之后,请求发出的时候就能打印出对应参数(这里对应的就是参数1)编码前的16进制内容。多跑几次观察下来,这段数据的长度是固定的256字节。
顺着这个encode函数往回看调用栈:能看到有一个函数总共调用了6个子函数,其中两个是free,一个是前面提的encode,还有一个是decode,还有一个是字符串解密函数,看了下结果很像RSA公钥。这样一排除,实际参与"生成这段256字节数据"的来源函数就只剩下一个了。点进这个函数看了一下里面用到的字符串,很直接就能看出是RSA相关的实现——加上前面观察到的256字节这个固定长度,本身也是2048位RSA典型的密文长度,进一步印证了这个判断。
这里有个比较关键的发现:这个RSA的实现其实有两套方案——一套是走native层libcrypto的RSA实现,另一套是走Java层(调用Java标准库的RSA API)来做。而当前分析的这次调用,命中的是Java层这一套。这个点很关键,因为后续再去分析其他版本的时候,如果发现走的是另一套(libcrypto)实现,也不必意外——两套方案应该是共存的,具体走哪一套可能跟版本、设备环境或者内部的降级/兼容策略有关,分析的时候得留意区分清楚,别把两套实现的细节搞混。
综合起来,参数1的生成逻辑就是:先把内置的公钥做base64解码,还原出真正的公钥数据;然后把前面确认的key和iv直接拼接在一起,作为明文,用这个公钥做RSA加密(本次命中的是Java层实现),得到固定256字节的结果,再整体做一次base64编码,放进请求参数里。
公钥本身其实前面在批量打印字符串解密结果的时候就已经出现过了,只是当时那批解密出来的字符串里有好几个都是类似长度较长的内容,一开始没法一下子确定哪个才是公钥,这次结合RSA的分析回过头看才对上号。
定位SN计算点
在通过sub_33288算出ky(也就是前面提到的随机key)之后,顺着这个返回值继续往下跟,发现被传进了sub_126F8——从行为上看很像是在做类似strcat的字符串拼接操作。hook打印了一下拼接过程,能看到拼进去的内容依次是cpt、ky、pms,这几个字段名跟前面日志里出现的变量对得上。
传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!
最后于 3天前
被梧桐生编辑
,原因: