某手势验证码纯算逆向分析

查看 123|回复 11
作者:LiSAimer   
声明
本文章中所有内容仅供学习交流使用,不用于其他任何目的,严禁用于商业用途和非法用途,否则由此产生的一切后果均与作者无关!若有侵权,请联系作者删除。
逆向目标
目标网站: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 上传

下载次数, 下载附件

LiSAimer
OP
  


zhufuziji 发表于 2025-8-14 09:28
楼主一个人搞不定这个吧?而且指纹,每个人都不一样,提取指纹的设备怎连接?怎么定义不同.防黑措施如何?

为什么觉得一个人搞不定呢,相信自己,多下功夫,多花时间,我也是抓脑研究了两周才搞定的。
再说指纹问题,每个人的确实不一样,一开始我写死指纹参数和代码模拟指纹,但是结果不给token,我怀疑指纹校验比较严格,尝试用自动化调用本地不同的浏览器生成还是不行,怀疑指纹被黑,又研究指纹浏览器提取指纹还是不行,最终发现还是代码里有些细节没对上。成功之后我又将上述失败的方法再试一下也都能成功了。 我没有大并发的跑过,你要稳妥起见防黑还是用指纹浏览器吧。
zhufuziji   

楼主一个人搞不定这个吧?而且指纹,每个人都不一样,提取指纹的设备怎连接?怎么定义不同.防黑措施如何?
wangxd   

学习到了,试试看
bssqcdf   

看起来高大上啊
wjl999   

跟着楼主学起来
kele2233   

又可以学习了
cgh2025   

膜拜大佬
personal   

牛哇!!!!
想喝饮料   

学习了!!
您需要登录后才可以回帖 登录 | 立即注册

返回顶部