查策网产业政策项目 —— AES 响应解密 + 字体反爬虫

目录

免责声明:本文内容仅用于合法授权范围内的技术学习、安全研究、逆向分析方法交流与风控防护理解,不针对任何网站、产品或服务提供绕过、攻击、滥用或破坏性使用建议。文中涉及的接口分析、参数加解密、调试定位、代码复现、数据请求等内容,仅用于说明相关技术原理和分析流程。读者应在遵守相关法律法规、平台规则、robots 协议、用户协议以及获得合法授权的前提下进行学习和实验。请勿将本文中的方法、脚本或思路用于未授权访问、批量采集、账号撞库、绕过风控、破坏验证码体系、规避平台限制、侵犯数据权益、商业化滥用或影响线上系统稳定性的行为。对于真实网站案例,读者不应直接复制代码对线上服务进行高频请求或非授权调用。若相关网站、产品方、权利方或平台认为本文内容存在不适宜公开展示之处,可通过评论区、私信或作者主页提供的联系方式联系我;核实后将及时删除、替换或调整相关内容。读者因不当使用本文内容造成的任何法律责任、业务风险或经济损失,均由使用者自行承担,与作者无关。

一、分析(目标+抓包+定位+结论)

需求: 抓取查策网产业政策项目列表,循环采集前 2 页数据。目标页面:

text 复制代码
https://www.chacewang.com/chanye/index#

目标接口:

text 复制代码
GET https://web.chace-ai.com/api/ccw/project/evaluation/getList/

典型参数:

text 复制代码
page=2
size=20
industry=
area=RegisterArea_HNDQ_Guangdong_SZ
dept=
partition=
pe_name=
currentArea=RegisterArea_HNDQ_Guangdong_SZ
query_date=0
full_search=0
sort_type=0

打开 F12 调试工具后,页面会先被一个反调试 debugger 干扰:

处理方式比较简单,直接在 DevTools 里对这个 debugger 位置做禁用/忽略处理,然后按 F8 继续执行,让页面能正常加载并抓包:

接着看 Network 面板,翻页时能看到列表接口:

这个请求有一个关键现象:请求参数都在 URL Query 里,pagesizeareaquery_date 这些字段是明文,没有看到请求体加密,也没有必须参与计算的动态 signtimestamp 一类字段。但是响应里的 data 字段是密文,接口返回结构大致如下:

json 复制代码
{
    "code": 200,
    "message": "成功",
    "data": "aXJ4Mm9Sc0srL2Njd2ZwX19fM2U2YzRkZGM2YWJlODQ1ZjVkNGM4...省略...WQxYTljM2UwZTRlMQ==",
    "count": 1707,
    "pagination": {
        "page": "2",
        "page_size": "20",
        "page_set": "7429db60f70b32...省略...edd72e965f5fce96920ccdbd==",
        "keyword": ""
    }
}

所以这题的主线先不要往 请求参数加密 上想,而是先解决 响应 data 解密。响应解密函数通常可以从两个方向定位:

  1. Hook JSON.parse,因为业务代码最后一定要把解密后的 JSON 字符串转成对象。
  2. 全局搜索 decryptmyDecryptAES.decrypt 这类关键词。

这里我实际测试时发现,Hook JSON.parse 命中的业务逻辑比较多,噪声偏大。这个站点更直接的方式是全局搜索 decrypt

点进去后能看到一个 u(t) 函数。先不要急着扣代码,先在这个函数里打断点,然后回页面翻页,确认列表接口是否真的走到这里:

翻页后断点命中:

这时要做两个验证:

  1. 看入参 t 是否等于接口响应 JSON 里的 data 密文。
  2. 看函数末尾的 v 是否已经是解密后的 JSON 字符串。

验证结果是匹配的,说明这个 u(t) 就是响应 data 的解密函数:

关键函数如下:

javascript 复制代码
function u(t) {
    var e = s.decode(t)
    , a = e.slice(0, 10)
    , i = l(a, e)
    , r = i.slice(0, 32)
    , o = i.slice(32, i.length)
    , c = n.default.enc.Utf8.parse(r)
    , u = n.default.enc.Utf8.parse(o)
    , d = e.slice(50, e.length)
    , f = n.default.enc.Hex.parse(d)
    , h = n.default.enc.Base64.stringify(f)
    , p = n.default.AES.decrypt(h, c, {
        iv: u,
        mode: n.default.mode.CBC,
        padding: n.default.pad.Pkcs7
    })
    , v = p.toString(n.default.enc.Utf8);
    return v.toString()
}

