以腾讯滑块为例,VMP的公式化解法

该文章已生成可运行项目,

腾讯滑块也是VMP,该样本难度不高,适合练手,而且关于腾讯滑块的文章很多,我这里也冷饭热炒一下,依旧是公式化解法找关键点(函数调用、属性访问)、插桩、补环境、分析算法。

炒冷饭.png

申明

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

目标网站

网站

aHR0cHM6Ly9jbG91ZC50ZW5jZW50LmNvbS9wcm9kdWN0L2NhcHRjaGE=

目标:验证请求参数collect

分析过程

    整体请求链路干净整洁,就两个请求,第一个请求cap_union_prehandle,获取初始化信息(js地址、滑块图片地址);第二个请求cap_union_new_verify,提交结果。

1.png

    先看第一个请求/cap_union_prehandle,参数大多为固定值,没什么好分析的,直接看响应。响应中有几个关键值需要留一下吗,sess为验证时携带的ticket,pow_cfg为pow验算时的nonce和md5,bg_elem_cfg和fg_elem_cfg为底图和滑块的下载地址和图片属性,滑块fg_elem_cfg中size_2d是裁切尺寸,sprite_pos为裁切位置,如下两张图。

2.png

3.png

    然后看第二个请求/cap_union_new_verify,参数collect就今天的主角,稍后我们详细分析;tlg为collect的长度;eks是主角tdc.js中一个动态字符串,可直接获取;sess就上一步响应的;ans为滑块对齐的位置;pow_answer是pow计算的结果;pow_calc_time为pow计算消耗时长。

    pow_answer是pow计算结果,关于pow(工作量证明),之前的文章有提到过,简言之,就是对服务端提供的md5和nonce进行暴力搜索的过程:客户端接收服务端下发的随机数nonce和目标哈希target,通过暴力搜索一个数字后缀suffix,对拼接后的字符串workload_nonce + suffix进行MD5哈希计算,将得到的哈希值与workload_target进行比较,直到找到完全匹配的后缀值为止,从而完成一次可验证的、需要消耗一定计算时间的工作量证明。一般通过异步worker边缘计算来完成。全局搜索pow_answer,就可以找到具体代码位置,不过一般pow都为标准md5,所以我们知道逻辑后,直接计算即可,无需分析源码。
 

import hashlib
import itertools
import string
import time
def calculate_pow(workload_nonce, workload_target, max_nonce_length=6):
    """暴力计算POW答案"""
    start_time = time.time()
    attempts = 0
    for length in range(1, max_nonce_length + 1):
        for combo in itertools.product(string.digits, repeat=length):
            attempts += 1
            nonce = ''.join(combo)
            test_str = workload_nonce + nonce
            if hashlib.md5(test_str.encode()).hexdigest() == workload_target:
                calc_time = time.time() - start_time
                return nonce, calc_time
    return None, time.time() - start_time
if __name__ == "__main__":
    workload_nonce = "d162aeb605f109df#"
    workload_target = "cdfab89aa7a42730b8ddb5f8f8d4f367"
    pow_answer, _ = calculate_pow(workload_nonce, workload_target)
    print(pow_answer)

5.png

    ok,接下来着重分析一下collect的生成过程。

补环境

    依旧先从补环境起手,按照以往文章公式化思路,第一步先找关键插桩点:属性访问和函数调用(apply、call)。

    该样本代码结构清晰且无混淆,很容易就能找到对应位置(复杂样本可丢给AI协助分析)。属性访问和函数调用都有两个位置,一个返回上下文的和不返回的,统统插上桩,打印日志。直接拿日志来补环境。

6.png

    关于该样本的补环境,其实没什么难点,就是费时间,对照浏览器日志,依旧是缺啥补啥的思路,将出现的每个dom或bom函数补齐就行。具体补环境代码我放后台了,有需要的可以后台回复企鹅滑块获取,我这里就简单列举一些需要补齐的函数:

window.TCaptchaReferrer = 'https://cloud.tencent.com/product/captcha';
global.navigator = {
    userAgent: 'Mozilla/5.0 (Windows NT 10.0; WOW64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36',
    platform: 'Win32',
    language: 'zh-CN',
    languages: ['zh-CN', 'zh'],
    hardwareConcurrency: 32,
    deviceMemory: 8,
    maxTouchPoints: 10,
    vendor: 'Google Inc.',
    webdriver: false,
    cookieEnabled: true,
    product: 'Gecko',
    productSub: '20030107',
    requestMIDIAccess: {}, 
    serviceWorker: {}
};
window.innerWidth = 360;
window.innerHeight = 360;
canvas相关:fillText、fillRect、fillStyle、font
webgl相关:getExtension、getSupportedExtensions、getParameter
localStorage、sessionStorage
global.screen = {
    width: 2560,
    height: 1440,
    availWidth: 2560,
    availHeight: 1392,
    colorDepth: 24,
    pixelDepth: 24,
    availLeft: 0,
    availTop: 0,
    orientation: {
        type: 'landscape-primary',
        angle: 0
    }
};

    这里也有小坑,就是会判断某些属性是否存在于对象中,比如判断navigator中是否有requestMIDIAccess。如果你是采用proxy方式补环境,可能很难发现这个地方。具体代码如下

    在一块可以插个桩,打印一下日志,方便我们补齐缺失的内容。

function () {
                let child = I[I.length - 2]
                let obj = I[I.length - 1]
                kfc_log({
                    idx: __VM_EXEC_INDEX,
                    op: 66,
                    type: '从属',
                    child: child,
                    Obj: obj,
                    result: child in obj,
                });
                I[I.length - 2] = I[I.length - 2] in I.pop()
            }

    虽然这是滑块,但是并不需要传入轨迹,可以直接省略鼠标事件的绑定和轨迹模拟,具体原因稍后说明。

算法分析

    在上面的分析中我们知道tdc.js的地址是在初始化请求中的,而不是一个固定地址,所以,这是一个动态vmp,虽然不同js的代码一样,但是字节码数组是不一样的。也就是说无法做到真正意义上的纯算,但这并不妨碍我们来分析一下他的参数生成逻辑。

    先从日志入手,可以看到将生成的环境信息字符串切割,具体切割逻辑为每次切割两段,每段长度为4,然后将这两小段字符串转为一个int,接下来做的就是先找到字符串转int的具体逻辑。

11.png

    如上图第一个红框内,“7360”是如何变成808858423?这一块比较繁琐,需要单步调试,然后打印出做逻辑运算步骤的日志。这里给几个逻辑运算函数插桩,如:左移、右移、按位与等等,由于当前算法只用到了左移按位或,所以下面示例插桩只展示这两个:

function() {
            let operand2 = I[I.length - 1];
            let operand1 = I[I.length - 2];
            kfc_log({
                idx: __VM_EXEC_INDEX,
                type: 'bitwise_or',
                operand1: operand1,
                operand2: operand2,
                result: operand1 | operand2
            });
            I[I.length - 2] = I[I.length - 2] | I.pop()
        }
function() {
            let operand2 = I[I.length - 1];
            let operand1 = I[I.length - 2];
            kfc_log({
                idx: __VM_EXEC_INDEX,
                type: 'left_shift',
                operand1: operand1,
                operand2: operand2,
                result: operand1 << operand2
            });
            I[I.length - 2] = I[I.length - 2] << I.pop()
        }

然后查看日志:

此过程实际上就是小端序的字符打包。字符串 "7360"的每个字符被取出其 ASCII 值:

  • '7'-> 55 (0x37)

  • '3'-> 51 (0x33)

  • '6'-> 54 (0x36)

  • '0'-> 48 (0x30)

接着,每个值依次左移,移动的位数是其字节位置的8倍,然后通过按位或合并:

  • 第1个字节左移 0 位:55 << 0= 55

  • 第2个字节左移 8 位:51 << 8= 13056

  • 第3个字节左移 16 位:54 << 16= 3538944

  • 第4个字节左移 24 位:48 << 24= 805306368

