光速虚拟机逆向分析 --verbose
2022-10-20更新
最近有些人@我让我教他们教程具体怎么操作,我感到奇怪并问他们怎么回事,因为我没有对外发布过这篇文章,这只是我逆向实战的一个研究记录。然后我发现这个教程已经出现在了很多平台上,这让我感到非常惊讶,因为作者没有在任何论坛或群聊等公开发布过这篇文章。如果那些地方有人称自己是作者,那么他那句话一定是假的。当然如果有人想走一遍教程并且学习相关知识,却碰到教程中的遗漏和错误,欢迎来咨询我(Q1837009039)。但如果你只需要最后的破解结果,那你找错人了,作者不提供破解成品,实在需要的话找那些走教程破解成功的人吧,他们走一遍教程而且弄对也挺不容易的。这篇文章需要你具备一些IDA和Python的知识,如果你不知道这些是什么,可以找除我以外的其他人帮忙。破解适用于版本2.4.0(3327)和当前最新版3.0.0(3328,3330),但估计下一个版本官方会加入更多的防破解措施,所以如果你很期待APP的新功能的话,我推荐购买正版,花很大劲去整一个盗版软件不值得。而这篇教程也会成为历史,且看且珍惜吧。
之前需要动态调试几个apk,于是准备在电脑上装一个安卓模拟器。但是我需要经常用到wsl,而它需要开启hyper-v功能,众所周知这个功能与大部分的安卓模拟器冲突。一开始我安装了wsa(windows安卓子系统),可惜的是它的兼容性不是很好,而且对于ida的动态调试,经常无法命中断点,而且看不到寄存器的状态,后面又尝试了几个能与hyper-v共存的模拟器,都有类似的问题。我怀疑是android_server的锅,然后拿gdbserver又试了一下,看到了下面的提示信息:
gdbserver也获取不到寄存器,猜测是架构的问题,在电脑上找模拟器这条路就放弃了。
于是我又去试了真机调试,能做到,但还是想要一个能root的设备,所以我安装了vmos pro,在设置中打开网络adb,也能够进行调试。然而vmos的root总感觉怪怪的,我调试apk时,想用gg修改器导出内存,但它始终检测不到root的存在,一直卡在启动界面。然后我去找了其他的虚拟机,发现这个光速虚拟机能正常用root,而且支持magisk和安卓10,这正好能解决打ctf时一些应用SDK版本高于模拟器安卓支持的最高版本导致不能启动的问题。但应用启动时广告很多,而且大部分特性仅限会员,在网上也找不到比较新的破解版,然后就想自己尝试一下,就诞生了这篇文章。
Java层初探
我下载的版本是当前的最新版2.4.0,与上一版本2.3.1相比它新增一个多开管理的功能,于是就想从这个版本入手。先什么也不改,对应用直接签名,结果启动时出现initialize feature fail(51)
错误,那应该就有什么验签机制。
用mt管理器自带的去除签名校验处理了一下,发现并不可行。在classes.dex中搜索base64字符串,结果只发现了微信SDK的签名,这个应该不是我们想要的,那验签逻辑应该在native层了。再看一眼Java层代码,发现这个应用并没有进行名称混淆,一些类似isVip
字样的方法名十分的显眼:
那么看来过签后vip的破解会比较轻松,于是就想着把验签的逻辑找出来就万事大吉了事实证明我这个想法还是太太太太年轻了。
Native层—签名验证
通过对几个so文件的观察,猜测可能的so有4个:libuserkernel*.so
和libVPhoneGaGaLib.so
,其他so要么太小,要么名称在网上可以直接搜到(比如libp7zip.so
),应该不会有验签逻辑:
其中前者有3个版本,分别是两个32位版和一个64位版(为什么有两个32位版我现在还没弄清楚)。libVPhoneGaGaLib.so
文件最大,于是就先从这个so入手。
用IDA打开这个文件,发现代码中布满了这样的操作:
这很明显是字符串加密,随便解了几个,字符串的内容大概有函数的名称,日志文本之类的。然而我们不可能一个个进行处理。经过观察,我发现很多这种加密文本的代码段都有这样的格式:
把一些数放到寄存器中
把寄存器中的数写入栈内
for循环,对字符逐个异或解密,解密方法有两类:
x ^= i + c
和x ^= c
。c
是一个常数,i
是字符的位置。
可以用这种模式去遍历整个代码段,找到它们的位置,然后用Unicorn模拟执行引擎来解一下:
1 | from sys import argv |
简单解释一下思路:代码逐句反编译后,脚本会识别解密循环的固定模式,然后向前(小地址方向)尽可能长地匹配除跳转指令等以外的所有指令,这样就能把解密字符串时的寄存器状态最大程度还原出来。每识别到一个这样的模式,就让Unicorn先运行循环前的代码,这时栈中字符已经放置好了,然后开启写内存的记录器,再来运行循环代码,于是栈上的字符被逐个解密,我们也通过hook知道了解密后的字符序列。运行脚本之后,的确解出了不少字符串:
然而这种方法无法应对一些情况。原因是这些字符串的解密代码都是C++动态生成的,既然是编译器,那它想怎么做就怎么做,只要最后生成的代码能达到目的就行,而我们只能识别一些固定模式。例如一个字符串过短时,C++编译时自动展开循环,就没有for的过程,我们就检测不到;又或者C++认为这个解密函数不应该内联,那for循环代码就变成了函数调用,我们也检测不到;还有一种是寄存器在很早以前就赋值了,但我们在Unicorn中模拟执行的时候不知道这一点,结果执行时输出错误结果或者直接报错,就像上图那样。循环展开这种情况比较好解决:直接IDA Ctrl+F5导出全部代码,IDA会帮我们自动还原字符串。剩下的情况现在我还没有想到什么好的办法。总结下来,这个脚本能解密出来的字符串还不到二分之一,但这个解密结果对接下来的逆向分析帮助还是很大的。
先写个IDA脚本,把这些字符串中看着像方法名的给函数重新命名,成功还原了一百来个方法名:
1 | import re, idaapi |
然后我们直接搜索应用提示启动失败的字符串,找到了一个,在Engine::StartEngine
函数中:
往前翻到上一个分支,那么验签的主要函数就找到了,把它随便命名一下,然后点开函数,往下一滑就找到了一个memcmp
函数:
通过对0x4DBF22
处的内容进行分析,结合长度为0x14
这一特征,可以发现这是签名的SHA1值。那么后面就简单了,修改函数返回值或者0x4DBF22
处的数据即可过签。
Native层—数据验证
然而实际上修改后再安装发现,应用进入后会直接黑屏。。。但是如果我们不去修改classes.dex
文件又不会黑屏(但是会出现购买vip的弹窗)。通过对几个文件的修改得知,如果修改下列文件之一,应用就会黑屏:classes.dex
,classes2.dex
,classes3.dex
,AndroidManifest.xml
。即使只在AndroidManifest.xml
后面再加几个字节也会黑屏,所以猜测应用对这几个关键文件有另一个验证机制。然后在so中继续找了很久,还是没有什么头绪。在找的过程中看了很多函数,如果碰到有加密字符串的,就手动解密字符串并且还原函数名,还有一些函数名是根据它的行为猜测的(这些函数一般以xxx
开头,而且会注明),所以后文如果看到一些莫名其妙的函数名,不用感到奇怪。
在一开始过签失败的弹窗中可以发现,这个应用会生成一些加密的日志。于是我很好奇应用的日志打印在哪,和它的解密方式,分析之后发现日志在/sdcard/Android/data/com.vphonegaga.titan/files/instanceX/logs
中,日志记录方式如下一大片红请忽略:
log_encode
函数就是加密过程(函数名随便起的),通过简单分析,可以看出这是RC4加密,密码是unhexlify('206DEA86C313F2E3')
。先写个解密脚本试试:
1 | from binascii import unhexlify |
成功解密出4个日志文件:
然后看看黑屏与不黑屏的日志有什么不同。可以使用git diff file1 file2
直观的比对文件内容。比对下来发现两次titan.log
除了顺序不同并无异常,AndroidLog.log
是虚拟机内部应用的日志,UserKernelApi.log
只有几行,只有UserKernel.log
中有几百行,而且尽是些不认识的符号,简单分析发现这是libuserkernel*.so
中的日志,使用git diff
发现一处不明所以的差异:
1 | @1637@@678@0 |
于是尝试性地打开了libuserkernel64.so
,定位到这条字符串处:
然后查看sub_18B758
,翻到最后,发现下面的代码:
通过前面的代码发现应用运行时会把0x3213C0
处的一些片段异或0x86
,那我们也尝试异或一下,结果豁然开朗:这个函数会对META-INF/KEY0.RSA
、AndroidManifest.xml
、classes.dex
、classes2.dex
、classes3.dex
、META-INF/KEY0.SF
进行长度和CRC的校验(如果CRC=0就不验证,因为第一个和最后一个文件是签名相关文件,在应用打包之前内容是未知的,没办法验证)。那么我们把对应CRC的位置都改成0即可,但是注意libuserkernel*.so
有三个,它们都需要改。改完以后验证成功通过。
Native层—VIP验证
光速虚拟机的安卓ROM有两个,分别是安卓7和安卓10。破解前面两处校验后,再次打包运行,发现安卓7能正常使用了,但是安卓10界面点不动,悬浮窗中的导航键也没反应,而悬浮窗中的“展开通知栏”功能是可以正常使用的。这个问题我找字符串无从下手,因为此现象无法提供更多信息,然后我对着so逆了很久,最后只知道触摸输入最后传到了0x240FA8
处,悬浮窗输入传到了0x95490
处。于是简单学了下frida入门教程,边学边用,尝试拦截并记录运行时函数的调用信息。一路跟着函数调用,我发现应用最后把输入数据包传到了pipe_stream::write_fully(0x27369C)
中。这个函数的第一个参数是this
指针,第二、三个是发送的数据和长度。函数调用sendto
函数把数据包发送出去,可以在发送前调用libc函数查看传入sendto
的文件描述符:
1 | function getPathById(fd){ |
打印结果显示这是一个socket,用netstat -ax
查看,发现这是一个unix domain socket。用这个函数获取socket的名字:
1 | function getSocketNameById(fd){ |
得到的结果是titan-pipe-1-input-qwerty
。再用下面的代码找到这个socket的对端:
1 | function getSocketTarget(fd){ // pid uid gid |
它得到了对端的pid,uid和gid。ps -A | grep <pid>
查看进程名称,结果为titan64_0:kernel
。 用IDA附加到此进程,发现里面没有libVPhoneGaGaLib.so
,只有libuserkernel64.so
。那么对端的代码应该就在这个so里面了。经过寻找,最后找到了关键函数:titan::dev_input::on_input_event_callback(0xA7648)
,这个函数循环接收数据包,然后调用process_input(0xA785C)
,名字随便起的:
这里推测29-31行是验证逻辑,因为我对比了2.3.1老版本的同名函数(2.3.1版本还没有这么多奇怪的验证,它在过完前面两个验证后是能接收触摸事件的),确认这几行是新加上来的。然后Ctrl+F5导出所有代码,搜索+ 3605
等字样,筛选出给它赋值的,最终定位到了titan::kernel::on_network_event(0xECD20)
。这个函数是另一个socket的接收端,它负责与libVPhoneGaGaLib.so
通信注意这个函数中有一个巨大的。于是我们又回到switch-case
,以后会考(libVPhoneGaGaLib.so
中来。
使用frida记录pipe_stream::write_fully(0x27369C)
的所有调用,打印他们的文件描述符对应的socket name(如果有)和lr
寄存器(函数调用方),最后定位到了HwNetwork::SendPacket(0x244FC8)
处,名字随便起的。对此函数查看交叉引用,能定位到好几个函数。经过分析,我发现发送的数据包有如下格式:
一个四字节的
ETEN
头(魔法值)一个四字节整数,标识数据包的类型,取值为1-8共八种
一个八字节整数,存放数据主体的长度
数据主体,定义与数据包类型有关
对libuserkernel64.so
中的switch-case
进行分析,类型7应该是我们想要的。于是在libVPhoneGaGaLib.so
中找到了发送这种数据包的函数: xxxSaveUserVipInfo(0x245A00)
,名字随便起的。对此函数查看交叉引用,有Engine::SetUserLogin(0x169F2C)
等共三个函数,另两个对逆向作用不大暂时忽略。分析Engine::SetUserLogin
函数,这个函数会把Java层中Lcom/vphonegaga/titan/user/User;
类的一个实例的uid
、token
、token2
、uuid
字段读到Engine
中保存。于是xxxSaveUserVipInfo
函数的参数就水落石出了:
void *this
,推断是HwNetwork
类bool bLogin
,是否登录const char *uid
,用户uidconst char *token2
,一个tokenconst char *uuid
,暂时不清楚有什么用const void *sign1
,JNI调用Landroid/content/pm/PackageInfo
获取的签名信息unsigned sign1len
const void *sign2
,解析ZIP包中的META-INF/[\w]+\.RSA
(正则表达式匹配一个结果)获取的签名信息unsigned sign2len
数据包的信息如下:
1 | size: 3180 |
把这一结果对应到socket的接收端case 7
处,就知道process_input
中检验的是什么了。
这里有个小插曲:虽然发送的数据包中有两种签名信息,但实际上对端
memcmp
比较两种签名的差异时,并没有保存成功与否的信息,只是把结果log了一下,可能是因为正常情况下两种数据的确是有些差异的,所以即使是正版应用这个校验也不会通过,猜测他们的源码中把验证失败的操作注释掉了,所以才有这一奇怪现象。不能保证这种验证会出现在未来的版本中。
process_input
的验证逻辑是检测是否登录、token2
是否为空,第31行不知道,但经测试与验证无关?接下来就简单了,回到Java层,token2
随便给个值就好了。
Native层—Token和代码段验证
再次打包运行,结果是,应用终于能接收输入了,但是运行大概5分钟后,应用又无法接收输入,而且与之前不同,这次连悬浮窗中的“展开通知栏”也没用了。这又双叒叕是一个验证,不过好在这次的验证点比较好找,就在titan::kernel::on_network_event
函数case 7
下面的case 8
。这里判断了uid
和token
是否为空,然后调用了函数isUidValid(0x18B150)
(名字随便起的)用于验证,过程就不展开说了,下面是代码修复结果:
1 | // local variable allocation has failed, the output may be wrong! |
这个函数的不同时期变量在栈中会重叠,所以一个变量名可能对应好几种意思。函数的逻辑是,抛开token2
的前32字节不谈,base64
解密token2
剩下的部分,然后用0x321196
处的der
格式证书中的公钥解密token2
,如果解密成功,会得到一个<isVip>|<uid>|<token_timestamp>|<expire_timestamp>|<uuid>
格式的字符串,然后应用对这个字符串作进一步的分析。由于是RSA,私钥没办法获取,所以必须把0x321196
处的证书替换成我们自己的。
Q:为什么不直接改代码让这函数返回1?
A:这里偷了一个懒,如果改.text段,要在三个so中分别找到这个函数的位置,改证书只要调用
bytes.find()
就可以直接处理三个文件。另一方面,之前修改.text段发现有另一个验证,这个后面再说。
用python生成自己的证书替换掉原证书,然后生成一个我们自己的token2
,我发现pythonCrypto
库里面竟然没有私钥加密公钥解密的函数???然后自己写了一个:
1 | from binascii import unhexlify |
把这个token2
放到Java层对应的字段即可。然而重新打包运行发现然并卵。再往后一看,原来后面还有个token2
的时间验证,有效期为两天内!于是我们不得不修改.text段了(mmp,证书白做了,),而.text段有另一处验证,没办法继续找吧(@_@) 。。。isUidValid
也白分析了,最终还是得修改.text,逸一时误一世啊
继续用日志对比大法,把.text段是否修改作为控制变量,最终找到了这个:
1 | @1809@@1014@ |
根据这个字符串寻找对应的代码,最终找到了xxxVerifyCodeModify(0x13A724)
(除了gettime
外这里的函数名都是随便起的):
IsDebuggerPresent
会读取/proc/self/status
中的TracerPid
来检测自身是否被调试:
codeNotModified
读取参数二指定的文件,对它的.plt和.text段进行MD5校验,然后与参数三进行对比:
剩下的就简单了,修改返回值或者MD5即可。这里选择了后者,因为在三个libuserkernel*.so
中MD5字节可以用python推算出来然后自动修改,而修改返回值得动代码,要分别在三个版本的so中找,挺麻烦的。
再补充点小细节,完成
这回打包运行终于没问题了,我重新捋一下要破解的位置,写了个完整的脚本,然后再次打包运行,能启动了!!!结果还没高兴30秒应用又崩溃了(′⌒`;)不知道是忽略了什么,但是之前搜签名的SHA1的时候搜到一个奇怪的位置:Engine::DoVerifyLocal(0x1DD984)
,它里面除了签名的哈希值外,还有一个设置30秒定时器的操作,与现在的情况比较相符,尝试性的改了一下,然后运行成功。应用打开放着跑了半个小时,终于没有什么奇怪的现象了,只是手动打开登录界面,点击退出登录时(实际上自己并未登录,登录效果是改smali代码改出来的),会提示你充vip。这个是小问题,找到对应xml把那些控件隐藏就OK了。至此,光速虚拟机的破解完结撒花~~
逆向时的奇怪问题
我在逆向时还是走了很多弯路的,尤其是C++的std::string
在编译内联后产生的代码,老人地铁手机.jpg:
在这个应用的so中,有大量的这种代码,所以有必要先搞清楚它的结构。std::string
的结构是这样的:
sizeof (std::string) == 24
。如果字符串长度(含终止
\0
)不超过23,那么结构体第一个字节是strlen(s) * 2
,剩下的空间用来存放字符串,这样就可以不用在堆上分配内存从而提高效率。如果字符串长度不满足上述条件,那么结构体最后八字节是指向真正字符串的指针,中间八字节是字符串长度,前八字节是为字符串申请的堆空间的长度加1,由于分配以16字节对齐,所以查看第一字节的第0位是否为1即可区别出字符串是哪一种情况。
数据结构的示意代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25namespace std{
struct string {
union {
struct {
uint8_t isLongString: 1;
uint8_t shortStringLen: 7;
char astr[23];
};
struct {
size_t spaceLenPlus1;
size_t stringLen;
char *pstr;
};
};
// 取C风格字符串指针
const char * operator*() const {
return isLongString ? pstr : astr;
}
// 取长度
size_t length() const {
return isLongString ? stringLen : shortStringLen;
}
...
};
};
像开头那段IDA反编译的代码,事实上是把(std::string)var_2F8
字符串存储到a1
(类指针)的一个成员变量里,乱七八糟的代码是拷贝构造函数内联后的杰作:如果(a1 + 1368) != var_2F8
(肯定不相等,因为a1
在堆上,var_2F8
反编译时是个数组在栈上,但拷贝构造函数不知道这一点,它只知道两个指针相等就不用复制了。我在开始的时候没看懂这段代码,然后就很迷茫),就把后者调整为const char *
指针并传入以它和它的长度为参数的构造函数sub_6F400
。所以实际上源码中可能只是一个简单的engine.uidStored = uid;
,然后得益于C++函数的内联,生成的代码就成了这副样子。
在逆向时发现的其他奇怪之处:
verifyfunction_doVerifyKernelSignature(0x1D95A8)
处,看名称和逻辑猜测也是验证函数,但好像没有任何地方调用它?Engine::DoVPhoneVerifyLocal(0x1DD984)
处,确实是在验签,目前仍不清楚触发条件,只知道修改了libVphoneGaGaLib.so
的某处后启动时触发,然而我无法复现不触发的情况了,索性让函数直接返回。0x174124
、0x16F378
、0x1D91C4
和0x17191C
处的函数,对字符串的分析发现它们都与签名/验证有关,然而最后没有修改这几项仍然正常运行,就先不改了吧。xxxRegisterTimer(0x7B854)
,名字随便起的:函数会设置一个定期调用回调函数的定时器,但是最后两个参数都是时间,猜测第一个是第一次调用的间隔时间,第二个是其余调用的间隔时间,但是有几个对此函数的调用逻辑又说不通?
梳理应用验证流程
总的来说,这个应用在防破解方面还是做了很多工作的,具体如下:
引擎启动时,直接读取安装包中的
META-INF/KEY0.RSA
,进行签名的验证(这也就是在Java层拦截签名函数无效的原因)。验证失败则提示initialize feature fail!
。虚拟机启动时,通过
lib
位置间接获取安装包路径(Java层修改mAppDir
劫持路径无效的原因),读取其中的几个关键文件(classes*.dex
,AndroidManifest.xml
),检查它们的长度和Crc32,检查结果先放入成员变量中,启动后如果发现它为0就不进行画面渲染等等。另一方面,有一个触发时间为启动后、触发条件未知的函数,它也会检测应用签名,如果失败就设置一个延迟时间为30秒的timer,timer回调时应用崩溃。Java层材料(相关类名是
Lcom/vphonegaga/titan/personalcenter/beans/MaterialBean$Material;
)包含VIP的特权和过期时间等信息,它会传入libVphoneGaGaLib.so
,后者访问https://dcdn.appmarket.api.gsxnj.cn/api2/*.php
和https://dcdn.appmarket.api.gsxnj.cn/api/time.php
进行联网校验,其中安卓10特权校验成功时才生成输入数据包(触摸事件等等)。另一方面libVPhoneGaGaLib.so
会主动获取Java层中包含token2
等数据的应用登录信息(相关类名Lcom/vphonegaga/titan/user/User;
),然后通过socket发给libuserkernel*.so
,token2
和时间验证成功后输入数据包才被接受。libuserkernel*.so
中记录了一个时间戳T
,初始值为启动时间(猜测)。应用不定期获取时间,当超过T
4分钟就发送一条信息给libVPhoneGaGaLib.so
要求更新T
的值,当超过T
5分钟就故意陷入无限等待,这时虚拟机就无法正常使用了。但是,从用户登录起,每过3分钟libVPhoneGaGaLib.so
就会主动把token2
、uid
之类的信息发送给libuserkernel*.so
,后者用非对称算法验证token2
的有效性,只有合法才会更新T
的值。当T
更新后,除非再过5分钟,否则不会停机,而如果libVPhoneGaGaLib.so
一直每隔3分钟发送正确的信息,那么停机永远不会触发,虚拟机就正常运行了。草,什么摇篮系统libuserkernel*.so
有ptrace
反调试和运行时代码验证:用自身的.plt和.text段计算MD5,并与.data段中某处的值做对比,有差别就陷入无限等待,导致虚拟机启动一直卡在0%处。
应用的代码很多,而且很多验证失败的现象并不能提供什么线索,加上代码是C++编译的,关键函数并不好找。然而写入日志的符号信息让逆向分析难度降低了不少,上面的破解思路基本上是围绕着日志输出展开的。如果应用对日志采用了很复杂的加密方法,或者说对关键代码使用了ollvm,vmp之类的手段加固的话,逆向分析难度还是很大的。
最后的修改清单
因特殊原因,这里把具体的修改位置删掉了。逆向时遇到问题可以私聊。
把改好的4个so与原so替换,然后就可以优雅地对应用进行签名了,最后附上安卓10面具安装成功的效果图: