AppRE

文章目录

App 逆向基础

国产应用大多热衷于构筑自己的 App 围墙,很多功能没有网页版,也就无法利用浏览器一探究竟,不过我们仍然可以通过抓包、静态分析、动态调试的方法解开隐藏在 App 中的秘密。

抓包能让我们快速获得想要的 API,不过其门槛也在不断增高,Android 7.0 之后应用不再相信非系统证书,客户端应用也可能使用 SSL Pinning 等技术防止中间人的干扰,一般用 Xposed 模块 JustTrustMe 或 TrustMeAlready 可以解决,某些关键请求可能还需额外 hook,可以为其专门定制 Xposed 模块。

抓包获得关键请求后,分析其字段的意义,并在静态分析工具中全局搜索,定位至相关函数,应用大多会将数据编码、加密或生成摘要,这些逻辑可能放在 native 层实现,增大了逆向的难度。

所幸 frida 等工具的出现大大便利了动态调试,可以方便地 hook 得到 Java 层各个类及其成员、方法,对于 native 层,也可在获得函数的参数和返回值,快速验证逆向分析时的想法。若由于时机等原因难以 hook,还可直接将 so 库封装到自己创建的 app 中,在 build.gradle 里添加 abiFilters 参数以指定 arm 指令集,手动复制关键类并 import,再在 MainActivity 里 loadLibrary,即可直接调用 native 层方法,调试并在断点之间 hook 更改 context 寄存器的值,查看变量的值。

逆向得到加密数据、生成校验的算法后,便可以伪造合法的请求。编码上的细节需要多加考虑,抓包得到 params 或 body 中的参数大都是 urlencode 后的结果,但生成校验时的参数却可能是原始的字符串,构造请求时要头脑清醒。排查错误时要冷静,关键位置往往是正确的,但完全没料到的地方可能出岔子,比如谁能想到 f-string 中嵌入 bytes 型的参数,不会报错,生成的字符串里居然还带着引号,而且作为 body 发送居然看上去一模一样?

bstr = b'feiwu'
fstr = f'woshi {bstr}'
print(fstr)
# woshi b'feiwu'

不能以脚本小子的心态写脚本,必须做好代码的类型标注,模块化编程,这样即使无法避免问题的发生,也能在问题出现时快速定位。排查问题时脑子注意转过弯来,如果加密算法中有随机值,先固定下来,在静态的层面上观察结果,与真实样本做对比。

实战案例复盘

某品会 edata 参数(AES 加密)

仅有少量请求有 edata 参数,从一串 query params 型的键值对字符串,得到 AES 加密并 base64 编码后的 edata 结果,具体实现在 esNav 这个 native 函数中。

首先静态分析,IDA 反编译后两百多行,一上来就从全局变量中获取了未知的字符串,然后放入不知所云的 gsigds 函数中进行一通操作。此时盲目扎进细节中耗时耗力而且白费功夫,只需抓住 AES 加密的核心,无非是 key 和 iv,倒过来分析代码发现前者是 md5 后的值,后者是随机的16位 hex 字符串,生成 edata 的前十六位字符便是 iv,后面再拼接 AES 加密的结果,这样服务器获得发送过来的 edata 后即可对称解密,而 key 显然应该是每次固定的,所以只需 hook 生成 md5 的函数获得返回值,便能得到 key 进而实现加密算法。

但在测试手机上发现该应用在运行时 hook 容易崩溃,只能以 spawn 的形式 hook, 而抓包发现 edata 的请求似乎只在初始化时发送,刚启动时 native 层中的关键函数又尚未被加载,很难有合适的时机 hook,这时就可以自制 App 直接调用 Java 层函数,在断点之间 hook 即可拿到 key。

某品会 api_sign 验证头(SHA1 摘要)

每一个请求头都会带上 Authorization: OAuth api_sign={},全局搜索定位到 native 函数 gsNav,是从 TreeMap<String, String>(也就是 query params) 得到一串 SHA1 摘要。

进 IDA 分析,发现仍然调用了 gsigds 函数获取字符串,传入 getByteHash 获得了32位的 hex 字符串作为盐,拼接在从 Map 转成的 query param 型字符串前进行 SHA1 摘要,再对结果再来一次加盐摘要即得 api_sign,实际上如果熟悉 SHA1 的话看到 api_sign 是长为40的 hex 应该就能想到。

