7UQ0Vm.png

声明

本文章中所有内容仅供学习交流使用,不用于其他任何目的,不提供完整代码,抓包内容、敏感网址、数据接口等均已做脱敏处理,严禁用于商业用途和非法用途,否则由此产生的一切后果均与作者无关!

本文章未经许可禁止转载,禁止任何修改后二次传播,擅自使用本文讲解的技术而导致的任何意外,作者均不负责,若有侵权,请在公众号【K哥爬虫】联系作者立即删除!

前言

最近星球有个小伙伴,公司是做 gov 相关业务的,碰到一个采购网站,感觉有些棘手,故咨询 K 哥。该业务网站,一打开,转起来了,当然,和国外产品,完全不是一个量级。也挺别致,本期就对这个网站,进行逆向分析:

7UqQNZ.png

7UzCYb.png

逆向目标

  • 目标:某采购网,360 磐云盾、文字点选验证码逆向分析
  • 网站:aHR0cDovL3d3dy55bmdwLmNvbS9wYWdlL3Byb2N1cmVtZW50L3Byb2N1cmVtZW50TGlzdC5odG1s

360 网站云防护系统 --- 磐云

产品介绍:aHR0cHM6Ly9iLjM2MC5uZXQvcHJvZHVjdC1jZW50ZXIvMzYwLW5ldC1zYWZlL3Bhbi15dW4=

7U4zc9.png

网站接入 360 网站云防护系统后,所有的访问请求先经过防御平台过滤,实现源站隐身,使其不会暴露在公网的攻击威胁之中。依托 360 全面攻击样本库,可实时对恶意访问流量进行甄别和拦截,并保证正常访问请求返回源站。

逆向过程

抓包分析

打开网站链接,页面显示 进入前检查浏览器...,然后开始转圈,一段时间后,进入页面,内容渲染了出来。这个过程,看起来像是在校验浏览器环境。打开开发者人员工具,清空缓存,刷新页面,抓包后发现,procurementList.html 接口走了三遍:

① 响应返回的 html 的 title 为 请稍候... | 磐云,也就是正在检测中,检测的是什么目前还不知道,响应 cookie 为 PYCCV

7UqNaU.png

7UzJZe.png

② 携带参数 answerPYCCV(cookie)再次请求该接口,响应状态码为 302,重定向了,响应 cookie 为 PYCCS

7U42EI.png

③ 最后,携带 PYCCS(cookie)请求接口,即可获取到正确的 html 页面,响应 cookie 为 xincaigou

7U46cV.png

除此之外,在翻到第二页的时候,还会触发一个文字点选验证码(依次点击),后文会逐一分析:

7U4FnL.png

逆向分析

answer

综上所述,整套流程中,请求 cookie 都是响应返回的,只有 answer 参数是动态变化的。如果按照常规思路,全局搜索或者跟栈,尝试定位该参数,但是操作后会发现,这个参数并不是 js 文件中,通过算法生成的,那到底是怎么来的呢?

我们将目光放回到第一次请求 procurementList.html 接口时,返回的 html 中去,拉到最下面,会发现通过 eval 函数,输出了一段自执行的 js 代码:

7UCW1s.png

平时业务做的多的小伙伴,应该对这种类型较为熟悉,不少网站也是通过这种方式对参数或者 cookie 进行加密,接下来,我们就分析下,这段代码的逻辑,看其是如何执行的:

