常见反爬策略整理

384 阅读45分钟

声明

本文章中所有内容仅供学习交流使用,不用于其他任何目的,严禁用于商业用途和非法用途,否则由此产生的一切后果均与作者无关!

本文章未经许可禁止转载,禁止任何修改后二次传播,擅自使用本文讲解的技术而导致的任何意外,作者均不负责,若有侵权,请在评论区联系作者立即删除!

引言

在日常采集工作中,常常碰到一些表面人畜无害的网站,实则暗藏玄机,有时候无法一眼分辨出到底使用了什么手段,今天我们来盘点一下目前常见的一些反爬策略,帮我们快速筛查策略类型并定位问题吧~

正文

一、headers 反爬

通常会针对以下字段进行检测

  • user-agent:该字段用来表示请求的身份,部分网站会检测该字段是否有值,或者值的类型是否已被淘汰无效

  • referer:来源站点信息

  • cookie:请求加密的重点区域;session 保持检测

  • accept-encoding:在脚本中模拟浏览器行为时,需要关注网站本身请求所支持的压缩编码格式,缺少该字段可能导致服务器返回未压缩数据(体积大)导致请求超时失败,甚至拒绝请求。

      常见的编码类型包括以下:
      gzip:最常见的压缩算法,兼容性好,压缩率较高。
      deflate:类似于 `gzip`,但结构略有不同,常见于一些较老的服务器或应用。
      br(Brotli):压缩率高于 `gzip`,适用于现代浏览器和服务器。
      identity:表示不进行压缩,直接传输原始数据。
      *:表示客户端可以接受所有编码格式。
    
  • Accept:字段用于告诉服务器客户端希望接收的响应内容类型,如果缺失或者未和网站请求保持一致,会导致返回的请求无法正常解析。

      Accept 字段的值是由 MIME 类型和通配符组成,常见的值包括:
      text/html`:HTML 页面内容(浏览器默认)。
      application/json`:JSON 数据格式(常用于 API)。
      application/xml`:XML 数据格式。
      text/plain`:纯文本格式。
      image/*`:接受所有类型的图片(如 JPEG、PNG 等)。
      */*`:接受所有内容类型。
      
    
  • Content-Type:告诉服务器或客户端请求和响应中数据的类型,缺少该字段可能会导致服务器和客户端无法正确解析对方传输的数据内容。

     `application/json`: 表示数据是 JSON 格式。
     `application/x-www-form-urlencoded`: 表示数据是表单格式。
     `multipart/form-data`: 表示是文件上传数据。
     `application/octet-stream`: 是一种通用的 MIME 类型,用于表示 任意二进制数据
    
  • 自定义字段:如 token、sign 等等,网站通过自定义字段传递加密数据。

  • 字段顺序:部分网站会检测 headers 中的字段顺序,所以在脚本中模拟请求时,尽量保持字段顺序与原网站一致,可以减少不必要的尝试。

    • 注意⚠️:在 Nodejs 中使用 axios 请求无法保证 headers 中字段的顺序。

二、js 加密

常见方式有两种:通过各种手段混淆源代码,降低源码阅读性;对关键请求参数进行加密,提升破解难度。一般网站都会采用二者结合的方式来提升网站数据安全性。

2.1 加密方式

常见的加密按照应用方式分为这几种:对称加密、非对称加密、哈希加密、编码加密等,而其中哈希加密是不可逆的。

2.1.1 可逆加密

可逆加密意味着加密后的密文通过密钥可以还原明文。根据加密方式又可分为两类:对称加密非对称加密

对称加密

对称加密的特点是 数据的加密和解密使用同一个密钥。目前流行的对称加密算法为 AES,当然还有一些其他同样得到广泛使用的算法,如 3DES、ChaCha20、SM4、RC4 等。

  • AES 加密的特点
    • 明文长度不一致,输出的密文长度也有所变化

    • 如果使用随机化的加密模式(如 CBC、CTR),密文会因 初始化向量(IV) 或其他随机值的不同而改变,会产生一个现象:即相同的明文加密后的密文是变化的

    • AES 加解密示例 image.png

非对称加密

非对称加密则是使用 一对公钥和一个私钥进行加密和解密,经过公钥加密的数据只有对应的私钥可以解密,公钥无法解密,而只有服务端拥有私钥。目前常见的非对称算法是 RSA,还有其他如 ECC、DSA 等。

  • RSA 加密的特点
    • 如果密文较长,且长度与密钥长度差不多,可能是 RSA 加密
    • 密文的长度和明文无关,和密钥有关,因此不同的明文加密后的结果长度一致
    • 同样的明文会生成不同的密文
快速区分 对称加密非对称加密

基于两种加密方式的原理区别,通过找到加密的密钥,如果可以用加密密钥解密,则是 对称加密,反之则是 非对称加密

2.2.3 哈希加密

哈希加密也叫不可逆加密,是一种单向加密。常见的算法包括:MD5、SHA,这两个算法的共同点:不管原始明文的长度和复杂度如何,加密后的长度是固定不变的,因此在碰到加密数据时,可以通过这个特点快速定位加密的类型。

以下整理了常见的两种算法,算法原理在此不多赘述,根据个人的经验做一些简单总结。

MD5
  • 如何快速辨别 MD5 算法?

    1. 密文长度

      标准的 MD5 算法生成的密文为 16 字节(128 位),每个字节的值范围是 0 到 255(即 0x00 到 0xFF),表示为 128 位的二进制数据。而在开发过程中,通常看到的 md5 加密后的哈希值为长度为 32 或 16 个字符,将原来 128 位的二进制数据转为 32 位的十六进制表示形式。长度为 16 字符的则是对 32 位第 9-24 位置内容的截取,

    2. 初始密文值

      md5 算法在初始化阶段,有4个固定值的寄存器,在 js 文件中如果可以搜到这些固定值,基本可以确定是 MD5 算法加密。

       A = 0x67452301 (十进制:1732584193)
       B = 0xefcdab89 (十进制:4023233417,转为有符号32位整数是 -271733879)
       C = 0x98badcfe (十进制:2562383102,转为有符号32位整数是 -1732584194)
       D = 0x10325476 (十进制:271733878)
      
SHA

包括 SHA-256、SHA-512 等常用版本

  • sha 算法的特点

    sha-256: 密文长度 256 位,常见 hex 输出长度为 64

    sha-512:密文长度 512 位,常见 hex 输出长度为 128

2.2.4 编码加密

编码加密本身不属于加密算法类,而是通过不同的编码格式,将明文变成无法直观阅读的形式,达到混淆源代码的作用。

Base64 编码
  • 编码后的内容组成

    • 大写字母:A-Z
    • 小写字母:a-z
    • 数字:0-9
    • 特殊字符:+/
    • 结尾可能有 =
  • 关键词 btoa

    这是 js 提供的 base64 编码的方法

  • 编码 和 解码 示例代码

image.png

Unicode 编码

字符用 \u 开头的两位十六进制数形式表示。

image.png

  • 解码示例

    image.png

十六进制转义

将每个字符的 Unicode 编码值以 \x 开头的两位十六进制数形式表示。

image.png

  • 解码示例

    image.png

URL 编码

对一些特殊字符转换为以 % 开头的编码格式,这种编码方式将非 ASCII 字符和某些特定字符(如 &, =, / 等)转换为 URL 安全的字符形式,避免这些字符在 URL 中引起问题,结合其他编码方式,也可以使字符串的某些部分变得不易理解。

  • 编码 和 解码 示例

    image.png

2.2 混淆 js

ob 混淆
  • ob 混淆特征

    如图所示通过 obfuscator 生生成了一段标准的 ob 混淆代码,可以观察到 ob 混淆有如下几个明显的特征:

    • 函数名和变量名通常以 _0x 或者 0x 开头,后接 1~6 位数字或字母组合

    • 字符串加密,将字符串转换为16进制、Base64或Unicode编码等形式

    • 大多情况有大数组,且有对数组进行移位的操作,伴有关键字:push,shift

    • 控制流平坦化:一般都是核心的业务代码

      image.png

  • 处理方式

    • 推荐 v_jstools猿人学 ob 解混淆 工具进行解析。图为 v_jstools 解混淆示例

      image.png

    • AST 还原代码

    • 直接扣取所有代码,利用 hook 定位到关键加密位置,补环境处理

js_fuck

内容仅由 ! [] + () {} 这些符号组成,无法直观的区分代码结构和逻辑,难以调试。

JSFuck 转码符号含义可查阅官网地址:jsfuck.com/

image.png

  • 解决方案

    • 推荐 v_jstools 工具,进行解密得到结果供参考分析。图为 v_jstools 解析 JSFuck 混淆代码示例:

    image.png

    • 控制台直接运行代码

      • 如有报错,点进去即可查看到源码

      image.png

      • 若不报错,则截取部分代码直接运行,主动令其出错
    • 单步调试,观察代码做了什么

    • 复杂的代码需要根据实际情况,结合上述多种方式~

aa 混淆 & jj 混淆

二者都是通过 Js 内置的构造函数 Function 创建自执行匿名函数,将源代码作为函数体执行;或者是通过 eval 执行编码后的代码。

  • aa 混淆如图,著名的表情包代码

    image.png

  • jj 混淆如图 image.png

  • aa 混淆 和 jj 混淆 解决方案

    • 将混淆代码直接在控制台运行,同 JSFuck 一样

    • 单步调试,进入虚拟机后可查看到源码

    • hook Function 或者 eval,源码从 arguments 中可以查看到

      image.png

2.3 Webpack

Webpack 是一个流行的前端模块打包工具,站点一切资源皆模块,通过加载器进行加载,核心构成就是资源模块和加载器。

一、Webpack 特征
  • 自执行函数 + 函数大数组/函数大对象,这个函数大集合称为 模块
  • 大数组中的函数的调用都要通过一个入口,这个入口称为 加载器(分发器)
  • 加载器中有 exports 关键字

image.png

二、解决方案

参考 案例网站,以新闻列表的接口 weMediaNews 为例,简述一下 Webpack 打包的逆向技巧。

首先抓包请求,通过重放攻击得到关键的加密参数,如下:

    X-ITOUCHTV-CLIENT
    X-ITOUCHTV-Ca-Timestamp
    X-ITOUCHTV-Ca-Key
    X-ITOUCHTV-BRANCH
    X-ITOUCHTV-Ca-Signature
  • 找到加密函数所在的模块

    找到加密位置,全局搜索 X-ITOUCHTV-Ca 关键字,定位到加密位置。

    image.png

    观察这部分代码,整体结构是一个大对象,对象属性的值都是函数,非常符合 Webpack 的特征,再断点在 440 这个函数入口,调试发现了这些方法调用方式均为 n(数字),此时可以确认这是 Webpack 打包了,440就是加密函数所在的模块。

  • 找到加载器,扣取 Js

    断点在 440 入口,调试分析得到加载器 a 函数,存放已加载模块的对象 de 则是所有模块的集合,将加载器的核心 a 函数 整体扣取,放到 main.js,补充需要的对象 de,后续程序所需要的模块均放入 e 中,如图所示:

    image.png

  • 扣取加密模块,补充加密所涉及到的其他模块

    440 加密模块放入 e 中,根据 440 执行流程,将 145 模块扣取放入 e 中,再补齐 145 中涉及到的其他模块,按照这个补充流程,依次将加密模块中的 223441 模块完整扣取,直至扣取下来的代码正常执行。

    image.png

  • 提升核心加密方法到全局

    440 中的核心加密逻辑是 function a(t, n, i) {...},后续发送请求需要调用该加密过程,因此在模块中需要把 a 放到全局。

    image.png

  • 验证扣取的 Js

    a(440)为模块加载入口,调用加密函数 window.headers_crypto('GET', url, '');,得到正确结果。

    image.png

  • 完整扣取的模块如图所示

    image.png

2.4 瑞数

2.4.1 瑞数特征

列举一些常见的瑞数特征,符合以下情况的基本可以确定是 瑞数 反爬。

  • 标志性的三次请求

    • 对网站 target_url 发起首次请求,请求的响应状态为 202412
    • 发起外链 js 请求,同一个页面返回的 js 内容是不变的
    • 对网站 target_url 再次发起请求,获取正确数据
  • Cookie 加密,这是瑞数的核心,且 Cookie 的 key 的后缀一般为 O/P 或者 S/T 结尾,OS 结尾的 Cookie 来自于首次请求服务端的返回。

    image.png

  • 首次响应的 html 结构

    • meta 部分,有个动态的超长的 content 内容,后续代码会根据 content 的内容进行解码解密,然后生成 window 的一些属性。
    • 第一个 script 的外链接,这个就是 三次请求中 的第二个请求(js 请求)的请求来源,一般同一个页面 js 的文件的内容不变,因此在补环境时,可以在本地保存一份,给本地程序执行时读取。
    • 第二个 script 中涵盖了核心的外层代码,这是唯一需要扣取的核心的代码,扣取下来的代码作为我们补环境的验证代码。
    • 最后的 body 中的 script 和 底部的 script 标签执行的 js 一般用来刷新当前页面,无需过多关注。

    image.png

2.4.2 区分瑞数版本

整理了一些大佬们给出的瑞数版本特征,帮助我们快速分析和判断逆向难度。

  • 3 代

    • 首次请求响应状态为 202
    • T 结尾的 Cookie 的值的前缀为 3
    • Cookie 的 key 中,在 S/T前带有端口号 80/443,如 FSSBBIl1UgzbN7N80T

    image.png

  • 4 代

    • 首次请求响应状态为 202
    • T 结尾的 Cookie 的值的前缀为 4
    • Cookie 的 key 中,在 S/T前带有端口号 80/443

    image.png

  • 5 代

    • 首次请求响应状态为 412
    • T/P 结尾的 Cookie 的值的前缀为 5

    image.png

  • 6 代

    • 首次请求响应状态为 412
    • T/P 结尾的 Cookie 的值的前缀为 6 image.png
  • vmp 版本

    • 首次请求响应状态为 412
    • T/P结尾的 Cookie 的值的前缀为 0 或者字母

    image.png

2.4.3 解决方案

案例网站 作为实战例子,进行一个解决方案的大致讲解,以及注意事项的简单说明。

抓包分析请求,发现是一个 瑞数第 3 代 的反爬

image.png

方案一:手动补环境
  • 找到设置 Cookie_T 的位置

    进入网页,打开浏览器的开发者工具,切换 sources 面板,右侧 Event Listner Breakpoints -> Script 勾选,在外部 js 内容中断住时,即可插入 hook 方法,放开 Script 断点,之后程序会在 Cookie_T 的设置位置就会断住。

    image.png

  • 利用上面的断点位置,分析堆栈,找到以下几个关键位置

    • Cookie_T 生成位置
    • VM 入口位置
  • 断住的同时,保存以下文件,用于后续步骤的调试和校验

    • 生成的 Cookie_T 的值
    • 服务器返回的 Cookie_S 的值
    • 首次请求 target_url 返回的 html 文件,后续作为覆盖请求的本地映射文件,也是扣取 js 的来源
    • 保存外链 js 的文件,这是本地调试所需要加载的前置环境
  • 扣取关键 js

    将首次返回的 html 中,<script type="text/javascript" r='m'>一大堆 js </script>的这段 js 扣取到本地文件 main.js,关键代码扣取完成。

  • 调试工具

    调试的关键是,确保同一套代码,本地的执行逻辑以及得出的结果和浏览器执行结果是一致的,这就需要将浏览器的执行变为静态可调试的,实现这一目的的方法有很多,下面简单介绍几个:

    • 浏览器的 override 功能

      找到想要覆盖的请求,右键选择 Override Content,之后这个请求的内容将会作为该请求的固定返回内容。 image.png

    • Charles 的 Map Local

      Map Local 支持 Charles 拦截目标请求,把请求的内容替换为本地文件。

      image.png

    • Proxyman 的 Map Local

    image.png

    • Fiddler 的 AutoResponder

    image.png

  • 本地代码补环境

    • 通过 node --inspect-brk main.js 打开本地 node 调试工具,之后在浏览器的开发者工具左上角,可以看到一个绿色按钮,点击绿色按钮,即可进入本地代码的调试面板。

      image.png

      image.png

    • 连接浏览器调试工具进行联调,对比 浏览器正常执行替换的 html本地 main.js 执行的流程和结果,缺啥补啥

    image.png

    • 补环境分为两个阶段

      • 进入 VM 之前

        在 VM 入口这里打上断点,确保本地执行可以正常走到入口,并且传入 eval 的待执行字符串内容一致,否则说明这部分的环境尚未补充完整。

      • 进入 VM 之后

        在 Cookie_T 的生成位置打上断点,确保程序正确执行到该位置,且验证 Cookie_T 的结果是否一致。

    • 校验结果

    image.png

方案二:推荐现有补环境框架

推荐 qxVm 补环境框架,下面简单讲解一下基于这个框架解决案例网站的步骤。

  • 准备框架环境

    • git 下载框架:git clone https://github.com/ylw00/qxVm.git
    • 准备程序入口:在 z_working 新建 main.js 作为程序入口。
  • 扣取业务代码

    • 从 html 中扣取 自执行的那段业务核心代码,放入 core.js
    • 将外链接的 js 内容放入 core.js
    • core.js 导出生成的 cookie,作为外部方法调用
    • core.js 内容结构如下

    image.png

  • 补充 main.js 执行逻辑

    • 读取业务代码文件 -> core.js
    • 配置浏览器基本环境 -> user_config.env ...
    • 执行代码,运行业务代码,文件结构如下 image.png
  • 准备工作完成,剩下就是对比浏览器和本地执行情况,缺啥补啥

    相较于手动补环境,框架可以过掉大多数不严格的浏览器环境检测,只需手动补充一些特定的变量和属性值,大大提升补环境效率。

    image.png

    • 补充的环境放在 core.js

    image.png

    • 根据浏览器执行结果对比,校验结果

    image.png

方案三:RPC

通过 RPC 获取 document.cookie 或者 直接执行 js 的翻页代码,达到刷新页面采集数据的目的,这里大致讲解下 RPC 的使用方式。

  • 打开案例网站的 console,注入 ws 的客户端代码

    image.png

  • 在 Python 端编写采集目标数据的逻辑 server.js

    image.png

  • 获取 Cookie

    image.png

方案四:自动化

推荐以下自动化工具,这里通过 DrissionPage 操作案例网站作为示例,实现抓取,其他自动化工具请自行查看文档。

image.png

三、CSS 加密

3.1 字体加密

  • 加密形式

    打开控制台,内容在 Elements 无法正常显示,选取页面的内容复制,粘贴到其他地方依旧是乱码。

    image.png

  • 区分静态/动态字体加密

    每次刷新页面,加密后的内容都保持不变,即可确定是静态加密,反之为动态加密。

静态字体加密

案例网站

  • 解决方案

    1. 找到文字和显示符号之间的映射关系。
    2. ocr 识别

    下面主要讲解方案一

    • 定位字体文件

      由于是静态加密,字体文件内容不变,找到关键的字体文件即可,以下两个方案找到字体文件的 url

      1、通过 chrome 抓包,点击 network -> Font,即可筛选出应用到的字体文件,根据请求的域名筛选出目标文件 url

      image.png

      2、请求获取的 html 页面中,搜索关键字 ”woff“ 或者 ”ttf“,定位到加载字体文件的位置,从而找到目标文件 url

    • 下载字体文件:

      • 浏览器复制抓包的文件 URL 到地址栏,直接下载
      • 通过 requests 请求将字体文件下载,保存到本地 result.ttf / result.woff
    • 通过 在线字体编辑器 导入 result.ttf,查看字体编码

      • 由于后面解析工具 fontTools 的顺序和哈希值有关,因此在字体编辑器查看字体时,请选择 工具 -> 按代码点进行排序,这样能保证解析工具输出的字体项 和 编辑器看到的字体项顺序一致。 image.png
    • 记录字形,得到编码和字形映射关系

      • 如果加密字体比较少,可以直接手动抄录映射关系,如下图中,”中“ 的编码是 ”$EDA9“,查看网页显示为 &#xeda9;,得出映射关系,将 HTML 的 &#xeda9; 替换为

      image.png

      • 加密字体较多,则按照编辑器展示的顺序抄录字体,在程序中直接对应匹配
      # woff 字体文件解析示例
      resp = requests.get(url, headers=headers, timeout=10)
      with open("result.woff", "wb") as fd:
          fd.write(resp.content)
      # 根据字体编辑器查看的字体文件抄录得到对应的字形列表
      font_list = ["7", "9", "1", "5", "8", "4", "6", "2", "0", "3",]
      # 加载 WOFF 字体文件
      font_path = "result.woff"  # 替换为你的 WOFF 文件路径
      font = TTFont(font_path)
      # 获取字体的 Unicode 映射表
      cmap = font["cmap"]  # 获取 cmap 表
      unicode_mapping = {}
      # 遍历所有 cmap 表中的子表
      for table in cmap.tables:
          if table.format in (4, 12):  # 常见的格式
              for codepoint, glyph_name in table.cmap.items():
                  unicode_mapping[codepoint] = glyph_name
      unicode_font_dict = [hex(key) for key in unicode_mapping.keys()]
      # 找出字体和字体文件中编码的对应关系,保存为字典
      font_dict = dict(zip(unicode_font_dict, font_list))
      print(font_dict)
      
      
    • 根据映射关系,替换 html 内容,得到最终结果

动态字体加密

动态字体加密的难点在于字形和编码是不断变化的,需要找到这个变化的规律。

  • 解决方案

    1. 找到文字和显示符号之间的映射关系。
    2. ocr 识别

    下面主要讲解方案一,案例网址

    • 下载文件,参考静态字体加密
      • 由于是动态的,需要下载多个文件进行对比和分析
    • 将下载的文件转成 xml,分析字体映射规律 def transfer_woff_to_xml(f_): """.woff文件转换成.xml文件""" font = TTFont(f_) font.saveXML(update_f_name) 转成xml后的文件内容,对比观察 4 的坐标信息,如图 image.png
      • 部分网站对于动态字体的处理较小,比如 on 的值是不变的,或者 xy 位置固定,对于这种就比较好处理,通过fontTools.ttLib.TTFont直接提取字体的坐标值,坐标结果相等,则表示是一个字形,可以得出映射关系

      • 本案例网站中的动态变化更复杂一些,字体的坐标都是随机变化的,因此解决步骤如下

        • 下载 4 份字体加密文件,根据字体的特性排序,找出 4 份文件排序最大程度接近的一个排序方式
        # 按 Unicode 值排序
        sorted_tables = sorted(newFont["cmap"].tables[0].cmap.items(), key=lambda x: x[0])
        # 按 nameID 或内容排序
        name_table = font["name"]
        name_records = [(record.nameID, record.string.decode("utf-8", errors="ignore"))
                         for record in name_table.names]
        sorted_name_records = sorted(name_records, key=lambda x: x[1])
        # 按字符轮廓信息排序
        # # 按轮廓点数量排序
        base_glyf_table = basefont["glyf"]
        base_glyphs = [(glyph, base_glyf_table[glyph].numberOfContours) for glyph in basefont.getGlyphOrder()]
        base_sorted_glyphs = sorted(base_glyphs, key=lambda x: x[1])
        # # 按边界框大小排序
        new_glyf_table = newFont["glyf"]
        unilist = sorted(
            newFont.getGlyphOrder()[2:],
            key=lambda g: new_glyf_table[g].xMax - new_glyf_table[g].xMin,
        )
        
        • 尝试了上述几种排序方式,发现 4 份文件中,排序最接近的是 按边界框大小排序,输出的内容是 1 2 0 4 6 7[3] 3[7] 9[8] 8[9] 5 这个排序,可以确定的是除了下标 5 ~ 8,其他基本固定值,可以直接排序后按照下标位置对应

        • 剩下的 5 ~ 8 这些下标的数字,5<->67<->8 是存在对调关系,确定了5,那么 6 也确定了,接下来就是区分这两对下标值

        • 区分两对下标值的方式

          将下载下来的文件中,随机选择一份文件 result1.woff 作为基准文件,分析其他 result2、result3、result4 等下标 5 和 这个基准文件 result1.woff 中下标 5 和 下标 6 之间跟谁更像,判断像不像的标准则是获取字体的坐标,进行算法对比,根据多份文件对比结果,得出一个坐标偏移的有效范围值,在有效偏移范围内则认定为是同一个值。

          image.png

        • 最终,按照排序后的下标,得到下标 0 ~ 4 和 9 对应的数值;根据算法比对,得出下标 5 和 6,7 和 8 的对应值,映射关系梳理完毕替换 html,代码如下

        def parse_dynamic_css_font(resp_html):
            # 下载的新的字体文件
            newFont = TTFont("result5.woff")
            # 按边界框大小排序
            new_glyf_table = newFont["glyf"]
            unilist = sorted(
                newFont.getGlyphOrder()[2:],
                key=lambda g: new_glyf_table[g].xMax - new_glyf_table[g].xMin,
            )
            # 选取基准文件
            basefont = TTFont("result1.woff")
            numlist = []
            # 根据基准文件排序后获取到的字体
            base_num = ["1", "2", "0", "4", "6", "3", "7", "8", "9", "5"]
            # 字体对应的编码
            base_unicode = [
                "uniE6D5",
                "uniEC68",
                "uniF66D",
                "uniEAB3",
                "uniE5AC",
                "uniF615",
                "uniE317",
                "uniE1B7",
                "uniE274",
                "uniEF74",
            ]
            i = 0
            while i < len(unilist):
                # glyf -> 字符轮廓信息
                newGlyph = newFont["glyf"][unilist[i]].coordinates
                if i < 10 and (i < 5 or i == 9):
                    numlist.append(base_num[i])
                    i += 1
                else:
                    # 5-8 下标的数值需要比对才能确定
                    baseGlyph = basefont["glyf"][base_unicode[i]].coordinates
                    # 确定了其中一个下标的值,另一个也能确定
                    if compare(newGlyph, baseGlyph):
                        numlist.append(base_num[i])
                        numlist.append(base_num[i + 1])
                    else:
                        numlist.append(base_num[i + 1])
                        numlist.append(base_num[i])
                    i += 2
            # 替换特殊符号
            rowList = []
            for i in unilist:
                i = i.replace("uni", "&#x").lower() + ";"
                rowList.append(i)
            # 生成映射字典
            dictory = dict(zip(rowList, numlist))
            # 替换目标的 html 中的内容
            for key in dictory:
                if key in html:
                    html = html.replace(key, str(dictory[key]))
        

3.2 雪碧图

CSS 雪碧的基本原理是把网站所需要的图片资源集中在一张大的图片中,再通过 CSS 中的 backgroundbackground-position 属性进行渲染,达到仅展示大图片中所需要的内容。

  • 解决方案

    参考 案例网站

    image.png

  • 找到大背景图,点击元素,查看控制台右边元素的 Styles 属性,找到 background-image 属性,即可看到大图片的地址

    • 参考案例网站,背景图片的内容如图所示,背景图片中包含了所有数字,且网页涉及到的 雪碧图背景图片的 url 相同,因此可以只研究该图片的位置映射关系;

      image.png

  • 找到显示内容 和 背景图片 之间 background-position 的映射关系

    • 第一个 span 元素,lass="sprite uvwxyz" 的 ,显示内容是 “3”,观察右边 Styles,其 background-position 是 -30px 0px
    • 第二个 span 元素,class="sprite cdefgh",显示内容是 ”9“,其 background-position 是 -21px 0px
    • 猜测每个数字的偏移量为 10 px,调整 background-position-x 的值,发现可以将数字对应上,因此找到了映射关系;
    • 有时候也可以通过标签的宽度,内嵌元素的宽度,二者之间的占比来分析出映射关系。
  • 编写程序,提取元素对应的 background-position-x

    # 提取 第一个 span 元素,lass="sprite uvwxyz" 的 background-position-x 属性值
    response = requests.get(url, headers=headers, timeout=10)
    soup = BeautifulSoup(response.text, 'html.parser')
    # 提取 <style> 内容
    style_content = soup.find_all('style')[1].string
    # 解析 CSS 样式
    css_parser = cssutils.parseString(style_content)
    # 查找 class 为 'sprite uvwxyz' 的元素
    span = soup.find('span', class_='sprite uvwxyz')
    # 获取该元素的所有 class 名
    classes = span['class']
    # 初始化 background-position-x
    background_position_x = None
    # 遍历解析 CSS 样式
    for rule in css_parser:
        if rule.type == rule.STYLE_RULE:
            # 检查该规则是否与元素的类匹配
            for class_name in classes:
                if f'.{class_name}' in rule.selectorText:
                    # 获取 background-position 属性
                    style = rule.style
                    if 'background-position' in style:
                        # 提取 background-position-x 值
                        position_values = style['background-position'].split()
                        background_position_x = position_values[0]  # x 值是第一个
                    elif 'background-position-x' in style:
                        background_position_x = style['background-position-x']
    # 输出结果:background-position-x: -80px
    print("background-position-x:", background_position_x)
    
    
  • 根据上面得到的 background-position-x 和 数字的映射关系,替换 html,采集数据

3.3 其他

元素偏移

CSS 偏移反爬的实质是利用 CSS 样式来控制数据在页面中显示的位置,让原始乱序的数据正常显示,但爬虫无法直接得到正确原始数据。

参考 案例网站,第一个数字 6081,查看元素发现,排在第一个位置的 0 由于设置了 style="left:11.5px",位置被放在了第二位。而第二个数字 6 同样设置了 left 移到了第一位,原本的数字 0681 变成了 6081

image.png

  • 解决方案

    元素偏移的关键是根据 Style 设置的偏移量,根据偏移量的计算规律确认元素本身的正确位置。

    • 在本案例中,元素仅有 left 偏移设置,只需要考虑左右位置
    • 每个 td 标签中有 4 个图片,假设将图片放在一个数组中,每个图片对应一个数组下标,对应好之后,即可得到正确数字
    • 分析得出 11.5 为一个数字图片完整偏移一个下标的量
    • 通过 BeautifulSoup + 正则 提取元素的 style
from bs4 import BeautifulSoup
soup = BeautifulSoup("返回的 html 图片内容", "lxml")
for td in soup.select("td"):
    actual_num = {}
    imgs = td.select("img")
    for index, img in enumerate(imgs):
        left_css = (
            int(re.search(r"-?\d+", img["style"]).group()) / 11.5
        )
        # 最终图片的正确摆放位置
        actual_num[int(index + left_css)] = img_num
CSS 伪元素

伪元素 在 CSS 反爬中利用其内容不存在于 HTML 源码中,而是通过 CSS 特定语法调用的特性,增加获取源数据的难度。

参考案例网站,评分通过 ::before::after 将真正的数字 9.7 隐藏在样式中,而不能直接通过获取这里的 span 元素提取。

image.png

  • 解决方案

    • 查看网页源代码,找到伪元素设置区域,提取伪元素和真实数据的映射关系

      image.png

    • 分析 html 源码,解析 html,根据映射关系替换成正确数据

    # 提取伪元素和真实数据的映射关系
    class_map = {
        "mnopqr::before": "9",
        "pkenmc::after": "7"
    }
    # 解析 html。根据映射关系替换内容,得到正确数值
    soup = BeautifulSoup(html, "lxml")
    spans = soup.findAll("span", string=".")
    for span in spans:
        class_ = span.attrs['class']
        full_cnt = class_map[f"{class_[0]}::before"] + "." + class_map[f"{class_[1]}::after"]
    print(full_cnt)
    

四、浏览器反调试

4.1 禁用控制台

  • 一般打开浏览器控制台有以下几种方式
    • 鼠标右键 -> 检查
    • 键盘快捷键
      1. F12
      2. command + option + i/j/c(Mac 系统)
      3. ctrl + shift + i(Win 系统)
    • 开发者工具
      1. 浏览器右上角 三个点 -> 更多工具 -> 开发者工具
  • 绕过禁用
    • 在进入目标网页前,打开新的标签页,先打开控制台,再进入目标网址
    • 浏览器右上角 三个点 -> 更多工具 -> 开发者工具

4.2 检测调试行为

4.2.1 检测控制台是否打开

常见的是通过 js 检测窗口大小来判断是否有打开控制台.

image.png

  • 解决方案

    • 控制台采用 undock into seperate window 模式展示可以绕过
    • 调试 js 定位检测位置,删掉检测代码
    • 有些是通过定时任务去定时检测,可以 hook 掉定时任务
    // 阻止新定时任务的创建 
    window.setInterval = () => { console.log('setInterval 已被禁用'); }; 
    window.setTimeout = () => { console.log('setTimeout 已被禁用'); };
    
    • clearInterval / clearTimeout 清除定时器
    // 清除所有定时任务
    (
        function() {
            function clearAllInterval(){
                let max_interval_id = setInterval(() => {}, 1000);
                for (let i = 0; i <= max_interval_id; i++){
                    clearInterval(i);
                }
            }
            setInterval(clearAllInterval, 3000);
        }
    )
    
4.2.2 检测代码是否阻塞

在调试模式下,某些操作(如断点调试、步进执行)会导致代码执行时间异常延长,可以利用这些特性检测调试行为。

// 示例:使用 `setInterval` 检测代码是否被阻塞
let start = Date.now();
setInterval(() => {
    const duration = Date.now() - start;
    if (duration > 100) {
        console.log('Debugger detected!');
        // 执行反调试操作
    }
    start = Date.now();
}, 50);

  • 解决方案
    • 调试 js 定位检测位置,删掉检测代码
    • 提前 hook 定时任务(参考上面)
    • clearInterval / clearTimeout 清除定时器(参考上面)
4.2.3 利用 console
  1. 通过覆盖 console 的方法,导致调用 console 时会进入开发者自定义的反爬逻辑中,误导调试效果,进入到错误逻辑中,甚至引起内存崩溃无法调试。

    image.png

  2. 利用浏览器正常模式和开发者模式下 console.log 的执行区别实现

    • 开发者工具模式下,console.log 为了展示更多信息,会尝试调用对象的 toString 方法,即使变量是字符串或简单对象。因此在打开开发者模式时,不管变量是什么类型,一律会调用它的 toStirng 方法。
    • 浏览器正常运行情况下,cnosole.log 会根据值的类型选择最合适的输出方式。对于字符串直接输出原始值,对于其他类型的对象则可能会试图调用 toString 方法来生成字符串表示,或输出对象的原始结构。

    image.png

  • 解决方案
    • 提前保存原始的 console 对象为 myconsole,禁用原来的 console,后续使用 myconsole 来打印信息 // 覆盖 console.log myLogger = console.log console.log = function(){};
    • 调试 js,定位到覆盖 console 位置,删除代码

4.3 debugger 地狱

在代码调试过程中,频繁中断在 debugger 语句,陷入一个频繁暂停的“地狱”状态,难以继续正常调试和执行代码。

4.3.1 构建 debugger 的方式
普通 debugger

在 html 中的 script 标签只要有 debugger,程序都会在该语句暂停,属于最直接的 debugger 干扰。

  • 静态构建 debugger

    在 html 中可直接看到 debugger 关键字。

    image.png

    • 解决方案

      • 借助浏览器工具,在 debugger 暂停行,鼠标右键 => Never pause here,即可绕过

      image.png

      • override 源文件,删掉 debugger
  • 动态构建 debugger

区别于上述直白的 debugger 形式,有时会通过 appendChild 在代码执行过程中对 html 的文档结构进行修改,动态增加嵌入了dubugger 关键字的节点,并且隐藏关键字和修改文档的位置。

image.png

  • 解决方案
    • 调试 js 定位到篡改位置,删掉代码
    • 提前 hook appendChild 解决
// 解决 appendChild debugger
_appendChild = Node.prototype.appendChild;
Node.prototype.appendChild = function () {
    if (arguments[0].innerHTML && arguments[0].innerHTML.indexOf('debugger') != -1) {
        arguments[0].innerHTML = '';
    };
    return _appendChild.apply(this, arguments);
}
Eval 构建 debugger

在 JavaScript 中,eval() 函数是一个内置的全局函数,函数将传入的字符串作为 JavaScript 代码解析并在虚拟机中执行,允许动态执行代码。

image.png

  • 解决方案

    提前 hook 掉 eval 函数,替换执行代码字符串中的 debugger 内容

    eval_ = eval;
    eval = function () {
       if ([arguments[0].indexOf('debugger') == -1]) {
           return eval_(this, arguments);
       }
    }
    
Function 构建 debugger

在 JavaScript 中,Function 是一个内置的构造器对象,用于创建新的函数。Function 构造器允许动态地创建函数并传递字符串形式的代码。它的行为类似于 eval(),但比 eval() 更加安全和受限。

  • Function 构建 debugger 的形式
    • 直接通过 Function 创建

      image.png

      • 解决方案

        提前 hook 掉 Function,替换掉 debugger 内容。

        MyFunction = Function;
        Function = function () {
          if (arguments[0].indexOf('debugger') != -1) {
            arguments[0] = arguments[0].replace('debugger', '');
          }
          return MyFunction.apply(this, arguments);
        }
        
    • 调用函数的 constructor 构建 debugger

      image.png

      • 解决方案

        提前 hook 掉 Function 的 constructor,替换掉 debugger 内容

        _Function = Function;
        Function.prototype.constructor = function () {
          if (arguments[0].indexOf('debugger') != -1) {
            arguments[0] = arguments[0].replace('debugger', '');
          }
          return _Function.apply(this, arguments);
        };
        
4.3.2 无限debugger 反爬的实现方式
  • 定时任务

    定时任务结合上述构建 debugger 的各种方式来达到贯穿全程的 debugger 效果。

    • 解决方案

      • 调试 js 定位到定时任务设置位置,删掉定时器
      • 测试下定时器中是否有关业务逻辑,无影响则删除所有定时器
      • hook 定时器
      _setInterval = setInterval;
      setInterval = function(code, time){
          // 此处的处理逻辑暂未考虑更复杂的情况,需要具体情况具体处理。例如:
          // 1、业务逻辑嵌入 debugger; 
          // 2、debugger 关键字被混淆等
          if(code.toString().indexOf('debugger') != -1){
              return _setInterval.call(this, code, time);
          }
      }
      
  • 递归

    通过函数的递归调用实现无限 debugger,如图所示:

    image.png

    • 解决方案

      • 根据 debugger 的构造方式,hook 代码
      • 递归函数中无业务代码,可将该递归函数置空

4.4 内存爆破

当程序检测到是爬虫行为时,会触发一些操作,这些操作通过不断消耗浏览器内存资源,使其崩溃或响应缓慢,从而达到阻碍爬虫获取数据的目的。

4.4.1 实现方式

通过定时任务或者递归操作,不断的进行一些消耗内存的行为,例如:

  • 死循环

    1. 利用 while-true 构造死循环。
    2. 函数无限递归,或者 for 循环不断往数组中写入数据,耗尽内存。
    3. 需要注意的是,对于函数无限递归的情况,最外层如果有 try-catch,则并不影响程序后续运行。这是因为同步栈溢出(如递归导致的 RangeError)可以被 try-catch 捕获。内存耗尽(如大量数据分配导致的内存溢出)通常无法被 try-catch 捕获,因为这是引擎层级的问题,会直接终止进程。
  • 大量DOM操作

    不断创建、删除或修改 DOM 元素,触发浏览器频繁的 DOM 重绘和重排,消耗大量内存。

  • 重写本地数据

    频繁重写或追加 cookie、history 等本地数据,耗尽浏览器资源。

4.4.2 触发条件

整理了一些常见的内存爆破检测条件,检测未通过则会触发反爬逻辑,js 代码可能会进入内存爆破的逻辑中。

  • 代码格式化检测

    伴有关键字 new RegExptesttoString,利用 RegExp 对象的 test 对代码进行是否格式化的检测,如:换行,空格之类的变化,检测到代码被格式化,则进入死循环中。

    • 解决方案
      • 尽量直接扣取源码,不做任何格式美化,很大程度避免
      • hook 被检测对象的 toString 方法,输出符合检测规则的内容
  • 浏览器指纹检测

    一般会对一些基本的浏览器 API 进行检测,如:documentlocationnavigatorglobalrequiretoStringsessionStoragelocalStorage 等进行不同程度的检测,检测不通过则进入反爬逻辑中。

    • 解决方案

      解决方案就是 ,分享有一些补环境的小技巧,尽可能提高补环境效率。

      • 扣取代码之前,将基本的浏览器对象简单补充,如 window、location 这些
      • 代码混淆严重的情况,可以先 AST 简单解混淆,得到更多的明文,便于调试
      • 出现了明文的情况下,则可以先搜索一些 node 环境检测的关键字,如:global、require 等,提前处理掉这些检测
      • 出现了明文的情况下,搜索一些浏览器常用的 api,提前补好,如:createElement、getElementById、webdriver 等
      • 测试定时任务是否影响主业务,不影响可以直接提前清理掉定时器
      • 仔细认真的调试,然后补环境

五、数据传输格式

5.1 二进制

一、表现特征
  • 经过二进制序列化处理的内容,最直观的表现就是出现很多不可读的字符
  • 调试 js 发现有 ArrayBuffer、Uint8Array、TextEncoder、atob 等方法对内容进行处理

image.png

二、解决方案

主要将请求明文转换为二进制,解析服务器响应的二进制为明文,以下是示例代码:

image.png

5.2 Protobuf

一、Protobuf 怎么实现反爬

Protocol Buffers (Protobuf) 是一种高效的、跨语言的序列化协议,用于结构化数据的编码和解码。网站或服务通过使用 Protobuf 格式传递数据来增加爬取难度,具体表现在以下几个方面:

  • 数据变得不可读

    Protobuf 编码后的数据是二进制格式,无法直接通过抓包或查看网络响应获得可读内容。

  • 需要 .proto 文件来解析

    • 要正确解析 Protobuf 数据,通常需要目标服务的 .proto 文件(定义数据结构的文件)。
    • 如果 .proto 文件不可获取,反向解析 Protobuf 会非常困难。
二、如何辨别 Protobuf
  • Content-Type

在 headers 中的 Content-Type,有时会标注类型为 grpc-web+protox-protobuf,或其他带有 protobuf 关键字的类型,表示这个请求用到了 protobuf 协议进行数据传输。

image.png

  • 其他特征综合判断

    • 无法直接显示的非 ASCII 字符(如 Ž, Â,  等),说明这些数据经过某种二进制序列化处理。Protobuf 是一种常见的高效二进制序列化格式。

      image.png

    • 排除其他二进制序列化的可能性

      • 不是 GZIP 压缩,不像典型的压缩数据(例如 GZIP 开头常见的 1F 8B 魔数)
      • 数据较为简短,protobuf 压缩会使得数据比其他序列化格式更为紧凑和简洁
    • 工具验证

      将抓包到的乱码内容使用 Protobuf 反序列化工具尝试解析,如果是 protobuf ,那么可以得到正确的 protobuf 数据结构。

      • 在线解析工具:gchq.github.io/CyberChef/

      • python 解析库:blackboxprotobuf

      • Protobuf 编译器:protoc --decode_raw < text.bin

三、解决方案

此处用案例网站做讲解,

  • 方案一:blackboxprotobuf

    1. 抓包数据,找到请求被序列化的请求参数内容

      image.png

      这里使用 Proxyman 抓包演示,在 Request 的 body 中看到请求参数内容乱码,但直接复制这里全部内容用 blackboxprotobuf 解析会发现解析失败,这说明我们选取的有效数据范围不对,因此需要回到 js 调试部分,找到数据发送位置,分析请求数据的构成。

      • 利用 xhr 断点找到请求发送入口

        image.png

      • 观察到 send 方法中传入了 e,而 e 的值是一个十六进制的数组,切换到 Proxyman,将 body 的视图显示改成 十六进制,会发现,这个 e 的内容和抓包的 body 内容十六进制换算成十进制的结果是一致的,因此确定 e 就是我们要找的请求参数。

        image.png

      • 找到了 e,那么分析 e 是如何生成的,通过堆栈网上调试,定位到 e 是 a 赋值,而 a 是通过 a = new Uint8Array(5 + n) 生成的,a 中的数据通过 a.set(new Uint8Array(l), 1), a.set(e, 5), 插入的。

        image.png

      • 分析这段 a 生成和赋值的关键代码,得到 a 的有效数据从下标 5 开始,因此可以确定抓包的 hex 数据的有效范围是从第 6 位数开始往后的内容。

        image.png

      • 从 Proxyman 选取从第六位开始对应的文本数据保存到 text.bin 文件中,再通过 blackboxprotobuf 进行解析,

        image.png

        • 需要注意的是 Proxyman 这里有个编码问题,会导致解析失败,需要先对 text.bin 进行一下转码

          image.png

    2. 利用 blackboxprotobuf 解析,得到参数的内容和消息结构

      def parse_protobuf():
          # data.bin 保存乱码的数据
          with open("data.bin", "rb") as f:
              data = f.read()
      
          serialize_data, message_data = blackboxprotobuf.protobuf_to_json(data)
      
          print("原始数据:{}".format(serialize_data))
          print("消息类型:{}".format(message_data))
      
      

      得到结果:

      image.png

    3. 构造请求

      根据上面得到的请求参数结构和消息类型,构造请求体,需要注意的是:

      • blackboxprotobuf 生成的数据全部是字符串,需要根据消息类型调整对应字段值的类型
      • 根据上面调试 js 得知,请求参数需要填充 5 位数据,填充算法从 js 中扣取逻辑即可

      image.png

    4. 解析请求

      同样利用 blackboxprotobuf 来解析内容即可,缺点是,解析后的结果字段含义不明,需要对比网页数据进行推测。

      image.png

  • 方案二:创建 .proto 文件

    这个的核心在于根据接口数据结合代码,找到 .proto 文件的原始定义,通过 proto 文件生成请求和响应的序列化方法。对于 proto 文件的创建方法,推荐大佬文章 学会逆向protobuf,写得很详细。

七、IP 封禁

  • 封禁原因

    • 频率限制:短时间内使用一个 ip 高频次访问网站触发封禁
    • 黑名单:使用的代理服务器的 ip 网段已被目标网站标记拉入黑名单,会导致请求失败
    • 地理限制:目标网站对访问者的 ip 有地区限制
    • 动态封禁:网站结合用户行为,制定封控规则
  • 解决方案

    根据以上可能性去推测不同的原因,尝试不同的解决方案

    • 降低访问频次
    • 修改 ip 归属地
    • 切换代理商
    • 购买服务器搭建自己的代理池,相对于各类流行的代理商而言,拉入黑名单的概率低一点

八、其他反爬策略

8.1 HTTP2

碰到无其他加密,请求头、参数均合法,但是 requests 请求失败,可以往协议版本这个方向去排查。通过 Charles 抓包可以查看到请求的 http 版本

image.png

  • 解决方案

    Python 通过 httpx 发起 http2 请求

    import httpx
    client = httpx.Client(http2=True)
    res = client.post(
        url="https://www.python-spider.com/api/challenge24", 
        headers=headers, 
        data=data, 
        cookies=cookies
    )
    

8.2 TLS 指纹(JA3 指纹)

TLS 指纹 也叫 JA3 指纹,是从 TLS 握手中提取特定数据,生成一个哈希值作为唯一指纹。TLS 指纹 反爬不那么容易一眼看出,如果碰到浏览器抓包无明显加密参数,使用抓包工具被检测,curl 请求和 request 请求会报错,排除其他反爬策略的可能,可以用 wireshark 抓包验证是否为 TLS 指纹,

如图,JA3 就是 TLS 指纹加密数据,JA3 Fullstring 则是指纹信息的组成,将 JA3 Fullstring 的值进行 MD5加密后得到JA3

image.png

  • JA3 Fullstring 结构组成

    指纹信息由 TLS Version, Cipher Suites, Extensions, supported_groups, ec_point_formats 这 5 部分组成,由 , 隔开。

    image.png

    • 771:是TLS Version 的版本用十进制表示的值,在抓包信息的表示方式是 Version: TLS 1.2 (0x0303),括号内的 0x0303 就是版本信息的值。

      image.png

    • 4865-4866-4867......-47-53:Cipher Suites 的值,表示可接受的加密算法,在抓包信息中,Cipher Suites 下列出了所有可接受的加密算法,这个值来自于所包含的 TLS 相关算法末尾括号内容的十六进制转为十进制后的值。

      image.png

    • 43-65037-10...-65281-13:支持的扩展列表的值,抓包信息中,以 Extensions 前缀的的这些扩展项下的 type 字段对应的值。

      image.png

    • 4588-29-23-24:代表支持的椭圆曲线组,在抓包信息中,Extension: supported_groups (len=12)Supported Groups (5 groups) 包含的项所对应的值。

      image.png

    • 0:支持的椭圆曲线格式

      image.png

  • 解决方案

    • curl_cffi

      基于 curl 请求,进行二次包装,添加浏览器的指纹信息,python 进行第三次封装成库,可以不用过多考虑当前浏览器的指纹信息,直接用库生成请求即可。

      image.png

    • cycletls

      支持直接复制浏览器的 ja3 指纹信息绕过校验。通过 tls.browserleaks.com/json 查看浏览器指纹信息,复制 ja3_hash 传递给 cycletls 请求的 ja3参数即可。

      const initCycleTLS = require('cycletls');
      function api_test(){
          (async () => {
              // Initiate CycleTLS
              const cycleTLS = await initCycleTLS();
              // Send request
              const response = await cycleTLS(url, {
                  body: 'page=1',
                  // 浏览器指纹信息
                  ja3: "771,4865-4866-4867-49195-49199-49196-49200-52393-52392-49171-49172-156-157-47-53,27-13-10-11-51-35-18-17513-45-65281-0-65037-23-5-43-16,4588-29-23-24,0",
                  userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36',
                  cookies: [
                      {
                          name: 'a',
                          value: 'xxxx',
                          domain: 'xxx',
                          path: '/',
                          expires: 1728826553, // 过期时间戳
                          secure: true,
                          httpOnly: false
                        },
                        {
                          name: '__yr_token__',
                          value: 'xxx',
                          domain: 'xxxxm',
                          path: '/',
                          secure: true,
                          httpOnly: false
                        },
                  ],
              }, 'post');
              console.log("api_test -> ", response);
              // debugger;
              cycleTLS.exit();
          })();
      }
      api_test();
      

8.3 WebSocket

WebSocketHTTP 上完成初始握手后切换到 WebSocket 协议,一次握手完成后,连接会保持打开状态,允许持续通信,直到一方主动关闭连接。

基于其一次握手持续通信的特点,建议在进入网站前就开启抓包,如果错过 WebSocket 连接时数据包,后续抓包会出现抓不到的情况,重新刷新网站即可。

image.png

  • 解决方案

    • 根据请求堆栈定位到 WebSocket 的初始化位置,再逐步调试,找到请求的发起位置 WebSocket.send

      image.png

    • Node 构造请求,获取数据

      image.png

8.4 请求规律

8.4.1 常见的反爬表现
  • 预请求

    在请求真正的数据之前,需要先发起其他请求之后,才能正常访问数获取数据的 api,例如先访问 ip 记录的 api,访问日志记录的 api 等,再发起数据请求。

  • 脏数据/响应异常

    网站设定一些风控规则,当请求的频率、访问次数、ip、请求头等携带的信息命中规则,被服务器判定为是爬虫时,将会做出异常反应,包括:

    • 异常页面/数据
    • 返回重复数据
    • 跳转至首页
    • 响应状态为 404、403、302 或其他
    • 其他待补充
8.4.2 解决方案

这类反爬更多为风控规则设定,需要多抓包进行基本分析,也需要记录和观察程序运行一段时间的情况,来分析出请求规律的,不断调整请求的策略来绕过。

8.5 蜜罐陷阱

蜜罐反爬虫 是一种故意暴露在网络上的诱饵系统或资源,其看起来像是合法的系统或数据,但实际上是用来监控、记录、误导或延缓攻击者的工具。

  • 嵌入检测爬虫程序的链接

    在网页嵌入用于检测爬虫程序的链接,并提供有限的模拟服务和响应,页面中不会显示,正常用户也无法访问,但爬虫程序有可能将该链接放入待爬队列,向其发起请求,这样服务器就会根据请求的信息记录爬虫的特征,进行屏蔽。

    • 解决方案

      批量测试目标 url,深度测试,分析返回结果的差异,筛选出蜜罐链接

  • 利用浏览器特性,检测环境

    有些网站会在混淆的代码中注入一些针对非浏览器环境下的执行命令,导致爬虫程序失败,甚至所在服务器宕机,

    • 案例一

      参考 猿人学第 16 题,魔改后的 btoa 中,发现有 delete window 的操作,在 JavaScript 中,windowdocument 是全局对象,属于 non-configurable 属性,delete 操作符不能删除全局对象或其非配置属性,尝试这样做会失败且没有实际效果,但该操作在 Node 中执行则会删除 window对象,导致后续调用 window 报错从而影响程序无法正确执行。

      image.png

    • 案例二

      在这个案例中,混淆的代码中注入了一段字符串,通过 eval 执行。

      image.png

      注入的字符串中的代码如下图所示,利用了浏览器环境中没有 require 的特性,引入了 execSyncexecSync 是一个同步执行 shell 命令的方法,会直接执行传入的命令字符串,类似于 eval,这段代码的执行在浏览器会失败进入最外层的 catch,执行 z = new Date().getTime(),而在 Node 程序中则会执行命令,导致抓取数据失败,甚至文件受损。

      image.png

    • 解决方案

      这类蜜罐陷阱需要根据具体的代码调试进行分析,下面提供一些小技巧帮助排查和避免中招。

      • 扣取的代码中,先进行预排查,搜索关键字 typeofrequireglobal 等,对明显的环境检测逻辑先进行处理。

      • 程序进入业务代码前,可以先将比较危险的 require 进行删除,这会让后续都无法使用,避免掉入陷阱

        image.png

      • 重点测试可能会有陷阱的位置

        • try-catch
        • if-else 分支
        • switch-case
        • 三目表达式
        • 逻辑表达式 &&||

总结

整篇文章是本人工作中碰到的问题和吸取的经验的一次归纳和总结,文章的内容也吸取了网上众多大佬的技术分享,集百家之长。如有错误和不足之处还请指正,文章会根据情况一直保持更新,尽量整理出更多的有用的知识,帮助到有相关困惑的人。