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

逆向目标

逆向过程

抓包分析有2个接口,一个是获取验证码的接口https://gcaptcha4.geetest.com/load,一个是进行验证的接口https://gcaptcha4.geetest.com/verify

获取验证码接口

入参

参数如下:

1
2
3
4
5
6
7
8
{
"captcha_id": "54088bb07d2df3c46b79f80300b0abbe",
"challenge": "10cea755-08d2-4c7f-900f-d30d81301aa5",
"client_type": "web",
"risk_type": "match",
"lang": "zh",
"callback": "geetest_1684505012579"
}

client_type表示客户端类型为web,risk_type表示验证码类型为消消乐,lang表示语言为中文,callback为固定字符串geetest和时间戳做了一个拼接。

再看另外两个参数,captcha_id和challenge,通过浏览器内存漫游,轻松定位到challenge生成的位置:

image-20230518234233992

challenge由uuid这个方法生成,抠出相关代码如下:

1
2
3
4
5
6
7
var uuid = function () {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
var r = Math.random() * 16 | 0;
var v = c === 'x' ? r : r & 0x3 | 0x8;
return v.toString(16);
});
};

而另一个参数captcha_id则是固定值,为54088bb07d2df3c46b79f80300b0abbe

接口返回值

1
2
3
4
5
6
{
"lot_number": "224a2186c59f470cb73897f377843df5",
"payload": "11-UPJ-Jb2g3IpmYoaJlOw5fEieqchiSh9mIS5Ifj...",
"process_token": "d4a636b32cee705e5314b90bce43f71ead1f2b6a0b5cb44c50b71bf28f6f9423",
"ques": [[0, 1, 3], [3, 3, 0], [3, 2, 2]]
}

其中lot_number,payload,process_token在验证接口都有用到,而ques则是消消乐对应的图案,不同的数值对应不同的图案。

验证码验证接口

入参

参数如下:

1
2
3
4
5
6
7
8
9
10
11
{
"lot_number": "224a2186c59f470cb73897f377843df5",
"payload": "11-UPJ-Jb2g3IpmYoaJlOw5fEieqchiSh9mIS5Ifj...",
"process_token": "d4a636b32cee705e5314b90bce43f71ead1f2b6a0b5cb44c50b71bf28f6f9423",
"w": "73bfc2bde060aac064f99128586dc53c9ee05cc25840aa1dc77445727e2e86c342b8cde...",
"callback": "geetest_1684505905348",
"client_type": "web",
"risk_type": "match",
"payload_protocol": 1,
"pt": 1
}

其中lot_number,payload和process_token三个参数由获取验证码接口返回,只有w参数需要逆向。

w参数逆向

将gcaptcha4.js文件反混淆之后全局搜索"w":,定位到w参数生成位置,并且打上断点,如下图:

image-20230521201117842

w=d.default(JSON.stringify(e), s)。

对象e

对象e的结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
"passtime": 550,
"userresponse": [[1, 0], [2, 0]],
"device_id": "9f5faf6dc7a77e1d394c8634f0893812",
"lot_number": "224a2186c59f470cb73897f377843df5",
"pow_msg": "1|0|md5|2023-05-22T11:10:02.686802+08:00|54088bb07d2df3c46b79f80300b0abbe|224a2186c59f470cb73897f377843df5||62b052493785e2b7",
"pow_sign": "506fe742ff81d4bb3bf34892714fa2fc",
"geetest": "captcha",
"lang": "zh",
"ep": "123",
"e0vm": "915661778",
"em": {
"ph": 0,
"cp": 0,
"ek": "11",
"wd": 1,
"nt": 0,
"si": 0,
"sc": 0
}
}

需要解析出passtime,userresponse,device_id,pow_msg,pow_sign。

  1. device_id

搜索deviceId,找到如下代码片段:

image-20230522123227473

可以看到device_id是由一个base64编码的图片经过md5加密形成,而这个图片是一个固定的图片,所以device_id也是一个固定的值。

  1. pow_sign和pow_msg

搜索powSign,找到如下代码片段:

image-20230522124939116

跟踪进v.default这个方法,可以看到:

image-20230522131110429

pow_msg是由一些固定的值和captcha_id,lot_number,16位的随机字符串以及当前时间做的一个字符拼接,而pow_sign则是对pow_msg做了一个md5加密。

整理代码如下:

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

const guid = e() + e() + e() + e();

function e() {
return (65536 * (1 + Math["random"]()) | 0)["toString"](16)["substring"](1);
}

function s(lot_number) {
const chapterId = "54088bb07d2df3c46b79f80300b0abbe";
const hashFunc = "md5";
const version = 1;
const bits = 0;
let _ = version + "|" + bits + "|" + hashFunc + "|" + new Date().toISOString() + "|" + chapterId + "|" + lot_number + "|" + "" + "|";
let l = _ + guid;
return {
"pow_msg": l,
"pow_sign": md5.update(l).digest('hex')
}
}
  1. passtime

passtime表示验证码验证花费的时间,整个过程包括拖动第一个图案到与第二个图案交换完成消消乐的时间。这里没有特殊的检测,所以直接随机一个1s内的时间即可。

1
passtime = Math.round(Math.random() * 1000)
  1. userresponse

经过分析,userresponse数组就是消消乐需要进行交换的图案的坐标。前面说过load接口返回的ques数组是消消乐的每个图案,,比如说ques = [[0, 2, 2], [1, 0, 0], [3, 1, 2]]。

如下图:

image-20230522153528665