eval(function (p, a, c, k, e, d) {
    e = function (c) {
        return (c < a ? "" : e(parseInt(c / a))) + ((c = c % a) > 35 ? String.fromCharCode(c + 29) : c.toString(36))
    };
    if (!''.replace(/^/, String)) {
        while (c--) d[e(c)] = k[c] || e(c);
        k = [function (e) {
            return d[e]
        }];
        e = function () {
            return '\\w+'
        };
        c = 1;
    }
    ;
    while (c--) if (k[c]) p = p.replace(new RegExp('\\b' + e(c) + '\\b', 'g'), k[c]);
    return p;
}('i n$=[\'\\q\\f\\a\\4\\k\\k\\3\\o\\p\\3\',\'\\q\\m\\4\\c\\B\\4\\o\\g\\m\\3\\6\',\'\\q\\w\\a\\4\\k\\k\\3\\o\\p\\3\\G\\7\\6\\b\'];$(n$[0])["\\g\\a\\7\\m"]();$(y(){F(y(){i 9={};i 8;i 5=A;i j=I["\\v\\g\\3\\6\\E\\p\\3\\o\\e"]["\\e\\7\\H\\7\\m\\3\\6\\w\\4\\g\\3"]();5=5*C;(8=j["\\b\\4\\e\\f\\a"](/D ([\\d.]+)/))?9["\\h\\3"]=8[l]:(8=j["\\b\\4\\e\\f\\a"](/J\\/([\\d.]+)/))?9["\\c\\h\\6\\3\\c\\7\\t"]=8[l]:(8=j["\\b\\4\\e\\f\\a"](/T\\/([\\d.]+)/))?9["\\f\\a\\6\\7\\b\\3"]=8[l]:(8=j["\\b\\4\\e\\f\\a"](/S.([\\d.]+)/))?9["\\7\\x\\3\\6\\4"]=8[l]:(8=j["\\b\\4\\e\\f\\a"](/R\\/([\\d.]+).*U/))?9["\\g\\4\\c\\4\\6\\h"]=8[l]:V;5=5+Q;r(9["\\h\\3"]||9["\\c\\h\\6\\3\\c\\7\\t"]||9["\\f\\a\\6\\7\\b\\3"]||9["\\7\\x\\3\\6\\4"]||9["\\g\\4\\c\\4\\6\\h"]){5=(5*L+K);r(5<s)5=5+z;i u=$(n$[1]);r(5>z)5=P["\\c\\k\\7\\7\\6"](5/s);u["\\O\\4\\k"](5);$(n$[2])["\\g\\v\\N\\b\\h\\e"]()}},M)});', 58, 58, '|||x65|x61|x08c924|x72|x6f|x0fcad9|x06dd1a|x68|x6d|x66||x74|x63|x73|x69|var|x01c264|x6c|0x1|x77|_|x6e|x67|x23|if|0x7b|x78|x0b515d|x75|x43|x70|function|0x929|0x14|x5f|0x0f|msie|x41|setTimeout|x46|x4c|navigator|firefox|0x7|0x3|0x3e8|x62|x76|Math|0x20|version|opera|chrome|safari|0x0'.split('|'), 0, {}))

自执行函数中,总共传入了六个参数 p, a, c, k, e, d,各自的含义如下:

  • p:一段被编码的 JavaScript 代码,包含转义字符(如 \\x61 表示 a);
  • a:基数(58),用于解码字符串;
  • c:关键字数量(58);
  • k:关键字数组(字符串分割后的数组);
  • e:解码函数(用于将数字转为字符);
  • d:存储解码后的键值对。

再将上面的代码拆分一下,逐步分析,首先就是 e 函数,功能就是解码:

e = function (c) {
    return (c < a ? "" : e(parseInt(c / a))) + ((c = c % a) > 35 ? String.fromCharCode(c + 29) : c.toString(36))
};

首先递归,将 c 除以基数 a,生成高位字符,取余后,若余数 c % a > 35,转为 ASCII 字符(如 36 -> 36+29=65 -> 'A'),否则转为 base36 字符串(如 10 -> 'a'),简而言之,就是将数字 c 转换为字符串。

接着做字符串替换:

if (!''.replace(/^/, String)) {
    // d[e(c)] = k[c] -> 将 k 数组中的值按解码函数 e 映射到 d
    while (c--) d[e(c)] = k[c] || e(c);
    k = [function (e) {
        return d[e]
    }];
    e = function () {
        return '\\w+'
    };
    c = 1;
}

