鬼手56 发表于 2024-1-18 12:15

数美点选验证协议全面剖析



### 目标网站

```
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写一个拦截脚本来处理这个问题,代码如下:

```python
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



点进去发现,所有的代码都是经过混淆的。

```javascript
var _0x19e1cf = _0x136e2f
```

这种混淆实际上就是把字符串,替换成了函数调用,用来干扰分析。我们把这个JS文件保存下来,做一个整体的分析



一共有四个大函数,其中两个是自执行函数。这个页面的混淆代码大部分在第三个自执行函数里面,其他三个函数代码量都比较小。



其中大部分的混淆代码都是在执行`_0x1f0d`这个函数。

那么我们就可以把除了第三个大函数以外的函数全部抠下来,拿到本地,去执行一下加密的函数,看能不能得到结果



经过实际测试,确实是可以正常运行,并且打印出对应的字符串。

```javascript
console.log(_0x1f0d(process.argv))
```

然后我们把这个混淆函数的参数替换为命令行参数,从而使用python来调用,来达到批量去混淆的目录,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`在这个位置被赋值,那我们接下来就要分析这个代码。

```javascript
_0x2049e1 = userConfig || _0x332a8d]();
```

这个代码实际上就是一个函数调用



这里可以对这个位置的代码进行选中,然后跳转到相应的代码页面



就跳到了这个位置,代码如下:

```javascript
'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(Math() * _0x3531f2));
                }
                return _0x529f28(this(), _0x1163e8);
            },
```

这个代码的分析就没有什么技术含量了,一行一行硬看,反正也没有多少代码

```
captchaUuid: 20240116213802Zwas5htESARemRJWfW
```

参考这个`captchaUuid`的值,一边调试一边分析,我这里直接说结论

```
captchaUuid=当前时间+17个随机字符
```

实现代码如下:

```python
def gen_captcha_uuid():
    total_string = "ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678"
    part = "".join()
    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个。



查看请求的调用栈,在最后两个调用栈下断点



断下之后来分析下当前的这一行代码

```javascript
this(_0x5c100a, _0x599170, _0x1ed2cb, _0x28800d, _0x5c6636, _0x4d541b);
```

为了方便阅读,我把这个代码进行简化

```javascript
this[""sendRequest""]('https://', "captcha1.fengkongcloud.cn", "/ca/v2/fverify", _0x28800d, _0x5c6636, _0x4d541b);
```

这个位置实际上是在发送https请求,前面三个参数是在拼接请求的域名



而第四个参数是一个对象,里面包含的内容就是我们需要跟踪的字段。那么我们现在就摇追踪`_0x28800d`的来源,看这个数据是怎么生成的。



我们先把这一段代码的混淆给处理一下,用之前解混淆的那个方法。

```javascript
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]["extend"]((_0x1fe1cf = {
      'organization': _0x49bb92
    },
      
            _0x1f0d12])(_0x1fe1cf, 'mp', this['getEncryptContent'](_0x20df23, _0x6f9c3c["NnjXa"])),
            _0x1f0d12])(_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])(_0x1fe1cf, _0x6f9c3c["ZGnpS"], _0x3717b1),
            _0x1f0d12])(_0x1fe1cf, _0x6f9c3c["CJfJJ"], _0x435e2e),
            _0x1f0d12])(_0x1fe1cf, _0x6f9c3c["WWhmm"], _0x294474),
            _0x1f0d12])(_0x1fe1cf, _0x6f9c3c["VRUfH"], "180"),
            _0x1f0d12])(_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加密。

```javascript
'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,看能否输出对应的结果

实现代码如下:

```python
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`大概可以猜到这个对象的数据应该是记录的一些鼠标的坐标信息。



然后跳转到函数代码的位置,我们把这段代码使用解混淆的脚本进行处理。

!(019 数美点选验证协议逆向.assets/1705496617569.png)

重点关注`case "spatial_select"`里面的代码,这个里面就是我们所需要的参数,这个switch里面对应的应该是各个不同的验证分支。

`spatial_select`对应的是点选,`slide`对应的是滑块。

!(019 数美点选验证协议逆向.assets/1705497808508.png)

然后函数结束的地方还有其他的一些返回数据

```javascript
_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]["__userConf"]["console"], '6f5e9847'),
   
_0x4639e5['en'] = this["getEncryptContent"](_0x2460cd]["runBotDetection"](), "9fc1337f"),

_0x4639e5['kq'] = this["getEncryptContent"](-(0x7 * -0x537 + -0x1f77 + 0x1 * 0x43f9), _0x6f9c3c['SGQFW'])
```

