光速虚拟机逆向分析

您所在的位置:网站首页 光速虚拟机启动失败是怎么回事 光速虚拟机逆向分析

光速虚拟机逆向分析

2024-07-16 14:48| 来源: 网络整理| 查看: 265

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模拟执行引擎来解一下:

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107from sys import argvfrom elftools.elf.elffile import ELFFile, Sectionfrom capstone import Cs, CS_ARCH_ARM64, CS_MODE_ARMfrom unicorn import *from unicorn.arm64_const import *assert len(argv) >= 2so = ELFFile(open(argv[1], 'rb'))section: Section = so.get_section_by_name('.text')base: int = section.header.sh_addrcode = section.data()class State: def __init__(self): self._ops = set('mov str movz ldp ldr adrp add sub movi ldrh and strh orr ldrb subs ldurb ldur strb eor sturb lsr movn movk csinc madd ubfx asr mul cmn lsl nop cinc ext sxtw smaddl fcvtzs udiv msub smull ldrsw smulh adds mvn neg sturh umull csneg umov sshll sdiv ldrsb stlrb bfi ands sxtb ushl umaddl umulh sxth bic orn ror rev sbfiz ldursw bfxil sbfx ldpsw ldrsh'.split()) self._pat = ('ldrb ldr add eor strb add cmp b.ne', 'ldrb ldr eor strb add cmp b.ne', 'ldrb ldur add eor strb add cmp b.ne', 'ldrb ldur eor strb add cmp b.ne') self._pat = tuple(tuple(item.split()) for item in self._pat) self._step = [0] * len(self._pat) self._set = set() def update(self, addr, op): match = None if op in self._ops: self._set.add(addr) for i, pat in enumerate(self._pat): if op == pat[self._step[i]]: self._step[i] += 1 else: self._step[i] = 0 if self._step[i] == len(pat): self._step[i] = 0 assert match is None match = i if match is not None: pos = None end = addr - 4 * len(self._pat[match]) + 4 for p in range(end - 4, -4, -4): if p in self._set: pos = p else: break assert pos is not None return pos, endclass DataRecorder: def __init__(self): self.reset() def reset(self): self._current = None self._data = None self.enable = False def hit(self, addr, data): if self.enable: if self._current is None: self._current = addr self._data = bytearray() else: self._current += 1 assert addr == self._current self._data.append(data) def finish(self): try: data = self._data.decode('utf-8') except UnicodeDecodeError: data = bytes(self._data) self.reset() return datadef onmemwrite(uc: Uc, kind: int, addr: int, size: int, value: int, dr: DataRecorder): if kind == UC_MEM_WRITE: assert size == 1 or not dr.enable dr.hit(addr, value) uc.mem_write(addr, int.to_bytes(value & (1 = 1 ) { alloc((__int64)&tokendatab64, tokendatalen); memcpy(tokendatab64, tokendata, tokendatalen); } if ( tokendata ) operator delete[](tokendata); v30 = 0xF00000000LL; tokendata = v31; v31[0] = 0; v9 = sub_1AC230(); buffer = cipher_init((__int64)v9); if ( (unsigned int)cipher_bufferload(buffer, (__int64)&unk_321196, 550u) == 550 ) { *(_QWORD *)&tv_4[1] = 0xF00000000LL; *(_QWORD *)&tv = &tv_4[3]; rsa = sub_1C04F8(buffer, 0LL); LOBYTE(tv_4[3]) = 0; v26 = 0LL; token_time = 0LL; blocksize = sub_1BB5A8(rsa); // 0x200 sub_73858((__int64)&token_time, blocksize + 1); v13 = tokendatab64len; if ( (_DWORD)tokendatab64len ) { current = 0LL; do { remain = v13 - current; if ( (int)remain = 1 ) { *(_BYTE *)(token_time + (unsigned int)v17) = 0; v18 = (const void *)token_time; if ( *(_BYTE *)token_time ) { v19 = 0LL; while ( *(unsigned __int8 *)(token_time + v19++ + 1) ) ; if ( (_DWORD)v19 ) { sub_71604((__int64)&tokendata, v30 + v19); memcpy((char *)tokendata + (unsigned int)v30, v18, (unsigned int)v19); LODWORD(v30) = v30 + v19; *((_BYTE *)tokendata + (unsigned int)v30) = 0; } } } v13 = tokendatab64len; current += (int)blocksize; } while ( current < (unsigned int)tokendatab64len ); } sub_1BB9CC(rsa); if ( token_time ) operator delete[]((void *)token_time); if ( *(_QWORD *)&tv && &tv_4[3] != *(_DWORD **)&tv ) operator delete[](*(void **)&tv); } sub_1AA098(buffer); if ( !(_DWORD)v30 ) { _uid = 0; goto LABEL_39; } *(_QWORD *)&uid = 0LL; expire_time = 0LL; token_time = 0LL; v23 = 0LL; tv = 53; strcpy((char *)tv_4, "%d|%d|%lu|%lu|%lu"); _uid = 0; if ( sscanf((const char *)tokendata, (const char *)tv_4, &isVip, &uid, &token_time, &expire_time, &v23) != 5 || isVip != 1 || (_uid = uid) == 0 ) {LABEL_39: if ( !tokendata ) goto LABEL_42;LABEL_40: if ( v31 != tokendata ) operator delete[](tokendata); goto LABEL_42; } if ( token_time < expire_time && _uid == atoi((const char *)(a1 + 3624)) ) { gettimeofday((struct timeval *)&tv, 0LL); // current second v21 = *(_QWORD *)&tv - token_time; if ( *(_QWORD *)&tv - token_time < 0 ) v21 = token_time - *(_QWORD *)&tv; _uid = v21 < 172800; // 2 day if ( !tokendata ) goto LABEL_42; goto LABEL_40; } _uid = 0; if ( tokendata ) goto LABEL_40;LABEL_42: if ( tokendatab64 ) operator delete[](tokendatab64); return _uid;}

这个函数的不同时期变量在栈中会重叠,所以一个变量名可能对应好几种意思。函数的逻辑是,抛开token2的前32字节不谈,base64解密token2剩下的部分,然后用0x321196处的der格式证书中的公钥解密token2,如果解密成功,会得到一个||||格式的字符串,然后应用对这个字符串作进一步的分析。由于是RSA,私钥没办法获取,所以必须把0x321196处的证书替换成我们自己的。

Q:为什么不直接改代码让这函数返回1?

A:这里偷了一个懒,如果改.text段,要在三个so中分别找到这个函数的位置,改证书只要调用bytes.find()就可以直接处理三个文件。另一方面,之前修改.text段发现有另一个验证,这个后面再说。

用python生成自己的证书替换掉原证书,然后生成一个我们自己的token2,我发现pythonCrypto库里面竟然没有私钥加密公钥解密的函数???然后自己写了一个:

123456789101112131415161718192021222324252627282930313233from binascii import unhexlifyfrom Crypto.PublicKey import RSAfrom base64 import b64encodedef encrypt(data: bytes, key: RSA.RsaKey): lenkey = (key.n.bit_length() + 7) >> 3 assert lenkey > 11 assert len(data) + 11 > 3 assert lenkey > 11 and len(data) == lenkey dec = pow(int.from_bytes(data, 'big'), key.e, key.n).to_bytes(lenkey, 'big') for i, c in enumerate(dec): if i == 0: assert c == 0 elif i == 1: assert c == 1 else: if c != 255: assert c == 0 i += 1 break else: assert 0 return dec[i:]key = RSA.import_key(open('new.der', 'rb').read())key1 = RSA.import_key(open('export.der', 'rb').read())raw = b'1|1|0|2147483647|0'enc = encrypt(raw, key)assert decrypt(enc, key1) == rawopen('../patch/token.txt', 'wb').write(b'A' * 32 + b64encode(enc))

把这个token2放到Java层对应的字段即可。然而重新打包运行发现然并卵。再往后一看,原来后面还有个token2的时间验证,有效期为两天内!于是我们不得不修改.text段了(mmp,证书白做了,isUidValid也白分析了,最终还是得修改.text,逸一时误一世啊),而.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即可区别出字符串是哪一种情况。

数据结构的示意代码:

12345678910111213141516171819202122232425namespace 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,初始值为启动时间(猜测)。应用不定期获取时间,当超过T4分钟就发送一条信息给libVPhoneGaGaLib.so要求更新T的值,当超过T5分钟就故意陷入无限等待,这时虚拟机就无法正常使用了。但是,从用户登录起,每过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面具安装成功的效果图:



【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3