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的错误报告

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*.solibVPhoneGaGaLib.so,其他so要么太小,要么名称在网上可以直接搜到(比如libp7zip.so),应该不会有验签逻辑:

native文件

其中前者有3个版本,分别是两个32位版和一个64位版(为什么有两个32位版我现在还没弄清楚)。libVPhoneGaGaLib.so文件最大,于是就先从这个so入手。

用IDA打开这个文件,发现代码中布满了这样的操作:

字符串加密

这很明显是字符串加密,随便解了几个,字符串的内容大概有函数的名称,日志文本之类的。然而我们不可能一个个进行处理。经过观察,我发现很多这种加密文本的代码段都有这样的格式:

  • 把一些数放到寄存器中

  • 把寄存器中的数写入栈内

  • for循环,对字符逐个异或解密,解密方法有两类:x ^= i + cx ^= cc是一个常数,i是字符的位置。

可以用这种模式去遍历整个代码段,找到它们的位置,然后用Unicorn模拟执行引擎来解一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
from sys import argv
from elftools.elf.elffile import ELFFile, Section
from capstone import Cs, CS_ARCH_ARM64, CS_MODE_ARM
from unicorn import *
from unicorn.arm64_const import *

assert len(argv) >= 2
so = ELFFile(open(argv[1], 'rb'))
section: Section = so.get_section_by_name('.text')
base: int = section.header.sh_addr
code = 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, end

class 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 data

def 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 << (size << 3)) - 1, size, 'little'))
else: assert 0

