Nginx中真正能实现TLS指纹级拦截的唯一位置是stream上下文的preread_by_lua_block,此时可解析Client Hello原始字节提取JA3/JA4指纹并毫秒级决策阻断;HTTP层access阶段已错过时机,仅能事后响应,无法阻止TLS连接建立。

不能在 SSL 握手阶段直接“决定是否放行”——Nginx 的 Lua 模块中,真正能干预握手流程、实现指纹级拦截的唯一位置是 stream 上下文中的 preread_by_lua_block。此时 Client Hello 原始字节尚未被 OpenSSL 解析,可提取 JA3/JA4 等指纹并实时决策;HTTP 层的 access 阶段早已错过时机,只能做请求级响应,无法阻断 TLS 连接建立。
必须用 stream + ssl_preread + Lua
SSL 指纹(如 JA3)来自 TLS 握手最开始的 Client Hello 消息,它是一段原始二进制数据,位于 TCP 载荷起始位置。Nginx 只有在 stream { } 块中启用 ssl_preread on,才能通过变量 $ssl_preread_raw_data 获取这段数据。HTTP 模块完全不可见该变量,所有基于 $http_user_agent 或 access_by_lua_block 的尝试都无效。
- 配置需启用
--with-stream --with-stream-ssl-preread-module编译选项 - 必须使用
preread_by_lua_block(不是 rewrite 或 access 阶段) - 依赖
lua-resty-ja3等库解析五元组并计算 MD5 哈希
指纹比对要快且可靠
匹配动作必须毫秒内完成,否则拖慢 TLS 握手,引发超时或重试。建议将已知恶意指纹(如 requests 默认栈、curl 无 ALPN、Playwright 无扩展等)哈希值预加载到 lua_shared_dict,避免每次查 Redis 带来网络延迟。
- 共享字典容量设为 10M~50M,支持数万指纹缓存
- 哈希键用纯字符串(如
"a2a8e7461d9b0934c1741414f9323e4e"),值可为标记("block")或限流策略编号 - 匹配成功调用
ngx.exit(429)或ngx.close(),立即终止连接
不推荐 fallback 到 HTTP 层处理
有人试图在 http 块里用 map 或 if 匹配 $http_user_agent 再返回 403,这看似“拦截”,实则已建立完整 TLS 连接和 HTTP 请求,资源开销大、延迟高,且无法阻止爬虫发起连接。对于高频扫描类流量,这种做法会显著抬升服务器负载。
- Client Hello 已发、TLS 密钥已协商、TCP 连接已保持 —— 此时拒绝属于“事后补救”
- 真实攻击者可绕过 UA 字段(伪造、清空、随机化),而 JA3 指纹由 TLS 栈底层行为决定,更难伪装
- 若业务允许部分降级,可结合 IP + 指纹双因子:同一 JA3 下,单 IP 超 5 次/分钟即限流
注意证书链与指纹逻辑分离
SSL 指纹识别和证书链发送是两个独立问题。前者发生在连接初始(stream preread),后者发生在证书选择阶段(ssl_certificate_by_lua*)。不要试图在同一个 Lua 脚本里混用二者逻辑——前者决定“让不让连”,后者决定“用哪张证”。两者可共存,但必须分层部署:stream 块做指纹过滤,server 块做 SNI 证书动态加载,职责清晰才稳定。
- stream 块只做读取、解析、比对、退出,不做文件 I/O 或证书加载
- server 块中启用
ssl_certificate_by_lua_file加载对应域名证书,与指纹判断解耦 - 若需对某类指纹强制返回精简证书链(如老 Android 客户端),应通过不同 SNI 域名或 header 触发内部重定向,而非在 preread 阶段操作证书


















