共计 8431 个字符,预计需要花费 22 分钟才能阅读完成。
申明
本文章中所有内容仅供学习交换应用,不用于其余任何目标,不提供残缺代码,抓包内容、敏感网址、数据接口等均已做脱敏解决,严禁用于商业用途和非法用处,否则由此产生的所有结果均与作者无关!
本文章未经许可禁止转载,禁止任何批改后二次流传,擅自应用本文解说的技术而导致的任何意外,作者均不负责,若有侵权,请在公众号【K 哥爬虫】分割作者立刻删除!
指标
指标:Luosimao 螺丝帽人机验证逆向剖析
网址:aHR0cHM6Ly9jYXB0Y2hhLmx1b3NpbWFvLmNvbS9kZW1vLw==
抓包剖析
进入官网提供的 demo 页面,F12 开启抓包,首先加载 demo 页面,这个页面蕴含一个 site-key
,每个网站都不一样,会在后续用到:
接下来是一个 captcha.js
,次要用于后续的加密参数生成,乍一看认为是个 OB 混同,其实只是更换了变量名,而后一些值是从大数组外面取的,没有 OB 混同里的打乱数组的操作,比 OB 混同要简略很多,后文会利用 AST 对这三个 JS 进行解混同,后续相似的还加载了 widget.js
和 frame.js
,也都是和加密参数的生成无关。
而后是一个 widget
的申请,该申请返回的源码外面有个 data-token
,也是后续要用到的。
接下来是一个 request
的申请,接口返回的一些参数也是后续要用到的,同时返回的 w
值,就是要点击的文字提示信息。
而后是一个 frame
申请,申请带了两个加密参数,这个申请返回的源码外面蕴含了验证码图片信息。
而后就加载了验证码图片,留神这里的图片是被切割之后乱序排列了的,和极验三代的相似,所以后文咱们还要对其进行程序还原。
点击图像实现之后,就会发动校验申请 user_verify
,校验胜利的话返回的 res
为 success
,相同校验不胜利就是 failed
。
点击立刻登录,触发最初一个 submit
申请,提交的 data 值就是上一步 user_verify
验证胜利后返回的 resp
值。
小结一下螺丝帽就能够分为三个比拟重要的步骤:request
接口申请失去要点击的内容,frame
接口申请拿到验证码图片,user_verify
接口验证点击是否正确,下文将详细分析这些步骤。
AST 解混同
先别着急找加密逻辑,后面抓包的时候说了,一共有三个 JS 参加了加密,别离是 captcha.js
、widget.js
和 frame.js
,这三个 JS 是被混同了的,为了后续比拟好剖析,咱们能够先应用 babel 将其转换成 AST 语法树后,进行解混同操作。
以 widget.js
为例,察看该 JS,咱们能够总结出以下三个问题:
- 结尾一个大数组,如
_0x8f24
,后续变量赋值操作就是从这个大数组里取值,如_0x8f24[1]
、_0x8f24[2]
; - 所有的字符串都被转换成了十六进制编码的模式,不易浏览;
- 拜访对象属性是
_0x3ba3x1["Number"]
,而不是_0x3ba3x1.Number
,不易浏览。
所以咱们只须要做三个操作:
- 从数组取值转为间接赋值(
_0x8f24[1]
=>"\x63\x61\x6C\x6C"
); - 十六进制编码的字符串还原(
"\x63\x61\x6C\x6C"
=>"call"
); - 对象属性还原(
_0x3ba3x1["Number"]
=>_0x3ba3x1.Number
)。
首先是从数组取值转为间接赋值,先将这个 JS 扔到 astexplorer.net 别离看看原始构造(如:_0x8f24[1]
)和替换后的构造(如:"\x63\x61\x6C\x6C"
):
从上图能够看到相似 _0x8f24[1]
取值的节点类型为 MemberExpression
,这个大数组没有像 OB 混同那样做了乱序操作,能够间接取值,那么如果咱们先拿到 _0x8f24
这个大数组,而后遍历 MemberExpression
节点,再将其替换成 StringLiteral
类型的节点就行了。当然遍历的时候也要有限度,必须是 path.node.object.name
的值和大数组的名称一样能力替换。而后就是咱们怎么拿到 _0x8f24
这个大数组呢?这个大数组在 AST 中的地位是 program.body[0]
,咱们能够将其转换成 JS 代码而后 eval 执行一下,把大数组加载到内存里,后续就能间接按索引取值了,当然办法不止这一种,能够依照本人的思路来实现,这一部分的 visitor 能够这么写:
const ast = parse(code);
eval(generate(ast.program.body[0]).code)
const visitor = {MemberExpression(path) {if (path.node.object.name === "_0x8f24") {path.replaceWith(types.stringLiteral(eval(path.toString())));
}
}
}
而后就是十六进制编码的字符串还原,察看前后的 AST 语法树:
能够发现只有将 path.node.extra.raw
的值换为 path.node.extra.rawValue
或者 path.node.value
即可,当然因为 NumericLiteral
、StringLiteral
类型的 extra
节点并非必须,这样在将其删除时,也不会影响原节点,所以还能够间接 delete path.node.extra
或者 delete path.node.extra.raw
来还原字符串,这一部分的 visitor 能够这么写:
const visitor2 = {StringLiteral(path) {if (path.node.extra) {
// 以下办法均可
// path.node.extra.raw = '"'+ path.node.extra.rawValue +'"'
// path.node.extra.raw = '"'+ path.node.value +'"'
// delete path.node.extra
delete path.node.extra.raw
}
}
}
最初就是对象属性还原,同样的先察看前后的 AST 语法树:
能够看到 _0x3ba3x1["Number"]
=> _0x3ba3x1.Number
,是 MemberExpression
下的 property
节点由 StringLiteral
类型的变成了 Identifier
类型的,computed
值由 true 变成了 false,这一部分的 visitor 能够这么写:
const visitor = {MemberExpression(path){if (path.node.property.type === "StringLiteral" && path.node.property.value !== "") {
path.node.computed = false
path.node.property = types.identifier(path.node.property.value)
}
}
}
后面抓包的时候也说了,一共有三个 JS 参加了加密,别离是 captcha.js
、widget.js
和 frame.js
,他们的混同都是一样的,所以综上所述咱们的 AST 解混同代码完整版能够是这样的:
const fs = require('fs');
const types = require("@babel/types");
const parse = require("@babel/parser").parse;
const traverse = require("@babel/traverse").default;
const generate = require("@babel/generator").default;
function deconfusion(code, arrName) {const ast = parse(code);
eval(generate(ast.program.body[0]).code)
const visitor1 = {MemberExpression(path) {if (path.node.object.name === arrName) {path.replaceWith(types.stringLiteral(eval(path.toString())));
}
}
}
const visitor2 = {StringLiteral(path) {if (path.node.extra) {
// 以下办法均可
// path.node.extra.raw = '"'+ path.node.extra.rawValue +'"'
// path.node.extra.raw = '"'+ path.node.value +'"'
// delete path.node.extra
delete path.node.extra.raw
}
},
MemberExpression(path){if (path.node.property.type === "StringLiteral" && path.node.property.value !== "") {
path.node.computed = false
path.node.property = types.identifier(path.node.property.value)
}
}
}
traverse(ast, visitor1);
traverse(ast, visitor2);
delete ast.program.body[0]
return generate(ast, {jsescOption: {"minimal": true}}).code
}
const widget = fs.readFileSync('widget.js', 'utf-8');
const newWidget = deconfusion(widget, "_0x8f24")
fs.writeFileSync('newWidget.js', newWidget, 'utf-8');
const captcha = fs.readFileSync('captcha.js', 'utf-8');
const newCaptcha = deconfusion(captcha, "_0x2d28")
fs.writeFileSync('newCaptcha.js', newCaptcha, 'utf-8');
const frame = fs.readFileSync('frame.js', 'utf-8');
const newFrame = deconfusion(frame, "_0x3f7b")
fs.writeFileSync('newFrame.js', newFrame, 'utf-8');
解混同之后,将代码替换掉原始代码,而后就能够欢快的进行剖析了。
获取验证码信息
首先来看 request
接口,POST 申请,params 有 k 和 l 两个参数,data 有 bg 和 b 两个加密参数,如下图所示:
k 参数通过间接搜寻能够发现就存在于页面的 html 里,如下图所示的 data-site-key
就是 k 的值,从这个名字也能够看出应该是每个网站调配的一个 key。
bg 和 b 参数搜寻不到,且每次都是变动的,通过观察可知这是一个 XHR 申请,那么就能够通过 XHR 断点,或者间接跟栈的形式来找加密入口,好在栈也不多,间接跟进去下断点,在 ajax send 办法这里,就能够看到 bg 和 b 曾经生成。
持续往上跟栈,就很容易发现 bg 和 b 的生成地位,如下图所示:
"bg=" + _0x3ba3xc.encryption(_0x3ba3x1) + "&b=" + _0x3ba3xc.encryption(_0x3ba3x3)
,先来看 _0x3ba3x1
和 _0x3ba3x3
是怎么生成的:
var _0x3ba3x1 = _0x3ba3xc.env.us + "||" + _0x3ba3xc.getToken() + "||" + _0x3ba3xc.env.sc.w + ":" + _0x3ba3xc.env.sc.h + "||" + _0x3ba3xc.env.pf.toLowerCase() + "||" + _0x3ba3xc.prefix.toLowerCase(),
_0x3ba3x3 = _0x3ba3xc.path[0] + ":" + _0x3ba3xc.timePoint[0] + "||" + _0x3ba3xc.path[1] + ":" + _0x3ba3xc.timePoint[1];
_0x3ba3xc.env.us
:User-Agent;_0x3ba3xc.env.sc.w
:屏幕宽度;_0x3ba3xc.env.sc.h
:屏幕高度;_0x3ba3xc.env.pf.toLowerCase()
:platform(如 win32)小写;_0x3ba3xc.prefix.toLowerCase()
:浏览器引擎(如 webkit)小写。
_0x3ba3xc.getToken()
是一个函数,跟进去能够看到是取 widget
申请返回的 html 外面的 data-token
值,如下图所示:
widget
申请还有个 i 参数,也是加密生成的,间接全局搜寻 i:
,能够发现在 captcha.js
里 _0x7125x5.id
就是 i 的值,如下图所示:
跟进去,generateID()
办法 return "_" + Math.random().toString(36).substr(2, 9);
就能够生成这个值了。
而后是 _0x3ba3x3
,次要由 path 和 timePoint 组成,重复比照你会发现,path = [鼠标第一次进入点击区域的坐标,鼠标点击时的坐标]
,timePoint = [页面加载结束的工夫,开始点击的工夫]
,如下图所示,能够在左上角和右下角都点一下看看这个点击的区域坐标范畴是啥,而后随机构建一下就行了。
总结下来,_0x3ba3x1
和 _0x3ba3x3
就能够通过以下代码实现:
function randomNum(min, max) {return Math.floor(Math.random() * (max - min + 1) + min);
}
const ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36"
const screen = {width: 1920, height: 1080};
const platform = "Win32";
const prefix = "Webkit";
//[鼠标第一次进入点击区域的坐标,鼠标点击时的坐标]
const path = [`${randomNum(60, 200)},${randomNum(0, 3)}`,
`${randomNum(60, 200)},${randomNum(10, 20)}`
];
// [页面加载结束的工夫,开始点击的工夫]
const time = +new Date();
const timePoint = [time, time + randomNum(1000, 6000)];
const _0x3ba3x1 = ua + "||" + token + "||" + screen.width + ":" + screen.height + "||" + platform.toLowerCase() + "||" + prefix.toLowerCase();
const _0x3ba3x3 = path[0] + ":" + timePoint[0] + "||" + path[1] + ":" + timePoint[1];
最初一步加密 "bg=" + _0x3ba3xc.encryption(_0x3ba3x1) + "&b=" + _0x3ba3xc.encryption(_0x3ba3x3);
,跟进 encryption
办法相熟的 iv、mode、padding,但他这里写的却是 SHA3,很显著是骗人的,比照测试一下加密后果,发现是 AES 加密,间接引库就完事儿了。
至此 request 接口就剖析结束了。
获取验证码图片
而后是获取验证码图片,间接搜寻图片的名称,能够发现是在 frame
申请返回的 html 源码外面,如下图所示:
这个 captchaImage
对象蕴含两个值,p 是验证码乱序的图片,有三个图片,这个应该是避免宕机,有多个节点,理论三张图都是一样的内容,而 l 则是用来还原乱序图片的。
var captchaImage = {
p:['https://i5-captcha.luosimao.com/22/aa27352e782eb74ccccef04eb91bc23c.png',
'https://i2-captcha.luosimao.com/22/aa27352e782eb74ccccef04eb91bc23c.png',
'https://i1-captcha.luosimao.com/22/aa27352e782eb74ccccef04eb91bc23c.png'],
l: [["40","80"],["220","0"],["280","0"],["200","80"],["100","0"],["40","0"],
["0","80"],["180","0"],["20","0"],["120","80"],["220","80"],["240","0"],
["180","80"],["0","0"],["280","80"],["140","80"],["140","0"],["200","0"],
["160","0"],["260","0"],["20","80"],["240","80"],["100","80"],["60","80"],
["120","0"],["260","80"],["160","80"],["80","0"],["80","80"],["60","0"]]
};
咱们查看图片的源码,能够发现这个 l 的坐标就是 css background-position
属性的值,如下图所示:
逻辑也很简略,图片尺寸 300x160
px,切割的乱序图片,分为高低两局部,每一部分又被分为 15 个小片段,那么上半局部从左至右,每一片段的左上角坐标为:[0, 0]
、[20, 0]
、[40, 0]
…,以此类推,下半局部则是 [0, 80]
、[20, 80]
、[40, 80]
…,以此类推,而后面的 l 的值,就示意原始图片第 N 个地位,对应乱序图片的某个片段的左上角的坐标,例如 l 的第一个值为 ["40","80"]
,则示意原始图片第一个地位是乱序图中坐标为 [40, 80]
的片段,换句话说,也就是原始图片第一个地位,应该是乱序图中下半局部从左至右的第三个片段。图片的还原在 Python 中能够用以下代码实现:
from PIL import Image
section = [["40","80"],["220","0"],["280","0"],["200","80"], ......]
image = Image.open("乱序图片.png")
canvas = Image.new("RGBA", (300, 160))
for index in range(len(section)):
x = int(section[index][0])
y = int(section[index][1])
slice_ = image.crop(box=(x, y, x + 20, y + 80))
canvas.paste(slice_, box=(index % 15 * 20, 80 if index > 14 else 0))
canvas.save("正确图片.png")
而后就是这个 frame
申请,蕴含了一个 s 参数,这个是后面 request
申请返回的,如下图所示:
发送验证
而后就是点击发送验证申请了,user_verify
蕴含三个参数 h、v 和 s,h 是后面 request
接口返回的,v 和 s 是须要咱们逆向的,如下图所示:
同样也间接跟栈,如下图所示 _0xaaefx15.toString()
就是最终的 s 值,而 s 是最终的 v 值:
先来看 s,s = _0xaaefx11.toString();
,而 _0xaaefx11
和后面一样也是 AES 加密,其中 key 是后面 request 接口返回的 i 的值,待加密的值是 _0xaaefx5
,而 _0xaaefx5 = _0xaaefx3.dots.join("#")
,_0xaaefx3.dots
就是点击的坐标,不过这个坐标要留神,他的 x 和 y 坐标是反着排列的,整个数组也是倒序的,直观点儿来讲就是 _0xaaefx3.dots = ["第三次点击的 y,第三次点击的 x", "第二次点击的 y,第二次点击的 x", "第一次点击的 y,第一次点击的 x"]
,如下图所示:
而后就是 _0xaaefx15
,通过 MD5 加密失去最终的值,如下图所示:
注意事项
申请会校验 header 的 Host 字段,frame 接口和其余接口的 Host 是不一样的,留神察看替换,Host 不正确会导致申请失败。
至此所有流程就都剖析结束了。
后果验证