吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 5479|回复: 49
收起左侧

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

  [复制链接]
LiSAimer 发表于 2025-8-8 14:04

声明

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

逆向目标

目标网站:aHR0cHM6Ly93d3cudmFwdGNoYS5jb20vI2RlbW8=
关键要点:canvas指纹、参数en、图片还原、手势轨迹

抓包分析

config接口分析
config.png
请求载荷:

vi:网站唯一标识,不同网站值不一样
t:embed嵌入式、popup点击式、invisible隐藏式
s:写死
z:当前时区,计算代码 0 - (new Date).getTimezoneOffset() / 60
v:写死
u:第一次访问可为空,后续获取于 localStorage.getItem("vaptchanu")
callback:jsonp格式

响应内容:包含关键参数knock和一些版本信息
config_res.png

get获取验证码接口分析
get.png
请求载荷:

vi:网站唯一标识,不同网站值不一样
k:前面请求config接口返回的值
origin_url:当前请求网页的地址
rm:写死的
en:经过加密生成的

响应内容:包含图片的链接以及一些后续需要用到的参数
get_res.png

validate验证接口分析
请求载荷:差别不大,加密参数en中包含了轨迹
validate.png

响应内容:token是我们需要获取的最终目标
validate_res.png

逆向过程

get接口的en参数逆向

获取验证码图片,需要逆向en参数。我们可以先打个XHR断点或者直接搜索'en'定位到目标位置,可以看出是在一个控制流里面
get_en.png

可以简单写个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

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

进入这个方法,可以看到会经过两步处理
getComplexCanvasFingerprint:canvas生成base64图片链接
extractCRC32FromBase64:将链接进行crc32校验
canvas.png

_0xb049bd
在控制流 case 1 里面,是由 case 0 的 _0x298102["GenerateFP"](![], !![]) 这个异步方法生成的
_0xb049bd_1.png

进入后发现它return了一个Promise,逐步分析后,可以得知最终返回的是 _0x297a60 的值。它是由一套环境经过murmurhash算法后生成的,环境可以先抠下来写死。之后再判断 _0x4d7c27,如果为true则直接返回_0x297a60,否则将_0x297a60切割前八位后返回
_0xb049bd_2.png

murmurhash算法

