0x00 背景
目标很明确:分析 cloudflare.js 在 window.onload 时发出的请求,确认以下几点。
- 请求是从哪里发起的。
key和time是如何构造出来的。- 返回值是否存在二次解密。
- 如何在本地用 Python 完整复现并请求到最终结果。
用户给了两条真实请求样例:
1 | https://api.uouin.com/index.php/index/Cloudflare?key=87bd023769e83ab9fa6b1b8fb6b1c497&time=1774183617366 |
这两条样例很关键,因为它们可以用来验证我们最后反推出的公式是否正确。
0x01 入口定位
最先做的不是硬啃整份混淆代码,而是定位触发点。
在 cloudflare.js 中检索:
onloadCloudflare?key=ajax
很快可以定位到两处关键位置:
window['onload'] = function() { ... }在cloudflare.js:654'/index.php/index/Cloudflare?key='在cloudflare.js:751
这说明页面初始化后,请求并不是在复杂事件链里触发的,而是页面加载完成即执行。
核心请求片段如下:
1 | window['onload'] = function() { |
虽然变量名全被混淆了,但结构很直观:
_0x1e39be是时间戳_0x44b4ab是hash的中间输入hash = hex_md5(_0x44b4ab)url = '/index.php/index/Cloudflare?key=' + hash + '&time=' + time
到这里,实际上只差把几个被 _0x1924(...) 包起来的字符串还原出来。
0x02 混淆特征识别
这个脚本不是普通压缩,而是典型的 jsjiami.com.v6 风格混淆。顶部能看到几个非常明显的特征:
- 头部字符串
jsjiami.com.v6 - 巨大的字符串数组
_0x17c3 - 统一字符串访问函数
_0x1924 - 顶部自执行函数对
_0x17c3做重排 - 一堆反调试、死循环、
Function(...)构造器干扰逻辑
这里最容易踩坑的点有两个。
第一,不能直接把 _0x17c3[index] 拿出来按 base64 解码,因为它不是单纯 base64,后面还套了 RC4。
第二,不能忽略顶部数组洗牌逻辑。
如果不先执行那段 shuffle,再去解 _0x1924('b6', ...) 之类的索引,得到的会是乱码,进而把算法误判成错误的 md5 公式。
这也是一开始最容易走偏的地方。
0x02A jsjiami.com.v6 风格混淆拆解
这一类混淆有很强的“模板感”,不是每次都完全一样,但核心套路通常比较固定。
如果之前见过几次,再看到这个文件,基本能在几十秒内判断出它属于哪一类。
1. 最醒目的识别特征
本样本顶部最典型的标记就是:
1 | var _0xodY = 'jsjiami.com.v6' |
这不是业务字符串,而是混淆器留下的特征标识。
看到这个标记,基本可以先假设后面会出现以下几层结构:
- 大型字符串数组
- 数组重排或索引扰动
- 字符串解码函数
- 反调试或环境探测
- 垃圾控制流
也就是说,拿到这种脚本后,不应该从头按业务逻辑顺序阅读,而应该先把它拆成“壳层”和“业务层”。
2. 这种混淆通常想解决什么问题
jsjiami 这种壳的目标一般不是提供真正意义上的密码学安全,而是提高静态阅读成本。
它主要防的不是“执行”,而是“看懂”。
常见目的包括:
- 隐藏关键字符串,比如接口路径、参数名、请求方法
- 破坏源码可读性,让字符串索引失去直觉含义
- 插入反调试逻辑,拖慢直接在浏览器里下断点分析的速度
- 制造大量无关代码,增加阅读噪音
换句话说,它更像“逆向阻力层”,而不是“业务加密层”。
3. 典型分层结构
这类脚本通常可以分为五层。
第一层:标识与数组定义
先声明一个类似 _0x17c3 = [...] 的大数组,数组里装的是编码后的字符串。
这些字符串一般不会直接明文出现,目的是避免你一眼搜索到:
getjson/index.php/index/Cloudflare?key=getTime
第二层:数组重排
定义完数组后,不会马上拿来用,而是先做一次 shift/push/pop 操作,把数组顺序打乱。
这一层非常关键,因为它直接破坏了“数组定义顺序 = 最终索引顺序”这个直觉。
很多人写个脚本直接:
1 | console.log(_0x17c3[0xb6]) |
然后发现结果完全不对,就会误以为后面的 RC4 或 base64 解码写错了。
实际上问题往往不在解码,而在索引已经被改写。
第三层:字符串解码器
也就是本样本里的 _0x1924(idx, key)。
它通常会把多个步骤包在一起:
- 根据索引取数组元素
- 做 base64 解码
- 做 URI 还原
- 做 RC4 解密
- 做缓存,避免同一字符串重复解密
所以 _0x1924 本质上不是业务函数,而是“字符串仓库的取值器”。
第四层:反调试和环境探测
本样本里可以看到大量类似下面的结构:
while (!![])Function(...)debuggerProtection- 正则检测函数源码
typeof window / process / require / global检查
这些逻辑的共同点是:
- 代码量大
- 可读性差
- 和业务请求关系很弱
它们的存在主要是为了干扰分析节奏。
第五层:真实业务逻辑
真正和业务有关的,往往只占很小一段。
本样本真正重要的其实只有:
window.onloadnew Date().getTime()- 两个字符串常量
- 两次
md5 - 一次
$.ajax
这也是处理这类混淆的一个基本原则:
1 | 不要试图一次性“看懂整份脚本”,而要先把真正发请求的那几行剥出来。 |
4. 为什么它喜欢用字符串数组 + RC4
这类混淆里,“字符串数组 + RC4 + 索引访问”几乎是标准配置,原因很实际。
原因一:阻止全文搜索
如果接口路径直接明文出现,搜索 /Cloudflare?key= 一秒钟就能找到。
但字符串被拆到数组里再经过解码,就没法直接全文检索关键字。
原因二:阻止简单替换
如果只是把变量名改掉,业务字符串还在,逆向成本并不高。
把字符串集中放到数组中,再通过 _0x1924(索引, key) 访问,会让代码阅读变成:
1 | _0x4a2333[_0x1924('bc', 'muGa')] |
而不是:
1 | config.apiSalt |
对阅读体验的打击非常明显。
原因三:便于模板化生成
混淆器最喜欢能批量生成、自动套壳的模式。
字符串数组、RC4、缓存、反调试这些都非常适合模板化拼装,所以会在不同站点里反复出现。
5. 本样本里各层的对应关系
如果把这份 cloudflare.js 映射到上面的五层结构,可以这样理解:
壳层 1:标识
1 | var _0xodY = 'jsjiami.com.v6' |
壳层 2:字符串池
1 | _0x17c3 = [...] |
壳层 3:字符串重排
顶部自执行函数里对 _0x17c3 做 shift/push/pop 处理。
壳层 4:解码器
1 | function _0x1924(...) { ... } |
内部完成:
- base64
decodeURIComponent- RC4
- 缓存
壳层 5:干扰逻辑
例如:
- 正则检测函数源码
Function(arguments[0] + "...")while (!![])- 类似
debuggerProtection的递归或自调用保护
业务层
真正业务逻辑集中在:
window.onload- 构造
hash - 调用
$.ajax - 遍历
res.data
6. 处理这种混淆时推荐的顺序
遇到 jsjiami.com.v6 样式脚本时,建议按下面顺序处理。
第一步:先定位请求入口
不要先解所有字符串。
先找:
ajaxfetchXMLHttpRequestonload- 接口路径特征
只要先把请求入口抓出来,后面只需要解少数几个字符串即可。
第二步:确认数组是否被重排
如果顶部有自执行函数在改数组顺序,这一步必须先模拟。
否则后面所有索引都可能错位。
第三步:复刻 _0x1924
只要把它拆成下面几步就行:
- 取索引
- base64 解码
- URI 还原
- RC4 解密
一般不需要关心它的缓存逻辑,缓存只是性能优化。
第四步:只解关键索引
优先解这些:
- 请求方法
dataType- URL 路径
- 时间戳方法名
- hash 输入常量
没必要从 0x00 一路把所有数组元素都解出来,那样成本高、收益低。
第五步:样例回代
只要用户给了真实参数,就必须拿真实样例做回代验证。
这是确认“已经逆到业务层”的最直接方式。
7. 如何区分“壳逻辑”和“业务逻辑”
这是逆这类脚本时很实用的一条经验。
通常属于壳逻辑的代码
- 名称毫无语义的大量小函数
- 大量
while (!![]) Function(...)- 检查
toString()内容的正则 - 明显不产出业务数据的递归
- 到处判断
typeof window/process/global
通常属于业务逻辑的代码
new Date().getTime()hex_md5(...)$.ajax({...})success(res) { ... }- 明确访问
res.code、res.msg、res.data
一个简单判断标准是:
1 | 如果删掉这段代码,请求还能不能发出去、结果还能不能渲染出来? |
如果答案是“能”,那大概率就是壳逻辑。
如果答案是“不能”,那才值得重点跟。
8. 为什么说这类混淆“烦”但不一定“深”
jsjiami.com.v6 经常给人的第一印象是代码很厚、很乱、断点难下。
但从逆向角度看,它的难点往往是“噪音太大”,不是业务太深。
像本样本,最终业务算法其实非常短:
1 | time = new Date().getTime() |
真正费时间的是:
- 识别壳
- 处理数组重排
- 正确解出关键字符串
一旦这几步过了,后面的业务逻辑反而非常普通。
0x03 _0x1924 的真实作用
_0x1924(idx, key) 的逻辑本质上是:
- 把
idx从形如'\u202bb9'这样的字符串转成十六进制索引。 - 从
_0x17c3中取出对应项。 - 先
base64解码。 - 再
decodeURIComponent还原字符。 - 最后走一遍
RC4(key)解密。
也就是:
1 | _0x1924(idx, key) = RC4(decodeURIComponent(base64_decode(_0x17c3[idx])), key) |
脚本中 RC4 的实现也很标准:
- 先初始化
s[0..255] - 再按 key 执行 KSA
- 最后按 PRGA 对数据逐字节异或
这一层没有业务逻辑,只是混淆壳负责还原字符串。
0x04 顶部数组重排为什么必须处理
脚本顶部不是简单定义 _0x17c3 = [...] 就结束了,而是马上进入一段自执行逻辑,对数组执行 shift/push/pop 组合操作。
它的目的不是“加密数据”,而是打乱下标,让静态分析时无法直接按数组原始顺序解出字符串。
如果忽略这一步,会出现这种情况:
1 | b8 -> 乱码 |
看起来像 RC4 key 错了,实际上是数组索引错位了。
我在分析时先完整模拟了这一段重排逻辑,再去解关键索引,结果立刻变得稳定,能还原出可读字符串。
这一点可以视为整个脚本逆向的关键分界线:
- 不处理 shuffle:样本全错
- 处理 shuffle 后:请求常量全部可读,样例 key 完全对上
0x05 关键字符串还原结果
在正确执行数组重排后,和请求相关的关键字符串可以还原为:
| 索引 | 还原结果 | 作用 |
|---|---|---|
b6 |
70cloudflareapikey |
第二段拼接常量 |
b7 |
get |
请求方法 |
b8 |
&time= |
URL 参数拼接 |
b9 |
getTime |
取毫秒时间戳 |
c1 |
oKjFr |
指向对象属性名 |
而对象本身中还有直接明文值:
1 | 'oKjFr': '/index.php/index/Cloudflare?key=' |
因此 URL 模板已经明确:
1 | /index.php/index/Cloudflare?key=<hash>&time=<time_ms> |
继续还原 756 行参与 hash 计算的对象属性值,可以得到真正参与计算的两个常量:
YNqYb -> "DdlTxtN0sUOu"shXyL -> "70cloudflareapikey"
所以这句:
1 | var _0x44b4ab = hex_md5(常量1) + 常量2 + time_ms; |
最终变成:
1 | tmp = md5("DdlTxtN0sUOu") |
0x06 为什么之前容易得出错误公式
如果只做关键词搜索,很容易在混淆脚本里搜到一些误导性内容,或者根据域名、页面名字脑补出像下面这样的假公式:
1 | md5(md5("uouin.com") + "uouin" + time) |
这个公式看起来“很合理”,但它不是脚本真实执行路径中的结果。
原因是:
- 混淆代码里有大量无关字符串。
- 反调试壳会注入许多无业务意义的干扰逻辑。
- 如果没有处理数组重排,解出来的索引对应关系就是错的。
真正可靠的办法不是“看起来像”,而是必须满足两个条件:
- 能和
window.onload代码路径严格对应。 - 能拿用户给的真实样例完全回代成功。
本次最终公式之所以可信,就是因为它同时满足这两个条件。
0x07 用样例验证最终公式
用户提供的两条样例请求如下:
1 | https://api.uouin.com/index.php/index/Cloudflare?key=87bd023769e83ab9fa6b1b8fb6b1c497&time=1774183617366 |
将公式写成:
1 | key = md5(md5("DdlTxtN0sUOu") + "70cloudflareapikey" + str(time_ms)) |
代入校验:
1 | time=1774183617366 -> 87bd023769e83ab9fa6b1b8fb6b1c497 |
与样例完全一致。
这一步非常重要。
到这里其实已经不是“猜测算法”,而是“算法已被样例证明”。
0x08 返回值是否存在二次加密
继续看 success 回调,从 cloudflare.js:762 往后可以看到逻辑非常直接:
- 读取
res.code - 读取
res.msg - 判断
res.data.ctcc.code == 200 - 遍历
res.data.ctcc.info - 判断
res.data.cucc.code == 200 - 遍历
res.data.cucc.info - 判断
res.data.cmcc.code == 200 - 遍历
res.data.cmcc.info - 把
ip/loss/ping/speed/bandwidth拼进表格 HTML
也就是说,返回值并没有额外加密层。
页面做的事情只是:
- 请求接口
- 拿到 JSON
- 按运营商分类渲染表格
因此如果只关心最终数据,完全不需要模拟 DOM,也不需要补任何浏览器解密流程,直接 Python requests.get() 即可。
0x09 最终算法结论
页面加载时发出的请求为:
1 | GET /index.php/index/Cloudflare?key=<key>&time=<time_ms> |
其中:
1 | time_ms = 当前毫秒时间戳 |
完整 URL:
1 | https://api.uouin.com/index.php/index/Cloudflare?key=<key>&time=<time_ms> |
可以进一步写成一行:
1 | key = md5(md5("DdlTxtN0sUOu") + "70cloudflareapikey" + str(int(time.time() * 1000))) |
0x0A Python 复现
当前目录已经实现了复现脚本:
cloudflare_api.py
核心实现如下:
1 | def generate_key(ts_ms: int) -> str: |
URL 构造:
1 | def build_url(ts_ms: int) -> str: |
请求部分:
1 | response = requests.get(url, headers=headers, timeout=30) |
这已经足够覆盖页面的真实行为。
0x0B 样例校验结果
脚本里已经固化了两组校验样例:
1 | 1774183617366 -> 87bd023769e83ab9fa6b1b8fb6b1c497 |
运行 python cloudflare_api.py --no-request 时会先打印校验结果,确认公式没有偏移。
这一步的意义是:
- 防止后续重构脚本时把算法改坏
- 防止把错误的“近似公式”当成真实逻辑
0x0C 实际请求结果
使用 Python 实际请求接口后,返回成功:
1 | code: 200 |
当时获取到的部分结果摘要如下。
ctcc
108.162.198.69198.41.222.149162.159.48.55
cucc
172.67.72.149104.26.6.211104.20.16.5
cmcc
104.19.44.52104.19.54.231104.19.37.244
这进一步说明:
- 参数算法正确。
- 直接请求接口就能拿到最终 JSON。
- 页面端没有隐藏的二次解密步骤。
0x0D 附录:关键索引与明文映射
这一节把本样本里和请求最相关的索引、属性名、实际明文统一列一下。
注意,下面这些结果都是在“先完成 _0x17c3 数组重排”之后得到的。
1. 直接解出的关键索引
| 索引 | 解出结果 | 含义 |
|---|---|---|
b6 |
70cloudflareapikey |
哈希拼接盐值 |
b7 |
get |
$.ajax 请求方法 |
b8 |
&time= |
URL 参数拼接片段 |
b9 |
getTime |
Date 取毫秒时间戳的方法 |
ba |
blbkw |
对象中的“字符串拼接函数”属性名 |
bb |
MxghC |
对象中的“函数调用包装器”属性名 |
bc |
YNqYb |
对象中的第一段哈希常量属性名 |
bd |
shXyL |
对象中的第二段哈希常量属性名 |
be |
OqcMs |
对象中的请求方法属性名 |
bf |
sJeKy |
对象中的 dataType 属性名 |
c0 |
blbkw |
仍然是字符串拼接函数属性名 |
c1 |
oKjFr |
对象中的请求路径属性名 |
2. 属性名继续映射到对象真实值
仅仅把索引解成属性名还不够,还要再看对象字面量里的实际值。
| 属性名 | 实际值 | 用途 |
|---|---|---|
YNqYb |
DdlTxtN0sUOu |
第一段哈希输入常量 |
shXyL |
70cloudflareapikey |
第二段哈希输入常量 |
OqcMs |
get |
请求方法 |
sJeKy |
json |
$.ajax 的 dataType |
oKjFr |
/index.php/index/Cloudflare?key= |
请求路径 |
pNETf |
&time= |
URL 参数后半段 |
3. 函数属性的语义
有几项虽然不是字符串常量,但对还原表达式很重要。
| 属性名 | 实际语义 | 可直接理解为 |
|---|---|---|
blbkw(a, b) |
返回 a + b |
字符串拼接 |
MxghC(fn, arg) |
返回 fn(arg) |
单参数函数调用包装 |
所以这段:
1 | var _0x44b4ab = _0x4a2333[_0x1924('ba', 'MMd$')]( |
翻译后就是:
1 | var _0x44b4ab = md5("DdlTxtN0sUOu") + "70cloudflareapikey" + time_ms; |
再结合下一句:
1 | hash = _0x4a2333['MxghC'](hex_md5, _0x44b4ab); |
即可得到:
1 | hash = md5(md5("DdlTxtN0sUOu") + "70cloudflareapikey" + time_ms); |
4. 最小可读版伪代码
把 window.onload 中真正有用的部分重写成最小伪代码,大致就是:
1 | window.onload = function () { |
5. 为什么这个附录有用
很多时候真正有价值的不是“把整份混淆都解完”,而是把最关键的映射沉淀下来。
以后再碰到类似站点,或者需要复核这份分析时,直接看这个表就够了,不需要再重新跑一遍整个字符串还原流程。
0x0E 推荐复现步骤
如果以后再次碰到类似脚本,建议按下面顺序做,而不是上来就盲猜 hash 公式。
第一步:先找真正入口
优先搜:
onloadajaxfetchXMLHttpRequest- 目标接口路径关键词
先缩小到真实执行链路。
第二步:不要急着解全文件
优先只解和请求相关的几个索引位:
- 请求方法
- URL 基础路径
- 时间戳方法名
- hash 输入常量
这样成本最低。
第三步:先确认数组是否被重排
只要看到顶部有 shift/push/pop 自执行逻辑,就先处理这一步。
很多“解出来像乱码”的问题,根本不是 RC4 算法写错,而是数组顺序已经变了。
第四步:一定用真实样例回代
如果用户给了真实请求参数,必须回代验证。
只有能完全命中样例,才能说明公式是真的,不是“差不多”。
0x0F 复现命令
只验证算法,不发请求:
1 | python cloudflare_api.py --no-request |
生成参数并发起真实请求:
1 | python cloudflare_api.py |
指定固定时间戳请求:
1 | python cloudflare_api.py --time 1774183617366 |
0x10 最终总结
这个脚本真正有价值的部分并不复杂,复杂的是外层混淆壳。
真正的业务逻辑只有四步:
- 用
new Date().getTime()生成毫秒时间戳。 - 计算
md5("DdlTxtN0sUOu")。 - 再计算
md5(md5("DdlTxtN0sUOu") + "70cloudflareapikey" + time_ms)作为key。 - 请求
/index.php/index/Cloudflare?key=<key>&time=<time_ms>,直接拿 JSON 结果。
一句话概括:
1 | 难点不在加密,而在先穿透 jsjiami v6 的字符串混淆和数组重排。 |
附:Python 代码
1 | import hashlib |