import base6
import hashlib
import json
import random
from urllib.parse import unquote, parse_qsl, urlencode
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad


def gen_sign(paramstr: str) -> str:
    """paramstr will unquoted automatically"""
    paramstr = unquote(paramstr).encode()
    salt = b"da19a1b93059ff3609fc1ed2e04b0141"  # True
    salt = b"aee4c425dbb2288b80c71347cc37d04b"  # False
    h1 = hashlib.sha1(salt + paramstr)
    cipher1 = h1.hexdigest().encode()
    h2 = hashlib.sha1(salt + cipher1)
    return h2.hexdigest()

def gen_edata(paramstr: str) -> str:
    """paramstr: app_name=...&dinfo=..."""
    paramstr = paramstr.encode()
    paramstr = pad(paramstr, 16)
    key = bytearray.fromhex("8c c7 03 f6 47 8e 58 f0 84 49 d5 c0 cf 2d d5 83")  # True
    key = bytearray.fromhex("cd d1 7a b2 9b 84 b3 25 52 dd cf bb 4a bf 02 25")  # False
    key = bytes(key)
    ran16b = ''.join(random.choices('0123456789abcdef', k=16)).encode()
    cipher = AES.new(key, AES.MODE_CBC, iv=ran16b)
    enctext = cipher.encrypt(paramstr)
    ans = base64.b64encode(ran16b + enctext)
    return ans.decode()

def dec_edata(b64s: str) -> str:
    enctext = base64.b64decode(b64s.encode())
    key = bytearray.fromhex("8c c7 03 f6 47 8e 58 f0 84 49 d5 c0 cf 2d d5 83")  # True
    key = bytearray.fromhex("cd d1 7a b2 9b 84 b3 25 52 dd cf bb 4a bf 02 25")  # False
    key = bytes(key)
    iv = enctext[:16]
    cipher = AES.new(key, AES.MODE_CBC, iv=iv)
    raw = cipher.decrypt(enctext[16:])
    try:
        return raw.decode()
    except:
        return raw

某物 app so newSign 参数分析

常规抓包只能看见小部分请求,检索 NO_PROXY,发现 okhttp3.OkHttpClient$Builder.proxy 处可以 hook,果然 hook 后才可抓到关键请求如 /api/v1/app/search/ice/search/list,检索该 URL 果然在 com.XXX.common.base.delegate.tasks.net.ApiConfigCons 中发现了 BLACK_LIST 这一个黑名单集合。

搜索请求中的 newSign 字段,发现 WebRequestInterceptor.intercept 会给请求附上 newSign, 其值为 RequestUtils.c(hashMap, timestamp) 的结果,c 这个方法就是在 map 中再补充一些键值对,然后生成按字典序拼接 kv 得到的字符串,传进 AESEncrypt.encode(context, str) 方法,返回值再套一层 f 方法(即 md5)即为最终的 newSign。关键是 AESEncrypt.encode 这个方法里调了 NCall.IL() 这个 Native 函数,然而在 libGameVMP.so 中却无法继续跟踪,用 frida dump 出的 so 不再显示格式错误,但仍然找不到 IL 这个函数。

模拟 Bili Android 客户端

YmlsaWJpbGk= app分析

Native逆向指北(一)——BiliBili Sign

com.bilibili.lib.accounts.BiliAuthService 列出了登录相关 API,com.bilibili.lib.accounts.a implements com.bilibili.okretro.interceptor.DefaultRequestInterceptor 会给这些请求 addCommonParam,并在最后附加 sign([0-9a-f]{32}),注意 a 重写了 DefaultRequestInterceptor 的方法,不要 hook 错成后者。

statistics={"appId":1,"platform":3,"version":"6.54.0","abtest":""}
qdic_base = {'appkey': '783bbb7264451d82', 'build': '6540300', 'buvid': '^XY[A-F0-9]{35}',
          'c_locale': 'zh-Hans_CN', 'channel': 'bili', 'mobi_app': 'android', 'platform': 'android', 's_locale': 'zh-Hans_CN',
          'statistics': json.dumps(statistics, separators=(',', ':'))}

