继上次给出了滑动验证码的处理方案后,本次带来的是一次滑动验证码认证逆向的实战,其目的是使用程序来代替人手来完成认证流程。
实战仍需用到上一篇文章中实现的代码,但本文不再对此做过多的讲解,如有疑惑,请前往阅读文章《一种可能可行的滑动认证码处理方案》。
图1 某云滑动验证码
目标网站地址:
https://music.163.com/
在正式开始之前,首先对文章做一个简要的概括,本次实战主要对目标网站的“https://c.dun.163.com/api/v3/get”、“https://c.dun.163.com/api/v3/check”两个接口进行分析,分别对应了“获取验证码”、“校验(验证码)”两个步骤。
预期的效果以校验接口/api/v3/check返回的数据为准, 当【result】为“true”及【validate】不为空时,表示校验通过。
图2 校验通过时返回的数据
所以文章的安排也已经比较明了了,一共分为三个部分,先是对获取验证码接口的逆向,然后是对校验验证码接口的逆向,最后整体调用方法。
另外,还有一些不太重要,但需要提前说明的事项。
1)本实战采用的是“极简”策略,简而言之,能采用默认&固定值就默认&固定值,能空则空,绝不做多余的事情;
2)不会复述一些基础逆向操作的同时(浏览器开发者工具相关、断点相关等),也不会给出太多细节,由于网站会经常进行迭代,编程的都知道,过于具体的代码,没有适应变化的能力,所以懂了... 没办法,逆向就是这样的,最好是读者自己上手(绝对不是因为觉得麻烦)。
截止到文章发布,本文涉及的代码仍可正常运行,但不排除后续会失效,一旦出现这种情况时,推荐采用排除法,具体为:将网站中生成的参数替换为本地参数,并查看执行结果,不断循环该步骤,最终可定位到问题所在,并加以解决。
下面开始补充细节部分。
一、获取验证码接口逆向
完整的方法如下,大多数参数可采用固定值,少数,如fp、cb等则需要分析js文件,虽不会详细描述分析的过程,但稍后会贴出参数生成的大致位置。
方法返回了验证码滑块和背景的链接,验证码令牌,以及cookie,校验时需要用到。
1、方法设计
def get_captcha(dt):
"""
获取验证码(url、token、cookies);在未获取到数据时,acToken会使用一个固定参数;该接口会检查以下请求头字段,缺少时将会导致后续的校验不通过
:param dt 设备id,可通过get_conf()获取;
:returns dict
"""
params = {
"referer": "https://music.163.com/#/download", "zoneId": "CN31",
"dt": dt,
"acToken": "9ca17ae2e6fecda16ae2e6eeb5cb528ab69db8ea65bcaeaf9ad05b9c94a3a3c434898987d2b25ef4b2a983bb2af0feacc3b92ae2f4ee95a132e29aa3b1cd72abae8cd1d44eb0b7bb82f55bb08fa3afd437fffeb3",
"id": "73a18dc827b24b18ad0783701a75277d",
"fp": captcha_js_ctx.call('generateFingerprint'),
"https": "true", "type": "undefined", "version": "2.27.2", "dpr": 2,
"dev": 1,
"cb": captcha_js_ctx.call('C22'),
"ipv6": "false",
"runEnv": 10, "group": "",
"scene": "", "lang": "zh-CN",
"sdkVersion": "undefined", "iv": 4, "width": 320,
"audio": "false", "sizeType": 10, "smsVersion": "v3", "token": "",
"callback": f'__JSONP_m3ilehc_{random.randint(1, 9)}'
}
headers = {
"Accept": "*/*",
"Accept-Encoding": "gzip, deflate, br, zstd",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Host": "c.dun.163.com",
"Referer": "https://music.163.com/",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/128.0.0.0 Safari/537.36",
}
r = requests.get(f'https://c.dun.163.com/api/v3/get?{urlencode(params)}', headers=headers)
cookies = r.cookies.get_dict()
r = re.compile(r'__JSONP_.*?\((.*?)\)')
return {
'cookies': cookies,
'bg': r.get('data').get('bg')[0],
'front': r.get('data').get('front')[0],
'token': r.get('data').get('token')
}
2、cb参数
这类由前端动态生成的参数,需要通过断点定位生成参数的算法,并移植到本地,以便在本地环境生成格式一致的数据。
图3 给获取验证码接口打上断点-1
图4 给获取验证码接口打上断点-2
定位到调用栈顶(图4红色部分),将鼠标移动到请求后可看到。
图5 给获取验证码接口打上断点-3
拦截到请求后,跟随调用栈,大概可在下图位置处找到如下代码:
图6 cb参数生成位置
之后顺着点过去,并将其复制到本地,并补全所依赖的代码即可。
3、fp参数
fp参数虽也在图6处赋值,但该处并非参数生成的位置。经分析后发现,fp取自window对象。
图7 fp赋值
其中,jK(0x300)方法返回的是“gdxidpyhxde”,这表示,此处可通过监听window属性变化的方式,来定位fp参数生成的位置。
在油猴中新建如下脚本:
当然,不用油猴直接在控制台插入代码也可以,只是说使用油猴比较方便。
// ==UserScript==
// @name 某云音乐调试工具
// @namespace http://tampermonkey.net/
// @version 2024-11-19
// @description try to take over the world!
// @author You
// @match https://music.163.com
// @icon https://www.google.com/s2/favicons?sz=64&domain=baidu.com
// @grant none
// ==/UserScript==
(function() {
'use strict';
// Your code here...
function listen(target, prop, container) {
const r = container || {};
Object.defineProperty(target, prop, {
enumerable: true,
configurable: true,
get: function() {
return r[prop];
},
set: function(value) {
console.log('已成功拦截' + prop + '属性变化:' + value);
r[prop] = value;
if (value) {
debugger;
}
}
});
return r;
}
var obj = listen(window, 'gdxidpyhxde');
console.log('网易云音乐调试工具运行中...');
})();
待脚本运行,并进入debugger模式后,可根据方法调用栈找到目标代码。
图8 fp参数生成
跟上个步骤一样,将代码补全。
ps:方法体内使用了大量的浏览器环境数据作为生成参数的依据,一般情况下,每个浏览器的数据都会存在差异。
二、校验接口逆向
也就是验证码中拖动滑块,并发送请求的部分。
与上一步类似,先给出最终的代码;不同的是,本步骤主要使用js代码来完成,主体python中只需将生成的url参数做拼接后发起请求。
1、方法设计
// 生成url参数;token从get_captcha()中获取;offset是缺口偏移量;scale为图片浏览器渲染与实际大小的比例;PX_4和PX_11是经尝试后得到的常量;
function generateParameters(token, offset, deviceToken) {
const captchaId = "73a18dc827b24b18ad0783701a75277d";
const scale = 2 / 3;
const PX_4 = 4;
const PX_11 = 11;
return _generateParameters(token, offset + PX_4, deviceToken, captchaId, atomTraceData, scale, PX_11)
}
function _generateParameters(token, offset, deviceToken, captchaId, _atomTraceData, scale, PX_11) {
const atomTraceData = filter(_atomTraceData, parseInt('' + (offset * scale + PX_11)));
const traceData = generateTraceData(token, atomTraceData);
const H = sample(traceData);
// 计算百分百:offset * scale / 320;下方滑块的偏移量会比缺块的偏移量大一些
const C1 = CC(C8(token, parseInt(offset * scale + '') / 320 * 0x64 + ''));
const C2 = h(unique2DArray(atomTraceData, 0x2));
const params = {
data: JSON.stringify({
d: CC(H.join(":")),
m: '',
p: C1,
f: CC(C8(token, C2.join(','))),
ext: CC(C8(token, '1,' + traceData.length))
}),
'id': captchaId,
'token': token,
'acToken': undefined,
'width': 320,
'type': 2,
'version': "2.27.2",
'cb': C22(),
'extraData': "",
'bf': 0,
'runEnv': 10,
'sdkVersion': undefined,
'iv': 4,
dt: deviceToken,
referer: 'https://music.163.com/#/download',
zoneId: "CN31",
"callback": "__JSONP_" + Math.random().toString(0x24).slice(0x2, 0x9) + "_1"
};
let r2 = [];
for (let k in params)
r2.push(encodeURIComponent(k) + '=' + encodeURIComponent(params[k]));
return r2.join('&');
}
其中,atomTraceData是一组在网站中将滑块从头拖到尾所生成的滑动轨迹数据,但该数据不能直接使用,还需经filter()方法过滤筛选,简单来说,只能使用"偏移量"这段距离(包括本身)之前的数据,这点与在网站上将滑块拖动至缺口处的效果一致,如有必要,可查看js代码;
2、deviceToken
在目标网站中以localStorage的方式存储,表明了该参数可长时间使用。
def get_conf():
"""
获取配置信息,dt(deviceToken)等,返回值可以持续使用
curl 'https://c.dun.163yun.com/api/v2/getconf?referer=https%3A%2F%2Fmusic.163.com%2F%23%2Fdownload&zoneId=&id=73a18dc827b24b18ad0783701a75277d&ipv6=false&runEnv=10&iv=4&loadVersion=2.5.0&lang=zh-CN&callback=__JSONP_e6zkt7z_6' \
-H 'Accept: */*' \
-H 'Accept-Language: zh-CN,zh;q=0.9,en;q=0.8' \
-H 'Cache-Control: no-cache' \
-H 'Connection: keep-alive' \
-H 'Pragma: no-cache' \
-H 'Referer: https://music.163.com/' \
-H 'Sec-Fetch-Dest: script' \
-H 'Sec-Fetch-Mode: no-cors' \
-H 'Sec-Fetch-Site: cross-site' \
-H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36' \
-H 'sec-ch-ua: "Chromium";v="128", "Not;A=Brand";v="24", "Google Chrome";v="128"' \
-H 'sec-ch-ua-mobile: ?0' \
-H 'sec-ch-ua-platform: "macOS"'
"""
pass
3、offset
获取到验证码后,需要取得缺口偏移量,可通过验证码缺口识别模块get_offset()计算得出。
def get_offset(bg, front):
"""
获取缺口偏移量;由于是链接,因此还需在方法内获取图片
:param bg 背景url
:param front 滑块url
"""
front_resp = requests.get(front)
front = Image.open(io.BytesIO(front_resp.content))
pos_list = collect(front)
bg_resp = requests.get(bg)
bg = np.asarray(bytearray(bg_resp.content), dtype="uint8")
bg_image = cv2.imdecode(bg, cv2.IMREAD_COLOR)
return match(bg_image, pos_list, start_x=90, offset_x=-90)
4、_generateParameters#params.data
该参数为前端动态生成,需要补全代码。
老规矩,给/api/v3/check接口打上断点,进入debugger模式后,跟随调用栈查找到参数生成的位置,如下图所示。
图9 data参数生成代码
5、_generateParameters#params.cb
cb参数就比较简单,它与获取验证码接口中的cb共用一个算法。
三、调用方法
dt通过get_conf()方法返回,运行该demo时,应该重新调用接口获取参数,以避免可能会引起的错误。
获取到验证码后,计算出缺口偏移量,随后生成校验接口的url参数,最后将其与接口地址拼接,并发起请求。
if __name__ == '__main__':
dt = "RE+Yi5ZI1vFBAgVQVFOGWtVNhsdVskhv"
token = ""
captcha_resp = get_captcha(dt, token)
print(captcha_resp)
offset_resp = get_offset(captcha_resp.get('bg'), captcha_resp.get('front'))
if offset_resp is not None:
offset = offset_resp.get('offset')
token = captcha_resp.get('token')
url = "https://c.dun.163.com/api/v3/check?"
url += captcha_js_ctx.call('generateParameters', token, offset, dt)
headers = {
'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36',
'referer': 'https://music.163.com/',
'Sec-Fetch-Dest': 'script',
'Accept': '*/*',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Sec-Fetch-Mode': 'no-cors',
'Sec-Fetch-Site': 'same-site',
'sec-ch-ua': '"Chromium";v="128", "Not;A=Brand";v="24", "Google Chrome";v="128"',
'sec-ch-ua-mobile': '?0',
'sec-ch-ua-platform': '"macOS"'
}
r = requests.get(url, headers=headers)
print(r.text)
运行代码后,执行结果如下:
__JSONP_5g71aef_1({"data":{"result":true,"zoneId":"CN31","token":"b9398b216c254817a4e96a691690166d","validate":"ogMP2QM05Uc35eePTcG3io2nqEO2Jpqh5f1NP7wdhLe1D95bcH8ZWxerGc6U2No6Oo/FuHPtQFBhkQJgfVNfwK+iYRCmnjwQABS6iF2xBAPfwrL7cRzIOjaQd1p/JN/a1yfnA3BwTSHJCbLgflhhlvZRQvQoVDCDR9Gv289kc8Q="},"error":0,"msg":"ok"});
“result”为true及“validate”不为空,数据符合预期,说明本次验证码校验已通过。
完整的代码已上传至github。
随便玩玩的话,应该没事
至此,本文已经进入尾声,不过,本文仅仅为验证码逆向的内容,不包扩账号登录,当然,这个部分之后有时间也会做,这里算是开了个新坑。
要注意的是,本次实战虽看上去简单,但其实暗坑不在少数,稍微出点偏差都大概率会得到错误的结果。比方说,之前作者就有过这样的遭遇:参数正常,但大多数情况下校验不通过,请求接口没有任何报错提示,完全不知道哪个环节出现了问题,很无助。经长时间的排查后,才发现是因为获取验证码接口中缺少了一个“host”请求头字段;还因为某个算法生成的错误数据而导致了验证失败,但同样地,接口也不会返回任何有用的提示信息;等等...
总的来说,逆向工程与很多事物类似,不能一蹴而就,也没有一套标准的解法,我们能做到的,只有多查、多猜、多想、多试,如此这般之后,很可能答案就在眼前。
以上。