免责声明:本文章中所有内容仅供学习交流,抓包内容、敏感网址、数据接口均已做脱敏处理,严禁用于商业用途和非法用途,否则由此产生的一切后果均与作者无关,若有侵权,请联系我立即删除!
逆向目标
逆向过程 把要做的事情拆分为几个步骤,分别为梳理请求关系,滑块验证码底图还原,滑块验证码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请求极验服务器完成对滑块验证码的验证。
滑块验证码底图还原与滑动距离计算 底图还原 要想计算滑块需要移动的距离,就需要先将乱序的验证码图片变成有序。
先看下滑块的大小:
图片大小 w = 260px, h = 116px。我们点击图片选择审查元素,可以看到底图是由52个div组成,每个div的w = 10px,h = 58px。分为上下两个半区,每个半区26个div。刚好组成260px * 116px的矩形验证码。如下图:
可以看到第一个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 mathfrom PIL import Imagediv_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表示粘贴图片。
测试效果如下:
不带缺口的乱序背景图以及还原后的图片:
带缺口的乱序背景图以及还原后的图片:
还原图后边依然存在乱序的部分,但是这些乱序的地方已经超出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":
,发现如下代码:
可以看到,w是由r7z和H7z拼接而成。
逆向r7z 整理下代码逻辑如下:
1 r7z = p7B.Ha (n0B.encrypt (h7B.stringify (Y7z), v7z.wb ()))
先看最里面的,全局搜索wb,发现wb实际上调用的是C7B方法,如下图:
再全局搜索下C7B(搜索的时候注意区分大小写,这样排除了很多干扰项),发现C7B实际上是四次调用H1W方法,并把四次返回结果拼接在一起,如下图:
再看下H1W方法的源码,如下:
不再调用其它封装的方法,综上,整理出来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,定位到代码如图所示:
传入的三个参数,第一个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这个方法,代码如下:
Ha调用T6B.Ga方法,跟进去:
代码如图,直接抠出来整个Ga代码,只不过图中圈出的this在node环境中需要补一下,经过调试,this中包含如下的方法和值友用到:
经过流程平坦化之后,完整代码为:
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的代码:
可以看到Y7z的属性有userresponse, passtime, imgload, aa, ep, rp。
先看下i7B.C方法,直接将这个方法扣下来拿来用即可,在node中测试不需要补环境。
接着看下c7B[“a”]这个方法,实际上是取得c7B[“Na”]这个对象的属性,为了方便跟踪这个对象,在浏览器watch栏中添加这个对象,然后给滑块添加一个断点,如下:
拖动滑块,进入单步调试,调试结果如下:
可以看到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,断点进入,代码如下:
整理下这个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方法修改之外,还有一个地方也修改了,如图:
调用了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方法生成。
抠出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是轨迹数组,通过上百次的滑动,预先建立一个轨迹字典,下次滑动时通过距离从这个字典中直接拿到轨迹数组。
具体实现如下:
用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 jsonimport mathimport picklefrom flask import Flask, requestfrom flask_cors import cross_originapp = 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 )
注入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 }));
代码插入位置如图:
通过手动过滑块就可以把轨迹数组收集到tracks.pkl文件了。
完整代码测试及总结 测试 测试结果如下:
总结 极验滑块之前发过相关的文章了,当时用到的方法是将整个Js文件抠下来然后补环境,整个代码有大几千行,这次换了个思路,只是抠调用到的代码,整个下来也就300行左右。
若需要完整代码,扫描加微信。