Skip to content

第16章 如何应对应用设置的反爬机制

某电商App的风控团队在2024年做了一次内部复盘:他们发现每天有超过800万次请求是"合法但可疑"的——请求格式完全符合规范,签名校验通过,设备指纹唯一,但行为模式却暴露了爬虫的本质。最终他们靠的不是某一项黑科技,而是一套从协议层到行为层的纵深防御体系。这一章,怕浪猫就来拆解这套体系的每一层,看看作为爬虫开发者,我们该如何逐一应对。

我是怕浪猫,这是Python移动端爬虫系列的第十六章。前面十五章我们从ADB基础一路走到群控架构、日志可视化,技术栈越来越完整。但有一个话题我一直压着没讲,因为它最难讲,也最容易过时——反爬对抗。

为什么说它容易过时?因为反爬技术本身就是一场军备竞赛。今天有效的绕过方案,明天可能就被风控引擎的一个规则更新废掉。所以这一章我不会给你某个"万能绕过脚本",那种东西不存在。我要讲的是对抗的思维方式:理解对方怎么检测你,然后从检测原理出发,逐一设计规避策略。这种方式不管对方怎么更新规则,你都能快速理解新的检测机制并找到对应的规避方向。

怕浪猫在刚入行的时候走过不少弯路。一开始觉得反爬就是"加个User-Agent伪装",后来发现对方校验签名,再后来发现对方连你TLS握手特征都要检测。每一次被拦截,都是一次认知升级。所以这一章的内容,与其说是教程,不如说是怕浪猫这几年踩坑的经验总结,希望能帮你少走一些弯路。在正式开始之前,先明确一点:本章讨论的所有技术方案仅用于学习和研究目的,实际应用中请遵守目标网站的robots.txt协议和当地法律法规。反爬对抗技术的学习价值在于理解攻防双方的思维方式,而不是鼓励无限制的数据抓取。

金句:反爬对抗的本质不是技术博弈,而是成本博弈。你不需要做到完美无缺,只需要让对方识别你的成本高于你爬取数据的收益。

16.1 协议逆向解析:通信链路拆解与加密算法重构

16.1.1 从一次抓包失败说起