构建一个字典 d,用于将混淆的字符(如 \\x61)还原为原始关键字。

最后将混淆的代码字符串 p 中的占位符(如 \\b0\\b)替换为实际值:

while (c--) if (k[c]) p = p.replace(new RegExp('\\b' + e(c) + '\\b', 'g'), k[c]);
return p;

一套操作下来,就吐出了真正的生成 answer 参数的算法:

var _$ = ['\x23\x63\x68\x61\x6c\x6c\x65\x6e\x67\x65', '\x23\x77\x61\x66\x5f\x61\x6e\x73\x77\x65\x72', '\x23\x43\x68\x61\x6c\x6c\x65\x6e\x67\x65\x46\x6f\x72\x6d'];
$(_$[0])["\x73\x68\x6f\x77"]();
$(function () {
    setTimeout(function () {
        var x06dd1a = {};
        var x0fcad9;
        var x08c924 = 0x14;
        var x01c264 = navigator["\x75\x73\x65\x72\x41\x67\x65\x6e\x74"]["\x74\x6f\x4c\x6f\x77\x65\x72\x43\x61\x73\x65"]();
        x08c924 = x08c924 * 0x0f;
        (x0fcad9 = x01c264["\x6d\x61\x74\x63\x68"](/msie ([\d.]+)/)) ? x06dd1a["\x69\x65"] = x0fcad9[0x1] : (x0fcad9 = x01c264["\x6d\x61\x74\x63\x68"](/firefox\/([\d.]+)/)) ? x06dd1a["\x66\x69\x72\x65\x66\x6f\x78"] = x0fcad9[0x1] : (x0fcad9 = x01c264["\x6d\x61\x74\x63\x68"](/chrome\/([\d.]+)/)) ? x06dd1a["\x63\x68\x72\x6f\x6d\x65"] = x0fcad9[0x1] : (x0fcad9 = x01c264["\x6d\x61\x74\x63\x68"](/opera.([\d.]+)/)) ? x06dd1a["\x6f\x70\x65\x72\x61"] = x0fcad9[0x1] : (x0fcad9 = x01c264["\x6d\x61\x74\x63\x68"](/version\/([\d.]+).*safari/)) ? x06dd1a["\x73\x61\x66\x61\x72\x69"] = x0fcad9[0x1] : 0x0;
        x08c924 = x08c924 + 0x20;
        if (x06dd1a["\x69\x65"] || x06dd1a["\x66\x69\x72\x65\x66\x6f\x78"] || x06dd1a["\x63\x68\x72\x6f\x6d\x65"] || x06dd1a["\x6f\x70\x65\x72\x61"] || x06dd1a["\x73\x61\x66\x61\x72\x69"]) {
            x08c924 = (x08c924 * 0x3 + 0x7);
            if (x08c924 < 0x7b) x08c924 = x08c924 + 0x929;
            var x0b515d = $(_$[1]);
            if (x08c924 > 0x929) x08c924 = Math["\x66\x6c\x6f\x6f\x72"](x08c924 / 0x7b);
            x0b515d["\x76\x61\x6c"](x08c924);
            $(_$[2])["\x73\x75\x62\x6d\x69\x74"]()
        }
    }, 0x3e8)
});

吐出来的算法经过了混淆,很多经过十六进制转义后的字符串,我们可以通过 DeepSeek 之类的 AI 工具,对代码进行美化还原,调校后效果如下:

navigator = {
    "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36"
};