最终合并为:55 | 13056 | 3538944 | 805306368 = 808858423。代码如下:

function stringToInt(s) {
    let result = 0;
    for (let i = 0; i < Math.min(s.length, 4); i++) {
        const asciiVal = s.charCodeAt(i);
        result |= asciiVal << (8 * i);
    }
    return result >>> 0;  // 转换为无符号32位整数
}
// 测试
console.log(stringToInt("7360"));  // 输出: 808858423

    继续往下看,从日志上可以看出,上一步的获得的结果为下一步的参数,且每两个为一组参数,比如字符串:"{\"cd\":[1,1736092800,"截取其[0,4)位后转int的值为80808080,再次截取[4,8)位后转int位80909090,然后80808080和80909090为一组参数,进行下一步计算。

15.png

分析过程和str转int一样,我这里直接说结论了:

1.初始化:输入v0=808858423,v1=808989241,密钥k=[1330013038,1213685070,1146703714,1248151874],常数delta=0x9E3779B9(2654435769)。

2.解密循环:执行多轮解密操作(日志中显示大量移位和与密钥的交互),每轮操作形式符合XTEA解密结构:

  • v1 -= (((v0 << 4) ^ (v0 >> 5)) + v0) ^ (sum + k[(sum>>11) & 3])

  • sum -= delta

  • v0 -= (((v1 << 4) ^ (v1 >> 5)) + v1) ^ (sum + k[sum & 3])

3.解密结果:得到新的v0和v1,在日志中显示为6763017792和1162296179(实际应视为32位有符号整数:-1826916800和1162296179)。

4.字节提取:从解密后的整数中提取4个字节:

  • v0 & 0xFF = 64→ '@'

  • (v0 >> 8) & 0xFF = 118→ 'v'

  • v1 & 0xFF = 27→ '\u001b'

  • (v0 >> 24) & 0xFF = 147→ '“'

5.字符串生成:调用String.fromCharCode(64,118,27,147)得到最终字符串"@v\u001b“"。

    以上就是从插桩日志中推导出来的过程,这其实就是一个TEA算法,算法通过多轮操作(包括移位、加法和异或)对输入块进行解密,得到两个新的32位整数(6763017792和1162296179),再从中提取字节序列[64,118,27,147],最后通过String.fromCharCode生成目标字符串。其中密钥k为动态的,每个JS都不一样。

function teaEncrypt(num_lis) {
    var num1 = num_lis[0];
    var num2 = num_lis[1];
    var sum = 0;
    var key = [1330013038,1213685070,1146703714,1248151874];//动态的,无法写死
    var delta = 2654435769;
    for (var i = 0; i < 32; i++) {
        num1 += (((num2 << 4) ^ (num2 >>> 5)) + num2) ^ (sum + key[sum & 3]);
        sum += delta;
        num2 += (((num1 << 4) ^ (num1 >>> 5)) + num1) ^ (sum + key[(sum >> 11) & 3]);
    }
    return [num1, num2];
}

    至此,最关键的两个算法推导出来了,接下来就是将所有的处理结果拼接,然后再将拼接结果求base64,这就是collect。

    "{\"cd\":[1,1736092800,1000000000,17360....“这只是其中一个需要处理的字符串,一共有三个字符串(如果除去轨迹的话),将这三个处理后的结果再次拼接。

16.png

    到这里就基本结束了,但是在上面我们还遗留了一个小问题,就是为什么不需要传入轨迹也能校验成功?

    仔细看这三个环境消息的字符串,这些字符串中并没有携带滑块位置坐标,或者标签的DOMRect等信息,也就是说服务端压根不知道当前滑块在浏览器中的位置坐标,所以校验轨迹没有任何意义。当然,人家就这样设计的,这里提出来只是一个小技巧,帮我们省去了模拟轨迹的时间。

    觉得有用的话,佬儿们帮忙点个赞。

点赞.jpg

结果验证

17.png

可添加宫中号:冰敷逆向笔记

本文章已经生成可运行项目
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值