目标网站
aHR0cHM6Ly9zZWN1cmUuZWxvbmcuY29tL3Bhc3Nwb3J0L2xvZ2luX2NuLmh0bWw/bmV4dHVybD1odHRwczovL3d3dy5lbG9uZy5jb20v
这一次要分析的是这个数美的点选验证。
分析请求
接下来对这个验证的请求进行抓包分析,看看有哪几个请求是需要逆向的。
首先是一个register请求,这个对应的是图片的获取
payload参数如图,其中的mode表示的是验证模式,当前是点选验证,如果model的值是slide
,那么说明是滑块验证。
返回的数据是一张点选的图片,还有order字段的值
通过preview可以看到,这个order字段就是文字内容。
所以我们接下来要做的事情就是通过调用这个请求,拿到返回的图片和文字数据,然后通过图片识别出文字的位置,找到坐标点,然后通过代码去构造坐标数据。
接着当我们对图片进行点选的时候,会产生这么一个请求
会发送一个verify请求,如果失败了,会返回REJECT,如果成功的话则是 PASS。
然后再往上会有一个conf请求,这个请求的payload参数和register提交的参数是一样的,所以这俩请求分析一个就可以了。
那么需要分析的核心的请求就只有下面三个:
https://captcha1.fengkongcloud.cn/ca/v2/fverify
https://captcha1.fengkongcloud.cn/ca/v1/register
https://captcha1.fengkongcloud.cn/ca/v1/conf
动态JS无法调试
在分析之前我们需要先解决一个问题,
这个conf请求调用栈的JS文件,是一个动态变化的,每次url都不一样,这样就导致我们的断点在下断以后再次断下。
这个问题的解决方案在于将加载JS时的url替换为固定的,可以通过charles或者mimproxy来解决,这两个都可以作为代{过}{滤}理拦截http请求。
在Charles中捕获这个请求,然后右键->SaveResponse,把页面源码保存下来。
打开Charles的Map Local功能
添加一个Mapping,设置了这个以后,当有网络请求访问了我们指定的这个map页面,就会被替换为本地保存的html文件,这样就可以保证JS一直是固定的了。
也可以用mitmproxy写一个拦截脚本来处理这个问题,代码如下:
from mitmproxy import http
from mitmproxy.http import Request
def request(flow):
if flow.url.startswith("https://secure.elong.com/passport/login_cn.html?nexturl=https://www.elong.com/"):
with open("res.html",mode="rb",encoding="utf-8") as f:
content=f.read()
flow.response = Response.make(
200,
content,
{"Content-Type":"text/html"}
)
def response(flow: http.HTTPFlow):
pass
不过还是Charles方便,直接配置好就可以了。
代码混淆处理
然后我们来解决代码混淆的问题。
接着抓一个包,找到conf这个请求,这个请求指向的是api.js文件,也就是我们刚刚替换的那个动态的JS
点进去发现,所有的代码都是经过混淆的。
var _0x19e1cf = _0x136e2f[_0x2ae8e9(0x2fd)]
这种混淆实际上就是把字符串,替换成了函数调用,用来干扰分析。我们把这个JS文件保存下来,做一个整体的分析
一共有四个大函数,其中两个是自执行函数。这个页面的混淆代码大部分在第三个自执行函数里面,其他三个函数代码量都比较小。
其中大部分的混淆代码都是在执行_0x1f0d
这个函数。
那么我们就可以把除了第三个大函数以外的函数全部抠下来,拿到本地,去执行一下加密的函数,看能不能得到结果
经过实际测试,确实是可以正常运行,并且打印出对应的字符串。
console.log(_0x1f0d(process.argv[2]))
然后我们把这个混淆函数的参数替换为命令行参数,从而使用python来调用,来达到批量去混淆的目录,python代码如下:
import subprocess
import re
def Decode(hex_rg):
res= subprocess.check_output(f"node main.js {hex_rg}",shell=True)
res_string=res.decode("utf-8").strip()
return res_string
def run():
with open("f1.js", mode="r", encoding="utf-8") as fr, open("f2.js", mode="w", encoding="utf-8") as fw:
for line in fr:
match_list = re.findall("(_0x425d8a\((.*?)\))", line)
if not match_list:
fw.write(line)
continue
for func_string,hex_str in match_list:
line=line.replace(func_string,f'"{Decode(hex_str)}"')
fw.write(line)
if __name__ == '__main__':
run()
这个脚本做的事情就是打开f1.js,读取里面的内容,通过正则匹配的方式筛选出符合要求的代码,通过调用nodejs解混淆脚本得到结果,对其进行批量替换。
等遇到需要重点分析的代码,可以用这个方式可以去除部分混淆,提高一些代码可读性,帮助分析代码。这个脚本在后面的分析里面可以帮我们节省很多时间。
conf请求分析
我们先来分析第一个conf请求,里面携带了这么几个参数
appId: default
organization: xQsKB7v2qSFLFxnvmjdO
callback: sm_1705412287345
sdkver: 1.1.3
model: select
captchaUuid: 20240116213802Zwas5htESARemRJWfW
rversion: 1.0.4
lang: zh-cn
channel: DEFAULT
organization是一个ID,这个是固定的,多测几次就会发现,而callback是一个时间戳,那么对于conf这个请求,我们只需要去分析captchaUuid这个字段就可以了。
找到conf请求的调用堆栈,在中间的一个位置打一个断点,反正都是混淆的,在哪都没区别,只要在附近找到了需要跟踪的字段,一直往上找就行了。
断下以后,当前的作用域里面,并没有我们需要的参数,所以需要沿着调用栈一直往上翻
翻到这个调用栈的时候,终于找到了我们需要的相关参数
'captchaUuid': _0x2049e1
所以我们现在就要往上找_0x2049e1
里面的值是从哪来的
_0x2049e1
在这个位置被赋值,那我们接下来就要分析这个代码。
_0x2049e1 = userConfig[_0x4c37ed(0x3be)] || _0x332a8d[_0x243c3a[_0x4c37ed(0x483)]][_0x4c37ed(0x4f4)]();
这个代码实际上就是一个函数调用
这里可以对这个位置的代码进行选中,然后跳转到相应的代码页面
就跳到了这个位置,代码如下:
'getCaptchaUuid': function _0x2d350e() {
var _0x49a2b0 = _0x247065
, _0x1163e8 = ''
, _0x40ad39 = _0x49a2b0(0x42a)
, _0x3531f2 = _0x40ad39['length'];
for (var _0x235133 = -0x149e + -0x231d * 0x1 + -0x37bb * -0x1; _0x235133 < -0x2214 + 0xd * 0x223 + 0xe9 * 0x7; _0x235133++) {
_0x1163e8 += _0x40ad39['charAt'](Math[_0x49a2b0(0x2c6)](Math[_0x49a2b0(0x134)]() * _0x3531f2));
}
return _0x529f28[_0x49a2b0(0x294)](this[_0x49a2b0(0x232)](), _0x1163e8);
},
这个代码的分析就没有什么技术含量了,一行一行硬看,反正也没有多少代码
captchaUuid: 20240116213802Zwas5htESARemRJWfW
参考这个captchaUuid
的值,一边调试一边分析,我这里直接说结论
captchaUuid=当前时间+17个随机字符
实现代码如下:
def gen_captcha_uuid():
total_string = "ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678"
part = "".join([random.choice(total_string) for i in range(18)])
ctime = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
captcha_uuid = f"{ctime}{part}"
return captcha_uuid
这样的话这个请求我们就搞定了。
分析fverify请求
整体代码分析
fverify请求携带的参数如下:
en: y+ugz9NIWys=
dy: Rfpr5oqb5y4=
xy: YabT6nmJOC0=
tb: 3jSn4gNaAVM=
mu: 9Zlg08y1MpaONqmLgK0lCn6u9wurdUqB5gECmblJXlSmnXHCWXXjiqvWSDI1PvzKgJdsNe46/hqf7zZh2MbjiPprUUXfMXi5+mLlFmVm6mrUS0NZ6OhMKZzNznVo51GI44gF1jVQF7Dh4/k62Zo93u4USF1RoqZU4LXyLyhHZMdhlk5nk5/NUMITc7kJqy7ngnBWIDPhl72rZtFxlvPKXxMXZnJU+GwA5pnf4/41T4rM6rYRV9Xr2E9XCLBhKno3eQAtSxt7wlDl6GMcg3LJ81dZGD6UVwy1DJJuG6fMv/M=
oc: h9oFKi8cHpg=
mp: WYfkIZp7GoA=
nu: C0kH/bWLjw8=
qd: adKA4Y0ncgGB4U6j47xchBZw9rO2THeeS/Z7vaOjAeKvmR57DOi6wsSJgovJUg2YQGQgooGYYrd2BhXCCkVQeY2gk1w7g2IquFeRYOlajtwqm5aZTx8FBx6ASb9VM7LNiZc0cVEnpnCrnXt0ICRagsT4sIYxGFg+EPenpHhwldu1Ufb8VNYIIrYBESmTOhpdttfd8gUBE36NGzqeXQpvRiqf4JT6eWB73TzFVEtzcjUHskwpvgqSmTYoQwG/gjy5APzSd96aTgTzKeX8I/wdupwYJnWvjmWWJ2R4JprB45o=
ww: aOGVECVeH60=
kq: mtlOTdT5LOE=
jo: lQ90183KgD4=
固定:
ostype: web
sdkver: 1.1.3
protocol: 180
rversion: 1.0.4
organization: xQsKB7v2qSFLFxnvmjdO
act.os: web_pc
已分析
captchaUuid: 20240117123606cSbtzkfecDkBDbC7Sr
callback: sm_1705466200576
register接口的返回值
rid: 202401171236155825d945349adfd458
这个请求里面,排除掉不需要分析的部分,剩下需要分析的字段一共有12个。
查看请求的调用栈,在最后两个调用栈下断点
断下之后来分析下当前的这一行代码
this[_0x2c1c24(0x1b5)](_0x5c100a, _0x599170, _0x1ed2cb, _0x28800d, _0x5c6636, _0x4d541b);
为了方便阅读,我把这个代码进行简化
this[""sendRequest""]('https://', "captcha1.fengkongcloud.cn", "/ca/v2/fverify", _0x28800d, _0x5c6636, _0x4d541b);
这个位置实际上是在发送https请求,前面三个参数是在拼接请求的域名
而第四个参数是一个对象,里面包含的内容就是我们需要跟踪的字段。那么我们现在就摇追踪_0x28800d
的来源,看这个数据是怎么生成的。
我们先把这一段代码的混淆给处理一下,用之前解混淆的那个方法。
var 0x2d6657 = {
'nyzWi': "mouseMoveX",
'SZdlb': _0x6f9c3c["pzOLX"],
'TGQiS': function (_0x569f5d, _0x276cc4) {
var _0x5d1724 = _0x2c1c24;
return _0x6f9c3c["onIeC"](_0x569f5d, _0x276cc4);
}
};
var _0x1fe1cf,
_0x44bcff = this["_config"],
_0x599170 = _0x44bcff['domains'],
_0x3d6fed = _0x44bcff["fVerifyUrlV2"],
_0x1ed2cb = _0x3d6fed === undefined ? _0x3b4628 : _0x3d6fed,
_0x49bb92 = _0x44bcff["organization"],
_0x20df23 = _0x44bcff["appId"],
_0x4143cf = _0x44bcff["channel"],
_0x435e2e = _0x44bcff['VERSION'],
_0x7306d7 = _0x44bcff['lang'],
_0x294474 = _0x44bcff["SDKVER"],
_0x1b27c8 = _0x44bcff["_successCallback"],
_0x2d5257 = _0x44bcff["mode"],
_0x5b58d1 = this['_data'],
_0x536387 = _0x5b58d1["errMsg"],
_0x74fdb5 = _0x5b58d1["trueWidth"],
_0x31e834 = _0x6f9c3c["tIGQt"](_0x74fdb5, undefined) ? -0x1338 + -0x145f + -0x7eb * -0x5 : _0x74fdb5,
_0x3717b1 = this["getRegisterData"](_0x6f9c3c["ZGnpS"]), _0x298b01 = this["getMouseAction"](),
_0x528bd = _0x6f9c3c["VQpVP"], _0x49f479 = this["getSafeParams"](),
_0x28800d = _0x2460cd[_0x6f9c3c["Nlbsb"]]["extend"]((_0x1fe1cf = {
'organization': _0x49bb92
},
_0x1f0d12[_0x6f9c3c["Nlbsb"]])(_0x1fe1cf, 'mp', this['getEncryptContent'](_0x20df23, _0x6f9c3c["NnjXa"])),
_0x1f0d12[_0x6f9c3c["Nlbsb"]])(_0x1fe1cf, 'oc', this["getEncryptContent"](_0x4143cf, "c2659527")),
_0x1f0d12['default'])(_0x1fe1cf, 'xy', this["getEncryptContent"](_0x7306d7, _0x6f9c3c["wIUSM"])),
_0x1f0d12["default"])(_0x1fe1cf, 'jo', this["getEncryptContent"](_0x49f479, _0x6f9c3c["HwXkn"])),
_0x1f0d12[_0x6f9c3c["Nlbsb"]])(_0x1fe1cf, _0x6f9c3c["ZGnpS"], _0x3717b1),
_0x1f0d12[_0x6f9c3c["Nlbsb"]])(_0x1fe1cf, _0x6f9c3c["CJfJJ"], _0x435e2e),
_0x1f0d12[_0x6f9c3c['Nlbsb']])(_0x1fe1cf, _0x6f9c3c["WWhmm"], _0x294474),
_0x1f0d12[_0x6f9c3c['Nlbsb']])(_0x1fe1cf, _0x6f9c3c["VRUfH"], "180"),
_0x1f0d12[_0x6f9c3c["Nlbsb"]])(_0x1fe1cf, "ostype", _0x528bd),
_0x1fe1cf), _0x298b01)
_0x2460cd["default"]["log"](_0x119bdb["LOG_ACTION"]["SEND_VERIFY"]),
this["sendRequest"](_0x5c100a, _0x599170, _0x1ed2cb, _0x28800d, _0x5c6636, _0x4d541b);
当然也可以不处理,直接调试,可能你有解混淆的功夫,人家代码都已经抠完了。
这一段代码在做的事情首先是定义了一个organization
的字典,然后往这个字典里面进行赋值;而_0x298b01
这个也是一个字典,然后再通过extend操作对两个字典进行合并。
而这四个字段的值,都是通过调用加密函数getEncryptContent,然后传入两个参数,来获取到的参数,所以我们可以先对这四个字段进行分析。
然后再对这个函数进行化简,我们要知道传入的参数是什么。化简也没什么好方法,就是一个个的手动替换。
xy: YabT6nmJOC0=
oc: h9oFKi8cHpg=
mp: WYfkIZp7GoA=
jo: lQ90183KgD4=
既然这个四个字段传入的参数是固定的,那么返回的值肯定也是固定的,所以这几个参数我们可以直接写死了。
getEncryptContent函数分析
接着来分析这个加密函数
把鼠标选中内容,然后就可以跳转到对应的位置
就可以定位到加密函数的位置
然后用手动挡去混淆的方式,可以看到大致的一些信息,盲猜是一个DES和base64加密。
'mp',this['getEncryptContent']('default', '9cc268c1'),
'oc',this["getEncryptContent"]('DEFAULT', "c2659527")),
'xy',this["getEncryptContent"]('zh-cn', 'b1807581')),
'jo',this["getEncryptContent"]('10', '6d005958')),
那么这里就可以做一个大胆的尝试,用python算法实现一个DES和base64加密,把第一个参数当作是需要加密的字符串,第二个参数当作是Key,看能否输出对应的结果
实现代码如下:
if __name__ == '__main__':
key = b'9cc268c1'
data_string = 'default'
pad_func = lambda text: text + '\0' * (DES.block_size - (len(text.encode('utf-8')) % DES.block_size))
aes = DES.new(key, DES.MODE_ECB)
enc_data = aes.encrypt(pad_func(data_string).encode("utf8"))
res = base64.b64encode(enc_data).decode('utf-8')
print(res)
然后查看运行的结果
mp: WYfkIZp7GoA=
和我们分析的请求中的mp结果是完全一致的。这种方式有些取巧,一部分情况可能不太好使,所以我们还有第二种方式,直接扣代码,大力出奇迹。
把鼠标放在这个位置,直接跳转到DES源码
跳转到这个位置之后 把这个函数还有Base64加密的函数全部扣下来,然后运行,缺什么扣什么,一直扣到不报错为止。
这样也可以拿到同样的结果
分析其他参数
前面四个通过DES加密的参数我们已经分析完了,接下来需要分析这个_0x298b01
里面的参数来源。
通过处理过的JS代码可以找到来源,_0x298b01
来自于this["getMouseAction"]()
的函数结果。通过函数名字getMouseAction
大概可以猜到这个对象的数据应该是记录的一些鼠标的坐标信息。
然后跳转到函数代码的位置,我们把这段代码使用解混淆的脚本进行处理。
![1705496617569](019 数美点选验证协议逆向.assets/1705496617569.png)
重点关注case "spatial_select"
里面的代码,这个里面就是我们所需要的参数,这个switch里面对应的应该是各个不同的验证分支。
spatial_select
对应的是点选,slide
对应的是滑块。
![1705497808508](019 数美点选验证协议逆向.assets/1705497808508.png)
然后函数结束的地方还有其他的一些返回数据
_0x4639e5['qd'] = this["getEncryptContent"](_0x23de95, '3c9ed5cb'),
_0x4639e5['mu'] = this['getEncryptContent'](_0x3602ea, "e7e1eb0d"),
_0x4639e5['ww'] = this["getEncryptContent"](_0x6f9c3c["pxDrO"](_0x53f1f2, _0x5caf5a), '17a94a08'),
_0x4639e5['nu'] = this['getEncryptContent'](_0x4c1632, "390aac0d"),
_0x4639e5['dy'] = this["getEncryptContent"](_0x1a7546, "a9001672"),
_0x4639e5["act.os"] = _0x46483a;
_0x4639e5['tb'] = this["getEncryptContent"](_0x2460cd[_0x6f9c3c["Nlbsb"]]["__userConf"]["console"], '6f5e9847'),
_0x4639e5['en'] = this["getEncryptContent"](_0x2460cd[_0x6f9c3c["Nlbsb"]]["runBotDetection"](), "9fc1337f"),
_0x4639e5['kq'] = this["getEncryptContent"](-(0x7 * -0x537 + -0x1f77 + 0x1 * 0x43f9), _0x6f9c3c['SGQFW'])
这些参数都是通过getEncryptContent
这个函数进行加密的,那么我们只需要搞清楚传入的参数分别是什么数据,就可以对整个请求进行模拟了。
第一个参数和第二个参数是一样的,是一个四个成员的数组,这个就对应了我们进行点选的坐标。
而另外三个参数则是固定值
后两个参数是整张点选图片的宽度和高度,可以直接看下_4fc323
这个对象
里面是这个图片对象的信息,包括图片的宽度和高度,然后把剩下的参数也处理一下
_0x4639e5['qd'] = this["getEncryptContent"](_0x23de95, '3c9ed5cb'),
_0x4639e5['mu'] = this['getEncryptContent'](_0x3602ea, "e7e1eb0d"),
_0x4639e5['ww'] = this["getEncryptContent"](28504615, '17a94a08'),
_0x4639e5['nu'] = this['getEncryptContent'](300, "390aac0d"),
_0x4639e5['dy'] = this["getEncryptContent"](150, "a9001672"),
_0x4639e5["act.os"] = _0x46483a;
_0x4639e5['tb'] = this["getEncryptContent"](1, '6f5e9847'),
_0x4639e5['en'] = this["getEncryptContent"](0, "9fc1337f"),
_0x4639e5['kq'] = this["getEncryptContent"](-1, 'ebee8dcc')
这一部分的字段,除去_0x23de95
是需要分析的坐标信息外,其他的都可以通过传参的方式,调用getEncryptContent
函数来获取到对应的值。
分析坐标算法
接下来我们需要对剩下的两个坐标信息的参数进行分析
其中,_0x23de95
是selectData
,_0x3602ea
是mouseData
,这两个数据全部都来自于this['data']
这里我们通过调用栈,找到最上一层的堆栈
定位到这个位置发现,this['_data']['mouseData']
被_0x226da4
赋值了,所以我们继续跟踪_0x226da4
的值。
正常来说switch case分支里面的代码不会被依次执行,但是这个函数不太一样,外面套了一层while循环,然后在switch的变量是一个数组,里面的值是2,0,3,4,1
,所以这个switch分支会按照20341的顺序去执行。
接下来需要对这整个函数进行分析,还是先用脚本去除部分的混淆
来分析去除混淆后的代码,这样比较方便,在54行这里通过push操作往selectData
里面添加数据,那么我们只需要看被添加的数据是在哪生成的
_0x27bb65
在这里被赋值,而这个就是我们要的坐标
_0x27bb65 = [
_0x42ca39,
_0x1d0195["IXgiz"](_0x28ed43['y'], _0x1e9886) / _0x30fbb2,
_0x52aa32
];
这个坐标信息实际上可以拆解为列表中的三个元素,通过逗号分割开,第一个和第三个元素是一个坐标数据,中间的元素是一个算法。我们需要搞清楚这三个数组成员分别是什么。
第三个成员_0x52aa32
实际上就是一个字符串格式的时间戳,前两个应该是当前的坐标通过算法计算出来的结果。
第一个值的调用来源于上面一行代码
_0x42ca39 = _0x1d0195["rlnuS"](_0x28ed43['x'] - _0x5a1fbf, _0xd3e4df);
这个是一个函数调用,传递了两个参数,分别查看一下这几个数据是什么
第一个参数是当前鼠标的X坐标,这个坐标可能和真实的坐标不一样,可能是做了等比例缩放,后续可以通过算法的方式来构造。
第二个参数是图片的宽度,然后再来看一下函数的原型:
'rlnuS': function(_0x4781a3, _0x52d7dc) {
return _0x4781a3 / _0x52d7dc;
这个函数的代码很简单,就是一个除法指令。
_0x1d0195["IXgiz"](_0x28ed43['y'], _0x1e9886) / _0x30fbb2
至于第二个值,是当前的y坐标,跟第一个值的算法几乎没啥区别。到这里,我们的坐标算法就分析完成了。
结束
最后,如果想要把整个接口进行自动化,需要做这么几个事情。首先把上面的分析过程整理成代码,把每个表单提交的数据对应上,
然后需要对接打码平台,获取到点选验证的图片以后,通过打码平台的接口拿到坐标信息,通过提交坐标信息等数据通过点选验证的校验接口。这样就可以完成这个网站的自动化登陆了。我对这个过程并没有兴趣,只研究点选验证的原理以及逆向分析思路,各位有兴趣可以自行尝试。