简介:这是一份围绕APK静态与动态分析整理的Python项目包,面向Android安全入门者、逆向分析学习者及需要评估应用风险的开发者,旨在用脚本辅助完成权限检测、敏感API定位、资源提取与运行时行为观察。压缩包共46个文件、约280KB,以20个Python脚本和20个dlist数据文件为主,辅以Shell运行脚本、Markdown说明及敏感方法、dexdump相关数据,分别承担解析分析、规则清单、环境执行与文档说明等用途。已有774人浏览学习。内容覆盖静态解析DEX与Manifest、混淆代码识别思路、动态hook与网络流量分析等场景,读者可据此理解APK解压、签名验证、资源提取、调用关系梳理和敏感接口排查,并参考目录组织搭建自己的分析流程。
1. 拿到一个 APK,先跑脚本还是先开模拟器
静态先行、动态验证,这是我做了几十个包之后固定下来的顺序。原因很实在:动态分析的时间成本高得离谱,模拟器启动、frida-server 推送、版本对齐、反调试对抗,一套流程下来十几分钟就没了,而静态分析往往三分钟就能告诉你这个包值不值得开设备。我的做法是先用 Python 加 androguard 跑一遍信息面,把包名、权限、导出组件、明文流量开关、加固特征全部拉成一张表,判断这个 APK 大概是什么档次,再决定要不要上 hook。这篇内容面向正在做 APK 静态分析和动态分析的人,不管你是刚接手一个自有应用的健壮性自查,还是在实验室里拆一个授权样本,下面这套顺序和脚本都能直接抄。
2. 静态分析:从解包到关键逻辑定位的四步
2.1 三个工具各自管一段,别指望一把梭
APK 静态分析的工具有一堆,但真正每天用的就三个,而且它们的职责边界非常清楚。apktool 管资源和 smali,它的价值在于保真:反编译出来的 AndroidManifest.xml 是接近原文的 XML,res 目录里的布局、字符串、颜色都是可读的,smali 目录里的字节码是一一对应的指令。你想确认某个字符串常量到底出现在哪、想改一个资源再回编译验证猜想,只能靠 apktool。它的短板也很明显,smali 读起来费眼睛,一个几百行的 onCreate 能翻成上千行 smali,人的脑子扛不住。
jadx 管逻辑理解,它把 dex 反编译成 Java 伪代码,控制流、循环、try-catch 都能还原出来,读业务逻辑的效率比 smali 高一个数量级。它的短板是遇到混淆和加固会大面积翻车,出现// failed to decompile或者干脆给你一个空方法体,这时候别急着骂工具,很多情况下是代码根本不在这个 dex 里。
androguard 管批量,它是 Python 库,把 APK 当成对象来读,你可以写脚本一次性拉几百个包的权限、组件、签名、证书指纹,做横向对比。它的短板是 API 在不同大版本之间改过,网上抄来的代码经常 import 就报错,用之前先确认自己装的是哪个版本。
我一般这么排:先用 androguard 跑批量体检拿到概览,再用 apktool 解包确认 Manifest 原文和资源,最后用 jadx 精读那两三个可疑的类。三步的顺序反过来也能做,但效率差很多。
# 解包:-f 覆盖旧目录,-o 指定输出,--no-src 只看资源不解 smali apktool d -f -o out_smali app.apk # 只解资源,速度更快,适合先看 Manifest 和 strings.xml apktool d -f -o out_res --no-src app.apk # jadx 命令行导出 Java 伪代码,--show-bad-code 保留反编译失败的占位 jadx -d out_jadx --show-bad-code app.apk # 只看某个包路径下的类,减少噪音 jadx -d out_jadx --single-class com.example.target.net app.apk参数里--no-src是最容易被忽略的一个,很多时候你只想确认 targetSdk 和 exported 组件,没必要等它把 smali 全吐出来。--show-bad-code则相反,它让 jadx 把失败的方法也保留成空壳,你能看到方法签名和调用关系还在,至少知道有这么个东西存在,比整段消失强。
2.2 Manifest 里这六项先过一遍,能省掉一半动态工作
Manifest 是 APK 的门牌号,信息密度极高,但新手容易从头看到尾然后什么都没记住。我固定看六项,按重要性排:
第一是android:debuggable。它为 true 意味着你可以直接用 jdwp 附加调试,动态分析的门槛瞬间降到最低;为 false 也不代表没戏,只是要上 hook。
第二是android:allowBackup。这个开关决定了应用数据能不能被 adb backup 导出来,很多应用的本地数据库、SharedPreferences 里的 token 就躺在那儿。
第三是android:usesCleartextTraffic和networkSecurityConfig。前者为 true 说明允许明文 HTTP,后者指向一个 XML 配置文件,里面可能写了哪些域名豁免了证书校验。这两个字段是判断传输层健壮性的第一手依据。
第四是 exported 组件。android:exported="true"的 Activity、Service、Receiver、Provider 都是对外暴露的入口,配合 intent-filter 看,能直接推断出哪些组件可以被外部应用拉起。这部分是攻击面分析的核心,写脚本批量统计比肉眼扫快得多。
第五是权限清单。危险权限和自定义权限要分开看,自定义权限的 protectionLevel 如果是 normal 或者 dangerous 而没做签名校验,等于白送。
第六是 minSdkVersion 和 targetSdkVersion。低 minSdk 往往意味着兼容老版本的加密算法和 TLS 配置,高 targetSdk 则意味着系统层面的限制更多,动态分析时要考虑分区存储、后台启动限制这些坑。
注意:Manifest 在编译后是二进制 XML,直接用文本编辑器打开是乱码。要么走 apktool 反编译,要么用 androguard 的
get_android_manifest_xml()拿解析好的对象,别硬啃二进制。
2.3 定位关键逻辑靠的是关键词,不是通读
一个中型应用的 dex 动辄几万方法,通读是不可能的。有效的做法是拿关键词去钓,钓上来的再精读。我常用的几组关键词:
字符串层面,搜 URL、域名、/api/、token、secret、key=、password。这些常量在 dex 的字符串池里是明文存的,即使代码混淆了,字符串往往还在。apktool 解出来的 smali 里用grep -rn直接扫,命中率很高。
API 层面,搜Cipher.getInstance、SecretKeySpec、IvParameterSpec、MessageDigest.getInstance、Mac.getInstance。这几个是 Java 层加密的必经之路,找到调用点就能顺着参数看到算法、模式、填充方式,以及密钥是从哪儿来的——是硬编码常量、是从 so 里返回、还是从服务端下发。
网络层面,搜OkHttpClient、Retrofit.Builder、HttpURLConnection、WebView的loadUrl。找到之后再去看有没有HostnameVerifier返回 true、有没有自定义X509TrustManager的空实现,这两处是证书校验最常见的失效点。
原生层面,搜native关键字声明的方法。Java 层看到public static native byte[] a(byte[] b)这种签名,就知道真正的逻辑在 .so 里,静态分析到这里就该转向动态了。
# 在 apktool 输出的 smali 目录里扫硬编码线索,-i 忽略大小写,-l 只列文件名 grep -rniE "(secret|apikey|api_key|access_key)[\"']?\s*[:=]" out_smali/ -l # 找加密 API 的调用点,定位到具体 smali 文件后再用 jadx 看对应的 Java grep -rn "Ljavax/crypto/Cipher;->getInstance" out_smali/ | head -20 # 找证书校验的可疑实现 grep -rn "X509TrustManager\|HostnameVerifier" out_smali/ -l三条命令跑完,基本能圈出三到五个文件。接下来的动作是拿类名去 jadx 里反查,读出完整的调用链。这个来回切换的过程一开始很别扭,做熟了就是肌肉记忆:smali 负责定位,jadx 负责理解。
2.4 加固和混淆的识别特征,判断要不要换打法
不是所有包都能静态分析出东西来。有些包你用 jadx 打开,主逻辑类全是几行的空壳,方法体里只有一句return null,这时候八成是被加固了。识别加固有几个稳定特征:
一是 Application 类被替换。Manifest 里android:name指向的那个 Application 类,反编译出来只有几十行,核心逻辑是反射调用另外一个不存在于当前 dex 的类。二是 assets 目录下有体积异常的文件,名字可能是.bin、.dat、.so或者干脆没有扩展名,几百 KB 到几 MB,这些就是加密后的原始 dex。三是存在DexClassLoader、InMemoryDexClassLoader、BaseDexClassLoader的构造调用。四是 native 方法数量异常多,且 .so 体积很大。
混淆的识别更简单:类名变成a.a.a、方法名变成a()、b(),字符串被拆成多段拼接或者用简单的异或解密。混淆不影响你读逻辑,只影响你读得爽不爽,配合 jadx 的重命名功能(快捷键 N)手工标几个关键类,后面就顺了。
加固就不一样了,静态分析能拿到的只有壳层,真实代码要等运行时解密加载进内存。这时候正确的动作不是死磕 jadx,而是转到动态,在类加载完成之后从内存里把 dex 捞出来,或者直接在运行时 hook 目标方法。判断标准很简单:核心业务类的方法体普遍为空,且存在 DexClassLoader 调用链,就走动态。
3. 动态分析:从 logcat 到 hook 取证
3.1 环境准备:三件套和版本对齐
动态分析的最小环境是三样:一台能跑目标应用的设备(真机或模拟器)、adb、frida-server。前两样没什么可说的,第三样是翻车重灾区。
frida 的客户端(你 pip 装的那个)和 frida-server(推送到设备上跑的那个二进制)版本必须一致,不一致的表现是frida-ps -U直接报unable to connect或者握手失败。这个错误的迷惑性在于它看起来像连接问题,实际上是版本问题。所以每次换环境,第一件事是frida --version和frida-server --version对一遍。
推送和启动的流程固定:
# 1. 确认设备架构,决定推哪个 frida-server 二进制 adb shell getprop ro.product.cpu.abi # 输出 arm64-v8a 就推 android-arm64 那个包 # 2. 推送到可执行目录并赋权 adb push frida-server-xx.x.x-android-arm64 /data/local/tmp/fs adb shell "chmod 755 /data/local/tmp/fs" # 3. 后台启动,注意要 root 权限 adb shell "su -c '/data/local/tmp/fs &'" # 4. 宿主机侧验证,能列出进程列表就算通了 frida-ps -U | head第 3 步的su -c在非 root 设备上会失败,这时候要么换一台能 root 的测试机,要么退回到 logcat 和 dumpsys 这类零成本手段。模拟器方面,带 root 的 AOSP 镜像比带 Google Play 的镜像好用,后者通常不允许 remount。
注意:所有动态操作都应发生在你自己开发的应用、明确授权的测试样本或你有权处置的设备上。别人的应用不在这个范围里。
3.2 logcat 和 dumpsys:不写一行 hook 也能拿到大量信息
很多人一上来就写 hook 脚本,其实 logcat 里躺着的东西比想象中多。开发阶段遗留的调试日志、异常堆栈、第三方 SDK 的初始化信息,全都会打出来。做法是先清空缓冲区,再启动应用,然后按关键字过滤。
# 清空历史日志,保证看到的都是本次运行产生的 adb logcat -c # 启动目标 Activity adb shell am start -n com.example.target/.ui.MainActivity # 抓日志并按关键字过滤,-i 忽略大小写 adb logcat -v time | grep -iE "crypto|http|token|sqlite|exception|leak"-v time加上时间戳很关键,因为它能让你把日志和界面操作对上。比如你点了登录按钮,三秒后出现一条带 URL 的日志,那基本就锁定了登录接口。
dumpsys 是另一个免费的信息源。adb shell dumpsys activity top能看到当前栈顶 Activity 的完整视图树和 Intent 参数,adb shell dumpsys package com.example.target能看到组件注册、权限授予、签名信息。这些信息的价值在于"交叉验证"——静态分析里你推测某个组件是导出的,dumpsys 能直接确认。
3.3 Frida hook 加密函数:拿到明文的那一刀
静态分析看到Cipher.getInstance("AES/CBC/PKCS5Padding"),你知道了算法,但密钥和明文还得靠动态。最直接的切入点是 hookjavax.crypto.Cipher的doFinal,因为不管上层怎么封装,最终都要走到这个方法。
Frida 脚本基本长这样:
// hook_cipher.js —— 挂 Cipher.doFinal 的所有重载,打印算法名与入参 Java.perform(function () { var Cipher = Java.use('javax.crypto.Cipher'); var JString = Java.use('java.lang.String'); // doFinal 有 byte[]、byte[],int,int 等多个重载,逐个挂才不会漏 Cipher.doFinal.overloads.forEach(function (ov) { ov.implementation = function () { var ret = ov.call(this, arguments[0], arguments[1], arguments[2]); var algo = this.getAlgorithm(); // 用 String 的构造把字节数组转成可读文本,失败就打印长度 var plain = ''; try { plain = JString.$new(arguments[0]).toString(); } catch (e) { plain = '[非文本]'; } console.log('[Cipher] algo=' + algo + ' in=' + plain); return ret; }; }); });这段脚本有三个关键点。Java.perform保证在 Java 运行时就绪之后再执行 hook,不加它脚本会在类加载前跑,直接报错。overloads.forEach遍历所有重载,因为doFinal至少有doFinal(byte[])、doFinal(byte[], int, int)、doFinal(byte[], int, int, byte[])这几个版本,只挂一个必然漏数据。this.getAlgorithm()是实例方法,能直接拿到当前 Cipher 实例注册的转换字符串,比你在静态分析里猜要准确得多。
启动方式有两种。attach 是应用已经跑起来之后再挂,简单但会漏掉启动阶段执行的加密;spawn 是先让应用挂起、注入脚本、再让它继续跑,能抓到 Application 初始化阶段的所有调用。分析启动期的密钥协商必须用 spawn:
# 用 spawn 模式启动,frida 会先挂起进程再注入 frida -U -f com.example.target -l hook_cipher.js --no-pause--no-pause表示注入完成后立即恢复运行,不加的话进程会一直停在那里等你敲%resume。
3.4 加固应用:内存里捞 dex,.so 里找入口
被加固的包,静态阶段只能看到壳。破局点在运行时——壳最终要调用DexClassLoader把真实 dex 加载进内存,加载完成的那一刻,真实代码就在进程里了。做法是在内存中扫描 dex 文件的魔数64 65 78 0A(也就是 "dex\n"),把匹配到的内存段 dump 下来。
这个操作有现成的思路可以借鉴:枚举进程的内存映射,逐个区间读字节,匹配魔数后按 dex 头部记录的 file_size 截断写出。整个过程可以在 Frida 脚本里完成,也可以写一个独立的注入脚本。写出来的 dex 文件修复一下头部校验就能用 jadx 打开,这时候看到的就是真实业务代码。
如果关键逻辑在 native 层,静态和动态的思路要换。先看 APK 里带了哪些 .so:
# 列出 APK 内所有原生库,注意 32 位和 64 位是分开的目录 unzip -l app.apk | grep "lib/" # 单独解出某个 so 看导出符号,-D 显示动态符号表 unzip -o app.apk "lib/arm64-v8a/*" -d out_so/ readelf -D out_so/lib/arm64-v8a/libnative.so | grep -i "FUNC.*GLOBAL"32 位和 64 位要分开处理,这是新手最常踩的坑:设备是 arm64 的,但应用可能优先加载 32 位的 so,你在 64 位目录里分析半天,运行时跑的根本不是这个文件。判断方法是在运行时打印/proc/<pid>/maps,看实际加载的路径。
4. 用 Python 把重复劳动压成一条流水线
4.1 androguard 批量体检:一次跑完几十个包
单个包靠手工看没问题,数量上去了就必须脚本化。androguard 的价值在于它把 APK 解析成了 Python 对象,你可以用列表推导式处理几百个样本。
# apk_triage.py —— 批量体检:包名、版本、开关项、导出组件、危险权限 from androguard.core.apk import APK import csv import sys from xml.etree import ElementTree as ET # Manifest 里的属性都带这个命名空间前缀,不写前缀取不到值 NS = "{http://schemas.android.com/apk/res/android}" # 我只关心这几类高风险权限,其余的不进报告,避免噪音 DANGEROUS = { "android.permission.READ_EXTERNAL_STORAGE", "android.permission.WRITE_EXTERNAL_STORAGE", "android.permission.READ_SMS", "android.permission.READ_CONTACTS", "android.permission.ACCESS_FINE_LOCATION", "android.permission.CAMERA", "android.permission.RECORD_AUDIO", } COMPONENT_TAGS = ("activity", "service", "receiver", "provider") def triage(path): apk = APK(path) # get_android_manifest_xml 返回的是解析好的 Element,可以直接 iter root = apk.get_android_manifest_xml() app = root.find("application") exported = [] for tag in COMPONENT_TAGS: for node in root.iter(tag): if node.get(NS + "exported") == "true": exported.append(node.get(NS + "name")) return { "包名": apk.get_package(), "版本": apk.get_androidversion_name(), "debuggable": app.get(NS + "debuggable") if app is not None else None, "allowBackup": app.get(NS + "allowBackup") if app is not None else None, "cleartext": app.get(NS + "usesCleartextTraffic") if app is not None else None, "导出组件数": len(exported), "导出组件": ";".join(exported[:8]), "危险权限": ";".join(sorted(set(apk.get_permissions()) & DANGEROUS)), } if __name__ == "__main__": rows = [triage(p) for p in sys.argv[1:]] writer = csv.DictWriter(sys.stdout, fieldnames=list(rows[0].keys())) writer.writeheader() writer.writerows(rows)导入路径androguard.core.apk是 4.x 的写法,老版本是androguard.core.bytecodes.apk,报 ImportError 先查版本。NS这个前缀必须加,因为get("exported")取不到值——Manifest 解析后所有 Android 属性都挂在{http://schemas.android.com/apk/res/android}命名空间下,这是最容易卡住的一个点。app.get(...)在 application 节点不存在时会抛异常,所以加了一层is not None兜底。导出组件只截前 8 个,是因为有些应用几十个 Activity 全是导出的,全打出来没法看。
跑起来就是python apk_triage.py *.apk > report.csv,几十个包的横向对比一张表搞定,按"导出组件数"排序,问题最大的排在最前面。
4.2 frida-python:把 hook 结果落盘而不是刷屏
用 frida 命令行跑脚本,输出全在终端里,滚过去就没了。要做分析就得落盘。frida 的 Python 绑定让你能在宿主机侧接收设备侧发来的消息,直接写文件。
# hook_to_file.py —— attach 目标进程,把 hook 到的数据逐行写成 JSONL import frida import json import sys PKG = "com.example.target" JS = """ Java.perform(function () { var Cipher = Java.use('javax.crypto.Cipher'); Cipher.doFinal.overloads.forEach(function (ov) { ov.implementation = function () { var ret = ov.call(this, arguments[0], arguments[1], arguments[2]); send({ tag: 'cipher', algo: this.getAlgorithm(), len: arguments[0].length, data: Array.from(arguments[0]) // 转成 JS 数组才能跨进程传输 }); return ret; }; }); }); """ def on_message(message, data): if message["type"] != "send": return # JSONL:一行一条记录,方便后续用 pandas 或 jq 处理 with open("hooks.jsonl", "a", encoding="utf-8") as f: f.write(json.dumps(message["payload"], ensure_ascii=False) + "\n") device = frida.get_usb_device(timeout=5) # spawn 而不是 attach,保证启动期的加密调用也能抓到 pid = device.spawn([PKG]) session = device.attach(pid) script = session.create_script(JS) script.on("message", on_message) script.load() device.resume(pid) sys.stdin.read() # 阻塞住,Ctrl+C 退出Array.from(arguments[0])这行是必须的,因为 Frida 的send只接受可 JSON 序列化的对象,Java 的 byte[] 直接传会报错。转成普通数组之后,宿主机侧收到的是整数列表,再按需用bytes(data).decode()还原文本。device.spawn加device.resume的组合是抓启动期的唯一办法,attach 只能看应用跑起来之后的事。JSONL 格式的好处是追加写不破坏结构,脚本中途崩了前面的数据还在。
4.3 正则扫描:把 apktool 的输出当成文本语料
apktool 解出来的 smali 是纯文本,用正则扫比用任何工具都快。这招在批量排查硬编码密钥、内网地址、调试开关时特别有效。
# scan_smali.py —— 扫 apktool 输出目录,正则命中可疑常量 import re import pathlib # 每条规则一个名字一个正则,命中就打印文件路径和行号 RULES = { "疑似硬编码密钥": re.compile( rb'(?i)(secret|apikey|api_key|access_key|aes_key)\s*[=:]\s*["\'][A-Za-z0-9_\-]{12,}["\']' ), "疑似内网地址": re.compile( rb'https?://(?:10\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.)' ), "疑似调试开关": re.compile(rb'(?i)build\.type\s*==\s*["\']debug["\']'), } root = pathlib.Path("out_smali") for rule_name, pattern in RULES.items(): print(f"=== {rule_name} ===") hits = 0 for f in root.rglob("*.smali"): # 大文件跳过,通常是被混淆的巨型方法,扫了也没信息量 if f.stat().st_size > 2 * 1024 * 1024: continue for i, line in enumerate(f.read_bytes().splitlines(), 1): if pattern.search(line): print(f"{f}:{i}: {line.strip()[:120]}") hits += 1 if hits >= 20: # 每个规则最多打 20 条,防止终端被刷爆 break if hits >= 20: break正则用 bytes 编译(前缀b),因为read_bytes读出来的是 bytes,混用 str 和 bytes 会抛 TypeError,这是最常见的报错。rglob("*.smali")递归遍历所有子目录,不用手写 os.walk。加 2MB 的文件大小上限是为了跳过被混淆工具生成的巨型方法,那些文件里字符串全是拼接出来的,正则扫不到有效信息还特别慢。
三条规则跑完,一般能捞出五到十条线索,拿去 jadx 里逐个验证就行。
5. 踩坑与排查:五个让我返工的现场
5.1 apktool 反编译报 resource not found
现象:apktool d app.apk跑到一半抛异常,提示某个attr或者style找不到,输出目录里只有半截文件。
原因:apktool 依赖一套框架资源(framework-res.apk)来解析属性引用,目标应用用了比 apktool 内置框架更新的属性,或者应用自己引用了系统私有的 attr。版本越新的应用越容易撞上。
解决:先升级 apktool 到最新版,它的内置框架会跟着更新。如果还不行,用--frame-path指定一个你自己从设备上拉下来的 framework-res.apk;再不行就加-s跳过 smali 反编译,先把资源解出来看 Manifest,smali 层改用 jadx 或者直接对 dex 用 baksmali 处理。
5.2 jadx 大片 failed to decompile
现象:用 jadx 打开,关键类的方法体显示// failed to decompile或者干脆是空的,但方法签名还在,调用关系也在。
原因:三种可能。一是代码被加固,真实逻辑不在这个 dex 里;二是用了 jadx 不支持的新字节码特性或者极端混淆(比如控制流平坦化);三是方法体确实就是空的,比如接口或者被抽离到 native 的实现。
解决:先看 Application 类和 assets 目录,确认是不是加固,是的话转动态。不是加固就看 smali,apktool 解出来的 smali 一定是对的,只是难读,对着 jadx 的方法签名去 smali 里找同名方法,逐条指令读。jadx 本身也有--decompilation-mode参数可以切换反编译引擎,fallback 模式对畸形字节码更宽容。
5.3 frida-ps -U 连不上或者版本不匹配
现象:frida-ps -U报Failed to enumerate processes: unable to connect to remote frida-server,或者提示unexpectedly timed out。
原因:按出现频率排——frida-server 没启动、版本和客户端不一致、端口被占用、设备没 root 导致进程起不来、selinux 拦截。
解决:按顺序排查。adb shell ps | grep fs确认进程在不在;adb shell /data/local/tmp/fs --version和宿主机frida --version对一遍;换一个端口用fs -l 0.0.0.0:1234再frida -H;adb shell getenforce看是不是 Enforcing,是的话临时改成 Permissive;最后确认设备确实有 root。
5.4 hook 时报 ClassNotFoundException
现象:Java.use('com.example.target.CryptoUtil')直接抛异常,说找不到类。
原因:类名写错了(混淆后类名可能是a.b.c)、类还没被加载(懒加载,只有用到才 load)、或者类被壳动态加载在另一个 ClassLoader 里。
解决:先用Java.enumerateLoadedClasses把当前已加载的类全打出来,搜关键字。如果确实还没加载,就把 hook 时机往后挪——hook Application 的onCreate或者某个已知一定会被调用 early 的方法,在里面再执行Java.use。如果是多 ClassLoader 场景,用Java.enumerateClassLoaders找到目标 loader,再配合Java.classFactory.loader切换。
5.5 应用一启动就闪退
现象:spawn 模式注入脚本后,应用刚起来就退出,logcat 里能看到SIGSEGV或者Abort message。
原因:应用带了反调试或者完整性校验,检测到 frida 的特征(进程名、端口、内存里的库名)就主动退出。也有可能是 hook 的时机太早,目标类还没初始化完就被改写,触发了空指针。
解决:先确认是不是反调试,用frida-ps -U看进程能不能起来,起来后几秒内消失基本就是。对抗思路是延迟注入——先让应用跑起来,等它过了检测窗口再 attach;或者用Java.perform里套setTimeout把 hook 往后推几百毫秒。另一种情况是 hook 太早,那就改成 attach 模式验证逻辑对不对,确认无误再回到 spawn 并加延迟。
6. 一条可复现的分析顺序:先静态圈地,再动态验证
把上面所有东西串起来,我实际执行的顺序是这样:
| 阶段 | 动作 | 产出 | 判断分支 |
|---|---|---|---|
| 一 | androguard 批量体检 | 权限、组件、开关项表格 | 导出组件多、cleartext=true 优先深挖 |
| 二 | apktool 解包看 Manifest | 组件清单、native 声明 | 有 native 方法就标记待动态 |
| 三 | jadx 读可疑类 | 加密调用链、网络配置 | 方法体为空则判定为加固 |
| 四 | frida hook 验证 | 算法、密钥来源、明文 | hook 不上则检查 ClassLoader |
| 五 | 内存 dump 或 so 分析 | 真实 dex 或原生入口 | 仅在前三步都没结果时启用 |
这个顺序的核心逻辑是成本递增:第一步几秒钟,第二步半分钟,第三步几分钟,第四步十几分钟,第五步可能一小时。永远让便宜的步骤先跑,用它的结果去决定要不要付昂贵的代价。我见过太多人拿到包直接开模拟器上 hook,挂了两个小时发现目标方法压根不在这个 dex 里。
验证环节我固定做一次闭环:静态分析里得出"登录请求用了 AES/CBC,密钥硬编码在 CryptoUtil 类的静态字段里"这个结论,然后写一个 hook 脚本打印Cipher.doFinal的算法和入参,跑一遍登录流程,看输出是不是 AES/CBC、明文是不是和界面输入一致。对上了,结论成立;对不上,说明静态分析的调用链读错了,回去重读。
最后留一个具体的技巧:分析加固包时,别急着上内存 dump,先在 Frida 里Java.enumerateClassLoaders把所有 ClassLoader 打一遍,看目标类到底被哪个 loader 加载。有时候壳只是换了 loader,类名并没有混淆,找到 loader 之后直接Java.use就能 hook,比 dump 出几百个 dex 再逐个翻要快得多。
回过头看,我踩过最深的一次坑是拿着 64 位的 so 分析了整整一个下午,最后发现应用运行时加载的是 32 位那个同名文件,符号表完全对不上。从那以后我每次分析 native 层,都强制先跑一遍adb shell cat /proc/<pid>/maps | grep lib,确认实际加载路径再动手。希望帮到你。
本文还有配套的精品资源,点击获取