本文章中所有内容仅供学习交流使用,不用于其他任何目的,严禁用于商业用途和非法用途,否则由此产生的一切后果均与作者无关!若有侵权,请联系作者删除。
逆向目标
目标网站:aHR0cHM6Ly93d3cudmFwdGNoYS5jb20vI2RlbW8=
关键要点:canvas指纹、参数en、图片还原、手势轨迹
抓包分析
config接口分析

config.png (32.94 KB, 下载次数: 7)
下载附件
2025-8-8 13:43 上传
请求载荷:
vi:网站唯一标识,不同网站值不一样
t:embed嵌入式、popup点击式、invisible隐藏式
s:写死
z:当前时区,计算代码 0 - (new Date).getTimezoneOffset() / 60
v:写死
u:第一次访问可为空,后续获取于 localStorage.getItem("vaptchanu")
callback:jsonp格式
响应内容:包含关键参数knock和一些版本信息

config_res.png (53.72 KB, 下载次数: 5)
下载附件
2025-8-8 13:44 上传
get获取验证码接口分析

get.png (48.39 KB, 下载次数: 3)
下载附件
2025-8-8 13:45 上传
请求载荷:
vi:网站唯一标识,不同网站值不一样
k:前面请求config接口返回的值
origin_url:当前请求网页的地址
rm:写死的
en:经过加密生成的
响应内容:包含图片的链接以及一些后续需要用到的参数

get_res.png (28.48 KB, 下载次数: 4)
下载附件
2025-8-8 13:46 上传
validate验证接口分析
请求载荷:差别不大,加密参数en中包含了轨迹

validate.png (102.94 KB, 下载次数: 3)
下载附件
2025-8-8 13:47 上传
响应内容:token是我们需要获取的最终目标

validate_res.png (31.03 KB, 下载次数: 2)
下载附件
2025-8-8 13:48 上传
逆向过程
get接口的en参数逆向
获取验证码图片,需要逆向en参数。我们可以先打个XHR断点或者直接搜索'en'定位到目标位置,可以看出是在一个控制流里面

get_en.png (91.23 KB, 下载次数: 6)
下载附件
2025-8-8 13:48 上传
可以简单写个ast还原一下后替换原文件方便分析,或者直接分析也影响不大
const fs = require('fs');
const types = require("@babel/types");
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const generator = require("@babel/generator").default;
const sourceCode = fs.readFileSync("./input.js", {encoding: "utf-8"});
const ast = parser.parse(sourceCode);
const NUMERIC_REGEX = /^0[obx]/i;
const STRING_REGEX = /\\[ux]/i;
const normalizeLiterals = {
NumericLiteral({node}) {
if (NUMERIC_REGEX.test(node.extra?.raw)) {
node.extra = undefined;
}
},
StringLiteral({node}) {
if (STRING_REGEX.test(node.extra?.raw)) {
node.extra = undefined;
}
}
};
const xorCalculator = {
BinaryExpression(path) {
if (path.node.operator === '^') {
const { left, right } = path.node;
if (types.isNumericLiteral(left) && types.isNumericLiteral(right)) {
const result = left.value ^ right.value;
path.replaceWith(types.numericLiteral(result));
if (types.isParenthesizedExpression(path.parent)) {
path.parentPath.replaceWith(types.numericLiteral(result));
}
}
}
}
};
const reverseString = {
CallExpression: {
exit(path) {
let code = path.toString();
if (code.includes("split") && code.includes("reverse") && code.includes("join")) {
try {
let value = eval(code);
path.replaceWith(types.valueToNode(value));
} catch { }
}
}
}
};
traverse(ast, normalizeLiterals);
traverse(ast, xorCalculator);
traverse(ast, reverseString);
const { code } = generator(ast, opts = {
"compact": false,
"comments": false,
"jsescOption": { "minimal": true },
});
fs.writeFile("./output.js", code, (err) => {});
ast后:

ast.png (126.17 KB, 下载次数: 5)
下载附件
2025-8-8 13:49 上传
en的结果是_0xad7777
_0xad7777是由_0xfa172["selectFrom"](3, 15)和_0x23221c经过encryFunc加密方法生成的
_0xfa172["selectFrom"](3, 15) 是取3到15的随机整数值
_0x23221c 是由多个值相加而成的,下面主要分析这些值是如何生成的
_0x23221c = _0x392e9d + _0xb049bd + _0x4eecbb + _0x1e45dc + _0xb0124 + _0x2d7772 + _0x1ac30b + _0xc3f820 + _0x1a6817 + _0x37ab28 + _0x101aa8['globalMd5']['slice'](0, 5);
_0x392e9d
在控制流 case 0 里面,由 _0x354394["GenerateFP"]()方法生成

_0x392e9d_1.png (34.08 KB, 下载次数: 4)
下载附件
2025-8-8 13:50 上传
进入这个方法,可以看到会经过两步处理
getComplexCanvasFingerprint:canvas生成base64图片链接
extractCRC32FromBase64:将链接进行crc32校验

canvas.png (53.54 KB, 下载次数: 6)
下载附件
2025-8-8 13:54 上传
_0xb049bd
在控制流 case 1 里面,是由 case 0 的 _0x298102["GenerateFP"](![], !![]) 这个异步方法生成的

_0xb049bd_1.png (11.52 KB, 下载次数: 4)
下载附件
2025-8-8 13:55 上传
进入后发现它return了一个Promise,逐步分析后,可以得知最终返回的是 _0x297a60 的值。它是由一套环境经过murmurhash算法后生成的,环境可以先抠下来写死。之后再判断 _0x4d7c27,如果为true则直接返回_0x297a60,否则将_0x297a60切割前八位后返回

