Skip to content

极验三代滑块拼图

更新: 2025/2/24 字数: 0 字 时长: 0 分钟

作为一位 JS 逆向爱好者,写本篇文章在于纯技术分析。无任何不良商业目的。旨在提高大家的网络安全意识,共同维护网络安全环境!请不要做任何有损国家或其他集体或个人的事情, 否者后果自负!本文如有任何侵权行为,请马上联系作者,立马删除。

本章简介

极验–全球交互安全创领者!拥有32万家使用客户!我们的这次分析选用官方给出的demo地址作为分析案例。极验提供的防御方式大概分滑块拼图、一键通过、文字点选三种,下面介绍滑块拼图。

极验的滑块拼图产生于大数据分析领域,目的是用于区分是否是爬虫还是真人在过滑块,区分的重点在于:滑块在滑动的过程当中会产生一个轨迹流程,通过大数据来校验这个轨迹流程是否是真人所拖动滑块产生,从而来阻挡爬虫进行访问。

难度:困难

地址:https://www.geetest.com/demo/

详细地址:https://www.geetest.com/demo/slide-float.html

20220725163058

通讯流程

30153517_64253bb5b019711725

30153520_64253bb82a99457314

流程分析

首先开启Chrome无痕模式进行抓包,重要的请求标注如下,我们可以把重要的请求分为三个部分:

  • 第一部分,访问页面时所发送的请求;
  • 第二部分,点击按钮生成滑动验证码时发送的请求;
  • 第三部分,滑动验证码时发送的请求,第一次失败,第二次成功。

20230626002635

?> 提示:这些请求都是GET请求,不存在POST请求。

!> 注意:在下面的分析中,可能某些请求的值发生改变,这是因为整个分析流程会比较长,期间会多次调试,因此这里的重点不是请求的值发生改变,而是如何逆向这些值的生成逻辑。

第一部分

地址:https://www.geetest.com/demo/gt/register-slide

作用:请求服务器获取验证参数和流水号(爬虫必须)。

请求参数:有一个 t 时间戳参数,请求时必须带上。

返回结果:返回了 challenge 参数、gt 参数。

20230626003853

地址:https://apiv6.geetest.com/gettype.php

作用:返回需要使用的JS资源,用于下一次请求(爬虫可省略)。

请求参数:gt 参数来源于上次请求的结果,callback 参数是 geetest_时间戳 固定格式。

返回结果:JSON格式的JS资源。

20230626004346

20230626004605

地址:https://static.geetest.com/static/js/fullpage.9.1.4.js

作用:请求需要使用的JS资源(爬虫可省略)。

请求参数:无重要参数。

返回结果:用于加密参数的JS代码。

20230626005019

?> 提示:这里注意一下地址中的 fullpage.9.1.4.js 字段,其中 fullpage 代表了资源名称,9.1.4 代表了JS代码的版本号,因为极验的JS代码是在不断更新的,只是大体的流程逻辑不会变。

地址:https://apiv6.geetest.com/get.php

作用:环境校验(爬虫必须)。

请求参数:gtchallenge 参数来源于第一次请求的结果,langptclient_typecallback 参数为固定格式,w 加密参数(当前请求不验证该参数,可以省略该加密值)。

返回结果:返回一些提示信息。

20230626005614

20230626005652

第二部分

地址:https://api.geetest.com/ajax.php

作用:获取滑块的状态(爬虫必须)。

请求参数:gtchallenge 参数来源于第一次请求的结果,langptclient_typecallback 参数为固定格式,w 加密参数(当前请求不验证该参数,可以省略该加密值)。

返回结果:{"status": "success", "data": {"result": "slide"}}

20230626232749

20230626235328

地址:https://static.geetest.com/static/js/slide.7.9.0.js

作用:加载滑块JS代码(爬虫可省略)。

请求参数:无重要参数。

返回结果:滑块JS代码。

20230626233622

20230626233835

?> 提示:这里滑块的JS代码是来源于前面的请求中的 slide.7.9.0.js,其中 slide 代表了滑块,7.9.0 代表了JS代码的版本号,因为极验的JS代码是在不断更新的,只是大体的流程逻辑不会变。

地址:https://api.geetest.com/get.php

作用:返回 c 参数(后面要用)、 s 参数(后面要用)、JS代码、滑块图片、背景图片资源路径,供下一次调用(爬虫必须)。