从这段代码可以先得到一个大方向:

  1. s.decode(t):先对响应 data 做解码。
  2. l(a, e):根据解码后的字符串派生 key/iv 材料。
  3. n.default.AES.decrypt(...):最后走 CryptoJS 的 AES 解密。

具体 s.decodel()c() 怎么扣,放到下一节拆开处理。

二、扣代码思路拆解

这一节重点记录扣代码的过程,而不是只贴最终函数。核心思路是:每改一步都和浏览器断点里的变量做对比,确认当前步骤没问题后再继续往下扣。

第一步:先处理解码链路。原始代码是:

javascript 复制代码
var e = s.decode(t)

单步进入 s.decode,可以看到它实际走到下面这条链路:

javascript 复制代码
D = t => I(L(t))
L = t => y(t.replace(/[-_]/g, t => "-" == t ? "+" : "/"))
// 这里依赖比较多,所以继续往上找,把从 const n 开始定义的相关变量一起扣下来
const n = "3.7.2"
, i = n
, o = "function" === typeof atob
, a = "function" === typeof btoa
, s = "function" === typeof Buffer
, u = "function" === typeof TextDecoder ? new TextDecoder : void 0
, h = "function" === typeof TextEncoder ? new TextEncoder : void 0
, f = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="
, c = Array.prototype.slice.call(f)
, l = (t => {
let e = {};
return t.forEach( (t, r) => e[t] = r),
e
}
)(c)
, d = /^(?:[A-Za-z\d+\/]{4})*?(?:[A-Za-z\d+\/]{2}(?:==)?|[A-Za-z\d+\/]{3}=?)?$/
, p = String.fromCharCode.bind(String)
, m = "function" === typeof Uint8Array.from ? Uint8Array.from.bind(Uint8Array) : (t, e=(t => t)) => new Uint8Array(Array.prototype.slice.call(t, 0).map(e))
, v = t => t.replace(/=/g, "").replace(/[+\/]/g, t => "+" == t ? "-" : "_")
, y = t => t.replace(/[^A-Za-z0-9\+\/]/g, "")
, g = t => {
let e, r, n, i, o = "";
const a = t.length % 3;
for (let s = 0; s < t.length; ) {
    if ((r = t.charCodeAt(s++)) > 255 || (n = t.charCodeAt(s++)) > 255 || (i = t.charCodeAt(s++)) > 255)
        throw new TypeError("invalid character found");
    e = r << 16 | n << 8 | i,
    o += c[e >> 18 & 63] + c[e >> 12 & 63] + c[e >> 6 & 63] + c[63 & e]
}
return a ? o.slice(0, a - 3) + "===".substring(a) : o
}
, b = a ? t => btoa(t) : s ? t => Buffer.from(t, "binary").toString("base64") : g
, w = s ? t => Buffer.from(t).toString("base64") : t => {
const e = 4096;
let r = [];
for (let n = 0, i = t.length; n < i; n += e)
    r.push(p.apply(null, t.subarray(n, n + e)));
return b(r.join(""))
}
, _ = (t, e=!1) => e ? v(w(t)) : w(t)
, M = t => {
if (t.length < 2) {
    var e = t.charCodeAt(0);
    return e < 128 ? t : e < 2048 ? p(192 | e >>> 6) + p(128 | 63 & e) : p(224 | e >>> 12 & 15) + p(128 | e >>> 6 & 63) + p(128 | 63 & e)
}
e = 65536 + 1024 * (t.charCodeAt(0) - 55296) + (t.charCodeAt(1) - 56320);
return p(240 | e >>> 18 & 7) + p(128 | e >>> 12 & 63) + p(128 | e >>> 6 & 63) + p(128 | 63 & e)
}
, S = /[\uD800-\uDBFF][\uDC00-\uDFFFF]|[^\x00-\x7F]/g
, x = t => t.replace(S, M)
, k = s ? t => Buffer.from(t, "utf8").toString("base64") : h ? t => w(h.encode(t)) : t => b(x(t))
, A = (t, e=!1) => e ? v(k(t)) : k(t)
, E = t => A(t, !0)
, O = /[\xC0-\xDF][\x80-\xBF]|[\xE0-\xEF][\x80-\xBF]{2}|[\xF0-\xF7][\x80-\xBF]{3}/g
, R = t => {
switch (t.length) {
case 4:
    var e = (7 & t.charCodeAt(0)) << 18 | (63 & t.charCodeAt(1)) << 12 | (63 & t.charCodeAt(2)) << 6 | 63 & t.charCodeAt(3)
      , r = e - 65536;
    return p(55296 + (r >>> 10)) + p(56320 + (1023 & r));
case 3:
    return p((15 & t.charCodeAt(0)) << 12 | (63 & t.charCodeAt(1)) << 6 | 63 & t.charCodeAt(2));
default:
    return p((31 & t.charCodeAt(0)) << 6 | 63 & t.charCodeAt(1))
}
}
, T = t => t.replace(O, R)
, C = t => {
if (t = t.replace(/\s+/g, ""),
!d.test(t))
    throw new TypeError("malformed base64.");
t += "==".slice(2 - (3 & t.length));
let e, r, n, i = "";
for (let o = 0; o < t.length; )
    e = l[t.charAt(o++)] << 18 | l[t.charAt(o++)] << 12 | (r = l[t.charAt(o++)]) << 6 | (n = l[t.charAt(o++)]),
    i += 64 === r ? p(e >> 16 & 255) : 64 === n ? p(e >> 16 & 255, e >> 8 & 255) : p(e >> 16 & 255, e >> 8 & 255, 255 & e);
return i
}
, j = o ? t => atob(y(t)) : s ? t => Buffer.from(t, "base64").toString("binary") : C
, P = s ? t => m(Buffer.from(t, "base64")) : t => m(j(t), t => t.charCodeAt(0))
, B = t => P(L(t))
, I = s ? t => Buffer.from(t, "base64").toString("utf8") : u ? t => u.decode(P(t)) : t => T(j(t))
, L = t => y(t.replace(/[-_]/g, t => "-" == t ? "+" : "/"))
, D = t => I(L(t))
, N = t => {
if ("string" !== typeof t)
    return !1;
const e = t.replace(/\s+/g, "").replace(/={0,2}$/, "");
return !/[^\s0-9a-zA-Z\+/]/.test(e) || !/[^\s0-9a-zA-Z\-_]/.test(e)
}
, F = t => ({
value: t,
enumerable: !1,
writable: !0,
configurable: !0
})
, U = function() {
const t = (t, e) => Object.defineProperty(String.prototype, t, F(e));
t("fromBase64", (function() {
    return D(this)
}
)),
t("toBase64", (function(t) {
    return A(this, t)
}
)),
t("toBase64URI", (function() {
    return A(this, !0)
}
)),
t("toBase64URL", (function() {
    return A(this, !0)
}
)),
t("toUint8Array", (function() {
    return B(this)
}
))
}
, q = function() {
const t = (t, e) => Object.defineProperty(Uint8Array.prototype, t, F(e));
t("toBase64", (function(t) {
    return _(this, t)
}
)),
t("toBase64URI", (function() {
    return _(this, !0)
}
)),
t("toBase64URL", (function() {
    return _(this, !0)
}
))
}
, H = () => {
U(),
q()
}
, z = {
version: n,
VERSION: i,
atob: j,
atobPolyfill: C,
btoa: b,
btoaPolyfill: g,
fromBase64: D,
toBase64: A,
encode: A,
encodeURI: E,
encodeURL: E,
utob: x,
btou: T,
decode: D,
isValid: N,
fromUint8Array: _,
toUint8Array: B,
extendString: U,
extendUint8Array: q,
extendBuiltins: H
}

