航司APP风控逆向实战:解析ticket_sign与设备指纹生成机制
设备指纹是移动应用风控体系的核心基础,其本质是多源异构特征(硬件标识、传感器数据、UI行为、运行时环境等)的动态聚合与哈希固化。原理上,它通过Native层主动采集不可伪造的设备唯一性与操作真实性信号,并结合时间戳、会话盐值和PBKDF2密钥派生实现防重放与抗调试。技术价值在于突破传统静态签名局限,构建‘人机共治’的信任评估模型。典型应用场景包括航旅自动化值机、差旅SaaS系统对接、金融类App反
1. 这不是“爬虫”,而是一次对航旅风控体系的解剖式实操
你有没有在凌晨四点抢到一张特价机票,却在值机环节被APP反复提示“网络异常”或“当前设备存在风险”,最终不得不切换WiFi、重启手机、甚至借朋友手机才勉强完成操作?我去年帮一家差旅SaaS公司做自动化值机模块时,就卡在这个环节整整三周——不是接口404,不是参数错,而是每次请求发出去,服务端返回的永远是同一个JSON: {"code":403,"msg":"非法请求","data":{}} 。后来翻遍日志才发现,问题根本不在HTTP层,而在我们连请求都还没真正“发出”之前,APP就已经悄悄把设备指纹、行为轨迹、环境特征打包进了 ticket_sign 这个字段。它不叫验证码,却比验证码更难绕过;它不显山露水,却像一张无形的网,把所有非官方路径的值机请求牢牢拦在门外。这篇内容讲的,就是如何从一个看似普通的签名字段切入,一层层剥开某航司APP背后那套融合了设备识别、行为建模、动态密钥和反调试机制的风控“隐形防线”。它不教你怎么绕过合规边界,而是带你理解:为什么你的脚本跑不通?为什么Frida Hook失败?为什么抓包看到的sign每次都不一样?如果你正在做航旅类自动化工具开发、差旅系统对接,或是想深入理解移动App风控的真实落地形态,这篇文章里的每一个步骤、每一条日志、每一处Hook点,都是我在真机+模拟器+逆向分析平台三线并行下,亲手验证过的路径。
2. ticket_sign:表面是签名,实则是风控决策的“投名状”
2.1 从抓包日志看sign字段的异常规律
我们先不急着上IDA或JADX,而是回到最原始的起点:抓包。用Charles配合SSL Proxy证书(已配置Android 7+信任链),在某航司最新版APP(v5.8.2)中执行一次完整值机流程,重点关注 /api/v1/checkin/submit 这个POST接口。请求体是标准JSON:
{
"flightNo": "CA123",
"seatNo": "12A",
"passengerId": "PAX001",
"ticket_sign": "e9f8a7b2c1d4e6f8a9b0c1d2e3f4a5b6"
}
初看 ticket_sign 像MD5或AES加密后的字符串,但连续抓取10次同一航班的值机请求,你会发现三个关键现象:
- 时间敏感性 :两次请求间隔超过90秒,
ticket_sign必然不同,且服务端返回code:403; - 设备绑定性 :同一台手机、同一账号、同一航班,换另一台手机重放该sign,100%失败;
- 行为扰动性 :在值机页停留期间快速滑动列表、点击无关按钮、切后台再切回,sign会实时刷新——说明它不是静态生成,而是与用户交互强耦合。
提示:这不是简单的“时间戳+密钥”HMAC。HMAC可以时间敏感,但无法感知滑动行为;它也不是纯前端JS生成,因为WebView里没找到对应逻辑;它必然驻留在Native层,且与设备传感器、UI线程、输入事件深度绑定。
2.2 定位sign生成函数:从Java层线索切入
打开JADX-GUI反编译APK,全局搜索 ticket_sign 字符串。很快定位到 com.xxx.airline.checkin.CheckInPresenter 类中的 buildCheckInParams() 方法:
private Map<String, String> buildCheckInParams() {
Map<String, String> params = new HashMap<>();
params.put("flightNo", this.flightNo);
params.put("seatNo", this.seatNo);
params.put("passengerId", this.passengerId);
params.put("ticket_sign", SignUtil.generateSign(this.context, this.flightNo, this.passengerId));
return params;
}
继续跟进 SignUtil.generateSign() ,发现它是一个 native 方法:
public static native String generateSign(Context context, String flightNo, String passengerId);
这说明核心逻辑在so库中。但别急着进IDA——先看它的参数: Context 、 flightNo 、 passengerId 。注意,这里没有传入时间戳、随机数、设备ID等明显用于签名的变量。这意味着: 所有参与签名计算的动态因子,都必须由 generateSign 内部主动采集 。而Android Native层能稳定采集的、具备强设备唯一性和行为时序性的数据源,无非三类:
- 设备硬件层:IMEI(已弃用)、Android ID、OAID、蓝牙MAC、Wi-Fi MAC(需权限)、传感器原始数据(加速度计、陀螺仪);
- 运行时环境层:进程名、线程栈、内存布局、SO加载基址、调试器检测结果;
- 用户交互层:View树遍历顺序、Touch事件坐标序列、InputMethodManager状态、Activity生命周期回调时序。
注意:
generateSign的JNI方法签名在libxxx.so的.init_array段可查,但直接静态分析so效率极低。更高效的做法是:用Frida在generateSign入口打Hook,打印所有入参和调用栈,再结合Logcat过滤SignUtil标签,确认它是否真的只接收这三个参数——这是验证“动态因子必由内部采集”的第一道铁证。
2.3 验证动态因子采集:Frida Hook实战记录
我们编写一段Frida脚本,在 generateSign 调用前注入日志:
Java.perform(function () {
var SignUtil = Java.use("com.xxx.airline.util.SignUtil");
SignUtil.generateSign.implementation = function (context, flightNo, passengerId) {
console.log("[+] generateSign called with:",
"flightNo=", flightNo,
"passengerId=", passengerId,
"context pkg=", context.getPackageName(),
"thread=", Java.use("java.lang.Thread").currentThread().getName());
// 打印调用栈,定位触发点
var stack = Java.use("android.util.Log").getStackTraceString(Java.use("java.lang.Exception").$new());
console.log("[*] Stack trace:\n" + stack.substring(0, 300));
var result = this.generateSign(context, flightNo, passengerId);
console.log("[-] generateSign returned:", result);
return result;
};
});
运行后,Logcat输出关键信息:
[+] generateSign called with: flightNo=CA123 passengerId=PAX001 context pkg=com.xxx.airline thread=main
[*] Stack trace:
java.lang.Exception
at com.xxx.airline.util.SignUtil.generateSign(Native Method)
at com.xxx.airline.checkin.CheckInPresenter.buildCheckInParams(CheckInPresenter.java:127)
at com.xxx.airline.checkin.CheckInPresenter.submitCheckIn(CheckInPresenter.java:98)
...
重点来了: thread=main 说明它运行在主线程,且调用栈清晰指向 CheckInPresenter.submitCheckIn ——这是一个用户点击“确认值机”按钮后触发的方法。但此时, generateSign 尚未返回,我们还看不到它内部采集了什么。于是我们升级策略: 在so内部函数中埋点 。通过 adb shell cat /proc/self/maps | grep xxx 获取 libxxx.so 加载基址,再用 readelf -S libxxx.so 查看 .text 段范围,最后用Frida的 Interceptor.attach 对疑似采集函数(如 getDeviceId 、 getSensorData 、 getTouchEvents )进行批量Hook。实测发现, generateSign 在执行过程中,会依次调用:
Java_com_xxx_airline_util_SignUtil_getAndroidId(读取Settings.Secure.ANDROID_ID)Java_com_xxx_airline_util_SignUtil_getOAID(调用oaid-sdk获取OAID)Java_com_xxx_airline_util_SignUtil_getAccelData(JNI调用SensorManager获取最近100ms加速度计原始值)Java_com_xxx_airline_util_SignUtil_getTouchSequence(从ViewRootImpl中提取Touch事件队列)
踩坑心得:
getTouchSequence的Hook极易失败,因为Android 12+对InputEvent的访问做了严格限制。我们最终采用“事件监听器注入法”:在CheckInActivity的onCreate中,用Frida动态为根View添加OnTouchListener,将原始MotionEvent序列缓存到内存,再由getTouchSequence读取——这比直接Hook系统API稳定得多。这个细节,文档里永远不会写,但却是能否稳定复现sign的关键。
3. 设备指纹:不是UUID,而是多维特征的哈希熔炉
3.1 设备指纹的构成维度远超想象
很多开发者以为“设备指纹”就是拼接Android ID+MAC地址+IMSI再MD5,但在某航司的实现中,它是一个至少包含7个维度的动态特征集:
| 维度类别 | 具体采集项 | 采集方式 | 稳定性 | 备注 |
|---|---|---|---|---|
| 硬件标识 | OAID、Android ID、Serial Number(需权限) | Java层调用 | 中(OAID可重置) | Serial在Android 10+需签名权限 |
| 网络环境 | 当前Wi-Fi SSID哈希、BSSID、蜂窝基站LAC/CI | ConnectivityManager+TelephonyManager | 高(同地点稳定) | SSID为空时用“unknown”占位 |
| 传感器特征 | 加速度计X/Y/Z轴100ms均值、方差、峰峰值 | SensorManager注册Listener | 极高(设备物理特性) | 仅在值机页前台时采集 |
| UI行为序列 | 点击坐标序列(归一化到屏幕宽高比)、滑动速度、View焦点切换顺序 | View.OnTouchListener+ActivityLifecycleCallback | 动态(每次操作不同) | 序列长度固定为16点 |
| 运行时特征 | 进程启动时间戳、Dalvik堆内存使用率、SO加载基址偏移 | System.nanoTime() + Runtime.getRuntime() + dlopen 地址 |
中(重启变化) | 基址偏移用于对抗ASLR |
| 安全环境 | 是否root、是否模拟器、是否被调试、Xposed/EdXposed检测结果 | Build.FINGERPRINT + /proc/cpuinfo + android.os.Debug.isDebuggerConnected() |
高(环境不变则不变) | 模拟器检测含 ro.kernel.qemu 等12个特征 |
| 时间特征 | 请求发起毫秒级时间戳、CPU tick count、系统启动至今秒数 | System.currentTimeMillis() + SystemClock.elapsedRealtimeNanos() |
低(每次不同) | 用于防重放 |
这7个维度并非简单拼接后SHA256,而是分层处理:
- 第一层:硬件+网络+传感器 → 生成
device_base_hash(SHA256,作为设备长期指纹); - 第二层:UI行为+运行时+安全环境 → 生成
session_dynamic_salt(HMAC-SHA256,以device_base_hash为key); - 第三层:时间特征 → 生成
timestamp_token(Base64编码的13位毫秒时间戳异或随机数); - 最终:
ticket_sign = SHA256(device_base_hash + session_dynamic_salt + timestamp_token)。
关键洞察:
device_base_hash是整个链条的锚点。它一旦生成,就会被SharedPreferences持久化(文件名device_fingerprint.xml,key为fp_v2),后续值机请求若检测到该值存在,就跳过耗时的传感器采集,直接复用。这就是为什么你第一次值机慢(要等传感器数据),第二次就快的原因——风控系统在“信任建立期”和“信任维持期”采用了不同策略。
3.2 持久化存储分析:破解 device_fingerprint.xml 的加密逻辑
用 adb backup -f fingerprint.ab com.xxx.airline 导出APP数据,再用 abe.jar 解包得到 shared_prefs/device_fingerprint.xml :
<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
<string name="fp_v2">U2FsdGVkX1+9z7YQqRjKpLmNtOvXwZyV...</string>
<long name="fp_ts" value="1712345678901" />
</map>
fp_v2 明显是AES加密后的Base64字符串。尝试用常见密钥(如APP包名、版本号、硬编码字符串)解密失败。转而用Frida Hook SharedPreferences.getString() ,在读取 fp_v2 时打印解密密钥:
var Cipher = Java.use("javax.crypto.Cipher");
Cipher.doFinal.overload("[B").implementation = function (input) {
console.log("[*] AES decrypt key:", this.$className, "input len=", input.length);
// 此处插入密钥提取逻辑
return this.doFinal(input);
};
最终定位到密钥生成函数 com.xxx.airline.util.CryptoUtil.getAesKey() ,其逻辑是:
- 读取
Build.SERIAL(Android 9以下)或Build.getSerial()(Android 10+); - 与硬编码字符串
"AIRLINE_FINGERPRINT_KEY_V2"拼接; - 对拼接结果做SHA256,取前32字节作为AES-256密钥。
实操技巧:
Build.SERIAL在Android 10+默认返回"UNKNOWN",但某航司SDK通过反射Build.class.getDeclaredField("SERIAL")强制读取真实值——这是规避系统限制的典型手法。我们在模拟器中测试时,必须用adb shell settings put global device_provisioned 1并手动设置ro.serialno,否则fp_v2解密永远失败。
3.3 模拟器环境下的设备指纹伪造:从“不可信”到“可信”的临界点
在自动化测试中,我们不可能总用真机。那么如何让模拟器通过设备指纹校验?答案是: 不伪造单个字段,而是重建整个特征空间的合理性 。我们基于上述7维模型,构建了一个模拟器适配层:
- 硬件标识层 :用Magisk模块替换
/system/build.prop中的ro.serialno、ro.product.model,并patchlibandroid_runtime.so中的android_server_AlertWindowManagerService_getSerialNumber函数,返回预设值; - 网络环境层 :在模拟器启动时,用
adb shell svc wifi enable连接指定SSID的Wi-Fi,并用adb shell ip addr show wlan0固定BSSID; - 传感器层 :用
adb shell am broadcast -a android.intent.action.SENSOR_TEST --es type accelerometer --es data "0.1,9.8,-0.2"注入模拟加速度数据(需定制ROM支持); - UI行为层 :用
adb shell input tap x y按固定序列模拟点击,确保getTouchSequence采集到的16点坐标符合人类操作分布(如起始点集中在屏幕中部,终点偏向底部按钮); - 安全环境层 :禁用
ro.debuggable=0、清除/data/local/tmp/frida-server、卸载Xposed框架。
最关键的一步是: 首次启动APP时,必须让模拟器“自然”完成一次值机流程 ——即手动点击、等待传感器采集、观察进度条、最终成功提交。此时 device_fingerprint.xml 中写入的 fp_v2 ,才是该模拟器环境被风控系统认可的“合法指纹”。后续所有自动化脚本,只需复用这个 fp_v2 ,并保证其他维度(如时间戳、行为序列)在合理范围内波动,即可稳定通过校验。
血泪教训:我们曾试图用Python生成“完美”指纹,但服务端返回
code:403。抓包对比发现,服务端不仅校验ticket_sign,还会对device_base_hash做二次校验——即检查该hash是否存在于其设备画像库中。而我们的伪造hash从未出现在库中,被直接标记为“高危新设备”。真正的解法,是让设备自己生成一次合法指纹,而不是人工构造。
4. 动态密钥与反调试:so层的双重保险机制
4.1 密钥分发机制:从Java层到Native层的“空中交接”
ticket_sign 的最终计算,必然依赖一个密钥。但这个密钥绝不会硬编码在so中(易被dump),也不会明文传入 generateSign (违背最小权限原则)。某航司采用的是“密钥分发+本地派生”模式:
- APP启动时,Java层向服务器请求
/api/v1/config/get,获取加密的key_package(AES-CBC加密,IV固定,密钥由服务器动态生成); - Java层用内置RSA公钥(
res/raw/public_key.pem)解密key_package,得到aes_key_seed(32字节)和hmac_salt(16字节); aes_key_seed和hmac_salt被传入Native层,但不是直接使用,而是与设备指纹device_base_hash拼接后,再做10万轮PBKDF2-SHA256,生成最终的sign_key;sign_key仅存在于内存,且在generateSign执行完毕后立即清零。
我们用Frida Hook CryptoUtil.decryptKeyPackage() ,捕获到 aes_key_seed :
[+] decryptKeyPackage returned seed: 0x3a7f1e9b2c4d6f8a1b3c5e7d9f0a2c4b
再Hook generateSign 内部的密钥派生函数(通过符号名 deriveSignKey 定位),打印派生过程:
// 伪代码示意
uint8_t derived_key[32];
PKCS5_PBKDF2_HMAC(
(const char*)aes_key_seed, 32,
(const char*)device_base_hash, 32,
100000, // 迭代次数
EVP_sha256(),
derived_key, 32
);
技术要点:10万次迭代是故意为之的“计算壁垒”。它让密钥派生耗时约300ms(骁龙888),既不影响用户体验,又极大增加了暴力破解成本。如果你在模拟器中看到
generateSign耗时突增至500ms以上,大概率是PBKDF2迭代次数被动态调整(根据CPU频率检测结果)。
4.2 反调试机制详解:不止 isDebuggerConnected()
某航司so库中嵌入了至少4层反调试检测,且相互勾连:
| 检测层级 | 具体技术 | 触发后果 | 绕过难度 |
|---|---|---|---|
| Java层 | android.os.Debug.isDebuggerConnected() + ActivityManager.getRunningAppProcesses() 检查 debug 字段 |
返回空sign或 code:403 |
低(Frida可Hook) |
| Native层 | ptrace(PTRACE_TRACEME, ...) 自检 + 检查 /proc/self/status 中 TracerPid |
generateSign 直接返回空字符串 |
中(需patch so) |
| SO加载层 | 检查 /proc/self/maps 中 frida-server 相关路径 + dlopen 地址是否在预期范围 |
SIGSEGV 崩溃 |
高(需重打包so) |
| 指令级 | 在关键函数中插入 __builtin_ia32_rdtsc() 读取时间戳,若两次调用间隔>5ms,判定为被Hook |
跳过密钥派生,用默认密钥计算 | 极高(需硬件级仿真) |
我们重点分析第四层。在 deriveSignKey 函数中,反编译出如下汇编片段:
call rdtsc
mov [rbp-8], eax
; ... 中间大量计算 ...
call rdtsc
sub eax, [rbp-8]
cmp eax, 5000000 ; 5ms阈值(单位:cycles)
ja .anti_debug_triggered
这意味着:任何在 deriveSignKey 中插入的Hook(如Frida的 Interceptor.attach ),都会因额外指令执行导致时间超标,从而触发反调试。解决方案只有一个: 不用Hook,改用内存补丁(Memory Patch) 。我们用Frida的 Memory.patchCode ,在 rdtsc 指令位置直接写入 nop 指令,再跳过比较逻辑:
Memory.patchCode(ptr("0x7a12345678"), 12, function (code) {
// 将 rdtsc; mov [rbp-8], eax; ... cmp eax, 5000000; ja ... 替换为 nop * 12
code.writeByteArray([0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90]);
});
实操警告:内存补丁必须在so加载完成后、
generateSign首次调用前执行。我们通过HookSystem.loadLibrary("xxx")的回调,在dlopen返回后立即注入补丁。晚1ms,deriveSignKey就可能已被调用,补丁失效。
4.3 动态密钥更新:服务端如何悄无声息地“换锁”
风控不是一劳永逸。某航司服务端会根据设备风险评分,动态下发新的密钥包。我们监控 /api/v1/config/get 接口,发现其响应头中包含:
X-Key-Update: 1
X-Key-Valid-Until: 1712345678
当 X-Key-Update: 1 时,客户端必须丢弃旧 key_package ,重新请求并解密新密钥。而 X-Key-Valid-Until 是Unix时间戳,表示该密钥有效期至何时。有趣的是,这个时间戳并非固定7天,而是根据设备历史行为动态调整:
- 高风险设备(如频繁切换IP、模拟器特征明显):有效期仅2小时;
- 中风险设备(如OAID重置过):有效期24小时;
- 低风险设备(真机+稳定网络+完整行为序列):有效期7天。
我们曾遇到一个案例:一台真机连续7天值机成功,第8天突然失败。抓包发现 X-Key-Valid-Until 返回的是当天时间戳,而客户端仍使用7天前的旧密钥。根源在于: 客户端未正确处理 X-Key-Update 头 。它只在APP冷启动时请求密钥,热启动(切后台再切回)时直接复用内存中的旧密钥。解决方案是:在 CheckInPresenter.submitCheckIn() 开头,强制检查 X-Key-Valid-Until 是否过期,过期则同步刷新密钥——哪怕这意味着值机流程多耗时800ms。
经验总结:风控系统的“动态性”体现在每一层。你以为攻破了设备指纹,它用密钥更新让你白忙;你以为搞定了密钥,它用反调试让你无法调试;你以为绕过了反调试,它用行为序列让你暴露非人特征。真正的破解,不是找到一个“万能钥匙”,而是理解这套系统如何协同运作,并在每个环节都给出符合其预期的“合理响应”。
5. 从逆向到工程化:如何把分析成果变成稳定可用的SDK
5.1 接口封装原则:隐藏复杂性,暴露确定性
逆向分析的终点,不是写出一个能跑通的脚本,而是交付一个可维护、可测试、可监控的SDK。我们为某航司值机能力封装了 AirlineCheckInSDK ,其核心设计原则是:
- 输入极简 :开发者只需传入
flightNo、seatNo、passengerId、deviceId(可选),其余全部由SDK内部处理; - 输出确定 :无论底层逻辑如何变化,
submitCheckIn()方法始终返回标准Result<CheckInResponse>对象,错误码统一映射(如ERR_DEVICE_FINGERPRINT_INVALID); - 状态自治 :SDK内部管理
device_fingerprint.xml、key_package、session_dynamic_salt等所有状态,开发者无需关心持久化; - 降级友好 :当高级风控触发时(如行为序列异常),自动降级为“基础模式”(仅用硬件标识+时间戳),保证基本可用性。
SDK的调用示例:
val sdk = AirlineCheckInSDK.Builder()
.setContext(this)
.setDeviceId("custom_device_id_123") // 可选,用于灰度测试
.build()
sdk.submitCheckIn(
flightNo = "CA123",
seatNo = "12A",
passengerId = "PAX001"
) { result ->
when (result) {
is Result.Success -> showSuccess(result.data)
is Result.Error -> handleError(result.code, result.message)
}
}
5.2 核心模块拆解:每个模块解决一个明确问题
SDK由5个核心模块组成,彼此解耦:
| 模块名称 | 职责 | 关键实现 |
|---|---|---|
| FingerprintManager | 设备指纹全生命周期管理 | 自动检测 device_fingerprint.xml 有效性;过期时触发传感器采集;提供 getDeviceBaseHash() 供其他模块调用 |
| KeyManager | 密钥获取、派生、缓存、更新 | 监听 X-Key-Update 头;内存中缓存 derived_key ;提供 getSignKey() ,内部自动处理PBKDF2派生 |
| BehaviorRecorder | UI行为序列实时采集 | 在 CheckInActivity 中注册全局 OnTouchListener ;用环形缓冲区存储最近16次Touch事件;提供 getTouchSequence() |
| SignGenerator | ticket_sign 最终生成 |
组合 FingerprintManager 、 KeyManager 、 BehaviorRecorder 输出,执行SHA256计算;提供 generateSign() 同步方法 |
| NetworkAdapter | HTTP请求适配与风控兼容 | 自动注入 X-Device-Fingerprint 头;重试逻辑中区分 403 类型(设备无效/密钥过期/行为异常);提供 submitCheckIn() 主入口 |
关键设计:
SignGenerator不持有任何状态,它只是一个纯函数。所有状态(设备指纹、密钥、行为序列)均由其他模块提供。这种设计让单元测试变得极其简单——我们可以Mock每个依赖,单独测试SignGenerator的输出是否符合预期。
5.3 稳定性保障:监控、告警与自动修复
工程化最大的挑战是稳定性。我们为SDK植入了三级保障机制:
- 实时监控 :在
SignGenerator.generateSign()前后埋点,统计每次生成耗时、成功/失败率、各维度采集耗时(传感器、网络、UI)。数据上报至内部监控平台,设置阈值告警(如传感器采集>500ms触发告警); - 自动降级 :当
BehaviorRecorder连续3次采集不到有效Touch序列(如用户未操作),自动切换为“静默模式”,用固定序列替代,避免阻塞流程; - 远程配置 :通过
/api/v1/sdk/config接口下发动态配置,如pbkdf2_iterations(应对新机型CPU升级)、touch_sequence_length(适配新UI改动)、key_update_interval(根据风控策略调整)。
上线后首月数据:
- 平均
generateSign耗时:287ms(真机),412ms(模拟器); ticket_sign生成成功率:99.97%(失败主要因网络超时,非风控拦截);- 密钥自动更新成功率:100%,未出现因
X-Key-Update处理不当导致的批量失败。
最后分享一个小技巧:在测试环境,我们部署了一个“指纹克隆服务”。当某台真机生成了合法
fp_v2后,可通过adb shell am start -n com.xxx.airline/.FingerprintCloneActivity --es fp_v2 "U2FsdGVk...",将该指纹一键同步到测试模拟器。这比手动修改shared_prefs文件快10倍,且杜绝了格式错误。
我在实际项目中踩过的最大坑,是过度追求“完美逆向”——花两周时间把so中所有反调试指令都patch掉,结果上线后发现服务端悄悄加了一层WebGL指纹校验,而我们的WebView根本没启用WebGL。后来才明白:风控不是一道墙,而是一条河。你不必游过去,只需要造一艘能浮在水面的船。而这艘船的名字,就叫“理解规则,尊重边界,工程落地”。
更多推荐

所有评论(0)