免责声明:本文章中所有内容仅供学习交流,抓包内容、敏感网址、数据接口均已做脱敏处理,严禁用于商业用途和非法用途,否则由此产生的一切后果均与作者无关,若有侵权,请联系我立即删除!

逆向目标

逆向过程

把要做的事情拆分为几个步骤,分别为梳理请求关系,滑块验证码底图还原,滑块验证码w参数逆向,补环境,自动过验证码。

请求关系梳理

请求罗列

  • 第一个请求:https://napi-huawei.tianyancha.com/validate/init?_=1683636853751

    入参:当前时间戳;

    返回值:gt和challenge。

  • 第二个请求:https://api.geevisit.com/gettype.php?

    入参:第一个请求得到的gt和一个由时间戳拼接成的固定的参数callback;

    返回值:验证码的类型及其相关的资源文件。

  • 第三个请求:https://api.geevisit.com/get.php?

    入参:gt,challenge,callback以及第二个请求得到的资源文件信息。

    返回值:新的challenge,带缺口的乱序错位验证码底图,不带缺口的乱序错位验证码底图以及滑块图

  • 第四个请求:https://api.geetest.com/ajax.php?

    入参:gt,新的challenge,w值,callback

    返回值:滑块验证码是否验证通过

关系梳理

为了描述方便分别把第一至四个请求命名为A~D。A从天眼查服务端拿到一个gt和challenge,B用拿到的gt,challenge以及当前时间戳向极验服务器请求并拿到验证码js文件,C利用B拿到的资源文件信息以及gt,challenge去请求极验服务器拿到滑块验证码带缺口和不带缺口的乱序底图,这些底图经过前段js文件渲染就呈现出我们看到的滑块验证码的样子;D请求通过传入gt,challenge,w值和callback请求极验服务器完成对滑块验证码的验证。

滑块验证码底图还原与滑动距离计算

底图还原

要想计算滑块需要移动的距离,就需要先将乱序的验证码图片变成有序。

先看下滑块的大小:

image-20220420163229256

图片大小 w = 260px, h = 116px。我们点击图片选择审查元素,可以看到底图是由52个div组成,每个div的w = 10px,h = 58px。分为上下两个半区,每个半区26个div。刚好组成260px * 116px的矩形验证码。如下图:

image-20220420163621256

可以看到第一个div,即上半区左上角的第一个div,background-position = -157px -58px。表示将background-image向左偏移157个像素,向上偏移58个像素,作为第一个div放在上半区最左边。由于前面分析过,每个div的宽是10px,高是58px。所以第一个div四个顶点在background-image上的相对坐标是(157, 58), (167, 58), (157, 116), (167, 116)。

同理,我们推测上半区第二个div的四个顶点的相对坐标分别是(145, 0), (155, 0), (145, 58), (155, 58)。

此外,background-image就是我们抓包分析的第三步获取到的乱序图。

知道了每一个个div的坐标,以及乱序的背景图,就可以通过从乱序图上裁剪出一个个div,然后再拼接到一起,这样不就构成了正确有序的图片。

知道了原理,代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import math
from PIL import Image


div_offset = [
{"x": -157, "y": -58},
# 省略若干行
{"x": -205, "y": 0}
]


def restore_pic(pic_path, new_pic_path):
unordered_pic = Image.open(pic_path)
ordered_pic = unordered_pic.copy()

