做Android反向分析这些年,设备指纹一直是个绕不开的话题。不管是风控系统、广告归因,还是反作弊模块,几乎所有有点规模的产品都会在客户端采集一批设备信息,在服务端拼成一段唯一的“数字身份证”。我在帮某团队做App合规安全评估时,经常接到类似需求:为什么同一台设备会被风控反复标记?为什么模拟环境下测出来的指纹会飘忽不定?这些问题背后,往往都指向同一个源头——设备指纹检测。
Frida是这类分析绕不开的利器。它不依赖重打包,不用瞎改smali,运行起来之后直接用JavaScript代码注入到目标进程里,在运行时观察和调整关键函数的返回值。这篇文章我把“用Frida绕过安卓设备指纹检测”拆成三种常用姿势,分别对应数据链路分析、Java层Hook、Native层Hook三条思路。适合刚上手Frida的逆向新人,也适合需要做风控策略验证或兼容性测试的开发者参考。先说清楚:以下方法仅用于安全研究、自有产品健壮性评估、合规测试等场景,不要拿去做任何非法的事情。
1. 设备指纹检测的本质:一条采集、组装、上报、校验的链路
1.1 设备指纹到底包含哪些信息
设备指纹不是某一个单一的信息,而是“多源信息的聚合结果”。它通常包含几个层面:
- 硬件标识:Android ID、IMEI、MAC地址、设备序列号、Build字段、CPU型号、屏幕分辨率;
- 系统环境:系统属性(ro.build.*)、时区、语言、root状态、模拟器特征;
- 应用层信息:已安装应用列表、User-Agent、字体列表、传感器列表;
- 行为特征:传感器波形、屏幕触摸轨迹、时间戳节奏。
每一层信息都会被客户端按照特定顺序拼起来,加上一个随机盐或业务埋点值,做哈希或加密后上报到服务端。服务端拿到这个字符串之后,先查黑名单库,再和当前IP、账号、历史行为做关联分析,最终给出一份风险分。
这里需要理解一个关键逻辑:指纹是“采集→组装→编码→上报→比对”这样一条完整链路,任何一个环节的差异,都会影响最终的指纹结果。这也是为什么后面三种姿势,分别对应的是这条链路上不同的节点位置。只看某一个点,很难解决整条链路的问题。
1.2 为什么风控系统总能在“改值”之后把你追回来
很多新手拿到Frida,第一反应是:把IMEI改成“000000000000000”,把Android ID改成固定值,然后以为服务端就认了。实测往往很快被风控抓回,原因大概有这么几点。
第一,多源交叉校验。服务端不只看一组字符串,它会把客户端上报的指纹,和你这个IP下的行为数据、设备历史版本做关联。你改了IMEI,但MAC地址没改,新组出来的指纹和之前的黑名单记录仍然存在部分特征交集,照样命中。
第二,采集点不止一个。同一个信息可能被多处采集,比如Java层读一次,Native层通过ioctl又读一次。你只Hook了Java层入口,Native层读出来的还是原值,服务端一比对就知道不对。
第三,时间戳一致性问题。有些风控规则会检查“指纹最近一次变更的时间”和“账号激活时间”的先后关系。如果某台设备一天之内指纹换了三次,那这件事本身就是高危信号,根本不需要检查指纹值本身是否合法。
理解了这些,就会明白“找一个改一个”是走不远的。真正稳妥的做法是先把整条链路摸清楚,再决定在哪个环节动手。而这正好是三种姿势存在的价值。
2. 三种姿势的选型逻辑:不对症下药等于白忙
2.1 先说结论:三种姿势分别解决哪种场景
三种姿势本质上是在沿设备指纹的生成链路,从三个不同层次分别切入。
姿势一:链路分析式体检。不动任何业务行为,只做观测。通过Frida打印调用栈、枚举关键字段的读取时机,搞清楚目标App到底是什么时候、在哪个函数里把设备指纹拼出来的。这一步是基础,没有它,后面两个姿势容易变成盲改,改完自己都不知道改的是哪一环节。
姿势二:Java层Hook。适用于大部分常规App。设备指纹的采集逻辑通常写在一个工具类里,里面大量依赖
android.os.Build、TelephonyManager、Settings.Secure这类系统API。这一层Hook技术门槛相对较低,见效最快,也是大家最常说的“最常见姿势”。姿势三:Native层Hook。适用于加固App,或者采集逻辑下沉到so库的场景。此时Java层看到的信息已经是加工后的结果,光改Java层返回值不够,必须定位原生函数,在JNI层或者so内部做处理。
为什么不是“一招鲜”?因为现实里一个成熟的风控SDK,往往三层策略全部在用:Java层采集基础信息,Native层读取系统私有接口,服务端再做交叉校验。只覆盖一层的方案,必然活在不断被升级策略打脸的循环里。
2.2 动手之前,先做三个判断
拿到目标App之后,不要着急写脚本,先用一分钟做判断。
第一,目标包有没有加固?如果加固了,需要先确认so是否有加壳,这决定了姿势三是否绕得开。如果只是Java层加固,脱壳后依然可以走姿势二;如果是整体加固估计VMP,那就要走姿势三的更复杂分支。
第二,指纹采集的入口在哪?启动、登录、支付还是后台心跳?不同时机采集的内容可能不同。你想验证哪个场景的检测能力,就在哪个场景去Hook,不要在一个永远走不到的入口上浪费时间。
第三,服务端是否参与校验?用抓包工具看一下上报接口的请求体。如果指纹字段在加密body里且每次请求都不一样,说明服务端存在随机挑战机制,场景会更复杂,单点修改不一定能生效。
我习惯先跑一遍基础“体检”脚本,把所有常用设备信息采集点的调用栈全部打出来,快速确认到底是Java层在做采集,还是Native层也在参与。这样后面选择姿势才能有的放矢。
Java.perform(function () { var System = Java.use("java.lang.System"); System.getProperty.implementation = function (key) { var value = this.getProperty(key); if (key.indexOf("ro.") === 0) { console.log("getProperty(" + key + ") => " + value); var Log = Java.use("android.util.Log"); var Throwable = Java.use("java.lang.Throwable"); console.log(Log.getStackTraceString(Throwable.$new())); } return value; }; });这段脚本的意义不是修改,而是观察。很多Native层采集也会间接调用System.getProperty,所以一旦看到明显不属于业务代码的堆栈,就可以顺着往下追。
3. Frida实操:三种姿势从零到落地
3.1 环境准备与连接基础
在开始之前,先把Frida环境理顺。常见的问题大多出在版本不匹配上,这里值得单独提一下。
电脑端安装Frida CLI,版本需要和手机端frida-server完全一致。比如电脑端是16.x,手机端也必须放16.x的对应架构版本。设备是ARM64的话,就下载arm64版本的frida-server,放进/data/local/tmp/目录,给执行权限后启动:
adb push frida-server /data/local/tmp/ adb shell chmod 755 /data/local/tmp/frida-server adb shell /data/local/tmp/frida-server &然后电脑端重新开一个终端,执行frida-ps -U。如果能看到设备进程列表,说明连接成功。这里有个小坑:如果手机上开了多个frida-server进程,或者端口被占用,frida-ps -U会报错,先用adb shell ps | grep frida清理干净再试。
另一点是注入时机。如果App在启动早期就完成了指纹采集,而你的脚本在MainActivity才注入,那必然什么也抓不到。建议把注入时机尽量提前,比如用--runtime=v8配合--no-pause参数,或者直接在脚本里延迟等待目标类加载完毕后再Hook。你可以在脚本里动态监听类加载:
Java.perform(function () { var ActivityThread = Java.use("android.app.ActivityThread"); var currentApplication = ActivityThread.currentApplication(); if (currentApplication !== null) { console.log("App loaded: " + currentApplication.getPackageName()); } });Frida的注入时机越早,能看到的调用链越完整。这一点在后面的实操中非常关键。
3.2 姿势一:通过调用栈体检,定位指纹组装点
姿势一的核心目标是把“指纹在哪里组装”这个问题的答案找出来,不修改任何值,只做观察。
第一步,跑起来。建议用一台Android 7-12之间的真机,关闭开发者相关风险选项,装好目标App,登录或者触发一次目标业务动作,让指纹上报至少发生一次。
第二步,挂上Frida,打印采集点。用一个通用脚本把所有常见的属性读取路径都hook上,观察输出。重点看这些字段:ro.product.model、ro.boot.serialno、ro.serialno、ro.build.fingerprint。
第三步,从输出里找调用来源。观察日志里出现频率高的属性,再看堆栈里的业务类名。通常堆栈中出现2-3层的地方,就是关键工具类。这个类可能会被别的采集点重复引用,记住它的方法名。
第四步,用Frida打印调用栈,获得字段拼接次序。这时能大概看出指纹的组成规则,比如哪个字段在前、哪个字段在后,是否加了版本号、随机盐或时间戳。
第五步,验证。手动修改其中一个目标字段,观察服务端返回的指纹是否变化。如果改了没变化,说明真正上报的不是这个值,继续追。
以一个实际案例来说明:某个工具类会把一个包含22个字段的ArrayList按固定顺序转成JSON字符串,再交给一个DigestUtils.md5()方法出结果。此时如果随便改某个字段,最终MD5就不一样,但风控系统可能早就把原始JSON看过一遍了。所以重点不是改MD5本身,而是确保MD5前的字段是业务期望的“干净版本”。
这个阶段最忌讳的就是跳步。很多人直接写Hook脚本去改字段,结果不知道字段是从哪来的,改了之后服务端立刻发现异常。先做体检,再动手,整个过程反而更快。
3.3 姿势二:Java层批量Hook关键采集点
姿势二主要覆盖三个大类:Build类、系统API类、字符串工具类。
先看Build类。android.os.Build包含MODEL、BRAND、DEVICE、MANUFACTURER、SERIAL、FINGERPRINT、HARDWARE、PRODUCT等字段。这些字段在很多App里是直接以静态字段方式读取的,而它们是static final,直接反射修改在不同Android版本上不总是稳定。
更常用的方式,是对Build.getDevice()这类非final方法做Hook,或者对读取方做Hook。如果确实遇到直接读字段的代码,可以尝试反射修改字段。一个可用的脚本参考如下:
Java.perform(function () { var Build = Java.use("android.os.Build"); // 方案A:Hook非final方法 Build.getDevice.implementation = function () { return "generic_x86_64"; }; Build.getProduct.implementation = function () { return "sdk_gphone64_x86_64"; }; // 方案B:反射修改static final字段 var modelField = Build.class.getDeclaredField("MODEL"); modelField.setAccessible(true); modelField.set(null, "Pixel 7"); var fingerprintField = Build.class.getDeclaredField("FINGERPRINT"); fingerprintField.setAccessible(true); fingerprintField.set(null, "google/bramble/bramble:12/SP1A.210812.016/7847918:user/release-keys"); });这里有一个很容易翻车的地方:static final字段在Android 8以上可能被ART编译器优化成内联常量,反射修改完,代码里读取到的仍然是原来那个值。如果遇到这种情况,建议别跟它硬刚,转而去Hook读取这些字段的业务方法。
再看系统API类,主要是TelephonyManager。涉及getDeviceId、getImei、getMeid、getSubscriberId、getSimSerialNumber。在不同Android版本上,权限策略不同,但App想拿这些值,必然要调用对应方法。批量Hook的脚本可以这样写:
Java.perform(function () { var TelephonyManager = Java.use("android.telephony.TelephonyManager"); var targetMethods = ["getDeviceId", "getImei", "getMeid", "getSubscriberId", "getSimSerialNumber"]; targetMethods.forEach(function (methodName) { try { TelephonyManager[methodName].overload().implementation = function () { return "000000000000000"; }; } catch (e) { console.log("skip " + methodName + ": " + e); } }); });写这段脚本时,建议加上日志,确认方法确实被调用过。特别是Android 10以上,getImei需要权限,Hook时机太早,App又在检查权限失败后走了另一套逻辑,这个返回可能根本不会被走到。
最后一类是Settings.Secure.getString,读取Android ID通常经过这个入口。很多App会先尝试直接拿,拿不到就回退到其他方案。把回退路径一起堵上,脚本如下:
Java.perform(function () { var SettingsSecure = Java.use("android.provider.Settings$Secure"); SettingsSecure.getString.implementation = function (resolver, name) { var value = this.getString(resolver, name); if (name === "android_id") { console.log("android_id => " + value); return "fixed_random_android_id"; } return value; }; });还有一个从实践中得出的经验:不要一次只改一个字段。如果你只改了Build.MODEL,其他字段保持原样,那指纹变化很小,风控很容易根据和之前指纹的相似度判定为“同设备”。应该把一组相关联的字段一起改成目标形态,比如把Build字段、Android ID、MAC、系统属性的采集时间戳全部对齐,让服务端看到的是一个完整的“新设备形态”。
3.4 姿势三:Native层Hook,应对so库与加固场景
当Java层Hook打上去没有任何输出,或者App做了加固,指纹采集的代码在so里完成,就需要切换姿势。
第一步:定位so文件及关键函数。先从安装包里提取so,用文本搜索特征字符串,比如“android_id”“ro.serialno”“fingerprint”这类标识。找到后进IDA或radare2里分析函数链,确定目标函数的偏移地址。
第二步:用Frida的Module.findBaseAddress计算运行时基址,再用Interceptor.attach挂钩。
第三步:在onEnter里打印参数,在onLeave里修改返回值或参数内容。假设so里某个导出函数叫get_fingerprint,它返回一个char*,可以这样处理:
var base = Module.findBaseAddress("libdeviceinfo.so"); var fn = base.add(0x12345); // 偏移通过静态分析得到 Interceptor.attach(fn, { onEnter: function (args) { console.log("get_fingerprint called"); }, onLeave: function (retval) { var fakePtr = Memory.allocUtf8String("fake_fingerprint_value"); retval.replace(ptr(fakePtr)); } });这里有两个容易翻车的地方。第一,偏移地址必须考虑Thumb模式。如果是Thumb代码,实际地址是偏移+1;如果没做对齐,轻则Hook不生效,重则进程崩溃。第二,返回的是一个指针,不能用retval = xxx直接赋值,一定要用retval.replace,否则Frida只会改寄存器里的值,函数调用方读到的仍是原地址。
更复杂的情况是参数是个结构体指针,里面包含多个子字段。这时需要在onEnter里用args[0].readByteArray读取整个结构体,解析出目标字段后,再用args[0].writeByteArray写入修改后的内容。操作前先打印结构体内容,不要盲写。
如果在Native层遇到so加固或VMP,常规的Interceptor.attach就不一定行得通。可以考虑用frida-trace跟踪导入导出函数,配合Memory.scan扫描特征字节找函数,或者先做脱壳处理。这一层的复杂度会指数级上升,但原理和上面讲的类似:找准入口,观察行为,定点修改。
4. 常见问题与排查技巧实录
4.1 为什么Hook了还是被识别:先查三个方向
方向一:Hook点根本没被走到。这是最容易被忽略的。可能是方法重载导致Frida没有hook到具体签名,也可能是App在某个子线程里做采集,而你挂在主线程。解决方法是加日志,确认日志有没有打印出来。如果打印都没有,说明这个点根本没有被调用,一切修改都是空谈。
方向二:字段改了,但指纹没变。说明服务端使用的可能不是客户端上报的这个字段,或者客户端在上报前还有其他拼接逻辑。继续追指纹组装函数,不要停在采集点。
方向三:真实数据泄漏。比如修改了IMEI,但WiFi MAC通过另一条链路被采集走了,或者传感器数据被当成了指纹的一部分。风控系统不会看单字段,它看的是一整组特征,单点修改等于白改。
4.2 排查清单与实战习惯
下面这几条排查习惯,是踩过不少坑之后总结出来的,建议直接保存下来参考。
- 先抓包确认上报接口和请求体格式。如果请求体里有加密字段,先确认指纹是明文还是密文。如果是密文,修改客户端返回值后,还要确认加密逻辑是否会引入其他不确定因素。
- 保存一份“原始指纹”作为基线。所有修改开始前,先记录下原始值和原始返回结果,后面每次改动都和基线做对比。没有基线,后面出了问题无法回溯。
- 改动一轮,记录一轮。不要一次改太多,否则根本不知道哪个字段有效,也不知道是哪个改动引入了新问题。
- 用
frida-trace -i跟踪所有相关方法调用,能看到采集点之间的先后顺序和执行频率,对理解整体链路帮助很大。
4.3 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| Hook方法但日志无输出 | 方法重载签名不匹配、目标包有延迟加载 | 用frida-trace列出同名方法,确认签名 |
| 改了Java层字段,指纹无变化 | Native层还有一份采集逻辑 | 抓包确认指纹,在so里搜索特征字符串 |
| 改了Native层返回值,App崩溃 | 返回类型不是char*,或偏移错误(Thumb未处理) | 用IDA确认返回类型,处理Thumb对齐 |
| 改完仍然被识别 | 服务端还有IP、行为、时间戳等关联维度 | 检查关联维度的数据一致性 |
| 进程闪退 | Hook点还在初始化阶段,目标对象尚未存在 | 延迟执行,在onCreate之后再hook |
这些坑我几乎都踩过。最典型的一次,是某开发者反复改IMEI但一直无效,最后发现App在Application.attachBaseContext阶段就已经把设备信息发走了,而Hook脚本是在MainActivity才注入的,自然什么都没抓到。后来把注入时机提前到更早的生命周期回调附近,才真正生效。
5. 写在最后的个人体会
我个人最深的感受是:Frida绕设备指纹检测,表面上是个“改返回值”的活儿,实际上是对整条“采集→组装→上报→校验”链路的理解过程。先做体检,再做定点修改,最后联动验证,这套方法论比任何一条绕过技巧都值钱。如果只是抄一段脚本跑一遍,大概率第二天就被检测策略打了补丁;但如果你能画出完整的调用链,找到服务端校验的薄弱点,那才叫真正掌握。
从安全建设角度看,搞清楚这些绕法其实是在帮防守方成长。测试中发现的每一条链路,都能成为将来补上的风控防线;兼容性测试中整理的每种设备形态,都是评估SDK鲁棒性的重要样本。哪怕是做风控开发的人,也值得用逆向思路回头看一看自己的采集端到底漏了什么。
最后再分享一个小技巧:把常见Hook脚本封装成一整套“设备形态切换工具”,用一份JSON配置不同设备特征,比如Pixel 7、Pixel 4、某个国产机型等,跑测试时一键切换。这样既能复用,又能快速验证不同机型组合在目标业务下的表现,比每次手写脚本高效太多。希望这套方法能在你自己的合规测试与安全评估项目里派上用场。