也就是说,当前场景里的 s.decode 最终对应的是 D 函数,所以本地先改写成:

javascript 复制代码
let e = D(t)

验证方式很简单:浏览器断点里打印 e,本地 Node 环境也打印 e,两边结果一致后再继续往下处理。

第二步:确认 slice 都是普通字符串切片

javascript 复制代码
let a = e.slice(0, 10)

这一步不用继续扣源码,slice 是字符串原生方法。只要上一步的 e 一致,a 基本就不会有问题;但为了稳妥,仍然建议打印一次,避免前面的解码字符集处理错了。

第三步:处理函数名冲突 。接着单步进入 l 函数,代码如下:

javascript 复制代码
function l(t, e) {
    for (var a = arguments.length > 2 && void 0 !== arguments[2] ? arguments[2] : 48, n = e.slice(18, 50), i = t + n, r = c(i), s = r, o = 0; s.length < a; o++)
        r = c(r + i),
            s += r;
    return s.slice(0, a)
}

继续单步可以看到,l 函数内部又调用了 c 函数。再进入 c 函数:

javascript 复制代码
function c(t) {
    var e = o.createHash("md5");
    return e.update(t).digest("hex")
}

这里要特别注意:前面为了还原 s.decode 扣下来的那段代码里,已经存在变量 cl;而当前解密模块里也有函数 c()l()。如果直接合并到同一个文件,很容易发生变量名冲突,所以这里先做重命名:

javascript 复制代码
function c_(t) {}
function l_(t, e) {}

