fangpsh's blog

逆向办公大楼门禁APK,成功生成通行二维码

脱壳逆向,不必硬碰硬。大多数业务,迭代更新只是更新业务,但是加密的逻辑并无变化。

对于攻击方:

  1. 不少业务不会在一开始就加固: 可以直接找到历史包,最近一次未加固版本。 类似业务不会一开始套CDN,可以通过查询域名解析历史找到可能的源站。

  2. 找加固后较旧的版本: 各类加固不断更新,逆向思路/工具也不断升级。如果持有的工具能应对老版本的加固,何不找老版本的软件包?

对于业务方:
除了要保持更新加固软件,还需要按版本不断更换关键业务的AES KEY。

下面是逆向过程。

找历史包

  1. apkpure、apkimirror 之类
  2. 官方软件下载链接:可猜测历史版本号的链接,或把URL 前半部分丢搜索引擎里面
  3. 豌豆荚:国内软件,豌豆荚的历史包非常全

在豌豆荚找到该软件历史包,2分法下载apk改zip,解压查看assets和lib等内容判断是否进行加固和采用的加固厂商:

  1. 6.5.0: 最后一个未加固的版本;
  2. 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:

grep

关键词 passcode,另外这是个二维码,也可以从先找qrcode 相关函数,然后看看那些地方调用了它,倒着看。

createQRCode

QrCodeFragment

看到关键行:

param1String = EncodeUtil.encodeKey(CacheUtils.getUserId(), QrCodeFragment.this.mPassCode, "A", CacheUtils.getAppVersion());

encodeKey

出现一个关键信息:qrCodeKey, 最后进行了AES 加密:

return AESUtil.Encrypt(stringBuilder3.toString(), qrCodeKey, qrCodeKey);

先看一下AES 的加密模式和填充算法等,再回过来看这个str生成逻辑。

AESUtil

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 在线加解密的网站,试一试:

aes-decrypt

ok,搞定了sign_str,有了这个sign_str 能方便我们后续校对sign_str 生成逻辑和编写poc。

同时在这一步,可以发现前半部分字符串和上文出现UserID 有类似的地方。接下来就是读懂前面encodeKey 函数的逻辑,以及传入mCode 的生成落。

UserID+mPassCode+md5_16(mPassCode)+"B"+版本号。 UserID和mPassCode 不是直接拼凑,而是间隔交叉拼在一起。

再看下mPassCode的生成逻辑:

mPassCode

从代码来看,只是简单的递增。这个时候,再去生成几个实际的二维码,用AES在线解密出,拿到mPassCode 的md5_16,丢到cmd5.com 里面看看:

cmd5

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。

感谢各位师傅观看,献丑了,预祝大家五一快乐。