脱壳逆向,不必硬碰硬。大多数业务,迭代更新只是更新业务,但是加密的逻辑并无变化。
对于攻击方:
-
不少业务不会在一开始就加固: 可以直接找到历史包,最近一次未加固版本。 类似业务不会一开始套CDN,可以通过查询域名解析历史找到可能的源站。
-
找加固后较旧的版本: 各类加固不断更新,逆向思路/工具也不断升级。如果持有的工具能应对老版本的加固,何不找老版本的软件包?
对于业务方:
除了要保持更新加固软件,还需要按版本不断更换关键业务的AES KEY。
下面是逆向过程。
找历史包
- apkpure、apkimirror 之类
- 官方软件下载链接:可猜测历史版本号的链接,或把URL 前半部分丢搜索引擎里面
- 豌豆荚:国内软件,豌豆荚的历史包非常全
在豌豆荚找到该软件历史包,2分法下载apk改zip,解压查看assets和lib等内容判断是否进行加固和采用的加固厂商:
- 6.5.0: 最后一个未加固的版本;
- 7.0.0: 第一个使用360 加固的版本;
脱壳
安装7.0 版本,生成二维码,可正常通行,表明7.0 版逻辑正常。
先试一试用BlackDex 脱壳,一次性成功。
注意:如果安装BlackDex 之后未看到APP,表明选择的版本错了,换另外一个版本,BlackDex32或者64。
脱壳拿到的dex 文件,使用d2j-dex2jar 转为jar。
apktool、dex2jar、jd-gui 的安装,各位师傅写的都非常多了,没啥好说的。
注意:macOS 如果打开jd-gui 提示JAVA版本问题,Google 搜索“Java反编译利器JD-GUI(解决报错This program requires Java 1.8+)”,也可以找到很多师傅写的文章。
收集必要信息
开始逆向看代码之前,用proxyman 或者charles,走一遍完整的业务流程,抓取到所需的信息,同时也看一遍客户端和服务端交互的信息,会有个思路,特别是对一些关键参数名、接口名,方便我们后遍走读代码。
proxyman 的使用,也有非常多师傅的文章,Google 即可。
例如获取到的登录后的个人信息,留作备用,后续肯定会用到。
{
"code": "000",
"describe": "Operation succeed!",
"data": {
"id": "A5D00B33819E458B8XXXXXXXX",
"account": "1091XXXX",
"nickName": "",
"userName": "陈XX",
...
注意这个ID:A5D00B33819E458B8XXXXXXXX,后文会出现。
逆向代码
dex 拖入jd-gui 之后,从哪里开始看呢?
用apktool 直接逆向到apk 包虽然没有关键代码,但是会包含一些变量名,可以作为线索。 例如APP 上显示“扫码通行,点击刷新”,我们直接grep:
关键词 passcode
,另外这是个二维码,也可以从先找qrcode
相关函数,然后看看那些地方调用了它,倒着看。
看到关键行:
param1String = EncodeUtil.encodeKey(CacheUtils.getUserId(), QrCodeFragment.this.mPassCode, "A", CacheUtils.getAppVersion());
出现一个关键信息:qrCodeKey, 最后进行了AES 加密:
return AESUtil.Encrypt(stringBuilder3.toString(), qrCodeKey, qrCodeKey);
先看一下AES 的加密模式和填充算法等,再回过来看这个str生成逻辑。
ok,我们得到了,AES/CBC/PKCS5Padding
,也有密钥(qrCodeKey)了。
打开安装的APP,连续生成2,3张通行二维码,解析出二维码表示的字符串:
l/YKQnk8F/JcCXI3k4uvf05a1hcpnZSmdgBV...ak9A/xPh7KsgPhUffKRjajLi/2u5IIOA==
l/YKQnk8F/JcCXI3k4uvf3Ekfg1dzyTiTp+D...AhSGF3bqW2OjWiaCZDU/IbFQ9di9af3wUew==
l/YKQnk8F/JcCXI3k4uvf1VsKs6lu...3AtHsxb9I16t2vXJ0qpK+7DmEgADVBdw==
找一个AES 在线加解密的网站,试一试:
ok,搞定了sign_str,有了这个sign_str 能方便我们后续校对sign_str 生成逻辑和编写poc。
同时在这一步,可以发现前半部分字符串和上文出现UserID 有类似的地方。接下来就是读懂前面encodeKey 函数的逻辑,以及传入mCode 的生成落。
UserID+mPassCode+md5_16(mPassCode)+"B"+版本号。 UserID和mPassCode 不是直接拼凑,而是间隔交叉拼在一起。
再看下mPassCode的生成逻辑:
从代码来看,只是简单的递增。这个时候,再去生成几个实际的二维码,用AES在线解密出,拿到mPassCode 的md5_16,丢到cmd5.com 里面看看:
e6e61673edb05342 -> 00000171
7a56b435986b1611 -> 00000172
651bac14cc8b1285 -> 00000173
确定只是简单递增,那么这个code 不是基于时间的OTP算法,猜测服务端会记录当前用到最大的code,以使之前的code 失效(后续实际验证也是如此)。
到这里,基本就清晰了: mPassCode 单调递增,UserID 按4个分隔,,mPassCode按2个分发,两者交叉拼在一起,再加上mPassCode 的MD5-16,加字符“B”,再加版本号。
举例:
UserID 为:A12345678910ABCEED6A474E79AA4FF6
当前mPassCode为:00000123
,md5_16值为c9b62e572837d1b9
版本号为:7.1.2
拼凑字符串为:
A123 4567 8910 ABCE ED6A 474E79AA4FF6
00 00 00 01 23 c9b62e572837d1b9 B7.1.2
结果为:
A12300456700891001ABCE23ED6A474E79AA4FF6c9b62e572837d1b9B7.1.2
PoC
import hashlib
import random
import string
from Crypto.Cipher import AES
from base64 import b64decode, b64encode
BLOCK_SIZE = AES.block_size
pad = lambda s: s + (BLOCK_SIZE - len(s.encode()) % BLOCK_SIZE) * chr(BLOCK_SIZE - len(s.encode()) % BLOCK_SIZE)
unpad = lambda s: s[:-ord(s[len(s) - 1:])]
class AESCipher:
def __init__(self, secretkey: str):
self.key = secretkey
self.iv = secretkey[0:16] # 偏移量
def encrypt(self, text):
"""
加密 :先补位,再AES加密,后base64编码
:param text: 需加密的明文
:return:
"""
# text = pad(text) 包pycrypto的写法,加密函数可以接受str也可以接受bytess
text = pad(text).encode() # 包pycryptodome 的加密函数不接受str
cipher = AES.new(key=self.key.encode(), mode=AES.MODE_CBC, IV=self.iv.encode())
encrypted_text = cipher.encrypt(text)
# 进行64位的编码,返回得到加密后的bytes,decode成字符串
return b64encode(encrypted_text).decode('utf-8')
def decrypt(self, encrypted_text):
"""
解密 :偏移量为key[0:16];先base64解,再AES解密,后取消补位
:param encrypted_text : 已经加密的密文
:return:
"""
encrypted_text = b64decode(encrypted_text)
cipher = AES.new(key=self.key.encode(), mode=AES.MODE_CBC, IV=self.iv.encode())
decrypted_text = cipher.decrypt(encrypted_text)
return unpad(decrypted_text).decode('utf-8')
code='00000123'
c1=code[0:2]
c2=code[2:4]
c3=code[4:6]
c4=code[6:8]
md5_16=hashlib.md5(code.encode('utf-8')).hexdigest()[8:-8]
uid='A12345678910ABCEED6A474E79AA4FF6'
u1=uid[0:4]
u2=uid[4:8]
u3=uid[8:12]
u4=uid[12:16]
u5=uid[16:]
sign_str=f"{u1}{c1}{u2}{c2}{u3}{c3}{u4}{c4}{u5}{md5_16}B7.1.2"
print(sign_str)
encrypted_text = AESCipher("略").encrypt(sign_str)
print(encrypted_text)
再将encrypted_text 生成二维码即可。
如果我是业务方,可以怎么改进?
我的思路是:
每个新版,采用最新的加密固件,同时更换AES Key。 生成的二维码包含当前版本的明文信息和加密串,在服务端先按明文反查对于AES Key,再解密验证加密串。 这样既能保持向后兼容,必要时候强制用户升级,逐步吊销不安全版本的AES Key。
感谢各位师傅观看,献丑了,预祝大家五一快乐。