# 裁剪并拼接
for i, d in enumerate(div_offset):
im = unordered_pic.crop((math.fabs(d['x']), math.fabs(d['y']), math.fabs(d['x']) + 10, math.fabs(d['y']) + 58))
# 上半区
if d['y'] != 0:
ordered_pic.paste(im, (10 * (i % (len(div_offset) // 2)), 0), None)
else:
ordered_pic.paste(im, (10 * (i % (len(div_offset) // 2)), 58), None)

ordered_pic.save(new_pic_path)


if __name__ == '__main__':
restore_pic("img.png", "new_img.png")

解释一下上面用到的PIL库的几个方法:copy表示复制一张图片;crop表示以矩形区域裁剪,入参是一个四个元素的元组,分别是矩形左上角顶点的x坐标,左上角顶点的y坐标,右下角顶点的x坐标,右下角顶点的y坐标;paste表示粘贴图片。

测试效果如下:

不带缺口的乱序背景图以及还原后的图片:

不带缺口的乱序背景图 new_1

带缺口的乱序背景图以及还原后的图片:

2 new_2

还原图后边依然存在乱序的部分,但是这些乱序的地方已经超出260px,实际上不会展示到页面上,也即没有影响。

计算滑动距离

既然底图已经还原了,接下来就是缺口位置的计算,从而得到滑块需要滑动的距离。缺口计算有2种方式,一种是采用深度模型识别缺口坐标,参考文章如何利用深度学习识别滑块验证码缺口位置

第二种是计算图片的每个像素点位置的色差去判断缺口,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def diff_rgb(rgb1, rgb2):
return math.fabs(rgb1[0] - rgb2[0]) + math.fabs(rgb1[1] - rgb2[1]) + math.fabs(rgb1[2] - rgb2[2]) > 255


def get_moving_dst(complete_pic_path, incomplete_pic_path):
complete_pic = Image.open(complete_pic_path)
incomplete_pic = Image.open(incomplete_pic_path)

w, h = complete_pic.size

for i in range(0, w):
for j in range(0, h):
complete_pic_pixel_rgb = complete_pic.getpixel((i, j))
incomplete_pic_pixel_rgb = incomplete_pic.getpixel((i, j))
if diff_rgb(complete_pic_pixel_rgb, incomplete_pic_pixel_rgb):
return i

return 0

w参数逆向与滑块轨迹模拟

首先对geetest.6.0.9.js这个文件进行反混淆,将二进制数字转化为十进制数,将base64编码的字符串转化为ASCII码字符,最后将字面量还原。将Js文件进行反混淆处理后在浏览器中进行override,覆盖线上版本,进行本地调试。

w参数逆向

前面分析过,最终向极验后端提交的四个参数中,gt和challenge都是通过其它接口返回的,callback参数是当前时间戳生成的,只有w参数需要逆向。

全局搜索"w":,发现如下代码:

image-20230514210118875

可以看到,w是由r7z和H7z拼接而成。

逆向r7z

整理下代码逻辑如下:

1
r7z = p7B.Ha(n0B.encrypt(h7B.stringify(Y7z), v7z.wb()))

先看最里面的,全局搜索wb,发现wb实际上调用的是C7B方法,如下图:

image-20230514213545968

再全局搜索下C7B(搜索的时候注意区分大小写,这样排除了很多干扰项),发现C7B实际上是四次调用H1W方法,并把四次返回结果拼接在一起,如下图:

image-20230514213850874

再看下H1W方法的源码,如下:

image-20230514214049697

不再调用其它封装的方法,综上,整理出来wb方法,如下:

1
2
3
4
5
var H1W = function() {
return (65536 * (1 + Math.random()) | 0).toString(16).substring(1)
}

var wb = H1W() + H1W() + H1W() + H1W();

然后是h7B.stringify,经过测试,h7B.stringify这个方法的作用等同于JSON.stringify。

接着看下n0B.encrypt,定位到代码如图所示:

image-20230515013105304

传入的三个参数,第一个r7W是JSON.stringify(Y7z),第二个m7W是随机的字符串即上边的wb,第三个变量P7W未使用。简单分析下代码,不难看出这个encrypt方法实际上是对AES加密算法做了一个封装,然后自定义了一些逻辑。其中,m7W用作生成key,0000000000000000用作生成iv。

代码修改如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const CryptoJS = require('crypto-js'); 

function Encrypt(word, key, iv) {
let srcs = CryptoJS.enc.Utf8.parse(word);
let encrypted = CryptoJS.AES.encrypt(srcs, key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 });
return encrypted.ciphertext;
}

function encrypt(r7W, m7W, P7W) {
var p2r = 0;

const key = CryptoJS.enc.Utf8.parse(m7W);
const iv = CryptoJS.enc.Utf8.parse('0000000000000000'); //十六位十六进制数作为密钥偏移量

for (var W7W = Encrypt(r7W, key, iv), Z7W = W7W.words, H7W = W7W.sigBytes, d7W = [], l7W = 0; p2r * (p2r + 1) * p2r % 2 == 0 && l7W < H7W; l7W++) {
var q7W = Z7W[l7W >>> 2] >>> 24 - l7W % 4 * 8 & 255;
d7W["push"](q7W);
p2r = p2r > 33997 ? p2r / 5 : p2r * 5;
}
return d7W;
}

这段代码用到了crypto-js包,需要用命令npm install crypto-js安装。

再接着看下p7B.Ha这个方法,代码如下:

image-20230515014645346

Ha调用T6B.Ga方法,跟进去:

image-20230515014959508

代码如图,直接抠出来整个Ga代码,只不过图中圈出的this在node环境中需要补一下,经过调试,this中包含如下的方法和值友用到:

image-20230515015327784

经过流程平坦化之后,完整代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
var Ga = function (o6B, t6B) {
var D5r = 27;
var I9z = 3;
var X6B = {
"wa": 7274496,
"xa": 9483264,
"ya": 19220,
"za": 235,
"Aa": 24,
}
X6B.Da = function(r0B) {
var v9z = 1;
var h0B = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789()";
return (r0B < 0 || r0B >= h0B["length"]) && v9z * (v9z + 1) % 2 + 8 ? "." : h0B["charAt"](r0B);
}
X6B.Fa = function(R0B, C0B) {
return R0B >> C0B & 1;
}
t6B || (t6B = X6B);
var N6B = function (Q6B, x6B) {
var I6B = 0, v6B = t6B["Aa"] - 1;
while (v6B >= 0) {
if (v6B >= 0) {
1 === X6B["Fa"](x6B, v6B) && (I6B = (I6B << 1) + X6B["Fa"](Q6B, v6B));
v6B -= 1;
} else {
return I6B;
}
}
return I6B;
},
j6B = "",
K6B = "",
c6B = o6B["length"],
f6B = 0;

while (f6B < c6B && I9z * (I9z + 1) % 2 + 3) {
var B6B;
if (f6B + 2 < c6B) {
B6B = (o6B[f6B] << 16) + (o6B[f6B + 1] << 8) + o6B[f6B + 2], j6B += X6B["Da"](N6B(B6B, t6B["wa"])) + X6B["Da"](N6B(B6B, t6B["xa"])) + X6B["Da"](N6B(B6B, t6B["ya"])) + X6B["Da"](N6B(B6B, t6B["za"]));
} else {
var n6B = c6B % 3;
2 === n6B ? (B6B = (o6B[f6B] << 16) + (o6B[f6B + 1] << 8), j6B += X6B["Da"](N6B(B6B, t6B["wa"])) + X6B["Da"](N6B(B6B, t6B["xa"])) + X6B["Da"](N6B(B6B, t6B["ya"])), K6B = t6B["r"]) : 1 === n6B && (B6B = o6B[f6B] << 16, j6B += X6B["Da"](N6B(B6B, t6B["wa"])) + X6B["Da"](N6B(B6B, t6B["xa"])), K6B = t6B["r"] + t6B["r"]);
}
I9z = I9z > 53617 ? I9z - 7 : I9z + 7;
f6B += 3;
}
return {
"res": j6B,
"end": K6B
};
}

var Ha = function (M6B) {
var L6B = Ga(M6B);
return L6B["res"] + L6B["end"];
}

最后看下压轴部分,Y7z的代码:

image-20230515051007195

可以看到Y7z的属性有userresponse, passtime, imgload, aa, ep, rp。

先看下i7B.C方法,直接将这个方法扣下来拿来用即可,在node中测试不需要补环境。

接着看下c7B[“a”]这个方法,实际上是取得c7B[“Na”]这个对象的属性,为了方便跟踪这个对象,在浏览器watch栏中添加这个对象,然后给滑块添加一个断点,如下:

image-20230515144736431

拖动滑块,进入单步调试,调试结果如下:

image-20230515143849349

可以看到c7B[“Na”]对象的相关属性来自于L7z这个光标事件。其中有一个数组arr,里面每一个元素都是一个三维向量,分别代表x轴坐标,y轴坐标和经过的时间,这个数组即用来保存滑块的移动轨迹。

其中passtime是滑块滑动所需的时间,可以由轨迹数组计算出来。ep是版本号,这里写死为{v: "6.0.9"}即可。aa则是由轨迹数组经过加密生成的字符串。imgload是加载的图片数量,经过测试,给一个随机值即可。userresponse是调用i7B[“C”]生成的,这个方法已经扣下来了,参数g7z和challenge,challenge是由接口返回,所以只需要计算g7z即可。

经过调试,passtime和g7z的计算方法为:

1
2
3
4
5
6
7
let passtime = 0;
let g7z = 0;
for (let index = 0; index < X1z.length; index++) {
passtime += X1z[index][2];
g7z += X1z[index][0];
}
g7z -= X1z[0][0];

紧接着看下aa,断点进入,代码如下:

image-20230515154908221

整理下这个t方法,并且抠出其调用的方法,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
O6z = function (r6z) {
var d6z = "()*,-./0123456789:?@ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqr",
m6z = d6z["length"],
Z6z = "",
H6z = Math["abs"](r6z),
W6z = parseInt(H6z / m6z);
W6z >= m6z && (W6z = m6z - 1), W6z && (Z6z = d6z["charAt"](W6z)), H6z %= m6z;
var q6z = "";
return r6z < 0 && (q6z += "!"), Z6z && (q6z += "$"), q6z + Z6z + d6z["charAt"](H6z);

}

u6z = function (R6z) {
var t8r = 27;
var f5r = 9;
var z6z = [[1, 0], [2, 0], [1, -1], [1, 1], [0, 1], [0, -1], [3, 0], [2, -1], [2, 1]],
h6z = 0,
C6z = z6z["length"];
while (h6z < C6z && f5r * (f5r + 1) % 2 + 7) {
if (R6z[0] === z6z[h6z][0] && R6z[1] === z6z[h6z][1]) {
return "stuvwxyz~"[h6z];
} else {
f5r = f5r >= 62252 ? f5r - 6 : f5r + 6;
h6z++;

}
}
return 0;
};

var t = function (X1z) {
var o5r = 6;
var N1z,
f1z = [],
B1z = [],
o1z = [],
t1z = 0,
j1z = X1z["length"];
while (o5r * (o5r + 1) % 2 + 8 && t1z < j1z) {
N1z = u6z(X1z[t1z]), N1z ? B1z["push"](N1z) : (f1z["push"](O6z(X1z[t1z][0])), B1z["push"](O6z(X1z[t1z][1]))), o1z["push"](O6z(X1z[t1z][2]));
o5r = o5r >= 17705 ? o5r / 3 : o5r * 3;
t1z++;
}
return f1z["join"]("") + "!!" + B1z["join"]("") + "!!" + o1z["join"]("");
}

t方法调用传入的X1z就是滑块滑动产生的轨迹数组。

这里调试会发现,生成的aa与页面上的不一致,实际上F7z除了由上边的t方法修改之外,还有一个地方也修改了,如图:

image-20230516000744285

调用了e7B.u方法,接收3个参数,第一个是上面t方法生成的初步的F7z,第二个是c,第三个是s,其中c和s都是通过接口拿到。所以这里的重点工作是抠出e7B.u方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var e7B = {};
e7B.u = function (Q1z, v1z, T1z) {
var K5r = 2, j5r = 4;
if ((!v1z || !T1z) && j5r * (j5r + 1) * j5r % 2 === 0 )
return Q1z;
var i1z, x1z = 0, c1z = Q1z, y1z = v1z[0], k1z = v1z[2], L1z = v1z[4];
while ((i1z = T1z["substr"](x1z, 2)) && K5r * (K5r + 1) * K5r % 2 === 0) {
x1z += 2;
var n1z = parseInt(i1z, 16),
M1z = String["fromCharCode"](n1z),
I1z = (y1z * n1z * n1z + k1z * n1z + L1z) % Q1z["length"];
c1z = c1z["substr"](0, I1z) + M1z + c1z["substr"](I1z);
K5r = K5r > 10375 ? K5r / 8 : K5r * 8;
}
return c1z;
}

最后看下rp属性,不难看出rp是由gt,challenge的前32位,passtime经过md5加密算法生成。代码如下:

1
2
3
4
var crypto = require('crypto');
var md5 = crypto.createHash('md5');

Y7z["rp"] = md5.update(gt + challenge.slice(0, 32) + passtime).digest('hex');

至此,w参数的前半部分r7z剖析完了。

逆向H7z

进入断点调试,可以看到H7z是调用V7z.Ub方法生成。

image-20230515225024832

抠出V7z.Ub方法,并补全其中用到的RSA算法,代码如下:

1
2
3
4
5
6
7
8
9
10
from jsbn import RSAKey


def get_H7z(wb):
rsa = RSAKey()
rsa.setPublic("00C1E3934D1614465B33053E7F48EE4EC87B14B95EF88947713D25EECBFF7E74C7977D02DC1D9451F79DD"
"5D1C10C29ACB6A9B4D6FB7D0A0279B6719E1772565F09AF627715919221AEF91899CAE08C0D686D748B20"
"A3603BE2318CA6BC2B59706592A9219D0BF05C9F65023A21D2330807252AE0066D59CEEFA5F2748EA80BAB81",
"10001")
return rsa.encrypt(wb)

代码说明:需要安装RSA库,用pip install pyjsbn-rsa命令安装即可,因为没有找到合适的Node库,所以采用Python库。其中的RSA public key相关信息单步调试的时候可以拿到,wb是一个随机生成的字符串,产生随机字符串的代码在r7z部分已经逆向完成。特别注意:r7z和H7z通过这个随机字符串关联起来,生成r7z和H7z用到的随机字符串必须是同一个,所以wb最好定义为全局变量,并且全局生成一次。

滑块轨迹模拟

极验滑块的轨迹主要有三种方式,一种是直接用网上现有的滑动轨迹模型,另一种是自己搭建模型自己训练。这里介绍第三种,手动滑动滑块得到正确的拼图,同时保存滑块轨迹,数据结构采用key-value形式,key是滑块需要的滑动的距离,value是轨迹数组,通过上百次的滑动,预先建立一个轨迹字典,下次滑动时通过距离从这个字典中直接拿到轨迹数组。

具体实现如下:

  1. 用Flask搭建一个轨迹收集服务
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import json
import math
import pickle

from flask import Flask, request
from flask_cors import cross_origin

app = Flask(__name__)

@app.post("/track")
@cross_origin(supports_credentials=True, methods="*", allow_headers="*")
@cross_origin()
def track():
tracks = pickle.load(open("tracks.pkl", "rb"))
d = json.loads(request.data.decode())
print(d)

# 下载乱序的缺口图和完整图
download_image(d['bg'], "bg.png")
download_image(d['fullbg'], "fullbg.png")

# 还原乱序的缺口图和完整图
restore_pic("bg.png", "new_bg.png")
restore_pic("fullbg.png", "new_fullbg.png")

# 获取缺口的位置
x = get_moving_dst("new_bg.png", "new_fullbg.png")

if tracks.get(x):
tracks[x].append({'track': d['track'], 'g7z': d['g7z']})
else:
tracks[x] = [{'track': d['track'], 'g7z': d['g7z']}]
pickle.dump(tracks, open("tracks.pkl", "wb"))

return 'ok'


def download_image(url, image_file):
with open(image_file, "wb") as f:
f.write(requests.get(url).content)


if __name__ == '__main__':
track_data = {}
pickle.dump(track_data, open("tracks.pkl", "wb"))
app.run(host='0.0.0.0', port=8088)
  1. 注入Js代码

注入Js代码,每次滑动时向收集服务发送请求,将轨迹数组传递过去。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
const Http = new XMLHttpRequest();
const url='http://127.0.0.1:8088/track';
Http.open("POST", url);
Http.onload = function () {
console.log("请求成功, track: " + JSON.stringify(X1z));
// 请求结束后,在此处写处理代码
};
Http.send(JSON.stringify({
track: X1z,
bg: /\"(.*?)\"/g.exec(document.getElementsByClassName("gt_cut_bg_slice")[0].style.backgroundImage)[1],
fullbg: /\"(.*?)\"/g.exec(document.getElementsByClassName("gt_cut_fullbg_slice")[0].style.backgroundImage)[1],
g7z
}));

代码插入位置如图:

image-20220428155854981

通过手动过滑块就可以把轨迹数组收集到tracks.pkl文件了。

完整代码测试及总结

测试

测试结果如下:

image-20230517005330218

总结

极验滑块之前发过相关的文章了,当时用到的方法是将整个Js文件抠下来然后补环境,整个代码有大几千行,这次换了个思路,只是抠调用到的代码,整个下来也就300行左右。

若需要完整代码,扫描加微信。

image-20230517010053227