然后把调用同步改掉:

javascript 复制代码
let i = l_(a, e)

这个动作看起来很小,但真实扣代码时非常常见。webpack 压缩后的变量名通常很短,跨模块合并时尤其容易撞名。

第四步:确认浏览器中的 c() 是否为标准 MD5。原始代码:

javascript 复制代码
function c(t) {
    var e = o.createHash("md5");
    return e.update(t).digest("hex")
}

可以在浏览器里测试:

再和标准 MD5 对比:

javascript 复制代码
CryptoJS.MD5("1").toString()

如果结果一致,就说明这里不是魔改 MD5,本地可以直接替换为:

javascript 复制代码
function c_(t) {
    return CryptoJS.MD5(t).toString()
}

第五步:拆 Key 和 IV 。这里的 n.default 可以按 CryptoJS 对象来处理,因为后面能看到 encAESmodepad 这些典型属性。对应代码可以改成:

javascript 复制代码
let i = l_(a, e)
let r = i.slice(0, 32)
let o = i.slice(32, i.length)

let c = CryptoJS.enc.Utf8.parse(r)
let u = CryptoJS.enc.Utf8.parse(o)

这里要注意:ro 虽然长得像 hex 字符串,但 JS 用的是 Utf8.parse。所以 Python 里也应该使用 .encode("utf-8"),不能把它们当成 hex 再 bytes.fromhex()

第六步:处理密文本体。继续看后面的密文处理逻辑:

javascript 复制代码
let d = e.slice(50, e.length)
let f = CryptoJS.enc.Hex.parse(d)
let h = CryptoJS.enc.Base64.stringify(f)

d 是从解码结果第 50 位开始截出来的 hex 字符串。CryptoJS 的 AES.decrypt 在这里接收的是 Base64 字符串,所以 JS 里先 Hex.parse 转成 WordArray,再 Base64.stringify 转成 Base64。Python 里不用绕这一步,直接把 hex 转成 bytes 即可:

python 复制代码
ciphertext = bytes.fromhex(decoded_payload[50:])

第七步:AES 解密 。最后把 n.default 相关调用替换成 CryptoJS 调用:

javascript 复制代码
let p = CryptoJS.AES.decrypt(h, c, {
    iv: u,
    mode: CryptoJS.mode.CBC,
    padding: CryptoJS.pad.Pkcs7
})
let v = p.toString(CryptoJS.enc.Utf8)

三、JS 复现

下面是完整 JS 测试代码,保留了当时扣代码和对照验证的注释,便于回看每一步是怎么来的:

