你可能曾经遇到过需要在网站登录时校验验证码的情况,这就是一种叫做“极验”的安全技术服务。极验是行为式验证安全技术服务的领先者,他们提供验证码服务,让用户在登录网站时需要输入验证码来确保安全性。如果你曾经在某些网站登录时需要校验验证码,那很有可能这个验证码就是由极验提供的。
PS:动手能力弱得朋友可以直奔www.ttocr.com进行使用。
例如某“XX”官网,它使用了极验验证码来保护用户账户的安全。一种常见的破解极验验证码的方法是计算缺口位置,使用Selenium自动化测试工具模拟人类手动拖动滑块的过程。虽然这种方法实现简单,但存在两个缺点:首先,模拟滑动容易被极验检测到自动化软件,导致滑动操作失败;其次,每次登录任务都需要驱动浏览器,登录耗时较长,不适合实时性要求较高的数据采集任务。
因此,一些人选择更艰难的方法,他们死磕JavaScript代码,破解每个请求中的加密参数,然后在程序中发送请求以获取正确响应。这种方法最直接且高效,无需等待浏览器对页面进行渲染,只要解出加密的请求参数,发送请求获取响应即可。需要注意的是,用户在浏览器中的操作最终都会转化为请求发送到服务端,服务端对请求参数进行合法性校验,并响应结果。
请求参数分析
我使用登录方法作为分析入口。我输入错误的账号密码,然后点击滑动验证码直到正确的位置。此后,页面会提示“账户名或密码错误!”。我查看浏览器的Network栏,发现登录动作实际上是发送了一个名为“accLoginPC.do”的请求。
该请求的URL为“https://xxx.xxx/webapi/accLoginPC.do”(已隐去敏感信息)。
该请求参数Form Data中包含多个参数,如“appId”、“loginName”、“loginPwd”、“geetest_challenge”等。可以看到,密码已被加密为一长串字符“CN-SA95...”,另外还有三个以“geetest_”开头的加密参数,分别为:
• “geetest_challenge: 102f7d723ad76e387ad6000f87ff91f8j3”
• “geetest_validate: 651ecdf62cb1e940e5ea999b6af7fc10”
• “geetest_seccode: 651ecdf62cb1e940e5ea999b6af7fc10|jordan”
从参数命名上,我们可以清楚地看到这是极验滑动验证码的加密参数。也就是说,我们的点击和拖动操作最终会转换为这三个加密参数。因此,我们的主要工作就是破解这三个参数。注意到,其中的“geetest_validate”和“geetest_seccode”参数基本相同,只是后者多了一个“|jordan”的字符串后缀。因此,我们的主要工作是解密“challenge”和“validate”两个参数。
对于其他非极验加密参数,如“loginPwd”、“jtSafeKey”、“token”等,这些都是XX官网自身的加密逻辑,破解难度不大。因此,在本文中,我们将重点关注极验参数的破解。
我们进一步查看,发现了一个名为“ajax.php?”的请求,其中响应信息类似于JSON格式,其中包含“validate”参数。惊讶的是,这不就是我们之前提到的“geetest_validate”参数吗?
仔细观察其请求参数,我们发现它包含“gt”、“challenge”、“lang”、“w”和“callback”等参数。其中,“w”被加密为一长串字符。
通过分析,我们逐渐理清了思路,确定了每个请求需要解密哪些参数。接下来,我们需要查看 JavaScript 代码中这些参数的加密方式,逐步解决这个问题。
然而,极验为了增加破解的难度,对代码进行了大量的混淆操作,使得代码几乎无法被理解。它采用了 Unicode 编码和大量混淆代码,这导致我们无法直接搜索关键参数名如“challenge”来找到相关代码。
因此,我们需要将完整的 JavaScript 代码复制出来,然后使用一个 Unicode 编码还原工具进行解码。解码后,我们可以将代码复制到编辑器中,搜索“challenge”参数,终于找到了几个关键的参数。
然而,这些代码仍然难以阅读,因为极验对每个关键参数都进行了混淆。它使用类似于“UtTS”这样的代码进行替换,具体的混淆逻辑藏在 JavaScript 代码中。我们需要进一步还原代码。
此外,我们还会发现一些看起来毫无意义的代码,例如“var xow_list = uklgT.xow”。经过研究,我们发现这实际上是极验加入的冗余代码,主要目的是为了混淆视听。
比如,对于这样的冗余代码:
function tPcX(e) {
var SkB = uklgT.yaA()[0][22];
for (; SkB !== uklgT.yaA()[16][19]; ) {
switch (SkB) {
case uklgT.yaA()[0][22]:
var t = this;
var r = e["DxJq"];
SkB = uklgT.yaA()[0][21];
break;
case uklgT.yaA()[16][21]:
r["height"] = r["width"] = 0;
t["vjyG"] = r["getContext"]("2d");
SkB = uklgT.yaA()[4][20];
break;
case uklgT.yaA()[4][20]:
t["wOTb"] = t["xmDd"] = t["yZRm"] = t["AZ_O"] = 0;
t["BnKG"] = r;
SkB = uklgT.yaA()[4][19];
break;
}
}
}
去冗余之后,就变成了以下精简且易读的代码:
function tPcX(e) {
var t = this;
var r = e["DxJq"];
r["height"] = r["width"] = 0;
t["vjyG"] = r["getContext"]("2d");
t["wOTb"] = t["xmDd"] = t["yZRm"] = t["AZ_O"] = 0;
t["BnKG"] = r;
}
我们对多个 JavaScript 文件进行反混淆和去冗余的处理后,JavaScript的调用逻辑变得更加清晰了。此外,还需要从JavaScript代码中提取出关键的代码,即用于加密请求参数的代码。这个过程称为“代码解绑定”。 这个过程并不需要太高深的技巧,需要的是耐心和细心。通过在Chrome浏览器中打断点,分析请求的入口和出口,逐步剥离出关键代码。例如,如果需要解密参数a,就需要抽离出加密参数a的代码,并封装为一个名为“get_a()”的函数。将所需的JavaScript函数封装后,在Python程序中,我们可以使用PyExecJS库方便地执行JavaScript代码,以获取加密参数。以下是一些示例代码:slide.js/fullpage.js 等。
def get_js_object(js_file_path):
"""获取js可执行对象"""
with open(os.path.dirname(__file__) + js_file_path, encoding='GBK') as f:
js_file = f.read()
return execjs.compile(js_file)
pwd_encrypt_js = get_js_object(pwd_encrypt_js_path)
full_page_t1_js = get_js_object(full_page_t1_js_path)
full_page_w1_js = get_js_object(full_page_w1_js_path)
full_page_w2_js = get_js_object(full_page_w2_js_path)
u_js = get_js_object(u_js_path)
slide_u_js = get_js_object(slide_u_js_path)
slide_a_js = get_js_object(slide_a_js_path)
def get_encrypt_pwd(pwd):
"""获取加密后的密码"""
return pwd_encrypt_js.call('pwdEncrypt', pwd)
def get_full_page_t1(s):
"""获取fullpage的t1参数"""
return full_page_t1_js.call('get_t', s)
def get_full_page_w1(gt, challenge, s):
"""获取fullpage的w1参数"""
t = get_full_page_t1(s)
return full_page_w1_js.call('get_w', gt, challenge, s, t)
def get_full_page_w2(gt, challenge, s):
"""获取fullpage的w2参数"""
return full_page_w2_js.call('get_w', gt, challenge, s)
def get_slide_w(gt, challenge, s, offset, track):
"""获取slide的w参数"""
u = {
'lang': 'zh-cn',
'userresponse': u_js.call('getUserResponse', offset - 1, challenge),
'passtime': track[-1][-1],
'imgload': random.randint(110, 180),
'a': u_js.call("mouse_encrypt", track),
'ep': {"v": "1.2", "f": u_js.call("lmWn", gt + challenge)},
'rp': u_js.call("lmWn", gt + challenge[0:32] + str(track[-1][-1]))
}
u = slide_u_js.call('_encrypt', u, s)
a = slide_a_js.call('get_a', s)
return u + a
滑动轨迹采集
在完成代码解绑定之后,还需要做一件重要的工作,就是采集滑动轨迹。在之前的代码中,函数get_slide_w()中包含有偏移量offset和轨迹track。对于偏移量offset,我们需要获取两张验证码图片,分别有和没有缺口的版本,并通过对比像素计算出偏移量。而验证码图片还需要还原,因为我们通过URL获取到的图片是经过打乱的。我们需要根据极验的JavaScript代码对这两张打乱的图片进行还原,具体还原逻辑不再赘述。对于滑动轨迹track,若使用自动化工具模拟人类拖动滑块,容易被识别为机器行为而导致滑动失败。为了解决这个问题,我采用人工手动拖动验证码的方式收集一批轨迹,构成轨迹库。之后,只要算出滑动验证码缺口的偏移量,从轨迹库中找到与偏移量相近的轨迹即可。虽然有一定的错误率,比如,轨迹库不够丰富而导致取不到偏移量对应的轨迹,但是我们可以在接下来的程序里采用一种特殊的优化方式对其进行优化,可以保证每次登录100%成功。
以上便是整个极验滑动验证码破解的过程,再简单总结一下:
请求参数分析
代码反混淆
代码解绑定
滑动轨迹采集
其中,还包括验证码图片的还原,图片缺口的计算等等...
在文章中还提到了一种特殊的优化方式,即极验Session验证码池的设计。我们可以预先滑动好验证码(实际上就是发请求),然后把得到成功响应的Session存起来,放在一个池子中。这样做的好处主要有两点:提高登录效率,缩减耗时;解耦合,提高数据采集程序的健壮性。当有登录任务到来时,我们只需要从池子中取出一个极验Session,加上登录任务中的用户名/密码(按照XX官网,密码还需做加密)直接去登录,是不是既简单又高效呢?
本文主要介绍了如何设计一个自动更新和过期删除的验证码池子。作者使用 Redis Sorted Sets(zset)数据类型来存储极验 Session,将当前时间戳作为元素的 score,zset 会根据 score 对插入元素进行排序。这样,可以使用 zrevrange 命令方便地获取最新的一个验证码 session,并使用 zremrangebyscore 移除某个时间范围内的验证码,从而达到自动过期删除的目的。
作者提到,生产极验 Session 并放入池子的示例代码如下。
import json
import pickle
import re
import time
from io import BytesIO
from threading import Thread
from PIL import Image
from requests import session
from config import gt_register_url, common_login_headers, get_php_url, ajax_php_url, prefix_url, geetest_session_key
from param import get_full_page_w1, get_full_page_w2, get_track, get_slide_w, get_s
from utils.captcha import calculate_offset
from utils.fetch import fetch
from utils.logger import logger
from utils.response import Resp
from utils.times import now_str
from utils.zk import client
class GSession:
"""获取极验Session"""
def __init__(self):
self.session = session()
self.res = Resp.SUCCESS
self.gt = str()
self.challenge = str()
self.s = get_s()
self.validate = str()
self.sec_code = str()
self.bg_url = str()
self.full_bg_url = str()
self.offset = 0
self.track = list()
def set_gt_challenge(self) -> bool:
"""发送网络请求,拿到gt和challenge"""
params = dict(t=now_str())
resp = fetch(self.session, url=gt_register_url, headers=common_login_headers, params=params)
if resp is None:
logger.warning('无法获取gt/challenge...')
self.res = Resp.TIMEOUT
return False
res = resp.json()
logger.info('gt/challenge请求结果:{}'.format(res))
self.gt, self.challenge = res['gt'], res['challenge']
return True
def get_php(self):
"""注册参数s:s经过多层加密拼接成w"""
params = {
'gt': self.gt,
'challenge': self.challenge,
'lang': 'zh-cn',
'w': get_full_page_w1(self.gt, self.challenge, self.s),
'callback': 'geetest_' + now_str()
}
resp = fetch(self.session, url=get_php_url, headers=common_login_headers, params=params)
if resp is None:
logger.warning('无法注册参数s...')
self.res = Resp.TIMEOUT
return resp is not None
def ajax_php(self, step=1, params=None):
"""
step=1:发送请求,校验参数w
step=2:滑动滑块
"""
if step == 1:
params = {
'gt': self.gt,
'challenge': self.challenge,
'lang': 'zh-cn',
'w': get_full_page_w2(self.gt, self.challenge, self.s) + '1',
'callback': 'geetest_' + now_str()
}
resp = fetch(self.session, url=ajax_php_url, headers=common_login_headers, params=params)
if resp is None:
self.res = Resp.TIMEOUT
return False
if step != 1:
res = json.loads(re.search(r'(.∗?)
(
.
∗
?
)
', resp.text, re.S).group(1))
if res['data']['result'] != 'success':
self.res = Resp.SLIDE_ERR
return False
self.validate = res['data']['validate']
self.sec_code = self.validate + '|jordan'
return True
def get_slide_images(self):
"""获取验证码图片的地址"""
params = {
'is_next': 'true',
'type': 'slide3',
'gt': self.gt,
'challenge': self.challenge,
'lang': 'zh-cn',
'https': 'true',
'protocol': 'https://',
'offline': 'false',
'product': 'popup',
'api_server': 'captcha-api.com',
'width': '100%',
'callback': 'geetest_' + now_str()
}
resp = fetch(self.session, url=get_php_url, headers=common_login_headers, params=params)
if resp is None:
self.res = Resp.TIMEOUT
return False
res = json.loads(re.search(r'(.∗?)
(
.
∗
?
)
', resp.text, re.S).group(1))
# 获得滑动验证码图片的URL(带缺口+不带缺口)
self.bg_url = prefix_url + res['data']['bg']
self.full_bg_url = prefix_url + res['data']['fullbg']
logger.info('滑动验证码图片,bg_url:{}, full_bg_url:{}'.format(self.bg_url, self.full_bg_url))
# 更新gt/challenge
self.gt = res['data']['gt']
self.challenge = res['data']['challenge']
return True
def get_track(self):
"""获取滑动轨迹"""
resp1 = fetch(self.session, url=self.bg_url, headers=common_login_headers)
resp2 = fetch(self.session, url=self.full_bg_url, headers=common_login_headers)
if not (resp1 and resp2):
self.res = Resp.TIMEOUT
return False
img1 = Image.open(BytesIO(resp1.content))
img2 = Image.open(BytesIO(resp2.content))
# 计算偏移量
self.offset = calculate_offset(img1, img2)
# 根据偏移量获取轨迹
self.track = get_track(self.offset)
if self.track is None:
self.res = Resp.TRACK_ERR
return self.track is not None
def slide(self):
"""滑动滑块"""
params = {
'gt': self.gt,
'challenge': self.challenge,
'lang': 'zh-cn',
'w': get_slide_w(self.gt, self.challenge, get_s(), self.offset, self.track),
'callback': 'geetest_' + now_str()
}
return self.ajax_php(step=2, params=params)
def run(self):
self.set_gt_challenge() and self.get_php() and self.ajax_php() and self.get_slide_images() and self.get_track() and self.slide()
logger.info('获取极验session结果:{}'.format(self.res))
return self.res == Resp.SUCCESS
def produce_session():
"""生产极验session"""
while True:
gs = GSession()
try:
if gs.run():
res = pickle.dumps(gs.session)
client.zadd(geetest_session_key, {res: time.time()})
else:
logger.error('获取极验session请求出错')
except Exception as e:
logger.error('获取极验session失败,错误信息:{}'.format(e))
time.sleep(4)
def pop_session():
"""从池子中弹出极验session"""
res = client.zrevrange(geetest_session_key, 0, 0, withscores=True)
if res:
g_session, score = res[0]
g_session = pickle.loads(g_session)
client.zremrangebyscore(geetest_session_key, min=score, max=score)
return g_session
return None
if __name__ == '__main__':
thread_list = []
for i in range(4):
thread = Thread(target=produce_session)
thread.start()
thread_list.append(thread)
for thread in thread_list:
thread.join()
验证码定期删除的代码如下:
登录后复制
def expire_schedule():
"""定期删除"""
client.zremrangebyscore(geetest_session_key, 0, time.time() - 3600)
timer = threading.Timer(2, expire_schedule)
timer.start()
if __name__ == '__main__':
expire_schedule()
以上,全篇完!
如果上述代码遇到问题或已更新无法使用等情况可以联系Q:1436423940或直接访问www.ttocr.com测试对接(免费得哈)