uc = Uc(UC_ARCH_ARM64, UC_MODE_ARM)
uc.mem_map(base, (len(code) + 0xFFF) // 0x1000 * 0x1000, UC_PROT_READ | UC_PROT_EXEC)
uc.mem_write(base, code)
stack = (base + len(code) + 0x1000) // 0x1000 * 0x1000
stack_size = 0x100000
uc.mem_map(stack, stack_size, UC_PROT_READ | UC_PROT_WRITE)
uc.reg_write(UC_ARM64_REG_SP, stack)
dr = DataRecorder()
uc.hook_add(UC_HOOK_MEM_WRITE, onmemwrite, dr, stack, stack + stack_size - 1)

state = State()
cs = Cs(CS_ARCH_ARM64, CS_MODE_ARM)
current = base

while current != base + len(code):
for addr, _, op, operand in cs.disasm_lite(code[current-base:], current):
current = addr
result = state.update(addr, op)
if result is not None:
start, mid = result
end = addr + 4
try:
print('found encrypted string at 0x%X 0x%X 0x%X: ' % (*result, end), end='')
uc.emu_start(start, mid)
dr.enable = True
uc.emu_start(mid, end)
print(repr(dr.finish()))
except (UcError, AssertionError) as e:
print(repr(e))
dr.reset()
current += 4

简单解释一下思路:代码逐句反编译后,脚本会识别解密循环的固定模式,然后向前(小地址方向)尽可能长地匹配除跳转指令等以外的所有指令,这样就能把解密字符串时的寄存器状态最大程度还原出来。每识别到一个这样的模式,就让Unicorn先运行循环前的代码,这时栈中字符已经放置好了,然后开启写内存的记录器,再来运行循环代码,于是栈上的字符被逐个解密,我们也通过hook知道了解密后的字符序列。运行脚本之后,的确解出了不少字符串:

解密的字符串

然而这种方法无法应对一些情况。原因是这些字符串的解密代码都是C++动态生成的,既然是编译器,那它想怎么做就怎么做,只要最后生成的代码能达到目的就行,而我们只能识别一些固定模式。例如一个字符串过短时,C++编译时自动展开循环,就没有for的过程,我们就检测不到;又或者C++认为这个解密函数不应该内联,那for循环代码就变成了函数调用,我们也检测不到;还有一种是寄存器在很早以前就赋值了,但我们在Unicorn中模拟执行的时候不知道这一点,结果执行时输出错误结果或者直接报错,就像上图那样。循环展开这种情况比较好解决:直接IDA Ctrl+F5导出全部代码,IDA会帮我们自动还原字符串。剩下的情况现在我还没有想到什么好的办法。总结下来,这个脚本能解密出来的字符串还不到二分之一,但这个解密结果对接下来的逆向分析帮助还是很大的。

先写个IDA脚本,把这些字符串中看着像方法名的给函数重新命名,成功还原了一百来个方法名:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import re, idaapi

raw = open('out.txt').read()

raw, namef, mapf = raw.splitlines(), set(), {}
pat = re.compile('.*?0x([\dA-Fa-f]+): .*?([A-Za-z_][A-Za-z\d_]*::[A-Za-z_][A-Za-z\d_]*).*?')

for line in raw:
match = pat.fullmatch(line)
if match is not None:
addr, name = int(match.group(1), 16), match.group(2)
if name not in namef:
mapf[addr] = name
namef.add(name)

for addr, name in mapf.items():
func: idaapi.func_t = idaapi.get_func(addr)
addr = func.start_ea
idaapi.set_name(addr, name)
print('0x%x -> %s' % (addr, name))

还原的函数

然后我们直接搜索应用提示启动失败的字符串,找到了一个,在Engine::StartEngine函数中:

验签提示

往前翻到上一个分支,那么验签的主要函数就找到了,把它随便命名一下,然后点开函数,往下一滑就找到了一个memcmp函数:

验签结果

通过对0x4DBF22处的内容进行分析,结合长度为0x14这一特征,可以发现这是签名的SHA1值。那么后面就简单了,修改函数返回值或者0x4DBF22处的数据即可过签。

Native层—数据验证

然而实际上修改后再安装发现,应用进入后会直接黑屏。。。但是如果我们不去修改classes.dex文件又不会黑屏(但是会出现购买vip的弹窗)。通过对几个文件的修改得知,如果修改下列文件之一,应用就会黑屏:classes.dexclasses2.dexclasses3.dexAndroidManifest.xml。即使只在AndroidManifest.xml后面再加几个字节也会黑屏,所以猜测应用对这几个关键文件有另一个验证机制。然后在so中继续找了很久,还是没有什么头绪。在找的过程中看了很多函数,如果碰到有加密字符串的,就手动解密字符串并且还原函数名,还有一些函数名是根据它的行为猜测的(这些函数一般以xxx开头,而且会注明),所以后文如果看到一些莫名其妙的函数名,不用感到奇怪。

在一开始过签失败的弹窗中可以发现,这个应用会生成一些加密的日志。于是我很好奇应用的日志打印在哪,和它的解密方式,分析之后发现日志在/sdcard/Android/data/com.vphonegaga.titan/files/instanceX/logs中,日志记录方式如下一大片红请忽略

写出日志

log_encode函数就是加密过程(函数名随便起的),通过简单分析,可以看出这是RC4加密,密码是unhexlify('206DEA86C313F2E3')。先写个解密脚本试试:

1
2
3
4
5
6
7
8
9
10
11
12
13
from binascii import unhexlify
from Crypto.Cipher import ARC4
import os, re

pat = re.compile('^(\[\d+\]){2}\[(\d\d:){2}\d\d.\d{3}\]: ', re.M)

for dir, _, files in os.walk('patch/logs'):
for file in map(lambda x: os.path.join(dir, x), files):
rc = ARC4.new(unhexlify('206DEA86C313F2E3'))
if file.endswith('.log'):
data = rc.decrypt(open(file, 'rb').read()).decode('utf-8')
data = pat.sub('', data)
open(file, 'w', encoding='utf-8').write(data)

成功解密出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.RSAAndroidManifest.xmlclasses.dexclasses2.dexclasses3.dexMETA-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
2
3
4
5
6
7
8
9
10
11
12
function getPathById(fd){
if (readlink === undefined){
let readlink_addr = Module.findExportByName('libc.so', 'readlink')
readlink = new NativeFunction(readlink_addr, 'ssize_t', ['pointer', 'pointer', 'size_t'])
}
let path = Memory.allocUtf8String(`/proc/self/fd/${fd}`)
let buf = Memory.alloc(256)
let retLen = readlink(path, buf, 256)
if (retLen < 0)
throw new Error('getPathById: readlink fails')
return buf.readUtf8String(retLen)
}

打印结果显示这是一个socket,用netstat -ax查看,发现这是一个unix domain socket。用这个函数获取socket的名字:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function getSocketNameById(fd){
if (getsockname === undefined){
let getsockname_addr = Module.findExportByName('libc.so', 'getsockname')
getsockname = new NativeFunction(getsockname_addr, 'int', ['int', 'pointer', 'pointer'])
}
let addr = Memory.alloc(256)
let retLen = Memory.alloc(4).writeUInt(256)
let ret = getsockname(fd, addr, retLen)
if (ret !== 0){
throw new Error('getSocketNameById: getsockname fails')
}
let name = addr.add(3).readUtf8String(retLen.readUInt() - 3)
return name
}

得到的结果是titan-pipe-1-input-qwerty。再用下面的代码找到这个socket的对端:

1
2
3
4
5
6
7
8
9
10
function getSocketTarget(fd){ // pid uid gid
if (getsockopt === undefined)
getsockopt = new NativeFunction(Module.findExportByName('libc.so', 'getsockopt'), 'int', ['int', 'int', 'int', 'pointer', 'pointer'])
let addr = Memory.alloc(12)
let len = Memory.alloc(4).writeUInt(12)
let ret = getsockopt(fd, 1, 17, addr, len)
if (ret !== 0)
throw new Error(`getSocketTarget: getsockopt fails: ${ret}`)
return Array.from(new Uint32Array(addr.readByteArray(12)))
}

它得到了对端的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;类的一个实例的uidtokentoken2uuid字段读到Engine中保存。于是xxxSaveUserVipInfo函数的参数就水落石出了:

  • void *this,推断是HwNetwork

  • bool bLogin,是否登录

  • const char *uid,用户uid

  • const char *token2,一个token

  • const char *uuid,暂时不清楚有什么用

  • const void *sign1,JNI调用Landroid/content/pm/PackageInfo获取的签名信息

  • unsigned sign1len

  • const void *sign2,解析ZIP包中的META-INF/[\w]+\.RSA(正则表达式匹配一个结果)获取的签名信息

  • unsigned sign2len

数据包的信息如下:

1
2
3
4
5
6
7
8
9
10
11
12
size: 3180
0000-0004 'ETEN'
0004-0008 kind: 7
0008-0016 packLen: 3164
0016-0020 bLogin
0020-0036 uid
0036-1060 token2
1060-1124 uuid
1124-1128 sign1Len
1128-2152 sign1
2152-2156 sign2Len
2156-3180 sign2

把这一结果对应到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。这里判断了uidtoken是否为空,然后调用了函数isUidValid(0x18B150)(名字随便起的)用于验证,过程就不展开说了,下面是代码修复结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
// local variable allocation has failed, the output may be wrong!
__int64 __fastcall isUidValid(__int64 a1)
{
unsigned int lentoken2; // w0
unsigned int _uid; // w20
void *base64_vt; // x0
_QWORD *base64; // x20
__int64 tokenbuf; // x0
_QWORD *tokendbuf; // x20
signed int tokendatalen; // w21
void *v9; // x0
__int64 buffer; // x20
__int64 rsa; // x21
unsigned int blocksize; // w22
int v13; // w8
unsigned __int64 current; // x26
unsigned int remain; // w8
__int64 v16; // x0
int v17; // w0
const void *v18; // x23
__int64 v19; // x28
__int64 v21; // x8
__int64 v23; // [xsp+0h] [xbp-D0h] BYREF
unsigned __int64 expire_time; // [xsp+8h] [xbp-C8h] BYREF
__int64 token_time; // [xsp+10h] [xbp-C0h] BYREF
__int64 v26; // [xsp+18h] [xbp-B8h]
int tv; // [xsp+20h] [xbp-B0h] OVERLAPPED BYREF
_DWORD tv_4[7]; // [xsp+24h] [xbp-ACh] BYREF
void *tokendata; // [xsp+40h] [xbp-90h] BYREF
__int64 v30; // [xsp+48h] [xbp-88h]
_BYTE v31[16]; // [xsp+50h] [xbp-80h] BYREF
void *tokendatab64; // [xsp+60h] [xbp-70h] BYREF
size_t tokendatab64len; // [xsp+68h] [xbp-68h]
int uid; // [xsp+78h] [xbp-58h] OVERLAPPED BYREF
int isVip; // [xsp+7Ch] [xbp-54h] BYREF

if ( !*(_BYTE *)(a1 + 3605) || !*(_BYTE *)(a1 + 3640) )// not login or token2 is ''
return 0;
tokendatab64len = 0LL;
tokendatab64 = 0LL;
lentoken2 = strnlen((const char *)(a1 + 3640), 0x400uLL);
_uid = 0;
if ( (lentoken2 & 0xFFFFFC00) != 0 || lentoken2 < 0x21 )// 32 < len < 1024
goto LABEL_42;
alloc((__int64)&tokendatab64, lentoken2 - 32);
memcpy(tokendatab64, (const void *)(a1 + 3672), (unsigned int)tokendatab64len);
v30 = 0LL;
tokendata = 0LL;
base64_vt = sub_1B1094();
base64 = (_QWORD *)cipher_init((__int64)base64_vt);
cipher_addflag((__int64)base64, 256);
tokenbuf = cipher_memory2buffer((const char *)tokendatab64, tokendatab64len);
tokendbuf = cipher_decrypt(base64, tokenbuf);
alloc((__int64)&tokendata, tokendatab64len);
tokendatalen = cipher_buffer2memory((__int64)tokendbuf, (__int64)tokendata, v30);
cipher_bufferclear((__int64)tokendbuf);
if ( tokendatalen >= 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 <= (int)blocksize )
v16 = remain;
else
v16 = blocksize;
v17 = sub_1BB624(v16, (__int64)tokendatab64 + (unsigned int)current, token_time, rsa);// 1BCF50
if ( v17 >= 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,如果解密成功,会得到一个<isVip>|<uid>|<token_timestamp>|<expire_timestamp>|<uuid>格式的字符串,然后应用对这个字符串作进一步的分析。由于是RSA,私钥没办法获取,所以必须把0x321196处的证书替换成我们自己的。

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
from binascii import unhexlify
from Crypto.PublicKey import RSA
from base64 import b64encode

def encrypt(data: bytes, key: RSA.RsaKey):
lenkey = (key.n.bit_length() + 7) >> 3
assert lenkey > 11
assert len(data) + 11 <= lenkey
datapad = b'\x00\x01' + b'\xff' * (lenkey - 3 - len(data)) + b'\x00' + data
enc = pow(int.from_bytes(datapad, 'big'), key.d, key.n)
return enc.to_bytes(lenkey, 'big')

def decrypt(data: bytes, key: RSA.RsaKey):
lenkey = (key.n.bit_length() + 7) >> 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) == raw
open('../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外这里的函数名都是随便起的):

text段验证

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:

std::string

在这个应用的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
    25
    namespace 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的某处后启动时触发,然而我无法复现不触发的情况了,索性让函数直接返回。

  • 0x1741240x16F3780x1D91C40x17191C处的函数,对字符串的分析发现它们都与签名/验证有关,然而最后没有修改这几项仍然正常运行,就先不改了吧。

  • xxxRegisterTimer(0x7B854),名字随便起的:函数会设置一个定期调用回调函数的定时器,但是最后两个参数都是时间,猜测第一个是第一次调用的间隔时间,第二个是其余调用的间隔时间,但是有几个对此函数的调用逻辑又说不通?

梳理应用验证流程

总的来说,这个应用在防破解方面还是做了很多工作的,具体如下:

  • 引擎启动时,直接读取安装包中的META-INF/KEY0.RSA,进行签名的验证(这也就是在Java层拦截签名函数无效的原因)。验证失败则提示initialize feature fail!

  • 虚拟机启动时,通过lib位置间接获取安装包路径(Java层修改mAppDir劫持路径无效的原因),读取其中的几个关键文件(classes*.dexAndroidManifest.xml),检查它们的长度和Crc32,检查结果先放入成员变量中,启动后如果发现它为0就不进行画面渲染等等。另一方面,有一个触发时间为启动后、触发条件未知的函数,它也会检测应用签名,如果失败就设置一个延迟时间为30秒的timer,timer回调时应用崩溃。

  • Java层材料(相关类名是Lcom/vphonegaga/titan/personalcenter/beans/MaterialBean$Material;)包含VIP的特权和过期时间等信息,它会传入libVphoneGaGaLib.so,后者访问https://dcdn.appmarket.api.gsxnj.cn/api2/*.phphttps://dcdn.appmarket.api.gsxnj.cn/api/time.php进行联网校验,其中安卓10特权校验成功时才生成输入数据包(触摸事件等等)。另一方面libVPhoneGaGaLib.so会主动获取Java层中包含token2等数据的应用登录信息(相关类名Lcom/vphonegaga/titan/user/User;),然后通过socket发给libuserkernel*.sotoken2和时间验证成功后输入数据包才被接受。

  • libuserkernel*.so中记录了一个时间戳T,初始值为启动时间(猜测)。应用不定期获取时间,当超过T4分钟就发送一条信息给libVPhoneGaGaLib.so要求更新T的值,当超过T5分钟就故意陷入无限等待,这时虚拟机就无法正常使用了。但是,从用户登录起,每过3分钟libVPhoneGaGaLib.so就会主动把token2uid之类的信息发送给libuserkernel*.so,后者用非对称算法验证token2的有效性,只有合法才会更新T的值。当T更新后,除非再过5分钟,否则不会停机,而如果libVPhoneGaGaLib.so一直每隔3分钟发送正确的信息,那么停机永远不会触发,虚拟机就正常运行了。草,什么摇篮系统

  • libuserkernel*.soptrace反调试和运行时代码验证:用自身的.plt和.text段计算MD5,并与.data段中某处的值做对比,有差别就陷入无限等待,导致虚拟机启动一直卡在0%处。

应用的代码很多,而且很多验证失败的现象并不能提供什么线索,加上代码是C++编译的,关键函数并不好找。然而写入日志的符号信息让逆向分析难度降低了不少,上面的破解思路基本上是围绕着日志输出展开的。如果应用对日志采用了很复杂的加密方法,或者说对关键代码使用了ollvm,vmp之类的手段加固的话,逆向分析难度还是很大的。

最后的修改清单

因特殊原因,这里把具体的修改位置删掉了。逆向时遇到问题可以私聊。

把改好的4个so与原so替换,然后就可以优雅地对应用进行签名了,最后附上安卓10面具安装成功的效果图:

破解成功