javascript 复制代码
const n = "3.7.2"
, i = n
, o = "function" === typeof atob
, a = "function" === typeof btoa
, s = "function" === typeof Buffer
, u = "function" === typeof TextDecoder ? new TextDecoder : void 0
, h = "function" === typeof TextEncoder ? new TextEncoder : void 0
, f = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="
, c = Array.prototype.slice.call(f)
, l = (t => {
let e = {};
return t.forEach( (t, r) => e[t] = r),
e
}
)(c)
, d = /^(?:[A-Za-z\d+\/]{4})*?(?:[A-Za-z\d+\/]{2}(?:==)?|[A-Za-z\d+\/]{3}=?)?$/
, p = String.fromCharCode.bind(String)
, m = "function" === typeof Uint8Array.from ? Uint8Array.from.bind(Uint8Array) : (t, e=(t => t)) => new Uint8Array(Array.prototype.slice.call(t, 0).map(e))
, v = t => t.replace(/=/g, "").replace(/[+\/]/g, t => "+" == t ? "-" : "_")
, y = t => t.replace(/[^A-Za-z0-9\+\/]/g, "")
, g = t => {
let e, r, n, i, o = "";
const a = t.length % 3;
for (let s = 0; s < t.length; ) {
    if ((r = t.charCodeAt(s++)) > 255 || (n = t.charCodeAt(s++)) > 255 || (i = t.charCodeAt(s++)) > 255)
        throw new TypeError("invalid character found");
    e = r << 16 | n << 8 | i,
    o += c[e >> 18 & 63] + c[e >> 12 & 63] + c[e >> 6 & 63] + c[63 & e]
}
return a ? o.slice(0, a - 3) + "===".substring(a) : o
}
, b = a ? t => btoa(t) : s ? t => Buffer.from(t, "binary").toString("base64") : g
, w = s ? t => Buffer.from(t).toString("base64") : t => {
const e = 4096;
let r = [];
for (let n = 0, i = t.length; n < i; n += e)
    r.push(p.apply(null, t.subarray(n, n + e)));
return b(r.join(""))
}
, _ = (t, e=!1) => e ? v(w(t)) : w(t)
, M = t => {
if (t.length < 2) {
    var e = t.charCodeAt(0);
    return e < 128 ? t : e < 2048 ? p(192 | e >>> 6) + p(128 | 63 & e) : p(224 | e >>> 12 & 15) + p(128 | e >>> 6 & 63) + p(128 | 63 & e)
}
e = 65536 + 1024 * (t.charCodeAt(0) - 55296) + (t.charCodeAt(1) - 56320);
return p(240 | e >>> 18 & 7) + p(128 | e >>> 12 & 63) + p(128 | e >>> 6 & 63) + p(128 | 63 & e)
}
, S = /[\uD800-\uDBFF][\uDC00-\uDFFFF]|[^\x00-\x7F]/g
, x = t => t.replace(S, M)
, k = s ? t => Buffer.from(t, "utf8").toString("base64") : h ? t => w(h.encode(t)) : t => b(x(t))
, A = (t, e=!1) => e ? v(k(t)) : k(t)
, E = t => A(t, !0)
, O = /[\xC0-\xDF][\x80-\xBF]|[\xE0-\xEF][\x80-\xBF]{2}|[\xF0-\xF7][\x80-\xBF]{3}/g
, R = t => {
switch (t.length) {
case 4:
    var e = (7 & t.charCodeAt(0)) << 18 | (63 & t.charCodeAt(1)) << 12 | (63 & t.charCodeAt(2)) << 6 | 63 & t.charCodeAt(3)
      , r = e - 65536;
    return p(55296 + (r >>> 10)) + p(56320 + (1023 & r));
case 3:
    return p((15 & t.charCodeAt(0)) << 12 | (63 & t.charCodeAt(1)) << 6 | 63 & t.charCodeAt(2));
default:
    return p((31 & t.charCodeAt(0)) << 6 | 63 & t.charCodeAt(1))
}
}
, T = t => t.replace(O, R)
, C = t => {
if (t = t.replace(/\s+/g, ""),
!d.test(t))
    throw new TypeError("malformed base64.");
t += "==".slice(2 - (3 & t.length));
let e, r, n, i = "";
for (let o = 0; o < t.length; )
    e = l[t.charAt(o++)] << 18 | l[t.charAt(o++)] << 12 | (r = l[t.charAt(o++)]) << 6 | (n = l[t.charAt(o++)]),
    i += 64 === r ? p(e >> 16 & 255) : 64 === n ? p(e >> 16 & 255, e >> 8 & 255) : p(e >> 16 & 255, e >> 8 & 255, 255 & e);
return i
}
, j = o ? t => atob(y(t)) : s ? t => Buffer.from(t, "base64").toString("binary") : C
, P = s ? t => m(Buffer.from(t, "base64")) : t => m(j(t), t => t.charCodeAt(0))
, B = t => P(L(t))
, I = s ? t => Buffer.from(t, "base64").toString("utf8") : u ? t => u.decode(P(t)) : t => T(j(t))
, L = t => y(t.replace(/[-_]/g, t => "-" == t ? "+" : "/"))
, D = t => I(L(t))
, N = t => {
if ("string" !== typeof t)
    return !1;
const e = t.replace(/\s+/g, "").replace(/={0,2}$/, "");
return !/[^\s0-9a-zA-Z\+/]/.test(e) || !/[^\s0-9a-zA-Z\-_]/.test(e)
}
, F = t => ({
value: t,
enumerable: !1,
writable: !0,
configurable: !0
})
, U = function() {
const t = (t, e) => Object.defineProperty(String.prototype, t, F(e));
t("fromBase64", (function() {
    return D(this)
}
)),
t("toBase64", (function(t) {
    return A(this, t)
}
)),
t("toBase64URI", (function() {
    return A(this, !0)
}
)),
t("toBase64URL", (function() {
    return A(this, !0)
}
)),
t("toUint8Array", (function() {
    return B(this)
}
))
}
, q = function() {
const t = (t, e) => Object.defineProperty(Uint8Array.prototype, t, F(e));
t("toBase64", (function(t) {
    return _(this, t)
}
)),
t("toBase64URI", (function() {
    return _(this, !0)
}
)),
t("toBase64URL", (function() {
    return _(this, !0)
}
))
}
, H = () => {
U(),
q()
}
, z = {
version: n,
VERSION: i,
atob: j,
atobPolyfill: C,
btoa: b,
btoaPolyfill: g,
fromBase64: D,
toBase64: A,
encode: A,
encodeURI: E,
encodeURL: E,
utob: x,
btou: T,
decode: D,
isValid: N,
fromUint8Array: _,
toUint8Array: B,
extendString: U,
extendUint8Array: q,
extendBuiltins: H
}