请求参数:gtchallenge 参数来源于第一次请求的结果,其他参数为固定格式。

返回结果: c 参数、 s 参数、challenge 参数、gt 参数、JS代码、图片资源。

20230626234351

20230626234951

!> 注意:这里返回的 challenge 参数和最开始请求返回的 challenge 参数有所区别,尾部多了 hk 值,因此要保存,而 gt 参数却没有变化,为了保险起见,还是要保存。

地址:https://static.geetest.com/static/js/gct.b71a9027509bc6bcfef9fc6a196424f5.js

作用:获取JS代码资源(爬虫可省略)。

请求参数:无重要参数。

返回结果:JS代码。

20230626235440

地址:https://static.geetest.com/pictures/gt/7bfaaa72b/7bfaaa72b.webp

作用:获取乱序的背景图,大小为312X160(爬虫可省略)。

请求参数:无重要参数。

返回结果:乱序背景图。

20230628235434

地址:https://static.geetest.com/pictures/gt/7bfaaa72b/bg/b85a20e92.webp

作用:获取乱序有滑块缺口的背景图,大小为312X160(爬虫必须)。

请求参数:无重要参数。

返回结果:乱序有滑块缺口的背景图。

20230628235255

地址:https://static.geetest.com/pictures/gt/7bfaaa72b/slice/b85a20e92.png

作用:获取滑块缺口图(爬虫必须)。

请求参数:无重要参数。

返回结果:滑块缺口图。

20230626235959

第三部分

地址:https://api.geetest.com/ajax.php

作用:测试滑动失败。

请求参数:gtchallenge 参数来源于第二部分中的请求的结果,w 加密参数(猜测和环境校验、运动轨迹有关),其他参数为固定格式。

返回结果:验证失败信息。

20230627000217

20230627000244

地址:https://api.geetest.com/ajax.php

作用:测试滑动成功。

请求参数:gtchallenge 参数来源于第二部分中的请求的结果,w 加密参数(猜测和环境校验有关、运动轨迹),其他参数为固定格式。

返回结果:验证成功信息。

20230627000630

20230627000654

分析总结

到这里我们就把滑块完整的请求验证流程都走完了,可以看到上面步骤也不是每步都需要的,例如第一部分中 fullpage.9.1.4.js 这个JS代码资源爬虫就用不着请求,因为我们只分析加密值的生成逻辑。**总结下来就是上面的请求不用都去实现,只需要实现必须的就行。**这里我们先把前期代码写出来:

python
import json
import re
import time
import execjs
import requests


# 建立会话
session = requests.Session()
session.headers = {'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36'}

'''
第一部分
'''
# 第一次请求,获取challenge、gt
first_url = f'https://www.geetest.com/demo/gt/register-slide?t={int(time.time() * 1000)}'
first_res = session.get(url=first_url).json()
# 第二次请求,环境校验
one_params = {
    'gt': first_res['gt'],
    'challenge': first_res['challenge'],
    'lang': 'zh-cn',
    'pt': '0',
    'client_type': 'web',
    'callback': f'geetest_{int(time.time() * 1000)}'
}
session.get(url='https://apiv6.geetest.com/get.php', params=one_params)

'''
第二部分
'''
# 第三次请求,获取滑块的状态{"status": "success", "data": {"result": "slide"}}
session.get(url='https://api.geetest.com/ajax.php', params=one_params)
# 第四次请求,获取重要的c参数、s参数、乱码原图、乱码缺口图
params = {
    'is_next': 'true',
    'type': 'slide3',
    'gt': first_res['gt'],
    'https': 'true',
    'challenge': first_res['challenge'],
    'lang': 'zh-cn',
    'pt': '0',
    'protocol': 'https://',
    'offline': 'false',
    'product': 'embed',
    'api_server': 'api.geetest.com',
    'isPC': 'true',
    'autoReset': 'true',
    'width': '100%',
    'callback': f'geetest_{int(time.time() * 1000)}'
}
two_res = session.get(url='https://api.geetest.com/get.php', params=params)
json_res = json.loads(re.findall('geetest_\d+\((.*?)\)', two_res.text)[0])
# 第五次请求,滑块图
slice_url = 'https://static.geetest.com/' + json_res['slice']
slice_img = requests.get(slice_url).content
# 第六次请求,含缺口的乱码背景图
bg_url = 'https://static.geetest.com/' + json_res['bg']
bg_img = requests.get(bg_url).content