(function() {
    // 浏览器信息检测逻辑
    const browserInfo = {};
    let matchResult;
    let answer = 20;
    const userAgent = navigator.userAgent.toLowerCase();

    // 检测浏览器类型
    if (matchResult = userAgent.match(/msie ([\d.]+)/)) {
        browserInfo.ie = matchResult[1];
    } else if (matchResult = userAgent.match(/firefox\/([\d.]+)/)) {
        browserInfo.firefox = matchResult[1];
    } else if (matchResult = userAgent.match(/chrome\/([\d.]+)/)) {
        browserInfo.chrome = matchResult[1];
    } else if (matchResult = userAgent.match(/opera.([\d.]+)/)) {
        browserInfo.opera = matchResult[1];
    } else if (matchResult = userAgent.match(/version\/([\d.]+).*safari/)) {
        browserInfo.safari = matchResult[1];
    }

    // 计算延迟时间
    answer = answer * 15; // 20*15=300
    answer += 32;            // 300+32=332

    if (browserInfo.ie || browserInfo.firefox || browserInfo.chrome || browserInfo.opera || browserInfo.safari) {
        answer = (answer * 3) + 7; // 332*3+7=1003

        if (answer < 123) {
            answer += 2345; // 下限保护
        }

        if (answer > 2345) {
            answer = Math.floor(answer / 123); // 上限保护
        }

        // 输出结果 (替代表单提交)
        console.log('计算后的 answer:', answer);
    }
})();
// 计算后的 answer: 1003

与网站结果一致:

7U6tQG.png

可以通过 subprocess 模块结合正则表达式,执行出结果,当然,这只是其中一种思路,完整的算法同步在知识星球中,可供参考学习

文字点选验证码

通过以上步骤,就能成功采集到第一页的数据了,但是当我们接着采集第二页的时候,会发现,响应内容为 验证码校验不通过, 当前请求禁止访问,响应状态码 406。到网页中操作后发现,翻页就会触发文字点选验证码,同样的,我们来抓包分析下。

/captcha.get.svc 接口,响应返回了 originalImageBase64(验证码图片链接)、secretKey、token 以及 wordList(依次点击的文字),请求参数中,ts 为时间戳,clientUid 看起来像是 uuid,后文分析:

71mVgB.png

/captcha.check.svc 为验证接口,请求参数如下,除了前文提到的,还有两个参数,其中 token 参数是 /captcha.get.svc 接口返回的,pointJson 参数后文会进行逆向分析:

71mtY3.png

验证通过,响应内容如下,此时的 cookies 就白了:

71mYBf.png

验证失败,响应内容如下:

71mrgc.png

先来看看 clientUid 参数,从 /captcha.get.svc 接口处,跟栈到 verify.js 文件中去,ctrl + f 局部搜索一下该参数,有四个结果,全部打上断点,清除缓存,刷新网页,翻页再次触发验证码之前,会断住。此时 clientUid 参数的值已经生成了,从浏览器的 localStorage 中读取到的已存储的 point 数据:

71mzZj.png

既然能取到数据,那肯定有地方先存储了,直接局部搜索 localStorage.setItem 试试,成功定位到了生成的位置,就是 uuid,直接扣下来,或者用 python 复现都可以:

71pAWh.png

接下来分下验证参数 pointJson,该案例较为清晰,所有的参数算法,都在 verify.js 文件中。同上文,局部搜索 pointJson,在搜索到的位置,都打上断点,依次点击文字后,即会断住:

710ABY.png

这里就是个标准的 AES 加密,加密模式为 ECB,key 是由前面接口返回的,加密内容为点选的坐标,可以去 K 哥工具站(https://www.kgtools.cn/secret/aes)校验下:

713jyZ.png

验证通过后,接着请求 /procurementList.html 接口,抓包发现,该接口的 captchaCheckFlag 参数,从 "0" 变成了一段加密字符串:

71lN1c.png

直接到 verify.js 文件中搜索该参数,发现没有任何结果,那么就得跟栈分析了,向上跟栈到下图处,即可定位到参数生成的位置:

71lD07.png

生成算法如下:

var captchaVerification = aesEncrypt(_this.backToken + '---' + JSON.stringify(_this.checkPosArr), _this.secretKey)

至此,整个流程就分析完了,相关算法源码会分享到知识星球中,仅供学习交流!

结果验证

71le54.png


K哥爬虫
169 声望162 粉丝

Python网络爬虫、JS 逆向等相关技术研究与分享。