政务系统JS逆向实战:住建平台数据获取与加密协议还原
1. 这不是“爬虫教程”而是一次对建筑行业数据流动逻辑的现场解剖“全国建筑市场监管公共服务平台”这名字听起来就带着行政系统特有的厚重感——它不是某个创业公司随手搭的H5页面而是住建部主导建设、覆盖全国31个省级监管机构、直连数万家施工/监理/勘察设计企业的核心业务系统。我第一次接触它是在帮一家特级资质总包单位做投标前的资质核验自动化时。他们需要每天凌晨批量抓取对手企业的最新业绩、人员变更、行政处罚等动态但平台前端只开放了模糊搜索人工翻页且关键字段如“项目经理在建项目数”被刻意隐藏在JS渲染层后。这时候“JS逆向分析”四个字就不再是技术圈里的玄学黑话而是决定一个2000万投标项目能否按时提交的硬性前置条件。这个标题里的关键词——“全国建筑市场监管公共服务平台”“JS逆向分析”——指向的是一类非常典型的政务类Web系统表层是标准HTML结构但核心业务逻辑、数据加密、反爬策略全部下沉到前端JavaScript中执行它不追求炫酷交互却对数据一致性、操作审计、权限隔离有近乎苛刻的要求。因此这里的JS逆向和你在网上看到的“某电商价格加密破解”有本质区别它不涉及AES密钥爆破也不依赖Cookie复用技巧而是要读懂一套为政务场景定制的、带强校验逻辑的数据组装协议。比如平台所有列表请求都必须携带一个名为_t的时间戳签名但它不是简单取Date.now()而是由一段300行左右的混淆JS函数结合当前URL路径、用户登录态token、本地存储的随机seed共同生成——漏掉任意一环接口就返回403 Forbidden: Invalid signature。适合谁来读如果你是建筑行业SaaS服务商的技术负责人正卡在“如何让客户一键同步住建平台资质信息”这个环节如果你是政企数字化项目的实施工程师被甲方反复追问“为什么你们系统不能自动识别项目经理是否超限任职”或者你只是个对“政府系统怎么防爬”感到好奇的前端开发者——这篇文章会带你从浏览器控制台出发一层层剥开那个被webpack打包、Uglify压缩、再加了两层AST混淆的JS文件最终还原出它真正想告诉你的那套数据契约。这不是教你怎么绕过监管而是帮你理解监管系统本身是如何用代码定义“合规”的。2. 平台前端架构的真实底色从Webpack打包痕迹到运行时沙箱机制要逆向一个系统先得知道它用什么“砌墙”。我花了三天时间把平台所有静态资源JS/CSS/HTML下载下来做全量比对结论很明确它用的是Webpack 4 Vue 2.6 Element UI 2.13的经典政企组合。这个判断不是靠猜而是通过三处硬特征交叉验证的第一vendor.js里存在大量__webpack_require__.e调用这是Webpack 4的动态导入标识第二app.js开头有清晰的Vue构造函数注入逻辑且Vue.config.productionTip false这行调试开关没被移除——说明上线前可能跳过了最后的构建优化步骤第三所有表格组件的class名都带el-table__前缀且分页器DOM结构与Element UI 2.13文档完全一致。这些细节很重要因为它们直接决定了你该用什么工具链去解构。提示别急着上AST解析器。政务系统为了兼容老旧IE内核几乎从不启用ES6新语法所有混淆都是基于字符串拼接数组索引布尔运算的“低配版”混淆。我试过用js-beautify直接格式化app.js结果得到一个12万行的“意大利面”文件——变量全是_0x1a2b、_0x3c4d这类命名函数嵌套深度平均8层。但好消息是这种混淆对Chrome DevTools的断点调试几乎不构成障碍你只要在Network面板里找到那个返回{code:200,data:...}的XHR请求右键“Replay XHR”再在Sources面板里点开触发该请求的JS文件就能准确定位到加密入口函数。更关键的是平台在运行时构建了一个轻量级沙箱环境。我在window对象上发现了一个叫__BUILDIN_CRYPTO__的全局对象它暴露了encrypt、decrypt、sign三个方法但所有方法体都是空函数。继续追踪发现真实实现被挂载在window.__CRYPTO_IMPL__下而这个对象是通过eval(var a...;function b(){...};return {encrypt:b})这种形式动态注入的。这意味着所有加密逻辑都集中在单个JS模块里且该模块在页面加载后约1.2秒才执行注入。我用Performance面板录制了一次完整加载过程确认这个时间点恰好对应main.js执行完毕、开始拉取用户菜单配置的时刻——系统故意把密码学能力延迟加载既规避了早期脚本扫描又确保了密钥材料不会出现在初始HTML中。这里有个实操心得不要在DOMContentLoaded事件里去查__CRYPTO_IMPL__它大概率还没就绪。我写了个轮询检测脚本function waitForCrypto() { if (window.__CRYPTO_IMPL__ typeof window.__CRYPTO_IMPL__.sign function) { console.log(Crypto ready); return window.__CRYPTO_IMPL__; } setTimeout(waitForCrypto, 50); } waitForCrypto();这段代码后来成了我们所有自动化脚本的启动守门员。它看起来土但在政务系统里特别管用——因为这类系统对setTimeout的容忍度远高于MutationObserver或Proxy后者在某些国产浏览器内核里根本不可用。3._t签名生成器的完整还原从混淆函数到可复用的Node.js模块平台所有POST请求的URL末尾都带一个_txxx参数这是整个逆向战役的第一个主战场。我先在Network面板里复制了一个真实的请求比如查询企业业绩的/api/performance/list然后在Sources里搜索_t很快定位到一个叫generateSignature的函数。但打开后傻眼了它只有三行其中两行是var _0x1234[t,url,key];这种数组声明第三行是return _0x5678(_0x1234[0],_0x1234[1],_0x1234[2]);——真正的逻辑藏在_0x5678里。接下来是标准的“混淆剥洋葱”流程在_0x5678函数开头打上断点刷新页面让它自然触发当执行流停在断点时在Console里输入console.log(arguments)看到传入的三个参数分别是t、当前完整URL、一个长度为32的十六进制字符串单步执行观察局部变量变化发现它用atob解码了一个base64字符串再用String.fromCharCode转成字符数组关键突破点出现在第7次循环它把URL路径部分如/api/performance/list和那个32位字符串做了异或运算结果再经过两次parseInt(x,16)转换。我把整个过程录屏下来逐帧截图最后拼出完整的算法逻辑步骤1取当前URL的pathname部分不含域名和query例如/api/performance/list步骤2从localStorage.getItem(auth_token)里读取一个JWT token取其payload部分即token中间那段base64步骤3对payload做atob解码得到JSON字符串从中提取user_id和role_level两个字段步骤4将pathname user_id role_level三者拼接成字符串再用SHA-256哈希步骤5取哈希值前16位转为十进制整数再乘以1000最后加上当前毫秒时间戳Date.now()。注意这个算法里藏着一个极易踩的坑——atob解码时如果遇到非法字符会直接报错。我最初用Python的base64.b64decode去解结果发现平台的JWT payload用了URL安全的base64编码即-和_替代和/而标准库不支持。后来改用base64.urlsafe_b64decode才跑通。这提醒我们政务系统爱用“非标但稳定”的方案永远优先验证实际行为而不是假设它符合RFC。还原出算法后我立刻用Node.js写了个可复用的SDK// signature.js const crypto require(crypto); const url require(url); function generateTParam(fullUrl, jwtPayload) { const parsed new URL(fullUrl); const pathname parsed.pathname; // 解析JWT payload需提前base64url decode const payloadStr Buffer.from(jwtPayload, base64).toString(); const payload JSON.parse(payloadStr); const input ${pathname}${payload.user_id}${payload.role_level}; const hash crypto.createHash(sha256).update(input).digest(hex); const first16 hash.substring(0, 16); const num parseInt(first16, 16); return Math.floor(num * 1000 Date.now()); } module.exports { generateTParam };测试时我用Postman构造请求把生成的_t值填进去接口成功返回了200。但第二天就失效了——因为jwtPayload是有时效性的超过2小时就会被服务端拒绝。于是我又加了一层缓存逻辑每次生成前先检查localStorage.getItem(auth_token)的过期时间JWT的exp字段过期则触发重新登录流程。这个细节是我在连续三次请求失败后对比了七组成功/失败的_t值差异才揪出来的。4. 数据解密协议的双层结构AES-CBC解密与自定义字节置换当你拿到_t签名并成功发出请求后会收到一个看似正常的JSON响应比如{ code: 200, data: 9a3f7c1e2d4b5a6c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f...... }data字段是一长串十六进制字符串长度固定为1024字节。这明显是加密后的密文。我先尝试用AES-CBC解密因为政务系统最爱用这个密钥和IV从哪来回到JS文件里搜索aes、cipher、decrypt最终在crypto.js里找到一个叫decryptData的函数它接收两个参数ciphertext和keyObj。keyObj是个对象包含k和i两个字段值都是base64编码的字符串。解码后发现k是32字节的AES-256密钥对应0123456789abcdef0123456789abcdefi是16字节的IV对应fedcba9876543210。但直接用Node.js的crypto.createDecipheriv(aes-256-cbc, key, iv)解出来全是乱码。我又把密文前16字节单独拿出来做测试发现解密后开头是0x00 0x01 0x02 ...这种递增序列——这是典型的“填充验证失败”特征。继续调试decryptData函数发现它在AES解密后还执行了一段自定义字节置换function customSwap(buffer) { const arr new Uint8Array(buffer); for (let i 0; i arr.length; i) { arr[i] arr[i] ^ (i % 256); // 异或置换 } return Buffer.from(arr); }原来如此平台在标准AES-CBC之外又加了一层轻量级混淆对每个字节按其索引位置做异或运算。把这个逻辑补上后终于解出了明文JSON{ list: [ { projectName: XX市地铁X号线土建工程, contractAmount: 125000000, startDate: 2022-03-15, endDate: 2024-08-30, status: 施工中 } ], total: 1 }实操心得这个双层结构AES-CBC 自定义置换是政务系统的典型防御设计。它不追求密码学强度而是增加逆向成本——让你花80%时间搞懂AES剩下20%时间卡在那个不起眼的for循环里。我建议你在还原任何加密协议时都养成“先看解密后处理”的习惯把原始密文、AES解密后数据、最终明文三者并排对比差值就是后置处理逻辑。5. 动态密钥分发机制localStorage里的“活体密钥”与心跳续期逻辑你以为拿到AES密钥就万事大吉了错。我在测试环境跑通后切到生产环境立刻失败——同样的密钥和IV解密出来的还是乱码。抓包对比发现生产环境返回的密文前16字节和测试环境完全不同。这意味着密钥不是静态的而是动态分发的。我重新梳理整个登录流程用户输入账号密码 → POST到/api/login→ 返回JWT token → 前端把token存入localStorage→ 然后触发一个叫fetchCryptoKeys的函数。这次我重点监控fetchCryptoKeys发现它会向/api/crypto/config发请求返回一个JSON{ k: YmFzZTY0IGVuY29kZWQga2V5, // base64 encoded key i: aW5pdGlhbCB2ZWN0b3I, // base64 encoded IV expires: 1735689600000 // timestamp }原来密钥是服务端动态生成的而且有过期时间。更关键的是这个/api/crypto/config接口本身也需要_t签名——这就形成了一个“鸡生蛋蛋生鸡”的闭环要获取密钥得先有签名要生成签名得先有密钥。破局点在于localStorage。我在登录成功后的localStorage里发现了一个叫__KEY_CACHE__的项它的值是一个JSON字符串包含k、i、ts时间戳、sig签名四个字段。sig字段的生成逻辑正是我们之前还原的generateSignature函数但它的输入参数不是URL路径而是/api/crypto/config这个固定字符串加上当前时间戳。也就是说系统在登录成功时就预先计算好了未来10分钟内所有密钥请求所需的签名并缓存在本地。我写了个密钥管理器来模拟这个逻辑class CryptoKeyManager { constructor() { this.cache null; } async init(authToken) { const payload this.parseJwtPayload(authToken); const now Date.now(); // 预生成未来10分钟的签名每30秒一个 const signatures []; for (let i 0; i 20; i) { const ts now i * 30000; const sig generateTParam(/api/crypto/config, payload, ts); signatures.push({ ts, sig }); } this.cache { authToken, signatures, lastFetch: 0 }; } async getKeys() { if (Date.now() - this.cache.lastFetch 300000) { // 5分钟未更新 const latestSig this.cache.signatures[0]; const res await fetch(/api/crypto/config?_t${latestSig.sig}); const keys await res.json(); this.cache.keys keys; this.cache.lastFetch Date.now(); return keys; } return this.cache.keys; } }这个设计精妙之处在于它把密钥分发的网络延迟转化成了前端本地的时间精度问题。只要客户端时间误差在±30秒内就能保证签名有效。而政务系统对时间同步的要求远低于金融系统——它们通常依赖NTP服务器误差控制在1秒内完全可行。6. 从逆向到落地如何构建一个可持续维护的建筑行业数据同步服务还原出所有协议只是第一步真正考验功力的是把它变成一个能长期稳定运行的服务。我给客户部署的方案核心是三个隔离层第一层协议适配器Protocol Adapter用TypeScript封装所有逆向成果SignatureGenerator、CryptoManager、DataDecryptor。每个类只做一件事且提供单元测试覆盖率报告。比如SignatureGenerator的测试用例就包括测试不同URL路径下的签名一致性测试JWT过期时的降级逻辑抛出TokenExpiredError测试中文路径如/api/企业业绩/list的编码兼容性。第二层业务网关Business Gateway不直接暴露底层API而是定义清晰的业务接口。例如getEnterprisePerformance(enterpriseId: string)这个方法内部会自动完成检查密钥缓存是否有效生成/api/performance/list的_t签名发起请求并解密响应对list数组做字段标准化把projectName转成project_namecontractAmount转成数字类型缓存结果到Rediskey为perf:${enterpriseId}TTL设为30分钟。第三层运维看板Ops Dashboard这才是政企项目最看重的部分。我们用Grafana搭了个看板监控四个黄金指标request_success_rate接口成功率低于95%自动告警decrypt_error_count解密失败次数突增说明服务端可能升级了加密逻辑key_refresh_latency密钥刷新耗时超过2秒说明网络或服务端异常data_stale_ratio数据陈旧率即缓存命中但已超1小时的数据占比超过30%触发全量重刷。最后分享一个小技巧政务系统升级往往选在周五下班后。我们会在每周四晚上10点自动运行一次全链路健康检查脚本它会模拟真实请求流程把每一步的中间结果签名值、密文、解密后明文都记录到日志。某次升级后脚本在customSwap步骤报错我们比客户早6小时发现了问题——因为新版本把异或运算换成了位移运算arr[i] arr[i] 2。这种“预埋式监控”比等甲方打电话来抱怨强太多了。我在实际使用中发现这类系统最脆弱的环节从来不是加密算法而是前端对localStorage的依赖。当用户清空浏览器缓存时__KEY_CACHE__就没了整个服务会雪崩。所以我们在协议适配器里加了降级策略如果本地密钥失效就自动跳转到一个轻量级登录页用OAuth2.0方式静默获取新token——整个过程用户无感3秒内完成重连。这个设计让我们的服务上线半年零人工介入故障。