逆向分析

图片还原

**目前,我们能把前期代码写道获取含缺口的乱码背景图这步,接下来就是还原图片。**我们看看在网页中图片是怎么还原的,在图片中右键选择“检查”选项,发现是两个 canvas 绘图标签,根据名称 geetest_canvas_bg 是背景图,geetest_canvas_slice 是滑块,且高度均为160,宽度均为260

20230628233045

20230628233505

?> 提示:标签 canvas 是H5提供的一种元素,使用JavaScript可以在网页画布上绘制图像,画布一个矩形区域,你可以控制每一个像素。

既然是一个 canvas 绘图标签,那么我们就可以使用事件监听里面的 Canvas 断点中 Create canvas context 创建上下文断点:

20230628234244

**现在我们刷新验证码,断点卡在了这里,可以看到上面有两个变量值312和160,刚好就是乱码图片的宽度和高度。**而且在下面进行了一个52次的循环,我们打上断点继续调试:

20230629000428

接下来,我们可以看到有一个 drawImage 函数,说明要绘图了。下面又有两个变量值160和260,刚好就是上面 Canvas 还原后图片的高度和宽度,说明接下来还原的是乱码图片。由于乱码图片分为上下两部分,因此出现了 a = r / 2 = 160 / 2 = 80 即图片的上下两部分,下面的 Ut 变量是一个长度为52的数组,里面是0~51的乱序数字,说明还原图片是将乱序图片分为52个小份,再按数组里面的序号进行还原:

20230629001434

20863124-4216b8f6eed91bb7

?> 提示:经过多次测试,验证码图片的还原序号的顺序是写死的,永远是这样一个顺序来还原。

得到还原序号后,有人可能还发现了一个问题就是,乱码原图是312X160大小,而还原后的 Canvas 画布图片是260X160,宽度没对上,原因就在于绘图中的 _ % 26 * 10 表示每个小块取10 px像素。也就是说,乱码原图分为52份,上下两部分各26份,原图宽度312,除以26,小块宽度为12 px像素,现在只取前10 px像素,乘以26份,宽度就成了260,也就是还原后的 Canvas 画布图片宽度:

20230629003225

明白原理后,我们就可以使用代码对乱码原图进行还原,代码如下:

python
import io
from PIL import Image

# 乱码背景图
_img = Image.open(io.BytesIO(bg_img))
# 图片还原顺序, 定值
_Ge = [39, 38, 48, 49, 41, 40, 46, 47, 35, 34, 50, 51, 33, 32, 28, 29, 27, 26, 36, 37, 31, 30, 44, 45, 43, 42, 12, 13, 23, 22, 14, 15, 21, 20, 8, 9, 25, 24, 6, 7, 3, 2, 0, 1, 11, 10, 4, 5, 19, 18, 16, 17]
# 小份图片宽度、高度
w_sep, h_sep = 10, 80
# 还原背景图
new_img = Image.new('RGB', (260, 160))
for idx in range(len(_Ge)):
    x = _Ge[idx] % 26 * 12 + 1
    y = h_sep if _Ge[idx] > 25 else 0
    # 从背景图中裁剪出对应位置的小块
    img_cut = _img.crop((x, y, x + w_sep, y + h_sep))
    # 将小块拼接到新图中
    new_x = idx % 26 * 10
    new_y = h_sep if idx > 25 else 0
    new_img.paste(img_cut, (new_x, new_y))
# 保存还原图片
new_img.save('./bg_img.jpg')

位移计算

**现在我们已经将含缺口的背景图进行还原,再结合滑块缺口图,使用 cv2 库中的一些算法就可以计算出滑块需要位移的距离了。**这里就不贴代码了,设定最后位移距离保存在了 distance 变量当中。

逆向w值

**现在我们的位移距离 distance 计算出来了,接下来就要开始逆向在拖动滑动条后生成的 w 加密值了。注意这里是拖动滑动条后生成的 w 加密值,因此我们断点就要在图片加载完后,拖动滑动条之前进行生效。**首先我们进行一次错误的滑动,发送一次含 w 加密值的请求,选择 Initiator 选项卡,从第一个请求开始往前追溯 w 加密值的生成逻辑:

20230629234255

在追溯到 $_CCBk 函数时,我们找到了 w 加密值的生成逻辑,它是由 h 值和 u 值组合得到,而 h 值和 u 值刚好就在上面被定义:20230629234811

javascript
u = r[$_CAHJe(785)]()
l = V[$_CAHJe(372)](gt[$_CAHJe(239)](o), r[$_CAIAj(761)]())
h = m[$_CAIAj(783)](l)
w = h + u

可以看到 w 加密值由 h 值和 u 值组合得到,而 h 值由 l 值计算得到,接下来我们一个一个进行逆向。

逆向u值

首先我们将 u 值的代码中的混淆变量进行还原:

javascript
u = r["$_CCDf"]()

现在进入赋值的 r["$_CCDf"] 函数当中,函数最后返回了 e 值,而 e 值在函数中有两个赋值的地方,经过多次测试不走 while 循环,所以 e 值只有一次赋值,我们将里面的混淆变量进行替换,得到如下代码:

20230629235913

javascript
e = new U()["encrypt"](this["$_CCEV"](undefined))
内层函数

可以看到 e 值的生成也是函数套函数的形式,那么我们就从最里面的 this["$_CCEV"] 函数看起走,该函数最后返回的是 Ot 变量,而 Ot 变量又是由最声明时期的 rt() 函数进行赋值的:

javascript
Ot = rt()

20230715025411

**继续跟进到 rt 函数当中,发现该函数返回的是4个 t 函数的返回值的拼接,而 t 函数的定义就在上面,通过控制台输出,最后返回的是一串随机的值。**将代码还原简化后如下:

20230630001934

javascript
function rt() {
    var data = '';
    for (var i = 0; i < 4; i++) {
        data = data + (65536 * (1 + Math['random']()) | 0)['toString'](16)['substring'](1);
    }
    return data
}
// 返回值赋值给message变量
message = rt()
外层函数

现在我们来逆向 e 值的 new U()["encrypt"] 外层函数,可以看到关键词 encrypt 说明这个函数肯定和加密有关。那么我们就来看看 new U() 包含哪些方法,可以看到它包含一个 setPublic 设置公共,这里马上就能联想到RSA加密中的 setPublicKey 设置公钥:

20230701133306

到这里我们就可以衍生出两种逆向道路,一种是直接将 new U()["encrypt"] 外层函数扣出来,另一种就是它大概使用了RSA加密,我们去找密钥。显然第二种道路好走一点,那么我们进入到 U 里面去:

20230715024012

发现在这里它使用到了 setPublic 方法,而且每次向该方法传递的两个参数值是定值,其中一个定值是 10001,在爬虫逆向时经常遇到,公钥指数("65537")作为偏移量在JS代码中经常以("10001")十六进制方式出现 ,如 setPublic(模数, 指数) 的形式。而“模数”简单讲就是一组长度为258位的十六进制字符串,刚好就是 $_IAJX(317) 变量值的长度:

20230701134714

经过测试,这里就是使用的标准RSA指模加密,因此我们可以通过下面的进行还原 e 值的 new U()["encrypt"] 外层函数:

python
# 逆向还原JS代码(u值)
def js_u(message):  # message就是上面调用rt()函数的返回值的变量
    # 十六进制指数(固定值)
    rsaExponent = "10001"
    rsaExponent = int(rsaExponent, 16)  # 十六进制转十进制
    # 十六进制模数(固定值)
    rsaModulus = "00C1E3934D1614465B33053E7F48EE4EC87B14B95EF88947713D25EECBFF7E74C7977D02DC1D9451F79DD5D1C10C29ACB6A9B4D6FB7D0A0279B6719E1772565F09AF627715919221AEF91899CAE08C0D686D748B20A3603BE2318CA6BC2B59706592A9219D0BF05C9F65023A21D2330807252AE0066D59CEEFA5F2748EA80BAB81"
    rsaModulus = int(rsaModulus, 16)  # 十六进制转十进制
    # 依据模数和指数生成公钥
    public_key = rsa.PublicKey(rsaModulus, rsaExponent)
    # 加密字符串返回二进制流数据
    crypto = rsa.encrypt(message.encode('utf8'), public_key)
    # 将二进制流数据编码为十六进制字符串
    u = binascii.b2a_hex(crypto).decode()
    return u