const CryptoJS = require("./CryptoJS")

// function c(t) {
function c_(t) {
    // TODO 3.1.1 这个函数看着是md5的实现 在确认是标准算法之后我们可以用本地的CryptoJS.js 改写
    // var e = o.createHash("md5");
    // e.update('1').digest("hex") 可以在浏览器中测试这个结果 然后跟标准算法进行对比
    // 是标准算法 所以这里可以改写为 CryptoJS
    // return e.update(t).digest("hex")
    // 测试一样的
    // return CryptoJS.MD5('1').toString();
    return CryptoJS.MD5(t).toString();
}

function l_(t, e) {
    // TODO 3.1 扣这里的时候 很明显看到是需要一个 c函数的
    // 单步进入c函数 同理上面一起复制的时候有变量c 所以我们把名字改成c_ ==> TODO 3.1.1
    // for (var a = arguments.length > 2 && void 0 !== arguments[2] ? arguments[2] : 48, n = e.slice(18, 50), i = t + n, r = c(i), s = r, o = 0; s.length < a; o++)
    for (var a = arguments.length > 2 && void 0 !== arguments[2] ? arguments[2] : 48, n = e.slice(18, 50), i = t + n, r = c_(i), s = r, o = 0; s.length < a; o++)
        // r = c(r + i),
        r = c_(r + i),
        s += r;
    return s.slice(0, a)
}

function decryptRes(t) {
    // var e = s.decode(t)
    // TODO 1.改写 s.decode 其实是: D = t => I(L(t)) 则
    let e = D(t) // 对比和浏览器中返回的e是一致的
    // console.log(e)
    // TODO 2.在看slice 是字符串自带的方法 不用管 只需看看和浏览器返回的是否一致
    let a = e.slice(0, 10)  // e一样的 a应该没啥问题
    // console.log(a)
    // TODO 3.注意单步调试进入 l函数 不是上面一起复制的那个l 所以需要重新复制 ==> TODO 3.1
    // let i = l(a, e) 改写: 为了避免冲突 我们这里调用把l 改为l_
    // 改写完成之后测试一下看能否正常调用
    let i = l_(a, e) // 和浏览器是一致的 紧接着看下面
    // console.log(i)
    // TODO 4.i没有问题 那么r和o应该问题不大 测试一下 确实是一样的
    let r = i.slice(0, 32)
    let o = i.slice(32, i.length)
    // console.log(r)
    // console.log(o)
    // TODO 5.下面这些就简单了 熟悉 CryptoJS 就知道 接下来就做替换操作就行了
    // 其实 n.default 就是 CryptoJS 库
    // let c = n.default.enc.Utf8.parse(r)
    let c = CryptoJS.enc.Utf8.parse(r)
    // let u = n.default.enc.Utf8.parse(o)
    let u = CryptoJS.enc.Utf8.parse(o)
    // 先验证一下数组是否和浏览器中的一致 结果: 一样的
    // console.log(c)
    // console.log(u)
    // 剩余的接着验证就好了 一样的
    let d = e.slice(50, e.length)
    // console.log(d)
    // let f = n.default.enc.Hex.parse(d)
    let f = CryptoJS.enc.Hex.parse(d)
    // let h = n.default.enc.Base64.stringify(f)
    let h = CryptoJS.enc.Base64.stringify(f)
    // let p = n.default.AES.decrypt(h, c, {
    //     iv: u,
    //     mode: n.default.mode.CBC,
    //     padding: n.default.pad.Pkcs7
    // })
    // 替换
    let p = CryptoJS.AES.decrypt(h, c, {
        iv: u,
        mode: CryptoJS.mode.CBC,
        padding: CryptoJS.pad.Pkcs7
    })
    // let v = p.toString(n.default.enc.Utf8);
    let v = p.toString(CryptoJS.enc.Utf8);
    return v.toString()
}

// let e = D('服务端返回的data密文');
// console.log(e)
// 让test_str等于浏览器中u函数传入进来的t 来进行测试
let test_str = '服务端返回的data密文'
// 最后解密成功
console.log(typeof decryptRes(test_str));
console.log(JSON.parse(decryptRes(test_str)));
// console.log(c_('1'));

四、Python 请求测试

本案例保留两个测试方法。

4.1 方法一:Python 调用扣好的 JS 函数

思路:

  1. Python 用 requests 请求接口。
  2. 取响应 JSON 里的 data 字段。
  3. execjs 调 JS 里的 decryptRes
  4. json.loads 得到列表。