以上参数是所有请求的 base query,可固定在配置文件中,这样针对特定请求仅需添加 extra query 即可

其中 buvid 跟到 com.bilibili.lib.blkv.internal.kv.KVs.getString("buvid", ""),因为自行实现的 KV 存储,但有 getString 就会有 putString,直接 hook java.util.HashMap 的 put 方法,最早 put buvid_local 是在 com.bilibili.lib.biliid.api.c.b.a 这个方法中,而该方法里调用了 interface w1.g.x.g.aa 方法得到字符串,无法直接跳转,找 interface 的实现,最终在 w1.g.x.g.d.b 中生成字符串,传入参数是 interface w1.g.x.g.b 类型,依次调用其 c, d, a, b 方法,获得非空字符串则进行操作直接返回,静态跟入太烦,直接 hook 该参数打印四个方法所得字符串,发现就生成 buvid 而言,c 为空,d 为 MAC 地址,则直接对 MAC 地址进行相应 md5 操作即可,验证结果一致。

sign 的生成在 com.bilibili.nativelibrary.LibBili.signQuery(Map<String, String>),实际调用 native 函数 s,但其在 libbili.so 中是动态注册的,考虑编写 Xposed 模块主动调用

[RegisterNatives] java_class: com.bilibili.nativelibrary.LibBili name: s sig: (Ljava/util/SortedMap;)Lcom/bilibili/nativelibrary/SignedQuery; fnPtr: 0xc13138e9  fnOffset: 0x68e9  callee: 0xc1313303 libbili.so!JNI_OnLoad+0x14e
public String genSQ(Map<String, String> map) {
    try {
        Class cls = XposedHelpers.findClass("com.bilibili.lib.accounts.a", g_classLoader);
        Object ins = XposedHelpers.newInstance(cls);
        Object res = XposedHelpers.callMethod(ins, "signQuery", map);
        return res.toString();
    } catch (Exception e) {
        e.printStackTrace();
        Log.e(TAG, map.toString() + " " + g_classLoader.toString());
    }
    return "";
}

登录界面输完手机号,点击发送短信验证码会向 https://passport.bilibili.com/x/passport-login/sms/send 发起请求,除了用户控制的 cidtel 该请求还需附带 login_session_iddevice_tourist_id,不难逆得前者 buvid 拼接毫秒 timestamp 再 md5 得到长为16的 hex 字符串,后者实际键名为 guest_id,应用初始化时向 https://passport.bilibili.com/x/passport-user/guest/reg 发请求拿到,而该请求又要附带 dtdevice_info 两个参数。

接着 hook 对应函数和 HashMap 键值,得到关键类 com.bilibili.lib.accounts.BiliPassportApi 和方法 l,k,j,其中 l 创建了一个 kotlin Function,依次传到 j 中再 invoke,hook com.bilibili.lib.accounts.BiliPassportApi$getGuestID$1(这是一个 Function) 的构造方法即可看到传入的 HashMap 在 com.bilibili.lib.accounts.e.a 中生成

{
    "AndroidID": "",
    "BuildBrand": "",
    "BuildDisplay": "",
    "BuildFingerprint": "",
    "BuildHost": "",
    "Buvid": "",
    "DeviceType": "",
    "MAC": "",
    "fts": ""
}

invoke 返回 JSONString,getBytes 传入 com.bilibili.lib.accounts.n.c c,c 返回 Pair<随机[A-Z][a-z][0-9]{16}字符串, 长为bytes两倍的HEX字符串>,前者作为 AES/CBC/PKCS5Padding 的 key 和 IV 加密 JSONString 转成的 bytes,后者是 AES 加密后的 bytes 转成 HEX。然后再调用 com.bilibili.lib.accounts.model.AuthKey encrypt 对前者做 RSA/ECB/PKCS5PADDING 公钥加密并 b64encode,最后前者为 dt,后者即为 device_info 发送。RSA 的公钥又从哪来?https://passport.bilibili.com/x/passport-login/web/key 即得,因为不变动所以直接存为常量,注意加密时掐头去尾了。那么对方后端接收到 dt 后先 b64decode 再用私钥解密,即得 AES 的 key,然后即可解密 device_info