至此 e 值的逆向就已经全部完成,通过js代码的 rt() 函数生成内部值,再结合python代码的 js_u() 函数生成 e 值,回顾上面其实 e 值就是 u 值。

逆向l值

现在我们来逆向 l 值,首先我们将 l 值的代码中的混淆变量进行还原:

javascript
l = V["encrypt"](gt["stringify"](o), r["$_CCEV"]())

**我们从后往前,从里往外逆向,先来跟踪 r["$_CCEV"] 函数,发现和上面 u 值中的 this["$_CCEV"] 内层函数走了一样的路径,但由于最开始声明函数时已经调用了一次 rt() 函数生成了值,最后返回的还是这个值。**因此 l 值的代码可以写为:

20230715025411

javascript
// message就是上面调用rt()函数的返回值的变量
l = V["encrypt"](gt["stringify"](o), message)

接下来看 gt["stringify"] 方法感觉和 JSON.stringify 方法很像,对比看看,发现效果一样,都能把对象转换为字符串:

20230701162635

那么 l 值的代码可以写为:

javascript
l = V["encrypt"](JSON.stringify(o), message)

接着我们来看看 o 变量,它的来源在上面,它里面包含多个参数:

20230701165439

javascript
o = {
    "lang": i["lang"] || "zh-cn",
    "userresponse": H(t, i["challenge"]),  // t外来传参
    "passtime": n,  // n外来传参
    "imgload": r["$_CAGc"],
    "aa": e,  // e外来传参
    "ep": r["$_CCCP"](),
}

在生成 u 值的上面,又给 o 变量加了一个 rp 参数:

20230701170408

javascript
o['rp'] = X(i['gt'] + i['challenge']['slice'](0, 32) + o['passtime'])

现在我们进行两次不同距离的滑动,看看 o 变量中有哪些参数发生了改变:

javascript
// 短距离滑动
{
    "lang": "zh-cn",
    "userresponse": "ee555ee5e55ee1dda",
    "passtime": 415,
    "imgload": 86,
    "aa": "F(!@!HGsssUss(!!(aU9U7$)1$,-",
    "ep": {"v": "7.9.0", "$_BIo": false, "me": true, "tm": {"a": 1688200910013, "b": 1688200910359, "c": 1688200910359, "d": 0, "e": 0, "f": 1688200910014, "g": 1688200910018, "h": 1688200910124, "i": 1688200910124, "j": 1688200910139, "k": 1688200910129, "l": 1688200910139, "m": 1688200910355, "n": 1688200910356, "o": 1688200910361, "p": 1688200910653, "q": 1688200910653, "r": 1688200910655, "s": 1688200910655, "t": 1688200910655, "u": 1688200910655}},
    "rp": "f3638110e3c3662c2fb67e932feb0d53"
}
// 长距离滑动
{
    "lang": "zh-cn",
    "userresponse": "e5e55e55ee555e555d11c",
    "passtime": 589,
    "imgload": 104,
    "aa": "K----------.----..../[email protected]!!Kstssttyyyy(((U((((((((((((((((((((((((((((y(yytttttstststststttt!!($)A911111111111111111111111111111111111111201711111111111111111111",
    "ep": { "v": "7.9.0", "$_BIo": false, "me": true, "tm": {"a": 1689360677876, "b": 1689360678033, "c": 1689360678033, "d": 0, "e": 0, "f": 1689360677877, "g": 1689360677877, "h": 1689360677877, "i": 1689360677877, "j": 1689360677877, "k": 1689360677876, "l": 1689360677879, "m": 1689360678029, "n": 1689360678031, "o": 1689360678034, "p": 1689360678401, "q": 1689360678401, "r": 1689360678402, "s": 1689360678403, "t": 1689360678403, "u": 1689360678403}},
    "rp": "579f670ece40e95cd57d4321bbdb6312"
}

经过两次不同滑动距离 o 变量的对比,对参数进行总结:

javascript
{
    "lang": "固定值",
    "userresponse": "变化值",
    "passtime": "变化值",
    "imgload": "变化值",
    "aa": "变化值,距离越长,值的长度越长,估计就是滑动的轨迹",
    "ep": "变化值",
    "rp": "变化值"
}
userresponse

