福建公共资源交易平台 —— MD5 签名 + AES 响应解密

发布时间:2026/6/11 3:16:07

福建公共资源交易平台 —— MD5 签名 + AES 响应解密 目录一、分析二、JS 复现与验证三、Python 实现四、踩坑记录五、总结免责声明本文内容仅用于合法授权范围内的技术学习、安全研究、逆向分析方法交流与风控防护理解不针对任何网站、产品或服务提供绕过、攻击、滥用或破坏性使用建议。文中涉及的接口分析、参数加解密、调试定位、代码复现、数据请求等内容仅用于说明相关技术原理和分析流程。读者应在遵守相关法律法规、平台规则、robots 协议、用户协议以及获得合法授权的前提下进行学习和实验。请勿将本文中的方法、脚本或思路用于未授权访问、批量采集、账号撞库、绕过风控、破坏验证码体系、规避平台限制、侵犯数据权益、商业化滥用或影响线上系统稳定性的行为。对于真实网站案例读者不应直接复制代码对线上服务进行高频请求或非授权调用。若相关网站、产品方、权利方或平台认为本文内容存在不适宜公开展示之处可通过评论区、私信或作者主页提供的联系方式联系我核实后将及时删除、替换或调整相关内容。读者因不当使用本文内容造成的任何法律责任、业务风险或经济损失均由使用者自行承担与作者无关。一、分析目标地址https://ggzyfw.fujian.gov.cn/index/newList?type12抓取通知公告的标题和发布时间。F12 打开 DevTools切到 Network → Fetch/XHR翻页捕获数据接口POST https://ggzyfw.fujian.gov.cn/FwPortalApi/Article/PageList观察请求体{pageNo:2,pageSize:10,total:896,type:12,timeType:0,ts:1778878239848}{pageNo:4,pageSize:10,total:896,type:12,timeType:0,ts:1778884716697}{pageNo:5,pageSize:10,total:896,type:12,timeType:0,ts:1778884717559}多次翻页对比pageNo是页码ts是毫秒级时间戳每次都变。观察响应Data字段是一大段密文Base64 格式。如下{State:200,Success:true,Data:MZphJmFlelDpw2aSCfdFbxDDVxNfNhzjYsseMqcm8f4btjXSpZOqfRHmDLBtjuuRMf.....zRq8BUsp.......,Msg:操作成功}观察请求头有一个自定义头portal-sign每次请求值都不同32 位十六进制看着像 MD5。定位CtrlShiftF全局搜索portal-sign直接定位到app.xxx.js中的 axios 请求拦截器t.headers[portal-sign]f.getSign(e)顺便往下看几行发现响应拦截器里有解密逻辑m.interceptors.response.use(function(t){varet.data;return200e.State?JSON.parse(b(e.Data)):...})加密和解密在同一个文件的相邻位置——这是 axios 拦截器的典型结构找到一个另一个就在旁边。签名逻辑分析getSign 函数functiond(t){// 删除空值和 undefined 的属性for(vareint)!t[e]void0!t[e]||deletet[e];// 盐值 参数排序拼接 → MD5 → 转小写varnr[a]u(t);returns(n).toLocaleLowerCase()}其中u(t)是参数排序拼接函数functionu(t){// key 按字母升序排列大写比较for(vareObject.keys(t).sort(l),n,a0;ae.length;a)if(void0!t[e[a]])if(t[e[a]]instanceofObject||t[e[a]]instanceofArray)ne[a]JSON.stringify(t[e[a]]);elsene[a]t[e[a]];returnn}断点验证拼接结果B3978D054A72A7002063637CCDF6B2E5pageNo1pageSize10timeType0total896ts1778879269088type12s函数进去看是标准 MD5验证s(1)的输出确认无魔改。常量确认签名盐值r[a]B3978D054A72A7002063637CCDF6B2E5固定AES 密钥r[e]EB444973714E4A40876CE66BE45D593032字节AES-256AES IVr[i]B5A890420993186716字节解密函数functionb(t){vareCryptoJS.enc.Utf8.parse(r[e]),nCryptoJS.enc.Utf8.parse(r[i]),aCryptoJS.AES.decrypt(t,e,{iv:n,mode:CryptoJS.mode.CBC,padding:CryptoJS.pad.Pkcs7});returna.toString(CryptoJS.enc.Utf8)}注意这里密文直接传入AES.decrypt不需要Hex.parse或Base64.parse——CryptoJS 的decrypt方法接收字符串时默认按 Base64 解析。二、JS 复现与验证// ggzyfw_fujian.jsletCryptoJSrequire(../../CryptoJS)letsignSecretB3978D054A72A7002063637CCDF6B2E5letkeyEB444973714E4A40876CE66BE45D5930letivB5A8904209931867functionl(t,e){returnt.toString().toUpperCase()e.toString().toUpperCase()?1:t.toString().toUpperCase()e.toString().toUpperCase()?0:-1}functionu(t){for(vareObject.keys(t).sort(l),n,a0;ae.length;a)if(void0!t[e[a]])if(t[e[a]]t[e[a]]instanceofObject||t[e[a]]instanceofArray)ne[a]JSON.stringify(t[e[a]]);elsene[a]t[e[a]];returnn}functiongetSign(t){for(vareint)!t[e]void0!t[e]||deletet[e];varnsignSecretu(t);returnCryptoJS.MD5(n).toString().toLocaleLowerCase()}functiondecrypt(t){leteCryptoJS.enc.Utf8.parse(key)letnCryptoJS.enc.Utf8.parse(iv)letaCryptoJS.AES.decrypt(t,e,{iv:n,mode:CryptoJS.mode.CBC,padding:CryptoJS.pad.Pkcs7});returna.toString(CryptoJS.enc.Utf8)}签名结果与浏览器一致解密输出正确的 JSON 数据。三、Python 实现文件名ggzyfw_fujian_spider.pyimporthashlibimportjsonimporttimeimportbase64fromconcurrent.futuresimportThreadPoolExecutor,as_completedimportrequestsfromCrypto.CipherimportAESfromCrypto.Util.PaddingimportunpadclassGgzyfwSpider:福建公共资源交易平台爬虫API_URLhttps://ggzyfw.fujian.gov.cn/FwPortalApi/Article/PageListSIGN_SECRETB3978D054A72A7002063637CCDF6B2E5AES_KEYbEB444973714E4A40876CE66BE45D5930AES_IVbB5A8904209931867def__init__(self):self.sessionrequests.Session()self.session.headers.update({Content-Type:application/json;charsetUTF-8,User-Agent:Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36,})def_get_sign(self,params:dict)-str:生成 portal-sign盐值 参数排序拼接 → MD5 → 小写# 过滤空值和 Nonefiltered{k:vfork,vinparams.items()ifv!andvisnotNone}# key 按字母升序排列大写比较与 JS 的 sort 逻辑一致sorted_keyssorted(filtered.keys(),keylambdax:x.upper())# 拼接 keyvalueconcatforkinsorted_keys:vfiltered[k]ifisinstance(v,(dict,list)):concatkjson.dumps(v,separators(,,:))else:concatkstr(v)rawself.SIGN_SECRETconcatreturnhashlib.md5(raw.encode(utf-8)).hexdigest().lower()def_decrypt(self,ciphertext:str)-str:AES/CBC 解密响应数据密文为 Base64 格式cipherAES.new(self.AES_KEY,AES.MODE_CBC,self.AES_IV)ct_bytesbase64.b64decode(ciphertext)ptunpad(cipher.decrypt(ct_bytes),AES.block_size)returnpt.decode(utf-8)deffetch_page(self,page:int)-list:请求单页数据tsint(time.time()*1000)data{pageNo:page,pageSize:10,total:896,type:12,timeType:0,ts:ts}# 生成签名签名参数包含 tssignself._get_sign(data)# 注意不能写 self.session.headers[portal-sign] sign# 多线程下共享 session.headers 会互相覆盖导致签名与参数不匹配# 必须在每次请求中单独传 headersrespself.session.post(self.API_URL,datajson.dumps(data,separators(,,:)),headers{portal-sign:sign})enc_dataresp.json().get(Data,)ifnotenc_data:return[]decryptedjson.loads(self._decrypt(enc_data))return[{标题:item.get(TITLE),发布时间:item.get(TM),}foritemindecrypted.get(Table,[])]defrun(self,pages3,max_workers3):并发采集多页all_data[]withThreadPoolExecutor(max_workersmax_workers)asexecutor:futures{executor.submit(self.fetch_page,p):pforpinrange(1,pages1)}forfutureinas_completed(futures):pagefutures[future]try:resultfuture.result()all_data.extend(result)print(f[Page{page}] 获取{len(result)}条公告)exceptExceptionase:print(f[Page{page}] 请求失败:{e})returnall_dataif__name____main__:spiderGgzyfwSpider()dataspider.run(pages3)foritemindata:print(item)四、踩坑记录坑多线程下共享 session.headers 导致签名错乱# 错误写法把动态签名写到共享的 session headers 上self.session.headers[portal-sign]sign respself.session.post(url,datapayload)# 多线程时线程A算好签名写进去线程B立刻覆盖了# 线程A带着线程B的签名发请求 → 签名与参数不匹配 → 服务端返回空数据# 正确写法每次请求单独传 headersrespself.session.post(url,datapayload,headers{portal-sign:sign})# requests 会把这个头合并到 session 默认头上但只对本次请求生效现象并发 3 页只有最后一个线程的请求成功因为它的签名没被覆盖其他页返回空数据。规律凡是每次请求都不同的值签名、动态 token、一次性 nonce都不能放在共享的session.headers或session.cookies上。要么通过headers参数单独传要么每个线程用独立的 session 实例。五、总结环节要点抓包POST 请求请求体明文但带动态时间戳响应 Data 字段为 Base64 密文定位搜索portal-sign直接定位到 axios 请求拦截器解密函数就在旁边的响应拦截器里签名盐值 参数按 key 排序拼接 → MD5 → 小写。排序规则是 key 转大写后比较解密AES-256 / CBC / PKCS7密文为 Base64 格式不是 Hex常量盐值、AES Key、IV 都在同一个 webpack 模块a078中硬编码本案例的核心收获这是第一个涉及请求签名的案例——不是加密请求体而是对参数做 MD5 摘要放在请求头里。服务端用同样的逻辑验证签名是否匹配。axios 拦截器是 Vue 项目中加密/签名的高频位置。搜到请求拦截器后响应拦截器解密通常就在下面几行。参数排序拼接时注意 JS 的sort比较规则转大写后比较Python 端要用keylambda x: x.upper()保持一致。响应密文格式是 Base64不是 Hex因为 CryptoJS 的decrypt接收字符串时默认按 Base64 解析。

相关新闻