_0xb049bd_2.png (83.28 KB, 下载次数: 5)
下载附件
2025-8-8 13:56 上传
murmurhash算法
def rotl64(x, r):
return ((x > (64 - r))
def fmix(k):
k ^= k >> 33
k = (k * 0xff51afd7ed558ccd) & 0xFFFFFFFFFFFFFFFF
k ^= k >> 33
k = (k * 0xc4ceb9fe1a85ec53) & 0xFFFFFFFFFFFFFFFF
k ^= k >> 33
return k
def murmurhash3_x64_128(key, seed=0):
data = key.encode('utf-8')
length = len(data)
nblocks = length // 16
h1 = seed
h2 = seed
c1 = 0x87c37b91114253d5
c2 = 0x4cf5ad432745937f
for block_start in range(0, nblocks * 16, 16):
k1 = struct.unpack_from('= 8:
for i in range(8):
k1 |= tail 8:
k2 = (k2 * c2) & 0xFFFFFFFFFFFFFFFF
k2 = rotl64(k2, 33)
k2 = (k2 * c1) & 0xFFFFFFFFFFFFFFFF
h2 ^= k2
if len(tail) > 0:
k1 = (k1 * c1) & 0xFFFFFFFFFFFFFFFF
k1 = rotl64(k1, 31)
k1 = (k1 * c2) & 0xFFFFFFFFFFFFFFFF
h1 ^= k1
h1 ^= length
h2 ^= length
h1 = (h1 + h2) & 0xFFFFFFFFFFFFFFFF
h2 = (h2 + h1) & 0xFFFFFFFFFFFFFFFF
h1 = fmix(h1)
h2 = fmix(h2)
h1 = (h1 + h2) & 0xFFFFFFFFFFFFFFFF
h2 = (h2 + h1) & 0xFFFFFFFFFFFFFFFF
return'{:016x}{:016x}'.format(h1, h2)
_0x4eecbb _0x1e45dc _0xb0124
config接口返回的knock的值切割后5位处理
hex_md5是标准md5加密
_0x4eecbb = _0x354394['GenerateFP'](_0x2ac95b['knock']['substr'](-5, 5));
_0x1e45dc = _0x298102["GenerateFP"](_0x2ac95b["knock"]["substr"](-5, 5));
_0x323ff4 = "adszzSECRETB";
_0xb0124 = _0x23081c["hex_md5"](_0x4eecbb + _0x1e45dc + _0x323ff4)["slice"](0, 5);
_0x2d7772
get接口时ha是空值
_0x2d7772 = _0x101aa8["ha"] === '' ? "0123456789qwe" : _0x101aa8["ha"] + _0x354394["GenerateFP"](_0x101aa8["ha"]);
_0x1ac30b
get接口时是固定值
_0x3d86c7 = "0123456789qwe";
_0x1ac30b = _0x3d86c7;
_0xc3f820 _0x1a6817
初次访问时vaptchanu和vaptchaut无值,由get接口返回值存入localStorage
_0xc3f820 = localStorage["getItem"]("vaptchanu") ? localStorage["getItem"]("vaptchanu")['split'](",")[0] : "0123456789qwertyuiopasdf";
_0x1a6817 = localStorage["getItem"]("vaptchaut") ? localStorage["getItem"]("vaptchaut") :
"0123456789qwertyuiopasdf87654321";
_0x37ab28
userAgent + host + 固定值 计算出md5值后切割取前5位
_0x260187 = _0xfa172['uaDelExtra'](navigator['userAgent']) + location["host"] + _0x101aa8['secretC']['substr'](0, 10);
_0x37ab28 = _0x23081c["hex_md5"](_0x260187)["slice"](0, 5);
globalMd5
不是在当前js文件中计算的
取config接口的响应内容经过splicingObj方法后再进行md5加密

md5.png (34.62 KB, 下载次数: 4)
下载附件
2025-8-8 13:57 上传
验证码图片还原
get接口返回的图片是一张切割10份后打乱顺序的图片

png_1.png (41.88 KB, 下载次数: 5)
下载附件
2025-8-8 13:58 上传
打上canvas断点或者搜索关键词'splitImage'定位到目标位置

splitimage.png (45.84 KB, 下载次数: 3)
下载附件
2025-8-8 13:58 上传
往上跟栈,分析可知这个case只是进行闪图的动态视觉效果,并不是最终结果生成的位置

shantu.png (54.42 KB, 下载次数: 5)
下载附件
2025-8-8 13:59 上传
继续调试到如下位置才是图像正确顺序生成的地方

img_ord.png (12.77 KB, 下载次数: 4)
下载附件
2025-8-8 13:59 上传
_0x39a52f["img_order"]:get接口返回的
_0xef75b5:ha的canvas指纹转16进制 + murmurhash + 固定值 + Worker接口计算的值
Decrypt算法:
def get_img_order(number_str, subtract_value):
result = str(int(number_str) - subtract_value)
if len(result)
得到正确的顺序后就可以重新排列图像了
图像排列算法:传参图片链接和排序顺序
class ImageProcessor:
"""图像处理器类,用于处理图像块的重新排列"""
# 画布配置
CANVAS_WIDTH = 290
CANVAS_HEIGHT = 167
# 源图像配置
SOURCE_WIDTH = 400
SOURCE_HEIGHT = 230
def __init__(self):
"""初始化图像处理器"""
self.canvas_block_width = self.CANVAS_WIDTH // 5
self.canvas_block_height = self.CANVAS_HEIGHT // 2
self.source_block_width = self.SOURCE_WIDTH // 5
self.source_block_height = self.SOURCE_HEIGHT // 2
def download_image(self, url: str) -> Optional[Image.Image]:
"""
从URL下载图像
Args:
url: 图像URL
Returns:
PIL Image对象,如果下载失败则返回None
"""
try:
print(f"正在下载图像: {url}")
response = requests.get(url, timeout=10)
response.raise_for_status()
image = Image.open(io.BytesIO(response.content))
print("图片加载成功!")
return image
except requests.RequestException as e:
print(f"图片下载失败: {e}")
returnNone
except Exception as e:
print(f"图片处理失败: {e}")
returnNone
def validate_order(self, order: str) -> bool:
"""
验证排序字符串
Args:
order: 10位数字字符串
Returns:
验证结果
"""
if len(order) != 10:
print(f"错误:排序字符串长度必须为10,当前长度为{len(order)}")
returnFalse
ifnot order.isdigit():
print("错误:排序字符串必须全部为数字")
returnFalse
returnTrue
def calculate_source_position(self, block_index: int) -> Tuple[int, int]:
"""
计算源图像中指定块的位置
Args:
block_index: 块索引 (0-9)
Returns:
(x, y) 源图像中的位置坐标
"""
column = block_index % 5# 列位置 (0-4)
row = 0if block_index Tuple[int, int]:
"""
计算目标画布中的位置
Args:
position_value: 位置值 (0-9)
Returns:
(x, y) 目标画布中的位置坐标
"""
if position_value bool:
"""
处理图像 - 根据指定顺序重新排列图像块
Args:
order: 10位数字字符串,指定图像块的排列顺序
image_url: 源图像URL
output_path: 输出文件路径
Returns:
处理成功返回True,否则返回False
"""
# 验证输入参数
ifnot self.validate_order(order):
returnFalse
# 下载源图像
source_image = self.download_image(image_url)
if source_image isNone:
returnFalse
# 创建目标画布
target_canvas = Image.new('RGB', (self.CANVAS_WIDTH, self.CANVAS_HEIGHT), color='white')
print("开始处理图像块...")
# 按顺序处理每个图像块
for block_index in range(10):
# 获取当前块应该放置的位置
position_value = int(order[block_index])
# 计算源图像中当前块的位置
source_x, source_y = self.calculate_source_position(block_index)
# 计算目标画布中的位置
target_x, target_y = self.calculate_target_position(position_value)
# 从源图像中裁剪当前块
source_box = (
source_x, source_y,
source_x + self.source_block_width,
source_y + self.source_block_height
)
image_block = source_image.crop(source_box)
# 调整块的大小以适应目标画布
resized_block = image_block.resize(
(self.canvas_block_width, self.canvas_block_height),
Image.Resampling.LANCZOS
)
# 将处理后的块粘贴到目标画布
target_canvas.paste(resized_block, (target_x, target_y))
print(f"处理块 {block_index + 1}/10: 源位置({source_x}, {source_y}) -> 目标位置({target_x}, {target_y})")
# 保存结果
try:
target_canvas.save(output_path, 'PNG')
print(f"图像已保存为 {output_path}")
returnTrue
except Exception as e:
print(f"保存图像失败: {e}")
returnFalse
还原结果

png_2.png (36.31 KB, 下载次数: 5)
下载附件
2025-8-8 14:00 上传
validate接口的en参数逆向
和get接口差别不大,重点在轨迹,还有计算globalMd5时的splicingObj方法和get接口不一样,结尾要多拼接个固定值

validate_2.png (54.07 KB, 下载次数: 3)
下载附件
2025-8-8 14:00 上传
轨迹加密位置,轨迹可以用打码平台或者自己训练模型

guiji.png (79.95 KB, 下载次数: 4)
下载附件
2025-8-8 14:01 上传
轨迹加密前需要经过一套清洗算法

guijiqinxi.png (23.95 KB, 下载次数: 5)
下载附件
2025-8-8 14:01 上传
Tips
canvas指纹可以模拟生成
可以用nodejs的canvas库生成的
也可以和我一样用Python的PIL库模拟
或者用自动化方式调用浏览器执行js代码来获取canvas指纹,建议使用指纹浏览器防止指纹被黑
validate接口状态码
{
"AccessDenied": "0101",
"RefreshAgain": "0102",
"Success": '0103',
'Fail': '0104',
'RefreshTooFast': '0105',
"RefreshTanto": "0106",
"DrawTanto": "0107",
"Attack": "0108",
"ChannellError": "0109",
'JsonpTimeOut': "0703",
'challengeExpire': "0109",
"RequestToMouch": '0315',
"ResponseError": '0501'
}
结果验证

result.png (79.7 KB, 下载次数: 5)
下载附件
2025-8-8 14:02 上传