上面说过 userresponse 值来源如下:

python
userresponse = H(t, i["challenge"])

经过测试发现 t 参数就是 distance 滑动距离,而 i["challenge"] 就是第二部分请求返回 challenge 参数,因此可以写为如下格式:

python
userresponse = H(distance, two_res["challenge"])

下面就是扣出 H 函数的代码了,还原后具体如下:

20230715034129

javascript
function H(t, e) {
    for (var n = e["slice"](-2), r = [], i = 0; i < n["length"]; i++) {
        var o = n["charCodeAt"](i);
        r[i] = 57 < o ? o - 87 : o - 48;
    }
    n = 36 * r[0] + r[1];
    var s, a = Math["round"](t) + n, _ = [[], [], [], [], []], c = {}, u = 0;
    i = 0;
    for (var l = (e = e["slice"](0, -2))["length"]; i < l; i++)
        c[s = e["charAt"](i)] || (c[s] = 1,
            _[u]["push"](s),
            u = 5 == ++u ? 0 : u);
    var h, f = a, d = 4, p = "", g = [1, 2, 5, 10, 50];
    while (0 < f)
        0 <= f - g[d] ? (h = parseInt(Math["random"]() * _[d]["length"], 10),
            p += _[d][h],
            f -= g[d]) : (_["splice"](d, 1),
            g["splice"](d, 1),
            d -= 1);
    return p;
}
aa

**继续看和滑动距离有关的 aa 值,通过上面 o 变量优化后可以看到 aa 值就是 e 值,而 e 值是外部传入的。**我们通过回调栈往上找,在 $_CGII 函数中找到了其调用,就是 l 的值,刚好上面的就定义了 l 的值,简化后得到如下代码:

20230702021019

javascript
l = n["$_CICa"]["$_BBEI"](n["$_CICa"]["$_FDL"](), n["$_CJV"]["c"], n["$_CJV"]["s"]);

可以看到 l 的值是一个有三个参数函数的返回值,我们分别打印这三个参数的值:这里的 n["$_CJV"]["c"] 的值和 n["$_CJV"]["s"] 的值是不是有点眼熟,它就是上面第二部分中的请求返回的 c 参数、 s 参数,因为不是同一次极验调试,因此 s 参数发生了变化。

20230702022003

javascript
l = n["$_CICa"]["$_BBEI"](n["$_CICa"]["$_FDL"](), two_res["c"], two_res["s"]);

那么现在就剩下 n["$_CICa"]["$_FDL"]() 未知了,那么我们就进入到 n["$_CICa"]["$_FDL"] 函数中进行调试,发现过程中没有进入 function n(t) 函数中,而是直接进入了 function(t) 自执行函数中,传递的 this[$_BEGJp(302)] 参数值:

20230702022916

我们打印一下参数 this[$_BEGJp(302)] 值,发现是一个长度为31,包含3个元素的元组,这看起来就像是滑块的移动轨迹,大胆猜测一下:第一个元素就是x横轴位移,第二个元素就是y纵轴位移,第三个元素就是时间。最终最后一个数组的第一个元素就是滑块总的位移距离,第三个元素就是滑块总的位移距离所花费的时间。

20230702024828

通过大量实践,这里总结出一套生成滑块的轨迹算法,这里就不提供代码了,我们将滑块轨迹赋值给 track 变量:

python
# distance上面识别的位移距离,get_slide_track生成轨迹算法
track = get_slide_track(distance)

图中 function(t) 自执行函数最后返回 i 变量,它有 push 方法,说明 i 变量是一个数组,打印出来是一个长度为24,包含3个元素的数组,这里可以看到:第2组为例 [1, 0, 8] 就是上面 this[$_BEGJp(302)] 中的第2组 [1, 0, 70] 与第3组 [2, 0, 78] 的元素之差,至于为什么只有24组,不是30组,原因就是在计算的过程中会舍去一部分值。

20230702030042

这里我们将计算轨迹差值的方法给抠出来,代码如下:

javascript
// track就是上面的轨迹,返回轨迹差值数组
function cha(track) {
    for (var e, n, r, i = [], o = 0, s = 0, a = t["length"] - 1; s < a; s++)
        e = Math["round"](t[s + 1][0] - t[s][0]),
            n = Math["round"](t[s + 1][1] - t[s][1]),
            r = Math["round"](t[s + 1][2] - t[s][2]),
        0 == e && 0 == n && 0 == r || (0 == e && 0 == n ? o += r : (i["push"]([e, n, r + o]),
            o = 0));
    return 0 !== o && i["push"]([e, n, o]),
        i;
}

得到轨迹差值后,我们继续往下走,新定义了 r = [], i = [], o = [] 三个数组,有一个 function(t) 函数 return 0 跳过不看,下方还有一个判断,通过 e 函数的返回值,来判断执行包含 r[$_BEIAd(105)](n(t[0])), i[$_BEIAd(105)](n(t[1]))), o[$_BEHJn(105)](n(t[2]) 内容,里面的 $_BEHJn(105) 就是 push 方法,这里就是将上面计算出来的轨迹中的每个数组中的每个位置的元素通过 t[下标] 方式取出来再通过 n 函数对其转换为一个对应的字符再放入到新定义的三个数组中。继续向下,到了返回值的地方,我们解混淆后得到如下代码:r['join']('') 是x,i['join']('') 是y,o['join']('') 是时间值,将轨迹差值中不同位置的元素对应的字符连接起来,这里轨迹就分析完了。

20230702032101

javascript
// n函数
function jia_n(t) {
    var e = "()*,-./0123456789:?@ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqr"
        , n = e["length"]
        , r = ""
        , i = Math["abs"](t)
        , o = parseInt(i / n);
    n <= o && (o = n - 1),
    o && (r = e["charAt"](o));
    var s = "";
    return t < 0 && (s += "!"),
    r && (s += "$"),
    s + r + e["charAt"](i %= n);
}

// e函数
function jia_e(t) {
    for (var e = [[1, 0], [2, 0], [1, -1], [1, 1], [0, 1], [0, -1], [3, 0], [2, -1], [2, 1]], n = 0, r = e["length"]; n < r; n++)
        if (t[0] == e[n][0] && t[1] == e[n][1])
            return 'stuvwxyz~'[n];
    return 0;
}

// t就是上面轨迹差值数组,返回加密轨迹字符串
function jia_1(t) {
    var r = [], i = [], o = [];
    for (var index = 0; index < t["length"]; index++) {
        e = jia_e(t[index])
        e ? i["push"](e) : (r["push"](jia_n(t[index][0])),
            i["push"](jia_n(t[index][1]))),
            o["push"](jia_n(t[index][2]));
    }
    return r["join"]("") + "!!" + i["join"]("") + "!!" + o["join"]("");
}

到这里上面的 l 的值可以写为如下格式:

javascript
// jia_1(cha)轨迹加密字符串,two_res["c"]、two_res["s"]上面第二部分中的请求返回的c参数、s参数。
l = n["$_CICa"]["$_BBEI"](jia_1(cha), two_res["c"], two_res["s"]);

继续还原最后一个 n["$_CICa"]["$_BBEI"] 未知函数,还原后如下:

20230715155149

20230715155358

javascript
function jia_2(t, e, n) {
    var r, i = 0, o = t, s = e[0], a = e[2], _ = e[4];
    while (r = n["substr"](i, 2)) {
        i += 2;
        var c = parseInt(r, 16)
            , u = String["fromCharCode"](c)
            , l = (s * c * c + a * c + _) % t["length"];
        o = o["substr"](0, l) + u + o["substr"](l);
    }
    return o;
}

到这里 l 值的逻辑就全部逆向完毕,也就是 aa 值的逻辑全部逆向完毕:

javascript
// jia_2()轨迹二次加密的函数
l = jia_2(jia_1(cha), two_res["c"], two_res["s"]);
passtime

继续看 passtime 值,通过名称猜测为滑块的滑动时间,经过测试确实是这个值,那么我们可以直接使用 track 轨迹中的最后一个数组中的最后一个元素:

python
passtime = track[-1][-1]
imgload

继续看 imgload 值,通过名称猜测为图片加载时间,经过测试这个可以给一个随机值:

python
imgload = 96
ep

继续看 ep 值,它来源于 r["$_CCCP"]() 的返回值,进入到这个函数中得到如下规律:

20230715161728

javascript
"ep": {
    "v": "7.9.0",  // 固定值
    "$_BIo": False,  // 固定值
    "me": True,  // 固定值
    "tm": new bt()[$_CBGFG(726)](),  // 变化值
    "td": -1  // 固定值
}

继续进入 new bt()[$_CBGFG(726)] 函数中,发现值来源于下面的 this[$_BJBEc(677)] 函数中,经过几次测试我们得到如下一个规律:

20230715162832

python
# 时间戳
now_time = int(time.time() * 1000)
tm = {
    "a": now_time,
    "b": now_time + 401,
    "c": now_time + 401,
    "d": 0,
    "e": 0,
    "f": now_time + 1,
    "g": now_time + 12,
    "h": now_time + 23,
    "i": now_time + 23,
    "j": now_time + 83,
    "k": now_time + 59,
    "l": now_time + 84,
    "m": now_time + 433,
    "n": now_time + 436,
    "o": now_time + 448,
    "p": now_time + 632,
    "q": now_time + 632,
    "r": now_time + 638,
    "s": now_time + 644,
    "t": now_time + 644,
    "u": now_time + 644
}
rp

到这里 rp 值就很好还原了,上面的生成逻辑现在就可以写为如下格式了:

javascript
o['rp'] = X(two_res["gt"] + two_res["challenge"][:32] + str(o["passtime"]))

由于每次生成的值都是32位,经过比对这里使用了标准的md5哈希算法,因此 rp 值可以写为:

python
rp = two_res["gt"] + two_res["challenge"][:32] + str(o["passtime"])
o["rp"] = md5(rp.encode('utf-8')).hexdigest()

到这最终的 o 值就已经生成了:

python
# o值
now_time = int(time.time() * 1000)
o = {
    "lang": "zh-cn",
    "userresponse": js_code.call('H', distance, two_res['challenge']),
    "passtime": track[-1][-1],
    "imgload": 96,
    "aa": slide_track,
    "ep": {
        "v": "7.9.0",
        "$_BIo": False,
        "me": True,
        "tm": {
            "a": now_time,
            "b": now_time + 401,
            "c": now_time + 401,
            "d": 0,
            "e": 0,
            "f": now_time + 1,
            "g": now_time + 12,
            "h": now_time + 23,
            "i": now_time + 23,
            "j": now_time + 83,
            "k": now_time + 59,
            "l": now_time + 84,
            "m": now_time + 433,
            "n": now_time + 436,
            "o": now_time + 448,
            "p": now_time + 632,
            "q": now_time + 632,
            "r": now_time + 638,
            "s": now_time + 644,
            "t": now_time + 644,
            "u": now_time + 644
        }
    }
}
rp = two_res["gt"] + two_res["challenge"][:32] + str(o["passtime"])
o["rp"] = md5(rp.encode('utf-8')).hexdigest()
加密函数

回到前面,现在我们就已经解决大部分未知值了,就差一个 V["encrypt"] 加密函数:

javascript
// o就是上面的o值,message就是上面调用rt()函数的返回值的变量
l = V["encrypt"](JSON.stringify(o), message)

经过定位发现 V["encrypt"] 加密函数是一个返回函数,里面引用了外部值,这时我们就要考虑将整个 V 对象的实现给扣下来:

20230715164557

当前搜索一下 V = 发现在有赋值,我们刷新一下页面,断点断住,整个是一个自执行函数,我们将其全部扣下来并还原:

20230715165114

由于该函数过长这里就不展示代码了,反正扣下来后,能就返回加密值了,也就是 l 的值。

逆向h值

逆向h值就很简单了,由于 l 值已经生成,只有 m[$_CAIAj(783)] 函数是未知的:

javascript
h = m[$_CAIAj(783)](l)

经过定位发现 m[$_CAIAj(783)] 函数里面也引用了其他的函数,同上面一样,这时我们就要考虑将整个 m 对象的实现给扣下来:

20230715165900

我们往上面寻找,发现 m 对象就在上面进行了赋值,直接整个扣下来复原即可:

20230715170043

由于该函数过长这里就不展示代码了,反正扣下来后,能就返回 h 值了,最后再加上 u 值就是我们需要的 w 值了。

效果展示

这里我们展示一下最终的成品效果,结果当中返回了 validate 就说明成功通过极验三代滑块了:

20230715170809