爬虫伪装成Chrome怎么识破?TLS指纹实战

UA 写成 Chrome、IP 天天换,行为却还是采集器。UA 能改,握手改不了。本文用 10 个问答讲清 TLS指纹 为什么能戳穿假浏览器、nginx 上怎么采集、异常怎么判定和分级拦截。

Q:UA 改得跟 Chrome 一模一样,为什么还是被识别出来?

  • UA 是自报的字符串,TLS 握手是客户端库的实现。前者一条 curl -A 就改了,后者要换底层库。
  • 身份其实有三层,改造成本逐层上升:
  • 第一层 UA 与请求头:改字符串即可,但请求头的顺序和默认值往往露馅,比如 python-requests 默认不带 Sec-Fetch-*。
  • 第二层 HTTP/2 参数:SETTINGS 帧里的窗口大小、优先级顺序,各语言 HTTP 库差异明显。
  • 第三层 TLS ClientHello:套件列表及顺序、扩展列表、椭圆曲线、ALPN。这一层由 OpenSSL、Go crypto、BoringSSL 的实现决定,爬虫作者懒得改,也难改得完全一致。
  • 实践里最有效的一条判据就是这一层:声称是 Chrome,但指纹不是 Chrome。这比封 IP 稳得多,因为 IP 池可以租,指纹不会随 IP 变。

Q:TLS 指纹到底在采集 ClientHello 的哪些字段?

  • TLS 握手第一个包 ClientHello 是明文的,里面有几个字段组合起来区分度极高:
  • TLS 版本、密码套件列表(注意是顺序敏感的完整列表,不是协商出来的那一个)、扩展列表(含顺序与 GREASE 占位)、支持的椭圆曲线、曲线点格式。
  • JA3 的做法是把五个字段拼成字符串再取 MD5:版本,套件,扩展,曲线,曲线格式,字段内用 – 连接、字段间用 , 连接,最后 md5() 得 32 位十六进制串。同库同版本,JA3 基本稳定。
  • JA4 是改良版:加入 ALPN 首值、SNI 有无,按 TLS 版本分流,并对扩展顺序排序归一,抗随机化更好。
  • 关键认知:浏览器指纹是收敛的(桌面 Chrome 就那么几种),非浏览器指纹极度分散。分布差异本身就携带信息。

Q:爬虫为什么改不掉自己的指纹?

  • 不是改不掉,是改的代价远高于收益。要伪装,最省事的路径是直接上无头浏览器(那是行为识别的战场,另一条线),而不是把 requests 改造成 Chrome。
  • 真要伪装,得同时对齐套件顺序、扩展顺序、GREASE 值、曲线列表、ALPN,而 Chrome 每个大版本都在动,维护成本极高。
  • 所以绝大多数爬虫的指纹是长期不变的单一值。不必知道它属于哪个库,只要发现”几万个 IP 全是同一个指纹、同一个 UA、路径集中在列表页”,就能定性。
  • 反过来,正常浏览器同一指纹背后的 IP 数有上限(一个公司出口、一个 NAT 段)。单指纹关联 IP 数是很便宜且有效的特征。

Q:不装任何模块,用原生 nginx 能拿到指纹吗?

  • 能拿到”穷人版”:nginx 内置几个 SSL 变量,看不到完整套件列表,但协商结果 + 曲线 + ALPN 的组合已能区分大部分采集库。
  • 先开日志字段。$ssl_curves 需要 nginx 1.11.0 以上,$ssl_ciphers 需要 1.11.4 以上,$ssl_alpn_protocol 需要较新版本,老版本请先 nginx -V 确认。
log_format tls '$remote_addr - [$time_local] "$request" $status '
               'ua="$http_user_agent" proto=$ssl_protocol '
               'cipher=$ssl_cipher curves=$ssl_curves '
               'alpn=$ssl_alpn_protocol rt=$request_time';

access_log /var/log/nginx/access_tls.log tls;
  • 然后用 map 把曲线组合粗分成几档,浏览器档和”薄客户端”档分开。注意 map 的键必须完全匹配,顺序也要对,建议先跑一周日志看真实取值再写死。
map $ssl_curves $curve_tier {
    default                "odd";
    "X25519:P-256:P-384"   "browser";
    "X25519:P-256"         "browser";
    "X25519"               "thin";
}

map "$curve_tier|$ssl_alpn_protocol" $client_class {
    default                 "unknown";
    "browser|h2"            "browser";
    "browser|http/1.1"      "browser_h1";
    "thin|"                 "script";
    "thin|http/1.1"         "script";
}
  • 局限:这一层只能看到协商后的单个套件,看不到套件列表和顺序,区分度不如真 JA3,容易被刻意构造的客户端绕过。适合初筛分层,不适合当唯一判据。

Q:要完整的 JA3/JA4,nginx 上怎么落地?

  • 原生 nginx 拿不到完整 ClientHello,有三条可行路径,按改造成本从低到高:
  • ① 打 JA3 补丁:社区有 nginx-ssl-ja3 这类补丁,编译后在日志里多一个 JA3 变量(具体变量名以你用的补丁为准)。适合自编译 nginx 的环境。
  • ② 用指纹模块:ngx_http_ssl_fingerprint 这类模块直接提供 JA3/JA4 变量,需要重新编译加 --add-module。
  • ③ OpenResty 自己算:在 ssl_client_hello_by_lua 阶段取 ClientHello 拼串算 MD5。灵活但这段跑在握手阶段,不能有阻塞调用,否则拖慢所有 TLS 连接。
  • 无论哪条路,落地方式一样:写进日志 → map 打标签 → 分级处置。先用日志跑,别一上来就拦。
  • 离线验证指纹算得对不对,用 tshark 抓一个浏览器和一个爬虫的握手对比:
# 抓 ClientHello 并输出 JA3(需要 tshark 4.x)
tshark -i eth0 -f "tcp port 443" -Y "tls.handshake.type==1" \
  -T fields -e ip.src -e tls.handshake.ja3 -e tls.handshake.extensions_server_name

# 单次验证某个客户端的握手参数
openssl s_client -connect www.example.com:443 -servername www.example.com \
  -alpn h2,http/1.1 </dev/null 2>&1 | grep -E "Protocol|Cipher|ALPN|Server Temp Key"

Q:指纹采到了,异常怎么判定?阈值给多少?

  • 先建基线。连续 7 天按 指纹 × UA 大类 做二维统计,看每个组合的请求量占比和关联 IP 数。
  • 三条判定规则,满足任意两条再动手:
  • 声称不符:UA 自称 Chrome/Safari,但指纹不在浏览器白名单内。这条最准,误伤也最低。
  • 量级异常:单指纹占当日请求量超过 5%,且对应 IP 数超过 200。正常浏览器没这种集中度。
  • 行为异常:该指纹的请求里,静态资源(js/css/图片)请求占比低于 10%,Referer 为空比例高于 80%。真人浏览一定会拉资源。
  • 一条命令先把分布看清楚,别急着写规则:
# 按 ja3 聚合,看请求量与独立 IP 数(日志里需有 ja3= 字段)
awk '{for(i=1;i<=NF;i++) if($i ~ /^ja3=/){split($i,a,"="); print a[2], $1}}' \
  /var/log/nginx/access_tls.log \
  | sort | uniq -c \
  | awk '{c[$2]++; q[$2]+=$1} END{for(k in c) printf "%s\tip=%d\treq=%d\n", k, c[k], q[k]}' \
  | sort -t= -k3 -rn | head -20

Q:判定为假浏览器之后,怎么拦才不影响正常用户?

  • 三级处置,别一步到封 IP:
  • 第一级 降速:把该指纹丢进独立限流桶,给很低的速率让它自己慢下来。比直接 403 温和,也不会触发失败重试风暴。
  • 第二级 挑战:返回人机验证或 JS 挑战页。假浏览器跑不了 JS,真用户点一下就行。
  • 第三级 封禁:确认是采集集群再按 IP 封,用 ipset 带 timeout 自动过期,别写死 iptables 规则(没人回收,一年后还在)。
# 第一级:按指纹标签单独限流,与全站限流互不干扰
map $ja3_tag $bot_key {
    default         "";
    "fake_browser"  $binary_remote_addr;
}
limit_req_zone $bot_key zone=ja3bot:10m rate=10r/m;
limit_conn_zone $bot_key zone=ja3conn:10m;

server {
    # ...
    limit_req  zone=ja3bot  burst=20 nodelay;
    limit_conn ja3conn 5;
    limit_req_status 429;   # 必写,默认 503 会被 CDN 缓存成故障页
}

# 第三级:带过期时间的封禁,1 小时后自动解封
ipset create ja3bot4 hash:ip timeout 3600 -exist
ipset add ja3bot4 203.0.113.10 -exist
iptables -I INPUT -m set --match-set ja3bot4 src -j DROP

Q:TLS 1.3 普及之后,JA3 是不是就没用了?

  • 作用被削弱了,但没有作废,关键是别把它当唯一判据。
  • TLS 1.3 把大量握手参数挪进了加密的 EncryptedExtensions,明文 ClientHello 可观测字段变少,JA3 碰撞率上升:不同客户端算出同一个 JA3 的概率变高。
  • Chrome 的 GREASE 还会随机化扩展顺序,同一浏览器不同连接的 JA3 都不完全一样,区分度进一步被稀释。
  • 补法有两个方向:一是换 JA4(对扩展顺序做排序归一,按版本和 ALPN 分流),二是退回到”UA 与协商结果一致性”这种粗判,配合行为特征做组合判断。
  • 结论:JA3/JA4 是识别层的能力,用来把可疑流量分堆;真被打了靠的还是限速、缓存和清洗,别指望一个哈希挡住流量攻击。

Q:这么拦会不会误伤正常用户和搜索引擎?

  • 会。企业出口代理、老版 Android WebView、部分安全软件的中间人解密,都会让指纹偏离标准浏览器。
  • 顺序必须是只记录不拦截,跑满 7 天;灰度按 5% 流量先拦,每天看 403 明细里有没有正常 UA 和正常路径。误封率超过 0.5% 立即回退。
  • 搜索引擎蜘蛛不要参与指纹判定。按 IP 段 + PTR 反查单独走白名单,指纹再怪也放行,避免为了防爬把收录打掉。
  • 把申诉入口写在 403 页面上,留个邮箱或表单,比事后被客户投诉好处理。

Q:还有什么补充建议?

  • 部署顺序固定为:日志字段 → 7 天基线 → 三条判定规则 → 灰度限速 → 常态巡检。跳过任何一步都会踩坑。
  • 指纹日志至少保留 30 天,溯源和投诉处理都要靠它。
  • CDN/WAF 层能算指纹就在边缘算,源站再算一次兜底,两边用同一个标签名,排查时对得上。
  • 每季度回看一次规则命中率。指纹会随浏览器版本漂移,半年不维护的规则基本等于没有。