樱花动漫反爬实战:chameleon.js动态token与环境指纹破解

樱花动漫反爬实战:chameleon.js动态token与环境指纹破解
先交代下背景。做了五年逆向从早期 PC 端 flash 播放器的解密到后来主流视频站点的 m3u8 解析再到各种魔改协议的还原说实话我已经很久没有遇到能让我卡住超过两天的反爬了。直到这次接到樱花动漫的采集需求我才发现现在的反爬手段已经不只是“拦爬虫”而是开始“反人类”了。这个站的反爬并不在于 AES 有多难、RSA 有多复杂而是它把整个验证逻辑藏在了一个名为chameleon的 JS 文件里。它不是简单的生成一个 cookie也不是校验一个 header而是通过动态采集浏览器环境指纹结合时间戳和加密算法生成一套动态 token并且这套 token 的有效期只有几十秒。更关键的是它每次请求都会变更加密参数导致传统的“固定 cookie 固定 token”模拟方案完全失效。本文就基于这次真实的逆向过程完整拆解樱花动漫反爬体系的分析思路、JS 扣代码逻辑、环境补全方案以及最终代码落地。文中所有内容仅供学习交流请勿用于商业采集或非法用途。1. 樱花动漫反爬体系初探它到底在拦截什么在动手扣代码之前我先把樱花动漫的请求流程过了一遍。从打开首页到获取真实播放地址大致分为三个步骤访问首页或详情页服务器返回 HTML。页面加载过程中执行chameleon.js生成动态校验参数。携带校验参数请求视频接口接口返回真实的 m3u8 地址。从表面上看它只是加了一个 JS 文件但实际上这个 JS 文件承担了“环境检测 动态加密 数据上报”三个职责。1.1 第一层拦截Cookie 动态指纹当我们用 requests 直接请求首页时响应内容里会出现一段set-cookie逻辑但这段逻辑并不是服务端直接下发的静态 cookie而是由chameleon.js在本地计算后写入浏览器的。也就是说同一个 IP、同一个 UA在不同浏览器环境下拿到的 cookie 值完全不同。这个 cookie 名为fp_id值看起来像一段乱码实际上它的生成依赖以下几种环境数据navigator.userAgentnavigator.languagescreen.width与screen.heightcanvas.toDataURL()生成的图片数据哈希WebGL渲染器信息时间戳与随机盐Cookie 过期时间非常短通常在 30 到 60 秒之间。如果我们直接用普通 requests 去请求服务端返回的响应里会包含一段明文提示“当前环境异常请更换浏览器访问”。1.2 第二层拦截动态请求头 token除了 cookie樱花动漫还在请求头里加了一个自定义字段比如x-ca-token。这个字段的值并不会一直不变而是每次请求都会更新更新逻辑同样由chameleon.js完成。这就意味着即使你手动从浏览器里复制一个可用的 cookie也无法长期使用。因为当 token 过期后服务端会返回 403 状态码并且页面会重新加载chameleon.js来生成新的 token。1.3 第三层拦截JS 混淆与动态变量名打开chameleon.js第一眼感觉还好但往下看就会发现不对劲。它使用了类似obfuscator.io的混淆方式把所有变量名变成了_0xabc这类十六进制名称还把关键字符串做了编码。真正的加密逻辑被拆成多个函数并且互相调用如果你只是复制整个 JS 用execjs去执行大概率会因为缺少浏览器环境而报错。综合来看樱花动漫的反爬设计并不算复杂但“防人”的效果非常好。因为大多数开发者习惯了找一段 JS 去生成 cookie而忽略了完整的环境模拟。2. 环境准备逆向分析需要哪些工具在正式开始扣 JS 之前先把工具链补齐。这套流程不仅适用于樱花动漫也适用于其他类型的 JS 逆向。本次分析使用的环境如下操作系统Windows 11 / macOS 均可浏览器Chrome 122重点使用开发者工具抓包工具Charles 或 Fiddler用于查看请求头与 cookie脚本运行环境Node.js 16用于调试 JS 文件Python 3.9用于最终采集逻辑辅助插件Chrome 扩展Cookie-Editor、SwitchyOmega代码编辑器VS Code 或 WebStorm2.1 抓包工具配置对于 JS 逆向来说抓包工具的作用是查看浏览器实际发出的请求头、cookie 以及 JS 文件的加载过程。以 Charles 为例需要开启 SSL Proxying并安装证书到系统信任列表。打开樱花动漫首页我们可以观察到三个关键请求GET / GET /static/js/chameleon.js GET /api/video/play?vidxxxx其中第二个请求就是反爬核心 JS第三个请求是视频播放接口也是我们需要最终模拟的接口。2.2 Node.js 环境准备扣下来的 JS 文件里可能使用了 ES6 语法直接用 Python 的execjs执行可能会因为缺少 DOM 环境而报错。因此建议先用 Node.js 补环境再把补环境的思路迁移到 Python 中。建议在本地建一个工作目录结构如下chameleon-demo/ ├── js/ │ └── chameleon.js ├── node/ │ └── test.js ├── python/ │ └── spider.py └── output/ └── result.txt3. 抓包分析定位 chameleon 生成的关键参数本节是整个逆向的核心我们需要从浏览器开发者工具中找出最终请求中包含了哪些动态参数再反向定位这些参数是在哪里生成的。3.1 第一次请求与第二次请求对比在浏览器中访问樱花动漫首页打开开发者工具中的 Network 面板勾选 Preserve log然后刷新页面。观察两次请求的 Header重点注意以下内容第一次请求访问首页Cookie: fp_idxxxxx第二次请求访问播放接口Cookie: fp_idxxxxx x-ca-token: yyyyy可以看到第一次请求只有 cookie但第二次请求多了一个x-ca-token。这说明 token 并不是一开始就有的而是页面加载过程中执行 JS 后生成的。3.2 定位 token 由哪段 JS 生成在 Network 面板中点击chameleon.js通过搜索功能查找x-ca-token或者ca-token字符串。一般来说混淆后的代码会把字符串拆开比如var _0xabc [x-ca, -token]; var _0xdef _0xabc[0] _0xabc[1];如果直接搜索x-ca-token搜不到可以尝试搜索ca、token、headers等关键词。找到后使用 Chrome 的Pretty print格式化 JS 代码然后逐步分析。通过分析发现x-ca-token的生成逻辑在chameleon.js的一个自执行函数中它读取了以下几个值window.localStorage.getItem(fp)Date.now()一个随机数Math.random()一组固定的加密密钥然后通过某种加密算法组合成最终的 token。3.3 提取加密逻辑核心算法初判断将格式化后的代码中与 token 生成相关的部分提取出来梳理出如下流程1. 读取本地存储中的 fp 值如果不存在则生成新的 fp 2. 生成随机字符串 rand 3. 计算时间戳 timestamp Date.now() 4. 组合待加密字符串 source fingerprint timestamp rand 5. 使用 AES 或类 RC4 算法加密得到 token 6. 将 token 写入请求头 x-ca-token但这里有一个坑它使用的加密算法并不是标准 AES而是经过魔改的加密方式。标准 AES 库解出来是错的必须使用它自带的加密函数。4. 破解 chameleon.js从混淆代码中还原算法这一步是整个流程里最耗时、也最容易劝退新手的部分。难点不在于代码有多长而在于混淆后的代码可读性太差而且很多函数是“洋葱式”嵌套。4.1 代码混淆特征打开chameleon.js可以看到下面这种风格的代码var _0x42d8 [_0x4b82, _0x31a6, _0x7f2c]; function _0x4b82(_0x1a2b, _0x9c3d) { return _0x1a2b _0x9c3d; } function _0x31a6(_0x5e4f) { return _0x5e4f.toString(16); }这种代码的特征是大量十六进制字符串作为数组下标。函数名无意义无法从名字推测功能。关键字符串被拆分拼接。控制流扁平化把简单逻辑拆成多个小函数。面对这种混淆我的处理步骤是先全局搜索 headers 设置语句找到 token 赋值的位置。从赋值位置向上追溯找到 token 的生成函数。将生成函数复制到一个独立的 JS 文件中。通过 Node.js 逐步 console.log 中间结果观察每一步的输出。根据输出推断算法结构。4.2 核心加密函数解析经过还原樱花动漫的核心加密逻辑大致如下已脱敏处理仅展示结构思路function generateToken(fingerprint, timestamp, randomStr) { var source fingerprint | timestamp | randomStr; var key your-secret-key; var encrypted rc4Encrypt(source, key); var token base64Encode(encrypted); return token; }看起来很简单对吧但实际上它并不是纯 RC4而是对 RC4 的输出做了一次字节替换替换表藏在另一个函数里。如果不做字节替换解密结果会出现乱码。所以完整还原后真正的加密流程是RC4 加密 - 字节替换 - Base64 编码4.3 从 JS 到 Python 的代码移植当逻辑完全还原后可以将加密算法用 Python 重写。这里我选择直接用 Python 实现 RC4 和字节替换而不是通过execjs执行 JS原因是Python 直接实现速度更快。不依赖 Node.js 环境便于部署到服务器。可以更精细地控制中间结果。下面是一段核心移植代码# -*- coding: utf-8 -*- import base64 import time import random import string # 长度 256 的替换表实际值来自 chameleon.js 中的数组 S_BOX list(range(256)) REPLACE_TABLE [0] * 256 def rc4_encrypt(data: bytes, key: bytes) - bytes: RC4 加密逻辑 S list(range(256)) j 0 for i in range(256): j (j S[i] key[i % len(key)]) 0xFF S[i], S[j] S[j], S[i] i 0 j 0 result bytearray() for byte in data: i (i 1) 0xFF j (j S[i]) 0xFF S[i], S[j] S[j], S[i] result.append(byte ^ S[(S[i] S[j]) 0xFF]) return bytes(result) def byte_replace(data: bytes) - bytes: 字节替换逻辑 return bytes(REPLACE_TABLE[b] for b in data) def generate_fp_id() - str: 生成 fingerprint timestamp str(int(time.time() * 1000)) random_part .join(random.choices(string.ascii_lowercase string.digits, k16)) return timestamp random_part def generate_token(fingerprint: str) - str: 生成完整 token timestamp str(int(time.time() * 1000)) random_str .join(random.choices(string.ascii_lowercase string.digits, k8)) source f{fingerprint}|{timestamp}|{random_str} key bcustom-key encrypted rc4_encrypt(source.encode(), key) replaced byte_replace(encrypted) return base64.b64encode(replaced).decode()这段代码虽然可以运行但要成功生成服务端认可的 token还需要完成替换表的真实填充。5. 浏览器环境模拟为什么 Node.js 跑不通很多新手在扣下 JS 后第一步就是直接用 Python 的execjs执行结果会报各种找不到window、document、navigator的错误。这些错误本质上是因为浏览器环境缺失。chameleon.js在执行过程中会调用以下浏览器 APIwindow.localStoragedocument.cookienavigator.userAgentcanvas.toDataURL()WebGLRenderingContext因此为了让 JS 在 Node.js 中运行需要手动构造这些环境对象。5.1 构造最小浏览器环境以canvas.toDataURL()为例在 Node.js 中没有 canvas 对象但我们可以返回一个固定的字符串来模拟前提是我们已经知道了目标浏览器环境的 canvas 指纹。这里的关键思路是先把浏览器环境下生成的 fingerprint 固定下来然后在 Node.js 中模拟这个 fingerprint。// node/test.js const fs require(fs); const vm require(vm); const code fs.readFileSync(./js/chameleon.js, utf-8); const sandbox { window: { localStorage: { getItem: () null, setItem: () {} }, addEventListener: () {} }, document: { cookie: , createElement: () ({ toDataURL: () data:image/png;base64,fixed-canvas-data, getContext: () ({ getParameter: () WebGLRenderer, getExtension: () null }) }) }, navigator: { userAgent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, language: zh-CN, platform: Win32 }, Date, Math, console }; sandbox.window.window sandbox.window; vm.createContext(sandbox); vm.runInContext(code, sandbox);但这种方式有一个致命问题如果服务器端校验了指纹而我们提供的指纹与真实浏览器不一致生成的 token 仍然无效。所以更稳妥的做法是提前从浏览器环境采集一次真实指纹然后把指纹作为参数传入。5.2 从浏览器中采集真实指纹为了采集真实指纹可以在浏览器控制台执行下面这段代码(() { const canvas document.createElement(canvas); const ctx canvas.getContext(2d); ctx.textBaseline top; ctx.font 14px Arial; ctx.fillText(hello, 2, 2); const dataUrl canvas.toDataURL(); const gl document.createElement(canvas).getContext(webgl); const debugInfo gl.getExtension(WEBGL_debug_renderer_info); const renderer debugInfo ? gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) : unknown; return { userAgent: navigator.userAgent, language: navigator.language, platform: navigator.platform, canvasData: dataUrl, webglRenderer: renderer }; })();将输出结果保存下来在写采集脚本时直接作为环境参数使用。6. 完整爬虫代码从指纹生成到获取 m3u8在完成了算法还原和环境指纹采集后我们就可以写一个完整的 Python 爬虫脚本模拟浏览器请求并获取 m3u8 地址。6.1 完整 Python 脚本# -*- coding: utf-8 -*- import time import random import string import base64 import hashlib import requests # 替换表需要从 chameleon.js 中提取完整数据 REPLACE_TABLE list(range(256)) # 从浏览器端采集的固定指纹数据 FINGERPRINT your-fingerprint-string def rc4_encrypt(data, key): S list(range(256)) j 0 for i in range(256): j (j S[i] key[i % len(key)]) 0xFF S[i], S[j] S[j], S[i] i 0 j 0 result bytearray() for byte in data: i (i 1) 0xFF j (j S[i]) 0xFF S[i], S[j] S[j], S[i] result.append(byte ^ S[(S[i] S[j]) 0xFF]) return bytes(result) def byte_replace(data): return bytes(REPLACE_TABLE[b] for b in data) def generate_fp_id(): timestamp str(int(time.time() * 1000)) random_part .join(random.choices(string.ascii_lowercase string.digits, k16)) return timestamp random_part def generate_token(): timestamp str(int(time.time() * 1000)) random_str .join(random.choices(string.ascii_lowercase string.digits, k8)) source f{FINGERPRINT}|{timestamp}|{random_str} key bcustom-key encrypted rc4_encrypt(source.encode(), key) replaced byte_replace(encrypted) return base64.b64encode(replaced).decode() def fetch_video_url(vid): fp_id generate_fp_id() token generate_token() headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Referer: https://www.example.com/, x-ca-token: token, Cookie: ffp_id{fp_id} } url fhttps://api.example.com/api/video/play?vid{vid} resp requests.get(url, headersheaders, timeout10) if resp.status_code 200: data resp.json() return data.get(m3u8_url) else: print(请求失败状态码, resp.status_code) print(响应内容, resp.text) return None if __name__ __main__: url fetch_video_url(12345) print(m3u8 地址, url)6.2 运行流程说明运行上面的脚本预期流程如下生成一个指纹字符串fp_id。使用指纹、时间戳、随机数生成 token。携带 token 和 cookie 请求视频接口。如果一切正常接口返回 m3u8 地址。如果服务端校验通过返回的 JSON 类似{ code: 0, data: { m3u8_url: https://cdn.example.com/20240312/xxxx/index.m3u8 } }如果服务端校验失败返回 403 或 JSON 中 code 非 0。7. 常见问题与排查思路本节的坑全部是真实项目中容易踩到的按照从高到低的出现频率整理如下。问题现象常见原因解决思路执行 JS 报window is not defined缺少浏览器环境手动构造 sandbox模拟 window/document/navigatortoken 生成了但请求返回 403指纹不匹配从浏览器采集真实指纹替换脚本中的 FINGERPRINTtoken 第一次有效第二次失效时间戳过期每次请求前重新生成 token不要复用m3u8 地址能拿到但播放 403缺少 Referer 或 UA 校验在 M3U8 下载请求中携带与浏览器相同的 Referer接口返回滑块验证请求频率过高降低抓取频率或使用代理 IP 轮换base64.b64decode报错token 中包含非法字符检查 Base64 编码是否完整7.1 403 状态码排查步骤遇到 403不要盲目调代码按下面顺序排查确认 token 是否在有效期内。确认 cookie 是否与当前 token 配套。确认指纹是否与浏览器一致。确认请求头是否缺少Referer。确认请求头是否缺少Origin。确认是否触发了频率限制。7.2 为什么指纹匹配这么重要指纹匹配是樱花动漫反爬的核心。服务端收到 token 后会先解密 token取出其中的指纹值然后与服务端保存的指纹库对比。如果指纹库中不存在直接拒绝。这里最麻烦的是指纹并不是浏览器裸奔时生成的默认值而是每个用户访问时由chameleon.js动态生成并上报的。所以最稳定的方案是先用真实浏览器访问一次站点拿到浏览器生成的fp_id和指纹信息采集下来后用于代码。8. 工程化落地从单个脚本到批量采集如果你只是写一个脚本自己用那前面的内容已经足够了。但如果要接入线上业务还需要进一步优化。8.1 提高并发吞吐量单线程生产 token 加请求效率会比较低。推荐使用线程池或异步方式。from concurrent.futures import ThreadPoolExecutor def fetch_one(vid): return fetch_video_url(vid) vids [10001, 10002, 10003] with ThreadPoolExecutor(max_workers5) as pool: results list(pool.map(fetch_one, vids)) for vid, url in zip(vids, results): print(vid, url)注意并发太高会导致 IP 被限制。建议每个 IP 每秒不超过 2 个请求或者通过代理池分散请求。8.2 代理池与重试机制在采集场景中代理是标配。简单实现如下proxies { http: http://user:passproxy-host:port, https: http://user:passproxy-host:port } def fetch_with_retry(vid, retry3): for i in range(retry): try: return fetch_video_url(vid) except Exception as e: print(f第 {i1} 次请求失败{e}) time.sleep(2) return None8.3 日志与监控每天采集量上来之后日志非常重要。建议记录以下内容请求 URL状态码token 生成耗时请求耗时是否触发反爬推荐使用 Pythonlogging模块输出到文件并保留最近 7 天的日志。import logging logging.basicConfig( filenamespider.log, levellogging.INFO, format%(asctime)s - %(levelname)s - %(message)s ) logging.info(开始请求 vid%s, vid) logging.warning(请求失败状态码%s, status_code)9. 合规与最佳实践逆向的边界在哪里最后说点实际的。本文的逆向过程侧重于“分析思路 加密还原 环境模拟”这是 JS 逆向领域的通用技术。它的价值在于让你理解现代网站的反爬设计提升自己的技术水平。但技术本身是一把双刃剑使用前需要明确边界。以下几条红线建议每条都记牢不要将爬虫用于商业用途除非获得网站授权。不要对目标站点发起高频请求避免影响正常用户访问。不要绕过登录、付费墙等访问控制机制。不要爬取用户隐私数据。关注 robots.txt 和网站服务条款。采集到的数据仅用于个人学习及时删除。在真正的工程实践中合法的做法是优先寻找官方 API或者与网站方沟通合作。逆向只是在没有其他方案时的备选路径。10. 总结与进一步学习方向樱花动漫这个案例的价值在于它把多种反爬技术融合在一个站点中完整走一遍它的逆向流程相当于把 JS 混淆、环境模拟、加密算法还原、请求对抗都练了一遍。通过本文你掌握了以下核心能力使用开发者工具分析动态请求头来源。从混淆 JS 中定位 token 生成逻辑。构造 Node.js 沙箱模拟浏览器环境。使用 Python 重写加密算法。处理 403、token 过期、指纹不匹配等常见问题。将单脚本扩展为可用的采集服务。下一步可以继续深入的方向学习更复杂的混淆还原比如控制流平坦化。研究 AST 语法树未来可以使用 AST 自动脱混淆。学习浏览器指纹的原理包括 Canvas 指纹和 WebGL 指纹。研究 JS 虚拟机与 V8 引擎执行机制理解 JS 在浏览器外的运行原理。如果这篇文章对你有帮助建议收藏备用。你也可以把文章分享给身边正在学习 JS 逆向的朋友大家一起交流进步。下一期可以考虑写一下 AST 脱混淆实战把复杂的混淆代码自动还原成可读代码感兴趣的话可以持续关注。

最新新闻

日新闻

周新闻

月新闻