怕浪猫第一次做移动端爬虫的时候,流程很简单:打开Charles(一个HTTP代理调试工具,官方文档:https://www.charlesproxy.com ),配置手机代理,打开目标App,看请求列表。Web端爬虫就是这么做的,把URL复制出来,用requests发请求,完事。

但移动端App不是Web页面。当你打开Charles准备抓包的时候,你会发现两种情况:要么请求列表里空空如也,什么也抓不到;要么能抓到请求,但参数是一堆看不懂的加密字符串。

第一种情况,是因为App走了自己的网络栈,根本不经过系统代理。比如很多App使用OkHttp(Square公司开源的Android HTTP客户端框架,官方文档:https://square.github.io/okhttp/ )的自定义配置,在初始化OkHttpClient时显式设置了proxy(Proxy.NO_PROXY),这样App的所有网络请求都会绕过系统代理设置。还有一些App直接用C层网络库如cronet(Google基于Chromium网络栈的C++封装,官方文档:https://chromium.googlesource.com/chromium/src/net/+/refs/heads/main/cronet ),从底层就不走系统代理通道。这种情况下,你打开Charles什么也抓不到,因为App的流量根本就没有经过代理服务器。

第二种情况更常见,也更能说明问题。看下面这个真实请求:

POST /api/v3/product/list HTTP/1.1
Host: api.example.com
Content-Type: application/json
X-Sign: a3f8b2c1d4e5f6a7b8c9d0e1f2a3b4c5
X-Timestamp: 1719705600
X-Device-Id: 8f2a1b3c4d5e6f7a8b9c0d1e2f3a4b5c

{"data":"eJxLy88xkjNMSk0sSU3OT0lNzSvJzM9MSUxKTE8sSUzOzM3B0gUAAE0JDQ0="}

URL和Host都很清晰,但X-Sign是什么?请求体里的data字段为什么是Base64编码的乱码?X-Timestamp又有什么作用?这就是协议加密——App在发送请求之前,对请求参数做了加密处理,同时生成了签名和时间戳用于服务端校验。你即使拿到了URL,也无法直接用requests构造同样的请求,因为你不知道加密算法和签名生成逻辑。

怕浪猫第一次遇到这种情况的时候,愣是盯着Charles看了半个小时,试图从那串Base64编码中看出什么规律。当然什么都看不出来。后来才明白,协议加密的设计目的就是让你看不出规律。要破解它,你需要进入App的内部,去看它的代码是怎么处理这些参数的。

16.1.2 通信链路的全景拆解

要理解移动端App的通信链路,我们需要从上到下把整个链路拆开。一个典型的Android App网络请求,从代码调用到最终发出网络包,要经过以下几个层次:

+--------------------------------------------------+
|  Java/Kotlin 层 (业务代码)                       |
|  - 构造请求参数 Map<String, Object>              |
|  - 调用加密工具类 EncryptUtils.encrypt()         |
+--------------------------------------------------+
                     |
                     v
+--------------------------------------------------+
|  加密中间件层 (Interceptor / Filter)              |
|  - 对请求体做 AES/RSA 加密                        |
|  - 生成签名 sign = MD5(params + secret + ts)     |
|  - 添加请求头 X-Sign / X-Timestamp               |
+--------------------------------------------------+
                     |
                     v
+--------------------------------------------------+
|  HTTP 客户端层 (OkHttp / Retrofit)               |
|  - 序列化为 HTTP 请求                            |
|  - 压缩 (Gzip)                                  |
|  - 连接池管理                                    |
+--------------------------------------------------+
                     |
                     v
+--------------------------------------------------+
|  传输层 (TLS/TCP)                                |
|  - TLS 握手 + 证书校验 (SSL Pinning)             |
|  - 数据分包传输                                   |
+--------------------------------------------------+
                     |
                     v
+--------------------------------------------------+
|  服务端                                           |
|  - 解密请求体                                    |
|  - 验证签名                                     |
|  - 校验时间戳防重放                               |
|  - 风控引擎检测                                   |
+--------------------------------------------------+

这条链路的每一层都可能成为反爬的关卡。Java层的加密逻辑是最常见的,也是相对最容易逆向的,因为Java代码反编译后基本能还原出可读的源码。加密中间件层的签名算法通常依赖一个本地密钥,逆向时需要找到这个密钥的存储位置——它可能硬编码在Java代码中,也可能藏在native层的so文件里,甚至可能是从服务端动态下发的。传输层的SSL Pinning(证书绑定,一种防止中间人攻击的安全机制,通过在客户端硬编码服务器证书的哈希值来校验服务器身份)会阻止你用Charles抓包,让你连第一步抓包都做不了。

怕浪猫在拆解链路的时候有一个心得:从上往下拆比从下往上拆容易。因为Java层的代码通常最容易反编译和阅读,你先看懂了加密逻辑,再去处理传输层的抓包问题,目的性会更强。如果你连抓包都做不到,可以用frida动态Hook的方式绕过SSL Pinning先拿到请求样本,然后再对照反编译代码分析加密逻辑。

金句:抓包看到的不是App通信的全部,而是App允许你看到的切片。真正的加密逻辑藏在你看不到的字节码里。

16.1.3 逆向工具链与静态分析

要做协议逆向,第一步是拿到App的安装包并进行反编译。Android的安装包格式是APK(Android Package),本质上是一个ZIP文件,包含编译后的DEX(Dalvik Executable,Android虚拟机的可执行文件格式)、资源文件和签名信息。

反编译的工具链如下:

工具作用输出格式适用场景
jadxDEX反编译为Java源码.java快速定位加密逻辑
apktool资源文件反编译 + smali代码smali + XML分析资源文件和Manifest
frida动态Hook,运行时拦截函数调用脚本化输出验证逆向结果,实时观察
IDA ProNative层so库反汇编汇编/伪代码分析C/C++加密逻辑

实际操作中,怕浪猫的常规流程分为三步。第一步用jadx打开APK,全局搜索加密相关的关键词,比如"AES"、"encrypt"、"sign"、"MD5"、"SecretKey"等,定位加密函数在哪个类哪个方法里。第二步用frida动态Hook这个函数,打印输入参数和输出结果,验证你对加密算法的理解是否正确。第三步根据反编译的代码和Hook验证的结果,用Python重写加密逻辑。

核心步骤代码如下:

python
# frida Hook 脚本:拦截加密函数,打印输入输出
# 命令行执行:frida -U -f com.example.app -l hook_encrypt.js

# hook_encrypt.js
Java.perform(function() {
    var EncryptUtils = Java.use("com.example.app.utils.EncryptUtils");
    
    EncryptUtils.encrypt.overload('[B').implementation = function(input) {
        console.log("[encrypt] input: " + bytesToHex(input));
        var result = this.encrypt(input);
        console.log("[encrypt] output: " + bytesToHex(result));
        return result;
    };
    
    EncryptUtils.getSign.overload('java.lang.String')
        .implementation = function(params) {
        console.log("[getSign] params: " + params);
        var sign = this.getSign(params);
        console.log("[getSign] sign: " + sign);
        return sign;
    };
});

function bytesToHex(bytes) {
    return Array.from(bytes, function(b) {
        return ('0' + (b & 0xFF).toString(16)).slice(-2);
    }).join('');
}

这段脚本做的事情很简单:找到EncryptUtils类,在encryptgetSign方法被调用时,把输入参数和返回值打印出来。通过这种方式,你可以看到明文参数是什么、加密后的密文是什么,从而验证你对加密算法的理解是否正确。

怕浪猫刚开始用frida的时候,经常遇到Hook不上目标函数的问题。最常见的原因是类名不对——App可能做了代码混淆,EncryptUtils这个类名在反编译时看着像,但运行时的实际类名可能是a.b.c。解决办法是用jadx搜索方法特征而不是类名,比如搜索getInstance("AES")这个调用,找到实际的类和方法名,再做Hook。

16.1.4 加密算法重构的实战思路

假设通过jadx反编译,你找到了加密函数的核心代码(伪代码如下):

java
// 反编译后的加密逻辑
public static String encrypt(String plaintext, String key) {
    byte[] keyBytes = key.getBytes();
    byte[] iv = new byte[16];
    System.arraycopy(keyBytes, 0, iv, 0, 16);
    Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
    cipher.init(Cipher.ENCRYPT_MODE, 
                new SecretKeySpec(keyBytes, "AES"), 
                new IvParameterSpec(iv));
    byte[] encrypted = cipher.doFinal(plaintext.getBytes("UTF-8"));
    return Base64.encodeToString(encrypted, Base64.NO_WRAP);
}

这是一个标准的AES-CBC加密,密钥直接硬编码在代码中,IV(Initialization Vector,初始化向量,用于CBC模式下使相同明文加密后产生不同密文)取密钥的前16字节。用Python重构非常直接:

python
import base64
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad

def encrypt(plaintext: str, key: str) -> str:
    key_bytes = key.encode('utf-8')
    iv = key_bytes[:16]
    cipher = AES.new(key_bytes, AES.MODE_CBC, iv)
    padded = pad(plaintext.encode('utf-8'), AES.block_size)
    encrypted = cipher.encrypt(padded)
    return base64.b64encode(encrypted).decode('utf-8')

但实际项目中,情况往往没有这么简单。怕浪猫遇到过几种复杂的变种:密钥不是硬编码的,而是通过JNI(Java Native Interface,Java调用Native代码的桥梁)调用C++层代码动态生成的,这种情况下你需要用IDA Pro分析so文件,找到密钥生成逻辑。签名算法不是标准MD5,而是混入了设备信息、时间戳、请求路径等多个变量的自定义算法,你需要把所有参与签名的变量都找出来,并确定它们的拼接顺序。加密算法不是标准AES,而是做了魔改的对称加密,比如替换了S-Box(Substitution Box,替换盒,AES算法中的核心非线性变换组件),这种情况下你需要提取替换后的S-Box并在Python中实现自定义的AES。

怕浪猫的经验是:先找规律,再挖代码。用frida Hook拿到多组输入输出样本,观察明文和密文之间的映射关系。如果输入相同、输出也相同,说明是确定性加密,没有随机盐。如果输入相同、输出不同,说明有随机IV或随机盐,需要找到这个随机值的传递方式——通常随机IV会附在密文前面一起传输,或者放在请求头中。

金句:逆向工程不是猜谜游戏,而是假设验证的循环。每一个Hook点就是一个观察窗口,每一次输入输出对比就是一次假设检验。

16.1.5 抓包绕过SSL Pinning

前面提到,很多App设置了SSL Pinning,导致Charles无法抓HTTPS包。SSL Pinning的原理是App在代码中预先存储了服务器证书的哈希值或公钥,在TLS握手时校验服务器返回的证书是否匹配。如果不匹配,就中断连接。这样即使你在手机上安装了Charles的根证书,App也不会信任它,因为证书的哈希值对不上。

绕过SSL Pinning有多种方案,最常用的是使用frida脚本配合objection(基于frida的运行时探查工具,官方文档:https://github.com/sensepost/objection ):

python
# 使用 objection 一键禁用 SSL Pinning
# 命令行执行:
# objection -g com.example.app explore
# 然后在交互式shell中输入:
# android sslpinning disable

# 或者用 frida 脚本手动绕过
# frida -U -f com.example.app -l bypass_ssl.js

# bypass_ssl.js 核心逻辑
Java.perform(function() {
    var X509TrustManager = Java.use('javax.net.ssl.X509TrustManager');
    var SSLContext = Java.use('javax.net.ssl.SSLContext');
    
    var TrustManager = Java.registerClass({
        name: 'com.bypass.TrustManager',
        implements: [X509TrustManager],
        methods: {
            checkClientTrusted: function() {},
            checkServerTrusted: function() {},
            getAcceptedIssuers: function() { return []; }
        }
    });
    
    SSLContext.init.overload(
        '[Ljavax.net.ssl.KeyManager;',
        '[Ljavax.net.ssl.TrustManager;',
        'java.security.SecureRandom'
    ).implementation = function(km, tm, sr) {
        this.init(km, [TrustManager.$new()], sr);
    };
});

这段代码的核心思路是:创建一个不做任何校验的TrustManager,它的checkServerTrusted方法是个空函数,意味着任何证书都能通过校验。然后通过Hook SSLContext.init方法,把这个空的TrustManager替换掉原有的TrustManager。这样App就会信任所有证书,包括Charles的自签名证书,从而让抓包恢复正常。

需要注意的是,越来越多的App开始在Native层(C/C++代码)做SSL Pinning,比如使用BoringSSL(Google维护的TLS库,OpenSSL的分支)在C层校验证书。这时候Java层的Hook就不管用了,因为校验逻辑根本不经过Java层的SSLContext。针对这种情况,你需要用frida的Native Hook能力,Hook SSL_CTX_set_verify等Native函数,或者直接在so文件中patch校验函数的汇编指令,让它直接返回成功。这类操作需要一定的逆向工程基础,建议先从Java层的绕过开始练习,熟练后再挑战Native层。

16.1.6 签名校验的逆向与复现

签名校验是反爬体系中最基础也最普遍的防护手段。理解签名校验的原理对于协议逆向至关重要。签名的基本流程是:客户端把所有请求参数按特定顺序拼接成一个字符串,加上一个密钥(通常称为salt或secret),然后做哈希运算(通常是MD5或SHA256),得到的哈希值就是签名。服务端用同样的算法和密钥重新计算签名,如果与你提交的签名不一致,就拒绝请求。

签名校验的一个关键设计是防重放:签名中通常包含时间戳,服务端会检查时间戳与当前时间的差值,如果超过某个阈值(比如5分钟),就认为这个请求是过期的重放请求,拒绝处理。这就是为什么你在抓包时看到的请求都带有X-Timestamp头。除了时间戳,有些App还会在签名中加入一个随机数(nonce),服务端会记录已使用过的nonce值,防止同一个请求被重复提交。

逆向签名算法的关键是找到三个要素:参与签名的参数列表、参数的拼接顺序、以及使用的密钥。用frida Hook getSign方法可以拿到签名的输入和输出,但输入参数的拼接逻辑需要从反编译代码中分析。怕浪猫的习惯是先Hook拿到十组以上的输入输出样本,然后逐一比对:哪些参数参与了签名、参数的拼接顺序是什么、密钥是硬编码的还是动态生成的。通过足够多的样本对比,通常能在两三个小时内确定签名算法的完整逻辑。

16.2 流量仿真策略:客户端行为建模与特征复现

16.2.1 为什么"协议正确"还不够

假设你已经完成了协议逆向,能够正确构造加密参数和签名,用requests发出请求,服务器返回了200。你觉得你赢了?未必。

现代风控系统不只看请求的内容,还看请求的模式。一个真实用户打开App浏览商品,和爬虫程序批量拉取数据,在网络流量层面有本质区别:

真实用户的请求模式:
14:01:03  GET /api/v3/home/feed        (打开首页)
14:01:05  GET /api/v3/product/12345    (点击商品)
14:01:12  GET /api/v3/product/12345/comment  (查看评论)
14:01:25  GET /api/v3/shop/info        (进入店铺)
14:01:40  GET /api/v3/cart/list        (查看购物车)

爬虫的请求模式:
14:01:00  GET /api/v3/product/10001
14:01:00  GET /api/v3/product/10002
14:01:00  GET /api/v3/product/10003
14:01:00  GET /api/v3/product/10004
14:01:00  GET /api/v3/product/10005

区别一眼就能看出来:真实用户的请求间隔不均匀、有逻辑顺序(先看首页再看详情再看评论),爬虫的请求间隔均匀、缺乏上下文逻辑。风控引擎通过分析这些模式特征,就能区分正常用户和爬虫。

具体来说,风控引擎会从多个维度分析请求模式:请求频率分布是否均匀、请求路径是否符合人类浏览逻辑、请求头中Referer字段的来源是否合理、请求间隔是否符合人类操作节奏。任何一个维度异常都可能触发风控,即使你的加密和签名完全正确。

金句:协议正确让你进门,行为正确让你留下。风控系统的大门有两道锁,第一道是加密签名,第二道是行为模式。

16.2.2 客户端行为建模

要绕过行为分析,我们需要对客户端行为进行建模。建模的核心是回答三个问题:用户会做什么?什么时候做?以什么频率做?

先看一个行为建模的代码框架:

python
import random
import time
from enum import Enum

class UserAction(Enum):
    OPEN_APP = "open_app"
    VIEW_FEED = "view_feed"
    CLICK_PRODUCT = "click_product"
    VIEW_COMMENT = "view_comment"
    SEARCH = "search"
    BACK = "back"
    IDLE = "idle"

class UserBehaviorModel:
    def __init__(self):
        self.current_screen = "home"
        self.browsing_depth = 0
        self.session_actions = []
    
    def next_action(self) -> UserAction:
        weights = self._get_action_weights()
        action = random.choices(
            list(weights.keys()),
            weights=list(weights.values())
        )[0]
        self._update_state(action)
        return action
    
    def _get_action_weights(self):
        if self.current_screen == "home":
            return {
                UserAction.VIEW_FEED: 0.4,
                UserAction.SEARCH: 0.2,
                UserAction.IDLE: 0.3,
                UserAction.BACK: 0.1
            }
        elif self.current_screen == "product_detail":
            return {
                UserAction.VIEW_COMMENT: 0.3,
                UserAction.CLICK_PRODUCT: 0.2,
                UserAction.IDLE: 0.3,
                UserAction.BACK: 0.2
            }
        return {UserAction.IDLE: 0.5, UserAction.BACK: 0.5}
    
    def _update_state(self, action: UserAction):
        if action == UserAction.CLICK_PRODUCT:
            self.current_screen = "product_detail"
        elif action == UserAction.BACK:
            self.current_screen = "home"
    
    def get_delay(self) -> float:
        base = random.lognormvariate(0.5, 0.8)
        return min(max(base, 0.8), 15.0)

这个模型的核心思想是状态机。用户在App中有不同的"页面状态",在每个状态下,不同行为的概率不同。比如在首页状态下,浏览推荐流的概率是0.4,搜索的概率是0.2,发呆的概率是0.3。这些权重不是拍脑袋定的,而是通过观察真实用户的操作日志统计出来的。

get_delay方法用了对数正态分布来模拟人类操作间隔。为什么不用均匀分布的random.uniform(1, 5)?因为真实用户的操作间隔不是均匀分布的。快速滑动时可能1秒看一个商品,仔细看详情时可能停留30秒。对数正态分布的特点是大部分值集中在较小范围,但有较长的右尾,这正好符合"大多数快速操作,偶尔长时间停留"的模式。参数mu=0.5控制分布的中心位置,sigma=0.8控制分散程度,通过调整这两个参数可以模拟不同"用户"的操作速度偏好。

_update_state方法实现了页面跳转逻辑。用户点击商品后进入商品详情页,在详情页可以查看评论或返回首页。这种状态转移的设计确保了请求链路的逻辑性——你不会在首页状态下突然请求评论接口,因为状态机不允许这样的转移。

金句:爬虫与人类的最大区别不在于做了什么,而在于做事的节奏。节奏感是行为仿真的灵魂。

16.2.3 请求链路仿真

光有行为模型还不够,还需要把行为转化为实际的请求链路。一个完整的浏览链路不是简单的URL列表,而是一棵决策树:

打开App
  |
  +-- GET /api/v3/home/feed (首页推荐流)
  |     |
  |     +-- GET /api/v3/product/{id} (点击商品)
  |     |     |
  |     |     +-- GET /api/v3/product/{id}/comment (看评论)
  |     |     +-- GET /api/v3/product/{id}/recommend (看推荐)
  |     |     +-- 返回首页
  |     |
  |     +-- 滑动加载下一页
  |           |
  |           +-- GET /api/v3/home/feed?page=2
  |
  +-- POST /api/v3/search (搜索)
        |
        +-- GET /api/v3/search/result?keyword=xxx

关键代码实现:

python
class RequestChainSimulator:
    def __init__(self, session, behavior_model):
        self.session = session
        self.model = behavior_model
        self.viewed_products = set()
        self.feed_products = []
    
    def run_session(self, duration_minutes=10):
        end_time = time.time() + duration_minutes * 60
        self._open_app()
        
        while time.time() < end_time:
            action = self.model.next_action()
            delay = self.model.get_delay()
            time.sleep(delay)
            handler = getattr(self, f"_handle_{action.value}", None)
            if handler:
                handler()
    
    def _open_app(self):
        resp = self.session.get("/api/v3/home/feed")
        self.feed_products = self._extract_product_ids(resp)
        time.sleep(self.model.get_delay())
    
    def _handle_click_product(self):
        if not self.feed_products:
            return
        pid = random.choice(self.feed_products)
        self.session.get(f"/api/v3/product/{pid}")
        self.viewed_products.add(pid)
    
    def _handle_view_comment(self):
        if self.viewed_products:
            pid = random.choice(list(self.viewed_products))
            self.session.get(f"/api/v3/product/{pid}/comment")
    
    def _handle_search(self):
        keyword = self._generate_keyword()
        self.session.post("/api/v3/search", json={"kw": keyword})

这个仿真器的设计要点在于:请求不是孤立发出的,而是有上下文关联的。你必须先请求首页推荐流,才能拿到商品ID;有了商品ID,才能请求商品详情和评论。这种链路关联性是风控系统检测爬虫的重要维度——如果一组请求只有商品详情请求,没有首页请求和搜索请求,就会被标记为可疑。

_extract_product_ids方法从首页推荐流的响应中提取商品ID列表,后续的点击操作就从这个列表中随机选取。这保证了请求的商品ID是真实存在于推荐流中的,而不是从1开始递增的假ID。风控系统会检查你请求的商品ID是否在正常的推荐流中出现过,如果你请求了一个从未出现在推荐流中的冷门商品ID,也会被标记为异常。

16.2.4 TLS指纹与JA3hash

行为模型解决了"请求模式"的问题,但还有一个容易被忽视的维度:TLS指纹。

当你的Python爬虫用requests库发起HTTPS请求时,TLS握手过程中ClientHello包的特征(包括密码套件列表、扩展字段、椭圆曲线等)会形成一个独特的指纹,被称为JA3 hash(一种TLS客户端指纹识别技术,通过对TLS ClientHello包中的多个字段做MD5哈希来唯一标识客户端,论文参考:https://github.com/salesforce/ja3 )。不同的HTTP客户端库有不同的JA3指纹,requests的指纹和OkHttp的指纹完全不同。

风控系统可以通过JA3指纹识别出"虽然请求头伪装成Android设备,但TLS指纹显示是Python requests"。这就是为什么很多爬虫开发者发现,请求头明明模仿得很完美,还是被识别出来了。因为TLS指纹是写在网络栈底层的,你改不了requests的TLS行为,就像你改不了一个人的DNA一样。

解决这个问题的方案是使用支持TLS指纹自定义的库,比如curl_cffi(一个基于libcurl的Python HTTP库,支持模拟浏览器TLS指纹,官方文档:https://github.com/lexiforest/curl_cffi ):

python
from curl_cffi import requests

# 模拟 Chrome 浏览器的 TLS 指纹
response = requests.get(
    "https://api.example.com/v3/home/feed",
    impersonate="chrome120",
    headers={"User-Agent": "Mozilla/5.0 (Linux; Android 13)"}
)

# 模拟 Android OkHttp 的 TLS 指纹
response = requests.get(
    "https://api.example.com/v3/home/feed",
    impersonate="okhttp4_android",
    headers={"User-Agent": "okhttp/4.12.0"}
)

impersonate参数让curl_cffi使用目标客户端的完整TLS特征,包括密码套件顺序、扩展字段、ALPN(Application-Layer Protocol Negotiation,应用层协议协商,允许客户端在TLS握手阶段声明支持的协议)列表等。这样服务端看到的TLS指纹就和真实的Chrome或OkHttp客户端一致了。

怕浪猫在实际项目中做过一个对比测试:用requests库直接请求某个风控严格的App接口,50个请求内有3个被拦截;换成curl_cffi并设置impersonate="okhttp4_android"后,500个请求内零拦截。差距非常明显。所以如果你的爬虫遇到了"请求头全对但就是被拦截"的问题,优先检查TLS指纹这个维度。

金句:加密算法可以逆向,签名密钥可以提取,但TLS指纹是写在网络栈底层的身份证。你不伪造它,它就出卖你。

16.2.5 HTTP/2指纹的进阶检测

比JA3更进一步的是HTTP/2指纹。当App使用HTTP/2协议通信时,客户端的SETTINGS帧(HTTP/2协议中的设置帧,用于告知服务端客户端的配置参数)中的参数组合也会形成指纹。不同的HTTP客户端库对HTTP/2的实现细节不同,比如SETTINGS_MAX_CONCURRENT_STREAMS(最大并发流数)、SETTINGS_INITIAL_WINDOW_SIZE(初始窗口大小)等参数的默认值不同,这些差异可以被风控系统用来识别客户端类型。

应对HTTP/2指纹检测的方案与JA3类似,curl_cffi在impersonate模式下会同时模拟目标的HTTP/2设置参数。但如果你使用的库不支持HTTP/2指纹自定义,可以考虑直接降级到HTTP/1.1,前提是目标服务器允许降级。

16.3 动态密钥对抗:实时嗅探与解密算法动态反制

16.3.1 当密钥不再固定

前面16.1节讲的加密逆向,有一个前提假设:密钥是固定的。你逆向出加密算法和密钥后,就可以永久离线构造请求。但越来越多的App开始使用动态密钥方案——每次启动App、甚至每次请求,密钥都不一样。

动态密钥的典型流程如下:

1. App启动时,客户端生成临时密钥对 (client_pub, client_priv)
2. 客户端将 client_pub 发送给服务器
3. 服务器生成会话密钥 session_key,用 client_pub 加密后返回
4. 客户端用 client_priv 解密,得到 session_key
5. 后续请求用 session_key 做AES加密,密钥仅在本次会话有效
6. App退出或会话过期后,session_key 作废,需要重新协商

这种方案下,你即使逆向了加密算法,也无法离线构造请求,因为密钥是运行时动态协商的。每次启动App密钥都不同,你在A会话中拿到的密钥,无法用于B会话。而且密钥协商过程可能使用了非对称加密(如RSA或ECDH),你无法从密文逆推出密钥本身。从密码学角度看,动态密钥方案的安全性远高于静态密钥——即使你拿到了某个会话的密钥,也只能解密该会话的通信内容,无法推导出其他会话的密钥。

怕浪猫第一次遇到动态密钥的时候,心态直接崩了。之前花了三天逆向出来的加密算法,重启App后发现密钥变了,之前的代码全部失效。当时还以为自己逆向错了,反复验证了好几遍才确认是动态密钥方案。后来调整思路,从"离线破解"转向"在线截获",问题才得以解决。

16.3.2 Frida实时Hook方案

面对动态密钥,最直接的方案是用frida实时Hook加密函数,在运行时截获密钥和明文:

python
# hook_dynamic_key.js
# 实时拦截AES加密,打印密钥和明文
Java.perform(function() {
    var Cipher = Java.use('javax.crypto.Cipher');
    var SecretKeySpec = Java.use('javax.crypto.spec.SecretKeySpec');
    
    // 拦截密钥构造
    SecretKeySpec.$init.overload('[B', 'java.lang.String')
        .implementation = function(key, algorithm) {
        console.log("[KeySpec] algorithm=" + algorithm);
        console.log("[KeySpec] key=" + bytesToHex(key));
        return this.$init(key, algorithm);
    };
    
    // 拦截加密操作
    Cipher.doFinal.overload('[B').implementation = function(data) {
        var mode = this.getOpmode ? this.getOpmode() : "unknown";
        var result = this.doFinal(data);
        console.log("[Cipher] mode=" + mode);
        console.log("[Cipher] input=" + bytesToHex(data));
        console.log("[Cipher] output=" + bytesToHex(result));
        return result;
    };
});

function bytesToHex(bytes) {
    var hex = [];
    for (var i = 0; i < bytes.length; i++) {
        hex.push(('0' + (bytes[i] & 0xFF).toString(16)).slice(-2));
    }
    return hex.join('');
}

这段脚本Hook了两个关键点:SecretKeySpec的构造函数(密钥创建时触发)和Cipher.doFinal(加密/解密执行时触发)。每当App使用新的密钥,你都能立刻看到密钥内容、算法类型、以及加密前后的明文和密文。这就像是你在加密管道上装了一个透明的观察窗,所有经过的数据都一览无余。

但实时Hook有一个局限性:它需要App一直运行,且frida需要保持连接。如果你的爬虫需要高并发(比如同时跑50个任务),你不可能同时跑50个App实例。每个App实例都需要一台设备或模拟器来运行,50个实例就是50台设备,硬件成本和运维成本都很高。这时候需要一个混合方案:用frida实时获取密钥,然后将密钥传递给独立的Python爬虫进程。

16.3.3 密钥中继架构

怕浪猫在实际项目中用过一个"密钥中继"架构,解决的就是动态密钥场景下的高并发问题:

+----------------+     +------------------+     +----------------+
|  Frida + App   |     |  密钥中继服务     |     |  Python 爬虫   |
|  (密钥生产者)   | --> |  (Redis 队列)    | --> |  (密钥消费者)   |
|                |     |                  |     |                |
|  Hook到密钥 ->  |     |  存储最新密钥     |     |  取密钥 ->     |
|  推送到中继     |     |  密钥有效期管理   |     |  构造加密请求   |
+----------------+     +------------------+     +----------------+

核心实现分为生产者和消费者两部分。生产者运行在装有目标App的设备上,通过frida Hook捕获密钥后推送到Redis:

python
# 密钥生产者:frida脚本获取密钥后推送到Redis
import frida
import redis
import json

def on_message(message, data):
    if message['type'] == 'send':
        key_info = json.loads(message['payload'])
        r = redis.Redis()
        r.setex(
            f"app:session_key:{key_info['session_id']}",
            300,  # 5分钟过期,与App会话生命周期一致
            json.dumps(key_info)
        )
        print(f"[Relay] Key cached for session {key_info['session_id']}")

device = frida.get_usb_device()
session = device.attach("com.example.app")
script = session.create_script(hook_script)
script.on('message', on_message)
script.load()

消费者运行在独立的Python进程中,从Redis获取密钥后构造加密请求:

python
# 密钥消费者:Python爬虫从Redis取密钥
import redis
import json
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad

class DynamicKeyClient:
    def __init__(self):
        self.redis = redis.Redis()
    
    def get_key(self, session_id: str) -> dict:
        raw = self.redis.get(f"app:session_key:{session_id}")
        if not raw:
            raise KeyError("Session key expired or not found")
        return json.loads(raw)
    
    def encrypt_request(self, plaintext: str, session_id: str) -> str:
        key_info = self.get_key(session_id)
        key = bytes.fromhex(key_info['aes_key'])
        iv = bytes.fromhex(key_info['aes_iv'])
        cipher = AES.new(key, AES.MODE_CBC, iv)
        encrypted = cipher.encrypt(pad(plaintext.encode(), AES.block_size))
        return encrypted.hex()

这个架构的关键设计是:密钥生产者和消费者解耦。App端只需要一个实例运行(通过frida Hook持续产出密钥),爬虫端可以任意扩展并发数。密钥通过Redis中继,设置合理的过期时间与App的会话生命周期保持一致。当App的会话密钥更新时,frida Hook会自动捕获新密钥并推送到Redis,消费者在下一次取密钥时就能拿到最新的。

金句:动态密钥对抗的核心不是破解算法,而是截获密钥。与其在密码学层面硬碰硬,不如在运行时层面做中间人。

16.3.4 密钥协商协议的逆向要点

对于更复杂的动态密钥方案,比如基于ECDH(Elliptic Curve Diffie-Hellman,椭圆曲线迪菲-赫尔曼密钥交换协议,一种在非安全信道上协商共享密钥的方法)的密钥协商,逆向重点在于找到服务端公钥和椭圆曲线参数。

ECDH的核心原理是:客户端和服务端各自生成一对椭圆曲线密钥对,然后交换公钥。双方用自己的私钥和对方的公钥计算出相同的共享密钥。这个过程的数学基础是椭圆曲线上的离散对数问题——已知公钥和基点,计算私钥在计算上不可行。所以你无法从抓包数据中逆推出密钥,只能通过Hook客户端代码来截获最终计算出的共享密钥。

frida Hook的切入点是KeyAgreement类:

python
# hook_key_agreement.js
Java.perform(function() {
    var KeyAgreement = Java.use('javax.crypto.KeyAgreement');
    
    KeyAgreement.doPhase.implementation = function(key, lastPhase) {
        if (key.$className.includes('ECPublicKey')) {
            var w = key.getW();
            console.log("[ECDH] Server pubkey x=" + w.getAffineX());
            console.log("[ECDH] Server pubkey y=" + w.getAffineY());
        }
        var result = this.doPhase(key, lastPhase);
        if (lastPhase) {
            var secret = this.generateSecret();
            console.log("[ECDH] Shared secret=" + bytesToHex(secret));
        }
        return result;
    };
});

function bytesToHex(bytes) {
    return Array.from(bytes, function(b) {
        return ('0' + (b & 0xFF).toString(16)).slice(-2);
    }).join('');
}

这段脚本拦截了ECDH密钥协商的两个关键步骤:doPhase(接收对方公钥)和generateSecret(生成共享密钥)。你不仅能看到服务端的公钥坐标,还能直接拿到最终协商出的共享密钥。拿到共享密钥后,后续的AES加密就可以在Python端完整复现。

需要注意的是,有些App不会使用Java标准的KeyAgreement类来做ECDH,而是在native层用C/C++实现。这时候你需要用frida的Native API来Hook so文件中的导出函数,或者用IDA Pro静态分析so文件找到密钥生成的代码位置。

16.4 设备指纹隐匿:多维特征融合与身份伪装

16.4.1 设备指纹的维度

设备指纹(Device Fingerprint)是风控系统识别爬虫的最重要手段之一。它通过采集设备的多个特征,组合成一个唯一标识。即使你换了IP、换了账号,只要设备指纹相同,风控系统仍然能识别出你是同一台设备。

Android设备的指纹特征可以分为以下几大类:

+----------------------------------------------------------+
|                  设备指纹维度全景                          |
+----------------------------------------------------------+
|                                                          |
|  硬件特征                                                 |
|  ├── IMEI (International Mobile Equipment Identity)      |
|  │   移动设备国际身份码,唯一标识一台手机                    |
|  ├── ANDROID_ID                                           |
|  │   设备首次启动时生成的64位随机数                         |
|  ├── Serial Number                                       |
|  │   设备序列号                                            |
|  ├── MAC (Media Access Control address)                  |
|  │   网络接口物理地址                                       |
|  └── Build.Fingerprint                                    |
|      系统构建指纹,包含厂商/型号/版本等                      |
|                                                          |
|  软件特征                                                 |
|  ├── 系统版本 (Android 13 / 14)                          |
|  ├── 应用安装列表                                          |
|  ├── 系统属性 (ro.build.*, ro.product.*)                 |
|  └── 运行时环境 (Java VM版本, 内核版本)                    |
|                                                          |
|  行为特征                                                 |
|  ├── 传感器数据 (加速度计/陀螺仪基准值)                     |
|  ├── 屏幕分辨率和DPI                                      |
|  ├── 时区和语言设置                                       |
|  └── 网络特征 (WiFi BSSID, 运营商信息)                     |
|                                                          |
+----------------------------------------------------------+

怕浪猫在分析设备指纹时发现一个有趣的现象:单个特征的唯一性并不强。比如ANDROID_ID是64位随机数,理论上碰撞概率极低,但实际上很多模拟器生成的ANDROID_ID是固定的几个值。再比如Build.FINGERPRINT,同一个厂商同一批出厂的设备完全相同。风控系统不是靠单一特征来识别设备的,而是靠多个特征的组合。即使你有两个特征的值相同,只要其他特征有差异,仍然能区分出来。

16.4.2 Xposed与设备指纹修改

修改设备指纹最成熟的方案是使用Xposed Framework(一个Android上的Hook框架,允许在不修改APK的情况下改变系统行为,官方文档:https://github.com/rovo89/Xposed )配合自定义模块。核心思路是在App读取设备信息的API处做Hook,返回伪造的值。

关键代码示例:

java
// Xposed模块:Hook TelephonyManager获取IMEI
public class DeviceFaker implements IXposedHookLoadPackage {
    private static int deviceIdCounter = 1;
    
    @Override
    public void handleLoadPackage(LoadPackageParam lpparam) {
        if (!lpparam.packageName.equals("com.example.app")) return;
        
        // Hook getDeviceId 返回伪造IMEI
        XposedHelpers.findAndHookMethod(
            "android.telephony.TelephonyManager",
            lpparam.classLoader,
            "getDeviceId",
            new XC_MethodHook() {
                @Override
                protected void afterHookedMethod(MethodHookParam param) {
                    param.setResult(getFakeImei());
                }
            }
        );
        
        // Hook Settings.Secure.getString 返回伪造ANDROID_ID
        XposedHelpers.findAndHookMethod(
            "android.provider.Settings$Secure",
            lpparam.classLoader, "getString",
            ContentResolver.class, String.class,
            new XC_MethodHook() {
                @Override
                protected void afterHookedMethod(MethodHookParam param) {
                    if (param.args[1].equals("android_id")) {
                        param.setResult(getFakeAndroidId());
                    }
                }
            }
        );
    }
    
    private String getFakeImei() {
        // 基于设备序号生成合法格式的IMEI
        String base = "86" + String.format("%013d", deviceIdCounter++);
        return base + luhnCheckDigit(base);
    }
    
    private String luhnCheckDigit(String number) {
        // IMEI最后一位是Luhn校验位
        int sum = 0;
        for (int i = 0; i < number.length(); i++) {
            int d = number.charAt(i) - '0';
            if (i % 2 == 0) d *= 2;
            sum += d / 10 + d % 10;
        }
        return String.valueOf((10 - sum % 10) % 10);
    }
}

这段代码有一个关键细节:IMEI不是随便编一串数字就行的。IMEI有15位,前8位是TAC(Type Allocation Code,类型分配码,标识设备型号和品牌),第9到14位是序列号,最后一位是Luhn算法(一种简单的校验和算法,用于验证身份证号、信用卡号等数字串的有效性)的校验位。如果风控系统校验IMEI的Luhn位,你编的随机数就会直接被拦截。所以伪造IMEI时必须计算正确的校验位,而且TAC段要对应真实的品牌型号。

除了IMEI和ANDROID_ID,还需要Hook的位置包括:WifiInfo.getMacAddress返回伪造的MAC地址、Build.MODELBuild.BRAND返回与IMEI匹配的品牌型号、TelephonyManager.getSubscriberId返回伪造的IMSI(International Mobile Subscriber Identity,国际移动用户识别码)。每一项都需要确保与其他项保持一致。

16.4.3 多维特征的融合伪装

单点修改某个设备ID是不够的。风控系统会做交叉校验:你的IMEI显示是小米手机,但Build.FINGERPRINT显示是华为的系统,这种矛盾会直接触发风控。所以设备指纹伪装必须做到多维一致。

怕浪猫的做法是建立"设备画像模板"——每个模板包含一套完整的、互相兼容的设备信息:

python
import random
import hashlib

class DeviceProfile:
    """一套完整的设备画像,所有字段互相兼容"""
    
    def __init__(self, brand: str, model: str):
        self.brand = brand
        self.model = model
        self._generate_consistent_fields()
    
    def _generate_consistent_fields(self):
        # 根据品牌确定系统版本范围
        os_versions = {
            "xiaomi": ["13", "14"],
            "huawei": ["12", "13"],
            "samsung": ["13", "14"],
            "oppo": ["13", "14"]
        }
        self.os_version = random.choice(
            os_versions.get(self.brand, ["13"])
        )
        
        # Build.FINGERPRINT 必须与品牌/型号/系统版本一致
        self.build_fingerprint = (
            f"{self.brand}/{self.model}/{self.model}:"
            f"{self.os_version}/UP1A.{random.randint(220000,231200)})"
        )
        
        # ANDROID_ID 是16位hex,基于设备特征生成
        seed = f"{self.brand}{self.model}{self.os_version}"
        self.android_id = hashlib.md5(seed.encode()).hexdigest()[:16]
        
        # 屏幕分辨率与品牌型号匹配
        resolutions = {
            "xiaomi": [(1080, 2400), (1080, 1920)],
            "samsung": [(1080, 2340), (1440, 3088)],
            "huawei": [(1176, 2400), (1080, 2340)]
        }
        self.screen_w, self.screen_h = random.choice(
            resolutions.get(self.brand, [(1080, 1920)])
        )
    
    def to_dict(self) -> dict:
        return {
            "brand": self.brand,
            "model": self.model,
            "os_version": self.os_version,
            "build_fingerprint": self.build_fingerprint,
            "android_id": self.android_id,
            "screen_resolution": f"{self.screen_w}x{self.screen_h}",
            "dpi": 440,
            "timezone": "Asia/Shanghai",
            "language": "zh-CN"
        }

这个模板的设计原则是"一致性优先"。从品牌推导系统版本,从品牌和型号推导屏幕分辨率,所有字段之间不会出现矛盾。生成多个模板时,每个模板代表一台"虚拟设备",所有字段都是自洽的。比如你生成一个小米13的模板,那么品牌是xiaomi、型号是23013RK75C、系统版本是13或14、屏幕分辨率是1080x2400、Build.FINGERPRINT中包含xiaomi和对应型号——所有这些信息都在讲述同一个故事:"这是一台小米13手机"。

金句:设备指纹伪装的最高境界不是伪造每一个字段,而是让所有字段讲同一个故事。一个小米的IMEI配上华为的系统指纹,比不改还危险。

16.4.4 传感器指纹的挑战

硬件传感器是设备指纹中最难伪造的维度。每个传感器的制造工艺差异会导致微小的数据偏差,这些偏差可以作为设备的"指纹"。比如加速度计的零偏(Zero-g Offset,传感器在无加速度状态下的输出值),每台设备都略有不同,理论上可以作为设备的唯一标识。

更棘手的是,一些App会主动读取传感器数据来做活体检测。如果你用的是模拟器,加速度计返回的是全零值,陀螺仪也是全零值,风控系统立刻就能识别出这不是真机。即使你用的是真机但通过Xposed Hook了传感器数据,如果注入的数据过于规律(比如每次都是相同的值),也会被检测出来。

应对传感器指纹有三种方案:

方案一:使用真机。最简单也最有效。真机的传感器数据天然是真实的,不需要任何伪造。群控架构中用真机做Worker,传感器数据由硬件提供,完全无法被检测。这是怕浪猫推荐的方案,虽然硬件成本高一些,但在传感器指纹这个维度上是零风险的。

方案二:使用Xposed的传感器Hook模块。Hook SensorManager.registerListener,在传感器数据回调时注入预设的模拟值:

java
// 传感器数据注入核心逻辑
XposedHelpers.findAndHookMethod(
    "android.hardware.SystemSensorManager",
    classLoader, "dispatchSensorEvent",
    int.class, float[].class, int.class, long.class,
    new XC_MethodHook() {
        @Override
        protected void beforeHookedMethod(MethodHookParam param) {
            int handle = (int) param.args[0];
            float[] values = (float[]) param.args[1];
            
            if (isAccelerometer(handle)) {
                // 注入带微小噪声的重力值
                // 模拟手持设备的微小晃动
                values[0] = 0.02f + (float)(Math.random() - 0.5) * 0.01f;
                values[1] = -0.01f + (float)(Math.random() - 0.5) * 0.01f;
                values[2] = 9.81f + (float)(Math.random() - 0.5) * 0.02f;
            }
        }
    }
);

这里的关键细节是:注入的值不能是固定值,必须带微小噪声。因为真实传感器的读数永远不是完全稳定的,即使手机静止放在桌面上,加速度计也会有正负0.01左右的波动。如果你的注入值是固定的9.81,反而暴露了这是伪造的数据。

方案三:使用专门的传感器仿真工具。一些高级模拟器如Genymotion支持传感器仿真,可以预设加速度计和陀螺仪的数据曲线。但效果不如真机,高级风控系统仍能通过传感器数据的统计特征(如功率谱密度、自相关函数等)识别出模拟器。

16.5 人机验证绕过:轨迹仿真与行为画像模拟

16.5.1 滑动验证码的核心检测原理

当风控系统判断你的行为可疑时,最常见的响应是弹出人机验证。移动端App中最普遍的是滑动验证码——你需要把一个滑块拖到指定位置完成拼图。看起来很简单,但背后的检测逻辑远比你想象的复杂。

滑动验证码的检测维度远比"滑到正确位置"复杂。它主要检测以下几个维度:

检测维度具体内容爬虫常见的错误
轨迹形状拖动路径是否有自然的弯曲和抖动直线拖动,无弯曲
速度分布起步加速、中间匀速、末尾减速全程匀速
时间分布总时长是否在人类范围内(0.5-3秒)太快或太慢
抖动频率微小的随机偏移是否符合人手特征完全平滑无抖动
回退行为过冲后是否有小幅回退精准停在目标位置
多次一致性多次滑动的轨迹差异度每次轨迹完全相同

怕浪猫在做滑动验证码绕过的时候踩过一个印象很深的坑。当时轨迹生成算法已经做得很好了,速度分布、抖动、过冲全都有,但就是过不了。后来发现原因是:我的轨迹太完美了。每一个维度都精准地落在"人类范围"内,没有一点偏差。但真实人类滑动时,并不是每个维度都恰好落在最优区间——有的人可能速度快但抖动少,有的人可能抖动大但速度慢。风控系统检测的不只是"每个维度是否在人类范围内",还检测"维度之间的组合是否符合人类特征"。

16.5.2 轨迹生成算法

要绕过滑动验证码,核心是生成一条符合人类运动特征的拖动轨迹。怕浪猫在实际项目中用的是一个基于人类运动学模型的轨迹生成算法:

python
import random

def generate_slide_track(distance: int, duration: float = 1.2) -> list:
    """
    生成滑动轨迹
    distance: 需要滑动的像素距离
    duration: 总时长(秒)
    返回: [(x_offset, y_offset, timestamp_ms), ...]
    """
    track = []
    
    # 1. 加速阶段 (0% - 40%): 模拟手指起步加速
    accel_end = int(duration * 0.4 * 1000)
    t, v, a = 0, 0, 8
    while t < accel_end:
        a += random.uniform(-1, 1)  # 加速度有波动
        v = max(v + a, 0)
        x = int(v * t / 1000 * 0.3)
        y = random.randint(-1, 1)
        track.append((x, y, t))
        t += random.randint(10, 20)
    
    # 2. 匀速阶段 (40% - 75%): 手指稳定滑动
    cruise_end = int(duration * 0.75 * 1000)
    while t < cruise_end:
        x = track[-1][0] + random.randint(3, 6)
        track.append((x, random.randint(-1, 1), t))
        t += random.randint(12, 18)
    
    # 3. 减速阶段 (75% - 95%): 接近目标时减速
    decel_end = int(duration * 0.95 * 1000)
    while t < decel_end:
        x = track[-1][0] + random.randint(1, 3)
        track.append((x, random.randint(-1, 1), t))
        t += random.randint(15, 25)
    
    # 4. 过冲 + 回退 (95% - 100%): 人类特征性的微调
    overshoot = random.randint(2, 6)
    track.append((track[-1][0] + overshoot, 0, t))
    t += random.randint(50, 100)
    track.append((distance, 0, t))  # 回退到目标位置
    
    return track

这个算法的核心是模拟人类拖动的四个阶段:加速、匀速、减速、过冲回退。每个阶段的特征都不同——加速阶段的位移呈二次函数增长(因为速度在增加),匀速阶段的位移线性增长,减速阶段位移增长放缓,最后还有一个小幅过冲和回退。这四个阶段对应的是人类手指肌肉的运动模式:从静止开始发力、达到稳定速度、看到目标开始减速、因为惯性稍微过头然后修正。

几个关键参数的取值依据:加速度初始值8是经过多次实验调整的,太小会导致起步太慢,太大会导致起步太猛。过冲量2-6像素是真实人类操作中的典型范围。每个事件的时间间隔10-25毫秒对应着触摸屏的采样率(通常60-120Hz),间隔太短会显得不自然,间隔太长会导致轨迹点太少不够平滑。

金句:人类的手指永远不会画出一条完美的直线。轨迹仿真不是画线,是画犹豫。

16.5.3 ADB执行轨迹注入

生成轨迹之后,需要通过ADB(Android Debug Bridge,Android调试桥,官方文档:https://developer.android.com/tools/adb )将触摸事件注入到设备上。ADB的input tapinput swipe命令不支持精细轨迹注入,需要用sendevent直接写入触摸事件设备节点:

python
import subprocess
import time

class TrackInjector:
    def __init__(self, device_id: str):
        self.device_id = device_id
        self.touch_device = "/dev/input/event5"
        self.track_id = 0
    
    def inject_track(self, track: list, start_x: int, start_y: int):
        """将轨迹注入设备触摸事件"""
        self.track_id += 1
        
        # 触摸按下:ABS_MT_TRACKING_ID + 坐标
        self._send_event(0x0003, 0x0039, self.track_id)
        self._send_event(0x0003, 0x0035, start_x)
        self._send_event(0x0003, 0x0036, start_y)
        self._send_event(0x0000, 0x0000, 0)
        
        # 触摸移动:按轨迹时间戳逐个注入
        prev_time = track[0][2]
        for x_off, y_off, ts in track:
            delay = (ts - prev_time) / 1000.0
            if delay > 0:
                time.sleep(delay)
            self._send_event(0x0003, 0x0035, start_x + x_off)
            self._send_event(0x0003, 0x0036, start_y + y_off)
            self._send_event(0x0000, 0x0000, 0)
            prev_time = ts
        
        # 触摸抬起:TRACKING_ID设为0xFFFFFFFF表示离开
        self._send_event(0x0003, 0x0039, 0xFFFFFFFF)
        self._send_event(0x0000, 0x0000, 0)
    
    def _send_event(self, type_: int, code: int, value: int):
        subprocess.run([
            "adb", "-s", self.device_id, "shell", "sendevent",
            self.touch_device, str(type_), str(code), str(value)
        ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)

这种方案直接操作Linux内核的input子系统,写入的事件和真实触摸屏产生的事件完全一致。验证码SDK(Software Development Kit,软件开发工具包)无法区分这到底是人手触发的还是代码注入的,因为从内核层面看,两者产生的input event格式完全相同。从event的type、code到value,每一个字段的含义和取值范围都与硬件触摸屏产生的事件别无二致。

sendevent方案有一个性能问题:每个事件需要一次adb shell调用,一条轨迹可能包含上百个事件,每秒只能注入几十个点。如果需要高并发(比如同时操作多台设备),更好的方案是使用minitouch(一个开源的Android触摸事件注入工具,GitHub:https://github.com/DeviceFarmer/minitouch ),它通过Unix socket批量接收事件指令,一次性写入多个事件,性能远高于逐个sendevent。

需要注意的是,sendevent使用的设备节点路径(/dev/input/event5)因设备型号不同而不同。你可以通过adb shell getevent -p命令查看所有输入设备的信息,找到包含ABS_MT_POSITION_XABS_MT_POSITION_Y事件的那个设备,它就是触摸屏对应的设备节点。在群控架构中,不同型号的设备可能需要配置不同的设备节点路径。

16.5.4 行为画像的长期一致性

一次滑动验证码绕过成功,不代表你可以高枕无忧。风控系统会持续积累你的行为画像,做长期一致性分析。

举个例子:你第一次滑动验证码用了1.2秒,轨迹很自然,通过了。但如果接下来你每次滑动都用一模一样的轨迹,同样的时长、同样的抖动模式,风控系统会发现你的"每次行为差异度"为零——人类不可能每次都做一模一样的动作。即使每次的动作都很"自然",但完全相同的自然本身就是不自然的。

所以行为画像的维护需要做到两点:单次行为自然,多次行为有差异。核心代码:

python
import numpy as np
import time

class BehaviorProfile:
    """维护长期行为画像的一致性和差异性"""
    
    def __init__(self, device_id: str):
        self.device_id = device_id
        self.history = []
        # 每个虚拟设备有固定的行为偏好
        self.base_traits = {
            "avg_slide_speed": np.random.uniform(0.8, 1.5),
            "avg_reaction_time": np.random.uniform(0.3, 0.8),
            "overshoot_tendency": np.random.uniform(0.3, 0.8),
            "jitter_level": np.random.uniform(0.5, 1.5),
        }
    
    def generate_slide(self, distance: int) -> list:
        """基于行为偏好生成轨迹"""
        # 在基础偏好上叠加随机扰动
        duration = self.base_traits["avg_slide_speed"]
        duration += np.random.uniform(-0.2, 0.2)
        duration = max(0.6, min(2.5, duration))
        
        track = generate_slide_track(int(distance), float(duration))
        
        self.history.append({
            "type": "slide", "distance": distance,
            "duration": duration, "timestamp": time.time()
        })
        return track
    
    def check_diversity(self) -> float:
        """检查历史行为多样性,0表示完全重复"""
        if len(self.history) < 2:
            return 1.0
        durations = [h["duration"] for h in self.history[-10:]]
        return min(np.std(durations) / 0.3, 1.0)

这个行为画像管理器的设计思路是:每个虚拟设备有一套固定的"行为偏好"(比如这个设备对应的"用户"习惯快速滑动),这些偏好是长期不变的,代表了"这个用户"的个性。但每次具体的行为在偏好基础上叠加随机扰动,保证多次操作不会完全相同。check_diversity方法可以用来做自检,如果多样性得分太低,说明你的轨迹生成逻辑可能过于模板化了,需要增加随机性。

base_traits的设计是怕浪猫认为整个行为仿真体系中最精妙的部分。它解决了一个看似矛盾的需求:既要保持一致性(同一台虚拟设备的行为风格要稳定),又要保持差异性(每次操作不能完全相同)。通过"固定偏好 + 随机扰动"的两层模型,同一台虚拟设备的所有滑动操作都围绕一个基准速度波动,但每次波动的方向和幅度都不同,既有一致性又有差异性。

在实际部署中,怕浪猫还建议为每个虚拟设备维护一个"行为日志",记录最近一段时间内的操作历史。这样做的目的是防止短期内的行为模式出现异常。比如一个虚拟设备在过去一小时内已经滑动了一百次验证码,这个频率远超正常用户的行为范围,即使每次轨迹都很自然,累计频率也会触发风控。行为日志可以让你在发送请求前做一次自检,如果频率超标就主动降速或暂停。

金句:风控系统不怕你的行为完美,怕你的行为有规律。完美可以被模仿,规律可以被预测,只有随机是不可被复制的。

16.5.5 点选验证码与其他验证方式的应对

除了滑动验证码,一些App还会使用点选验证码(按照文字提示点击图片中的特定位置)、文字验证码(输入图片中的文字)等。这些验证方式的绕过策略各不相同:

点选验证码通常需要借助OCR(Optical Character Recognition,光学字符识别)技术识别图片中的文字和位置,然后按顺序点击。这种方案的技术门槛较高,因为不仅要识别文字,还要理解文字的语义顺序(比如"依次点击:苹果、香蕉、橙子")。目前有一些商业化服务提供点选验证码识别能力,但准确率和成本需要权衡。如果验证码图片中包含语义理解的要求(比如"点击所有的红绿灯"),可能还需要结合目标检测模型来定位图片中的特定物体。

文字验证码相对简单,用开源OCR库如ddddocr(一个专门针对验证码场景训练的OCR模型,GitHub:https://github.com/sml2h3/ddddocr )即可识别大部分文字验证码。

对于一些App使用的行为验证(如摇一摇、长按等),可以通过ADB命令直接模拟:adb shell input swipe模拟滑动、adb shell input touchscreen swipe模拟触摸滑动、adb shell input keyevent模拟按键。这些操作的关键不在于命令本身,而在于操作时序的自然性——和滑动验证码一样,需要模拟人类的操作节奏。比如摇一摇验证,你需要模拟加速度传感器在短时间内出现剧烈变化,但变化模式要符合人手摇动手机的物理特征,而不是简单的随机数值跳跃。长按验证则需要注意按压时长和微小位移的自然分布,人手在长按时不可能完全静止不动,必然会有零点几像素的微小漂移。

总结与实战清单

这一章我们从五个层面拆解了App的反爬机制及应对策略:协议层做加密逆向、流量层做行为仿真、密钥层做动态截获、设备层做指纹伪装、行为层做轨迹仿真。怕浪猫最后给你一份实战清单,按照从易到难的顺序排列,方便你对照自己的项目查漏补缺:

反爬对抗实战清单:

层级检查项工具/方案难度
协议层抓包是否成功Charles + SSL Pinning绕过初级
协议层加密参数是否能离线构造jadx反编译 + frida Hook中级
协议层动态密钥是否能实时获取frida实时Hook + Redis中继高级
流量层TLS指纹是否匹配目标客户端curl_cffi中级
流量层请求间隔是否符合人类模式对数正态分布模型中级
流量层请求链路是否有上下文逻辑行为状态机高级
设备层设备指纹各字段是否一致DeviceProfile模板中级
设备层IMEI等ID校验位是否正确Luhn算法初级
设备层传感器数据是否非零真机 / Xposed传感器注入高级
行为层滑动轨迹是否有四阶段特征运动学轨迹生成算法高级
行为层多次操作是否有差异性BehaviorProfile管理器高级

这份清单的使用方法是:每次启动一个新的爬虫项目时,从上到下逐项检查。如果某一项不满足,就优先解决这一项。不要试图同时解决所有问题,那样会导致战线太长、每个问题都解决得不彻底。怕浪猫的经验是:按照清单从易到难逐项攻克,每解决一项就做一次完整测试,确认这一项过了再进入下一项。

金句:反爬对抗没有银弹。与其追求某一项的完美,不如保证每一项都达到及格线。木桶效应在这里同样适用——你的伪装水平取决于最短的那块板。

如果你觉得这篇文章对你有帮助,点个收藏吧,后面写代码遇到反爬问题可以随时翻出来对照检查。

你在实际项目中遇到过哪些反爬机制?是用什么方案绕过的?欢迎在评论区分享你的实战经验,怕浪猫会逐条回复有意思的案例。

这是Python移动端爬虫系列的第十六章,下一章也是最后一章,怕浪猫会带你做一个完整的项目实战总结:从需求分析、架构设计、编码实现到部署上线,把前面十六章的知识点串联成一个完整的工程交付物。关注我,追更不迷路。

系列进度 16/17

下章预告:第17章将进行完整项目实战总结,从零到一搭建一个生产级的移动端爬虫系统,涵盖需求拆解、技术选型、架构设计、核心模块编码、测试验证到最终部署上线的全流程。所有前面章节的知识点——ADB操作、群控架构、协议逆向、反爬对抗、日志可视化——都将在这个最终项目中融会贯通。

怕浪猫说:反爬技术是一场没有终点的军备竞赛。今天有效的方案明天可能就会失效,但思维方式不会过时——理解检测原理,从原理出发设计规避策略,然后在实际项目中不断迭代。不要迷信某一个"终极方案",保持学习,保持警觉,保持敬畏。毕竟对面坐着的也是工程师,和你一样聪明,和你一样努力。下章见。

热爱生活,喜好美食,追求未来!