这里我使用的是本地离线版 CryptoJS.js,所以 JS 代码里直接通过 require 引入。实际复现时也可以选择 npm 安装 crypto-js,本质上没有区别,只要最终拿到标准 CryptoJS 对象即可:

javascript 复制代码
const CryptoJS = require("./CryptoJS")

4.2 方法二:Python 全部改写

核心解密代码:

python 复制代码
def md5_hex(value: str) -> str:
    return hashlib.md5(value.encode("utf-8")).hexdigest()


def derive_key_material(prefix: str, decoded_payload: str, length: int = 48) -> str:
    middle = decoded_payload[18:50]
    seed = prefix + middle
    digest = md5_hex(seed)
    material = digest

    while len(material) < length:
        digest = md5_hex(digest + seed)
        material += digest

    return material[:length]


def decrypt_response_data(encrypted_data: str) -> Any:
    padding = "=" * (-len(encrypted_data) % 4)
    decoded_bytes = base64.urlsafe_b64decode(encrypted_data + padding)
    decoded_payload = decoded_bytes.decode("utf-8")

    prefix = decoded_payload[:10]
    key_material = derive_key_material(prefix, decoded_payload)
    key = key_material[:32].encode("utf-8")
    iv = key_material[32:48].encode("utf-8")
    ciphertext = bytes.fromhex(decoded_payload[50:])

    cipher = AES.new(key, AES.MODE_CBC, iv)
    plaintext = unpad(cipher.decrypt(ciphertext), AES.block_size)
    return json.loads(plaintext.decode("utf-8"))

请求头模拟:

python 复制代码
HEADERS = {
    "Accept": "*/*",
    "Accept-Language": "zh-CN,zh;q=0.9",
    "Origin": "https://www.chacewang.com",
    "Referer": "https://www.chacewang.com/",
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
                  "(KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36",
    "Sec-Fetch-Dest": "empty",
    "Sec-Fetch-Mode": "cors",
    "Sec-Fetch-Site": "cross-site",
}

脚本使用 loguru.logger 输出采集进度,完整 JSON 明细放在 debug 日志中,避免把调试 print 留在正式代码里。验证结果示例:

text 复制代码
page=1 rows=20 total=1707
生态环境专项资金项目-污染处理设施更新改造项目 7471.45.46 7471.67.56

这里先能确认一件事:接口里的 data 已经被正常解密成列表 JSON。示例里这些数字字段暂时还不能直接和页面展示内容对应,下一节再继续排查它们到底对应页面上的哪个字段。

五、字体反爬:申报时间字段还原

前面把接口数据解出来之后,接下来要继续确认页面上的 申报时间 来自哪里。先回到 Network 面板观察翻页和页面加载过程,看看申报时间是否由其他接口单独返回。实际观察下来,没有发现新的列表详情接口,也没有看到其他专门返回申报时间的请求。这样就需要回到已经解密出来的列表数据里继续找线索。

在列表数据中,可以看到每条记录里都有两个比较像时间字段的名称:

text 复制代码
start_time
end_time

问题是,这两个字段的格式和页面上看到的申报时间完全不一样。例如页面肉眼看到的是:

text 复制代码
2026.03.01-2026.12.31

但接口解密后的字段可能是:

text 复制代码
start_time: "4549.52.58"
end_time: "4549.84.28"

到这里还不能直接下结论说是字体反爬,因为也可能是字段还要经过某种业务映射。于是做一个最简单的验证:直接在页面上选中申报时间并复制。

复制出来的结果不是页面上看到的 2026.03.01-2026.12.31,而是和接口里的假数字格式一致,例如:

text 复制代码
4041.08.06-4041.64.86

这一步就能基本判断:接口解密没有问题,DOM 文本里保存的就是这些假数字;浏览器只是通过自定义字体把这些数字渲染成了真实日期。前面抓包时其实也能看到字体请求,例如:

text 复制代码
https://web.chace-ai.com/media/fonts/UGsCBCC7QgVMzxgU.woff?version=8.205129435023679

所以这里的处理方向就很明确了:下载当前页面使用的 .woff 字体,建立接口假数字 -> 页面真实数字的映射,然后把 start_timeend_time 这类字段统一替换。

如果只是临时处理当前字体,可以人工打开字体文件观察 0-9 的显示效果,然后写一个固定映射。但这种方式不够通用,因为字体文件名和数字映射可能会变化。更稳一点的做法是用 fontTools 读取 glyph 轮廓,把当前字体里的数字轮廓和一个已知模板字体做匹配。示例处理逻辑如下:

python 复制代码
from pathlib import Path
from typing import Any, Dict

import requests
from fontTools.pens.recordingPen import RecordingPen
from fontTools.ttLib import TTFont


FONT_URL = "https://web.chace-ai.com/media/fonts/{}.woff?version=1"
CACHE_DIR = Path(__file__).with_name(".chace_font_cache")


def glyph_signature(font: TTFont, glyph_name: str) -> tuple:
    glyph_set = font.getGlyphSet()
    pen = RecordingPen()
    glyph_set[glyph_name].draw(pen)
    return tuple(pen.value)


def build_digit_templates(reference_font: Path) -> Dict[tuple, str]:
    # 这个模板字体需要先人工识别一次:
    # source char 0123456789 renders as 2438195067
    manual_map = {
        "0": "2",
        "1": "4",
        "2": "3",
        "3": "8",
        "4": "1",
        "5": "9",
        "6": "5",
        "7": "0",
        "8": "6",
        "9": "7",
    }
    font = TTFont(str(reference_font))
    return {glyph_signature(font, src): real for src, real in manual_map.items()}


def download_font(session: requests.Session, font_name: str) -> Path:
    CACHE_DIR.mkdir(exist_ok=True)
    path = CACHE_DIR / f"{font_name}.woff"
    if not path.exists():
        response = session.get(FONT_URL.format(font_name), timeout=30)
        response.raise_for_status()
        path.write_bytes(response.content)
    return path


def font_digit_map(font_path: Path, templates: Dict[tuple, str]) -> Dict[str, str]:
    font = TTFont(str(font_path))
    mapping = {}
    for src in "0123456789":
        mapping[src] = templates[glyph_signature(font, src)]
    return mapping


def translate_digits(value: Any, digit_map: Dict[str, str]) -> Any:
    if not isinstance(value, str):
        return value
    return value.translate(str.maketrans(digit_map))

实际项目里,字体名不是固定写死的,需要结合接口返回的 pagination.page_set 解出来。最终效果类似:

text 复制代码
8685.63.60-8685.08.30 -> 2026.03.01-2026.12.31

这部分不是本篇重点,真正通用的字体反爬后续会单独展开。这里先知道处理思路即可:先确认复制结果和接口假数字一致,再下载字体,通过 glyph 映射把假数字还原成真实数字

六、小结

这个案例的主线可以拆成两部分:接口响应解密申报时间字段还原

接口解密部分的实际分析路径是:

  1. 打开页面后先处理无限 debugger,保证页面能继续执行并正常抓包。
  2. 在 Network 面板观察翻页请求,确认列表接口是 getList,请求参数是普通 Query。
  3. 观察响应结构,发现外层 JSON 是明文,但 data 字段是密文。
  4. 因为 JSON.parse 命中位置比较多,所以没有优先走 Hook,而是全局搜索 decrypt 关键词。
  5. 搜到解密函数后打断点,翻页触发请求,验证入参 t 是否等于接口返回的 data,再验证函数末尾 v 是否为解密后的 JSON 字符串。
  6. 确认函数后再逐步扣代码:先扣 s.decode,再扣 l()c(),最后替换 CryptoJS AES 解密逻辑。

这部分最后还原出来的逻辑是:

步骤 处理
1 对响应 data 做 Base64 解码
2 取解码结果前 10 位作为前缀
3 取解码结果 18:50 和前缀拼接,通过 MD5 迭代生成 48 位材料
4 前 32 位作为 AES Key,后 16 位作为 IV
5 解码结果第 50 位以后是 hex 密文
6 AES-CBC + PKCS7 解密得到 JSON 字符串

需要注意的几个坑:

  1. 不要一开始就假设请求参数被加密 。这个接口请求参数是明文 Query,真正需要处理的是响应 data
  2. 搜索关键词定位时要验证调用关系 。搜到 decrypt 只是找到候选函数,还必须通过断点确认入参和接口密文一致。
  3. 扣多个 webpack 模块时要注意变量名冲突 。例如 Base64 模块里已经有 cl,解密模块里又有 c()l(),合并到本地测试文件时需要重命名。
  4. c() 确认是标准 MD5 后再替换 。不要看到 createHash("md5") 就直接跳过,最好用浏览器结果和本地 CryptoJS/标准 MD5 对一下。
  5. Key/IV 是字符串按 UTF-8 使用,不是 hex 解码 。JS 里是 CryptoJS.enc.Utf8.parse,Python 里对应 .encode("utf-8")
  6. 申报时间不是接口解密失败 。接口解密后拿到的 start_timeend_time 是 DOM 里的假数字,页面通过 .woff 字体渲染成真实日期。