这些参数都是通过`getEncryptContent`这个函数进行加密的,那么我们只需要搞清楚传入的参数分别是什么数据,就可以对整个请求进行模拟了。



第一个参数和第二个参数是一样的,是一个四个成员的数组,这个就对应了我们进行点选的坐标。



而另外三个参数则是固定值



后两个参数是整张点选图片的宽度和高度,可以直接看下`_4fc323`这个对象



里面是这个图片对象的信息,包括图片的宽度和高度,然后把剩下的参数也处理一下

```javascript
_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`在这里被赋值,而这个就是我们要的坐标

```javascript
_0x27bb65 = [
    _0x42ca39,
    _0x1d0195["IXgiz"](_0x28ed43['y'], _0x1e9886) / _0x30fbb2,
    _0x52aa32
];
```

这个坐标信息实际上可以拆解为列表中的三个元素,通过逗号分割开,第一个和第三个元素是一个坐标数据,中间的元素是一个算法。我们需要搞清楚这三个数组成员分别是什么。



第三个成员`_0x52aa32`实际上就是一个字符串格式的时间戳,前两个应该是当前的坐标通过算法计算出来的结果。



第一个值的调用来源于上面一行代码

```javascript
_0x42ca39 = _0x1d0195["rlnuS"](_0x28ed43['x'] - _0x5a1fbf, _0xd3e4df);
```

这个是一个函数调用,传递了两个参数,分别查看一下这几个数据是什么



第一个参数是当前鼠标的X坐标,这个坐标可能和真实的坐标不一样,可能是做了等比例缩放,后续可以通过算法的方式来构造。

第二个参数是图片的宽度,然后再来看一下函数的原型:

```javascript
'rlnuS': function(_0x4781a3, _0x52d7dc) {
   return _0x4781a3 / _0x52d7dc;
```

这个函数的代码很简单,就是一个除法指令。

```javascript
_0x1d0195["IXgiz"](_0x28ed43['y'], _0x1e9886) / _0x30fbb2
```

至于第二个值,是当前的y坐标,跟第一个值的算法几乎没啥区别。到这里,我们的坐标算法就分析完成了。

### 结束

最后,如果想要把整个接口进行自动化,需要做这么几个事情。首先把上面的分析过程整理成代码,把每个表单提交的数据对应上,

然后需要对接打码平台,获取到点选验证的图片以后,通过打码平台的接口拿到坐标信息,通过提交坐标信息等数据通过点选验证的校验接口。这样就可以完成这个网站的自动化登陆了。我对这个过程并没有兴趣,只研究点选验证的原理以及逆向分析思路,各位有兴趣可以自行尝试。

Sommuni 发表于 2024-1-22 15:49

看完了,分析的算是很详细了,有一点遗漏的补充一下,之前搞过shu美的点选,不同版本的js里面,fverify接口提交参数的字段名也不一样,而且这个js版本,平均一两周就会自动更新一版,频率极高,查阅了相关文档,有的大佬这一步有的是用ast,有的是用正则去匹配的,不过都是说的比较模糊,然后就卡在这了,也就没机会去研究后面的这么多逻辑了

ky_wei 发表于 2024-1-30 21:19

ali的智能验证有点头疼,断点找不出umidToken和b值的计算方法

aHR0cHM6Ly9oZWxwLmFsaXl1bi5jb20vemgvY2FwdGNoYS9jYXB0Y2hhMS0wL3VzZXItZ3VpZGUvZmVhdHVyZS1kZXNjcmlwdGlvbi0y

zuxin521 发表于 2024-1-18 14:16

支持一下大牛的作品

yenfenwo 发表于 2024-1-18 18:50

支持一下大牛的作品

仲舒 发表于 2024-1-19 22:24


支持一下大牛的作品

wjmzbmr 发表于 2024-1-20 18:03

学习了,感谢分享

xixicoco 发表于 2024-1-21 03:25

太牛逼了,这分析厉害

hhk007 发表于 2024-1-30 07:52

看到前面一部分就看不下去了,自己太菜了,不知道如何解的。{:1_889:}

beyondchampion 发表于 2024-1-30 09:12

感谢分享!!!

dxiaolong 发表于 2024-1-30 09:51

收藏慢慢研究,感谢老大
页: [1] 2 3
查看完整版本: 数美点选验证协议全面剖析