def rotl64(x, r):
    return ((x << r) & 0xFFFFFFFFFFFFFFFF) | (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('<Q', data, block_start)[0]
        k2 = struct.unpack_from('<Q', data, block_start + 8)[0]

        k1 = (k1 * c1) & 0xFFFFFFFFFFFFFFFF
        k1 = rotl64(k1, 31)
        k1 = (k1 * c2) & 0xFFFFFFFFFFFFFFFF
        h1 ^= k1

        h1 = rotl64(h1, 27)
        h1 = (h1 + h2) & 0xFFFFFFFFFFFFFFFF
        h1 = (h1 * 5 + 0x52dce729) & 0xFFFFFFFFFFFFFFFF

        k2 = (k2 * c2) & 0xFFFFFFFFFFFFFFFF
        k2 = rotl64(k2, 33)
        k2 = (k2 * c1) & 0xFFFFFFFFFFFFFFFF
        h2 ^= k2

        h2 = rotl64(h2, 31)
        h2 = (h2 + h1) & 0xFFFFFFFFFFFFFFFF
        h2 = (h2 * 5 + 0x38495ab5) & 0xFFFFFFFFFFFFFFFF

    tail = data[nblocks * 16:]
    k1 = 0
    k2 = 0

    if len(tail) >= 8:
        for i in range(8):
            k1 |= tail[i] << (i * 8)
        for i in range(8, len(tail)):
            k2 |= tail[i] << ((i - 8) * 8)
    else:
        for i in range(len(tail)):
            k1 |= tail[i] << (i * 8)

    if len(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

验证码图片还原
get接口返回的图片是一张切割10份后打乱顺序的图片
png_1.png

打上canvas断点或者搜索关键词'splitImage'定位到目标位置
splitimage.png

往上跟栈,分析可知这个case只是进行闪图的动态视觉效果,并不是最终结果生成的位置
shantu.png

继续调试到如下位置才是图像正确顺序生成的地方
img_ord.png

_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) < 10:
        result = '0' + result
    return 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 < 5else1# 行位置 (0-1)

        source_x = column * self.source_block_width
        source_y = row * self.source_block_height

        return source_x, source_y

    def calculate_target_position(self, position_value: int) -> Tuple[int, int]:
        """
        计算目标画布中的位置

        Args:
            position_value: 位置值 (0-9)

        Returns:
            (x, y) 目标画布中的位置坐标
        """
        if position_value < 5:
            # 上半部分
            target_x = position_value * self.canvas_block_width
            target_y = 0
        else:
            # 下半部分
            target_x = (position_value - 5) * self.canvas_block_width
            target_y = self.canvas_block_height

        return target_x, target_y

    def process_image(self, order: str, image_url: str, output_path: str = "image.png") -> 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

validate接口的en参数逆向
和get接口差别不大,重点在轨迹,还有计算globalMd5时的splicingObj方法和get接口不一样,结尾要多拼接个固定值
validate_2.png

轨迹加密位置,轨迹可以用打码平台或者自己训练模型
guiji.png

轨迹加密前需要经过一套清洗算法
guijiqinxi.png

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

免费评分

参与人数 24吾爱币 +22 热心值 +22 收起 理由
DsYs + 1 + 1 我很赞同!
LS888 + 1 我很赞同!
mjhwzwg6 + 1 + 1 用心讨论,共获提升!
FM1122 + 1 + 1 热心回复!
KOCBT + 1 + 1 我很赞同!
daxz + 1 + 1 谢谢@Thanks!
ioyr5995 + 1 + 1 热心回复!
aware2004 + 1 虽然看不懂,但感觉很厉害的样子,点赞
fiscivaj + 1 谢谢@Thanks!
麦的祸冷 + 1 + 1 我很赞同!
pantaoaichili + 1 + 1 我很赞同!
xiaogao66 + 1 热心回复!
Yin07 + 1 + 1 我很赞同!
liuxuming3303 + 1 + 1 谢谢@Thanks!
st0rm + 1 + 1 谢谢@Thanks!
allspark + 1 + 1 用心讨论,共获提升!
Issacclark1 + 1 谢谢@Thanks!
hyw108 + 1 + 1 我很赞同!
如果我是DJ? + 1 + 1 欢迎分析讨论交流,吾爱破解论坛有你更精彩!
3pen + 1 谢谢@Thanks!
ShriyGo + 1 + 1 谢谢@Thanks!
逍遥黑心 + 1 + 1 谢谢@Thanks!
buluo533 + 3 + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
helian147 + 1 + 1 热心回复!

查看全部评分

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

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

为什么觉得一个人搞不定呢,相信自己,多下功夫,多花时间,我也是抓脑研究了两周才搞定的。
再说指纹问题,每个人的确实不一样,一开始我写死指纹参数和代码模拟指纹,但是结果不给token,我怀疑指纹校验比较严格,尝试用自动化调用本地不同的浏览器生成还是不行,怀疑指纹被黑,又研究指纹浏览器提取指纹还是不行,最终发现还是代码里有些细节没对上。成功之后我又将上述失败的方法再试一下也都能成功了。 我没有大并发的跑过,你要稳妥起见防黑还是用指纹浏览器吧。
wangxd 发表于 2025-8-8 18:40
wjl999 发表于 2025-8-9 08:35
跟着楼主学起来
kele2233 发表于 2025-8-9 15:00
又可以学习了
cgh2025 发表于 2025-8-9 15:12
膜拜大佬
personal 发表于 2025-8-9 18:10
牛哇!!!!
想喝饮料 发表于 2025-8-9 20:36
学习了!!
Heswet 发表于 2025-8-9 20:49
对我还是太难了
qpqp1414 发表于 2025-8-9 21:19
这个难度还是太大了
st0rm 发表于 2025-8-9 21:56
好详细,很清晰
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-2-11 20:06

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表