进行验证码验证时,userresponse正好是2个图案的坐标:

image-20230522153723329

可以看到(0, 0)和(1, 0)正好是上边的金字塔和昆虫的坐标,而交换第一行的金字塔和昆虫刚好第三行消除,完成消消乐。

用穷举的方法,写了一个消消乐查找算法,代码如下:

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
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
function getCol(ques) {
function checkCol(ques, i, j) {
let other = (j + 1) % 3
// 第1列出现重复元素
if (i === 0) {
// 看后一列
if (ques[i][other] === ques[i + 1][j])
return [[i, j], [i + 1, j]]
} else if (i === 1) {
// 第2列出现重复元素
// 看后一列
if (ques[i][other] === ques[i + 1][j])
return [[i, j], [i + 1, j]]
// 看前一列
if (ques[i][other] === ques[i - 1][j])
return [[i, j], [i - 1, j]]
} else {
// 第3列出现重复元素
// 看前一列
if (ques[i][other] === ques[i - 1][j])
return [[i, j], [i, j - 1]]
}
return []
}

// 看每一列是否经过一次交换就可消除
for (let i = 0; i < ques.length; i ++){
// 前面2个元素相同,看第三个元素是否可以经过一次交换产生相同元素
if (ques[i][0] === ques[i][1]) {
let check = checkCol(ques, i, 2)
// 第i列第三个元素可以与其隔壁交换
if (check.length !== 0)
return check
}

// 第1个元素与第3个元素相同,看第2个元素是否可以与其隔壁交换
if (ques[i][0] === ques[i][2]) {
let check = checkCol(ques, i, 1)
// 第i列第二个元素可以与其隔壁交换
if (check.length !== 0)
return check
}

// 第2个元素与第3个元素相同,看第1个元素是否可以与其隔壁交换
if (ques[i][1] === ques[i][2]) {
let check = checkCol(ques, i, 0)
// 第i列第一个元素可以与其隔壁交换
if (check.length !== 0)
return check
}
}
}

function get_userresponse(ques) {
// 根据列去判断是否可以消除
let arr = getCol(ques);
// 如果按列不可消除,按照行去判断
if (arr === undefined || arr.length === 0) {
// 把按照行判断转化为按照列判断
let new_ques = [];
for (let index = 0; index < ques.length; index++) {
new_ques[index] = [ques[0][index], ques[1][index], ques[2][index]];
}
arr = getCol(new_ques);
// 得到的结果再将列转化为行
let new_arr = [];
for (let index = 0; index < arr.length; index++) {
new_arr[index] = [arr[index][1], arr[index][0]]
}
return new_arr
} else
return arr;
}
  1. e0vm

watch变量e,发现代码运行过此处之后,e对象才有e0vm属性,如下图:

image-20230522233524398

跟踪进去_gct方法,调试并整理代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function FvBQ(t) {
var e = 5381;
var n = t.length;
var o = 0;
while (n--) {
e = (e << 5) + e + t.charCodeAt(o++);
}
e &= ~(1 << 31);
return e;
}

function GRmF(t) {
t['e0vm'] = FvBQ(GRmF.toString() + FvBQ(FvBQ.toString())) + '';
return FvBQ(FvBQ.toString());
}

console.log(GRmF({"geetest": "captcha", "lang": "zh", "ep": "123"}));
d.default

抠完对象e之后,看下整个得到w参数的加密算法d.default,之所以不着急去解剖s,是因为s这个对象属性太多,可以先跟踪进去d.default,看下d.default方法里面用了s对象的哪些属性,然后再反过来看下这些值是怎样生成的。

跟进去d.default方法,代码如下:

image-20230522174718441

参数e是前边逆向出来的e,上边红框的部分可以看到t值只是一种特殊情况,所以不必对t也就是前边说的s进行逆向。下边的红框可以看到w是由两部分组成,前半部分是由e和s经过AES算法加密得到的,整理代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function get_w(ques, lotNumber, guid) {
let e = get_e(ques, lotNumber, guid);
let c = encrypt(JSON.stringify(e), guid, "0000000000000000");
let o = [];
for(let a = 0, i = c.sigBytes; a < i; a++) {
var u = c.words[a >>> 2] >>> 24 - a % 4 * 8 & 255;
o.push(u);
}
return arrayToHex(o)
}

function arrayToHex(e) {
for (var t = [], n = 0, s = 0; s < 2 * e["length"]; s += 2) t[s >>> 3] |= parseInt(e[n], 10) << 24 - s % 8 * 4, n++;
for (var r = [], i = 0; i < e["length"]; i++) {
var o = t[i >>> 2] >>> 24 - i % 4 * 8 & 255;
r["push"]((o >>> 4)["toString"](16)), r["push"]((15 & o)["toString"](16));
}
return r["join"]("");
}

后半部分则是u,跟进去可以看到是一个RSA加密,如下图:

image-20230522225604931

跟进去这个对象,可以看到公钥,如下图:

image-20230522225737557

公钥与滑块验证码的公钥一致。

生成u的部分代码如下:

1
2
3
4
5
def rsa_encrypt(wb):
rsa = RSAKey()
rsa.setPublic("00C1E3934D16144...打码...66D59CEEFA5F2748EA80BAB81",
"10001")
return rsa.encrypt(wb)

运行与测试

运行结果如下:

image-20230522234224909

验证成功,不同于滑块验证码,只要交换的图案完成消消乐,成功率就是百分百。

若需要完整代码或者讨论,扫描加微信。

image-20230517010053227