sms/send 只是短信登录的一半,该请求返回非空字符串 recaptcha_urlcaptcha_key,若为前者则需先完成极验滑动验证码才能继续登录流程,后者则直接发送了验证码,带上 captcha_key 和短信接收到的 codelogin/sms 发 POST 请求即可完成登录,返回 JSON 数据,包含关键信息如 mid(即用户 id)和 access_token(附加在后续请求头 authorization 中)

密码登录 https://passport.bilibili.com/x/passport-login/oauth2/login,明文密码前拼接上 web/key 得到的 hash,做 RSA/ECB/PKCS5PADDING 公钥加密并 b64encode,dtdevice_meta 组合仍然是一对,前者是 RSA 加密过的 AES 密钥,后者是 Function0 BiliPassportApi$loginV3$1 invoke() 后返回的 JSONString 做 AES 加密的 HEX 结果。返回 message 可能为 验证码错误 或 账号存在风险需使用手机号验证,前者则 url 为 h5 验证码,后者则 url 为短信验证码(点击发送前仍会弹出验证码),

琐碎的参数看的人头晕眼花,本质都是设备指纹(FingerPrint,缩写为fp),注意常见

接着看私信,抓包得 https://app.bilibili.com/bilibili.im.interface.v1.ImInterface/,发私信即 SendMsg,实测重放请求就能收到多条信息,有意思的是 Content-Type: application/grpc,看到这个就该想到直接找 protobuf 定义了,但初遇没经验还是在 Java 层抽丝剥茧,com.bapis.bilibili.im.interfaces.v1.ReqSendMsg,一路追溯确实能精准定位到方法 w1.g.h.d.b.b.i.t0.R 初次返回了具有附带足够信息的 Message,但对于这种场景,与其挖空心思跟踪客户端层面数据是如何生成的,不如直接从最终请求的层次上搞清数据是什么

工程化知识沉淀

抓包阶段:

要抓总能抓到,熟练使用趁手的软件

注意数据的呈现格式,以 Fiddler 为例, WebForms 栏中展示的是 urldecode 后的结果,而 TextView 和 SyntaxView 才是原始格式,对于 Content-Type: application/x-www-form-urlencoded 的 POST 请求亦是如此

分析请求中的基础参数,迅速导出为 JSON 备用

逆向阶段:

动静结合,静态查找 URL 或参数名称,从网络请求跟进到数据获取一般比较直接,但异步数据何时生成,由何生成可能难以看出,可以动态 hook 数据获取的函数以及 HashMap SharedPreference 等存取键值的函数,在打印的调用栈上获得下一步静态分析的目标,以此往复。

有时会看到新的语言特性,JADX 无法将 kotlin.jvm.functions.Function0 还原为类,而 GDA 做的就比较好

解密阶段:

区分字符串在 bytes 和 字符编码层面的表示,熟练转换 json, str, bytes, hexstring, bytearray

理解对称加密、非对称加密、摘要算法各自的特点和用途,熟知 MD5, AES, RSA 在 Java 与 Python 库中的实现,知道 PKCS5 与 PKCS7 的异同 常见流程:生成 [A-Z][a-z][0-9]{16} 的随机字符串,作为 AES 的 (128bit) Key 或 IV,对 bytes 明文进行加密,然后 AES Key 又用 RSA 公钥加密,把两者都在请求中发送,服务端用私钥解密得 AES Key,然后再 AES 解密明文。

请求阶段:

from urllib.parse import parse_qsl, quote, urlencode 熟练使用网络请求库,如 requests.post 如果 data 为字符串,Content-Type 默认为空,服务端预期为 application/x-www-form-urlencoded,故而会返回错误

使用 Sekiro 快速搭建主动调用加密函数的 API

Android逆向之无加固下的Java层和Native层模拟的调度解决方案

池化阶段:

打造批量 IP 代理池、伪造设备池,熟悉各种设备指纹

参考文献

https://pitechan.com/爬虫工程师的自我修养之基础模块/

https://curz0n.github.io/2021/05/10/android-so-reverse/

主流安卓APP反作弊及反反作弊的一些思路和经验汇总

评论正在加载中...如果评论较长时间无法加载,你可以 搜索对应的 issue 或者 新建一个 issue