小程序签名校验绕过实战:从逆向定位到动态签名复刻
1. 从一次真实渗透说起签名校验为何是必争之地前阵子测试某公司的一个小程序项目前端逻辑抽丝剥茧翻了个底朝天接口参数、加密算法都摸清了但真正卡住后续所有测试步骤的是一道不起眼的签名校验——请求体里必须带一个sign字段服务端会校验签名是否合法否则直接拒绝响应。那感觉就像你配好了钥匙但门锁上还加了一道电子口令口令错了门根本不开。其实在小程序生态里这是非常典型的“业务安全防线”。开发者的本意是好的防止请求被恶意篡改、防止重放攻击、防止批量刷接口。但问题是签名校验一旦做得不够严谨或者在前端留下了不合规的“后门”它就从安全屏障变成了纸糊的窗户。这篇文章要聊的就是从实战视角拆解小程序签名校验的绕过思路不是为了教人钻空子而是帮安全测试人员和后端开发理解你的签名到底防住了谁又漏给了谁。2. 先搞清楚签名校验的本质和常见实现2.1 签名到底在做什么签名校验的核心逻辑可以拆成三步前端按照约定算法把请求参数包括时间戳、随机数、业务字段等拼接成字符串。用约定的密钥对拼接结果做哈希或加密运算常见的有 MD5、SHA1、SHA256、HMAC-SHA256得到 signature。把签名一起发给服务端服务端用同一套算法和密钥重新算一遍比对是否一致。只要算法不泄露、密钥不泄露服务端就能识别出请求是否被中途篡改过。这个思路本身是成熟的很多开放平台的 API 设计也在用类似机制。但落地的过程中问题往往出在“约定”这两个字上。谁和谁约定前端小程序包里写了什么密钥藏没藏住算法能不能从代码里直接抠出来这些问题不解决签名就只是「看起来安全」。2.2 小程序端签名的常见实现方式从实际项目里见过的几种典型写法排序大部分后端签名校验逃不出这几类实现方式常见做法安全强度典型漏洞纯前端拼接把参数按字典序排序拼上固定 saltMD5 后传参弱算法和密钥全部暴露在前端 js 中直接可逆前端拼接 时间戳拼接时加入 timestamp防止重放弱时间戳可伪造密钥一旦抠出依然失效前端配合后端下发 token请求前先获取服务端下发的签名 token再用于签名计算中token 的获取接口往往可被单独调用同样依赖密钥保护云函数 / 服务端中间层前端不直接计算签名而是把请求转发到云函数服务端计算后统一签名强绕过难度大需要先攻破云函数或找到上下文漏洞前端仅做结果展示签名逻辑完全在服务端小程序只是结果页强前端无可利用点需转向其他攻击面注意现实中很多项目的签名机制会是上述方式的叠加组合。比如“排序拼接 salt SHA256 时间窗校验 随机 nonce”每加一层绕过的复杂度都会上一个台阶。从安全测试的视角看真正值得优先关注的是第一种和第二种——纯前端实现。因为所有关键信息都在用户端可见的代码包里逆向成本极低也是本文后续要重点展开的场景。3. 工具准备与绕过思路全景3.1 抓包解密小程序的 HTTPS 与二次加密小程序不同于普通网页它的网络请求默认跑在 HTTPS 上但仅靠 HTTPS 还不够很多小程序还会做二次加密常见的是对请求体整体做 AES/RSA 加密后再传输。所以第一步永远是把小程序的请求流量“变透明”。我用的工具组合是抓包代理Charles 或 Fiddler配合内置的 SSL Proxy 功能微信开发者工具可以模拟小程序运行配合抓包工具查看请求内容自建代理在某些反调试场景下用 mitmproxy 脚本定制转发更灵活解密插件Charles 支持在 Response 上挂 JavaScript 脚本配合小程序的加密算法样本可以实时解密实操里有个关键点小程序的 HTTPS 校验并不总是绑定系统证书不少小程序只校验自己服务器的证书链抓包工具需要额外注入根证书才能看到明文。而且现在的微信开发者工具已经不再“跳转原生系统代理”必须在启动参数或调试菜单里单独配置代理否则流量完全穿不过去。常见做法是把抓包工具的证书导入系统信任存储然后在开发者工具“网络安全”里开启“不校验合法域名、web-view业务域名、TLS 版本以及 HTTPS 证书”选项再把代理模式改成“手动设置代理”填上本机代理端口。这样就能看到绝大多数明文的 HTTPS 请求了。3.2 逆向定位签名逻辑的位置解包之后签名的计算逻辑一般藏在两个地方业务代码文件里或者某个封装好的公共 JS 模块里。用解包工具打开项目主包按关键词搜索比较快搜sign、signature、salt、secret、MD5、sha、hmac、nonce—— 这些都是签名相关的命名习惯搜wx.request—— 找到所有网络请求的入口再回溯看它们是否统一经过某个封装函数搜crypto、forge、jsencrypt、crypto-js—— 这些是常用的加密库引入路径一旦定位到计算位置直接查看源码分析拼接规则和密钥来源整个签名机制对攻击者来说就是透明的了。注意如果小程序做了分包加载签名逻辑可能不在主包里得先把所有分包解出来再合并搜索。否则主包里搜不到任何相关代码容易误判“签名不在前端”。3.3 绕过签名的三条主路线理解了签名机制之后绕过思路基本可以归纳成三条主线直接复用原签名字段不动原始请求只替换业务参数的部分字段但保留原签名不变。这种只适用于签名校验不完全绑定所有参数的情况常见于只校验了关键参数、漏掉了扩展字段的场景。动态计算签名把前端抠出来的签名算法完整复刻到本地脚本里每次请求都实时算一遍新的签名。这是最彻底的绕过方式只要密钥和算法还在前端就能无限生成合法签名。绕过签名校验本身如果签名校验只在某个特定接口上做了或者可以通过修改请求路径绕过那就根本不需要算签名——直接调用不带签名校验的接口即可。这种多见于后台开发时测试接口忘了删、或者前端代码里有隐藏接口的情况。实际上我碰到的大多数场景都是第二种——动态计算签名。这也是本文要重点展开讲的。4. 详细实操从小程序包里抠出签名算法并复刻调用4.1 解包小程序代码现在的微信小程序发布包是.wxapkg文件本质上是个二进制打包容器可以用现成的解析脚本直接解开。我用的是开源的解包工具链在命令行跑一下就出源码目录# 解包示例具体工具名称不重要思路通用 node unpkg.js app-id.wxapkg # 解出来通常是一个小程序目录结构包含 app.js、app.json、pages/ 等如果你没有拿到.wxapkg文件还有一个更方便的路径直接用微信开发者工具导入一个空白项目然后把目标小程序的 AppID 填进去开发者工具会同步远程包再通过缓存目录把源码抠出来。有几个坑需要提前踩掉解包后如果有分包subpackages需要逐个解包合并到一个目录里搜索否则签名逻辑可能漏掉很多小程序的 JS 代码虽然体积不大但做了混淆变量名都是_0xabc这类直接搜关键词可能搜不到——需要先做反混淆或者从调用栈回溯定位4.2 定位签名函数解包之后的代码是原生的 JavaScript 模块但脚本里通常没有直接暴露文件名和行号所以定位得结合调用关系。我用过一个比较顺手的流程先用抓包工具抓一个带签名的真实请求把sign参数值记下来。在解包后的代码里搜索这个具体的 sign 值往往能直接定位到生成它的函数因为算法里会有参数拼接所以搜索结果位置大概率就是核心逻辑。从定位点往上回溯找到这个函数被谁调用、传入了哪些参数就能顺藤摸瓜把整个签名生成链路摸清。搜索的时候注意一个细节签名值一般是 32 位或 64 位的十六进制字符串可以直接用正则匹配也可以在解包后的文件里直接搜前几位。如果搜索不到考虑大小写不同、加盐导致的结果不固定等情况也可能是拼接时的字段值里带了特殊符号导致签名结果格式有差异。4.3 分析签名拼接规则和密钥找到签名函数之后剩下的就是“翻译”工作。以我看到过的一个实际案例为例签名逻辑是function generateSign(params) { // 1. 去除空值字段 const filtered {}; for (const key in params) { if (params[key] ! params[key] ! null params[key] ! undefined) { filtered[key] params[key]; } } // 2. 按键名 ASCII 升序排序 const keys Object.keys(filtered).sort(); // 3. 拼接 let str ; for (const key of keys) { str ${key}${filtered[key]}; } // 去掉结尾的 str str.slice(0, -1); // 4. 拼接固定 salt const salt a1b2c3d4e5f6g7h8i9j0; // 前端硬编码 str salt; // 5. MD5 计算 return md5(str); }这个例子是最典型的“排序 拼接 salt MD5”结构密钥即 salt直接在代码里明文暴露。整个签名机制的强度完全押在了“前端代码不被看到”这个假设上而实际上前端代码是可以被任何人拿到的。如果碰到的算法是 HMAC-SHA256核心还是同一个思路——把密钥和待签名内容提取出来用同样的算法复算即可。因为 HMAC 的密钥也是被前端持有的不存在“前端无法算出”的情况。4.4 复刻签名逻辑到独立脚本拿到算法和密钥下一步就是把它搬出小程序环境做成一个独立可调用的脚本。我习惯用 Node.js 来做因为和前端 JavaScript 环境完全一致连加密库都是现成的// sign.js const crypto require(crypto); function generateSign(params) { // 1. 去除空值 const filtered {}; for (const key in params) { if (params[key] ! params[key] ! null params[key] ! undefined) { filtered[key] params[key]; } } // 2. 按键名 ASCII 升序排序 const keys Object.keys(filtered).sort(); // 3. 拼接 let str ; for (const key of keys) { str ${key}${filtered[key]}; } str str.slice(0, -1); // 4. 拼接固定 salt const salt a1b2c3d4e5f6g7h8i9j0; str salt; // 5. MD5 计算 return crypto.createHash(md5).update(str).digest(hex); } module.exports { generateSign };然后把真实的请求参数喂进去验证生成的签名是否和抓包里的一致node -e console.log(require(./sign).generateSign({id: 123, page: 1, timestamp: 1700000000}))这一步如果输出和原始请求一致就说明签名已经可以脱离小程序宿主环境任意生成了。4.5 联动请求篡改与重放有了签名生成器之后典型的攻击流程就变成了用抓包工具截获一个正常请求拿到完整的参数列表修改业务字段比如把商品价格、用户 ID、数量改成非预期值把修改后的参数丢进签名脚本生成新的合法签名用修改后的参数 新签名重新发送给服务端只要服务端没有做额外的校验比如时间戳窗口、一次性 nonce、频率限制服务端会认为这是一个完全合法的请求。我在实际测试中碰到过一个案例小程序有个领取优惠券的接口签名字段只校验了couponId和userId但请求里同时带着source渠道标识和remark备注这两个字段不参与签名。于是我把remark改成了任意内容甚至留空服务端照样接受了——因为签名校验只覆盖了部分参数扩展字段完全裸奔。这种情况比“签名算法泄露”还常见也更难被后端开发意识到。5. 服务端对抗策略签名校验并非一劳永逸5.1 为什么只做“算法保密”是不够的看到这里很多人会问那签名算法直接放在服务端不就不行了吗理论上确实是这个方向但现实是很多后端架构做不到。一个很常见的场景小程序需要访问第三方开放平台的接口平台要求请求必须携带签名。此时签名密钥如果只在服务端那生成签名也得在服务端完成前端只能把原始请求转发给服务端由服务端“代签”。但这样一来网络链路变成小程序 - 服务端 - 第三方平台所有请求都多一跳延迟和服务器压力都上去了。很多团队为了省事就直接在前端把签名算了结果安全防线拱手让人。另外即使算法不在前端服务端仍然可能因为“校验范围不全”而出现漏洞——上文提到的扩展字段不参与签名就是活生生的例子。类似地如果校验时不校验时间戳或是 nonce重放攻击依然有效。5.2 加固签名校验的几条可行路径从长远看签名校验的强度取决于“密钥能否被信任”。前端不可信是必然的所以真正有效的加固方向有三个把签名生成迁到服务端或云函数前端只负责展示。代价是每次请求多一跳但安全性上限明显提高。强制校验时间戳窗口比如只允许签名生成后 5 分钟内的请求生效过期丢弃。这个能挡住大部分脚本重放但挡不住即时生成的。引入一次性 nonce 值服务端记录已使用过的 nonce重复使用直接拒绝。这个能挡住重放但需要在服务端维护一套状态表。这三条可以叠加使用配合限流、设备指纹、风控评分才算得上完整的业务安全闭环。6. 常见问题排查与踩坑实录6.1 为什么我复刻的签名总是对不上这是前端复刻最容易遇到的问题。常见原因按出现频率排序参数漏了实际请求里还有隐藏参数比如appId、platform等前端可能参与了拼接但显示时没展示排序规则不对不是按字符序而是按字典序或者忽略了大小写拼接格式有差异有的项目用keyvaluekeyvalue有的用keyvalue拼接后再加 salt有的直接在字符串中间插入固定前缀或后缀编码问题URL 编码、空格替换、特殊字符转义等前后端处理不一致密钥没找全有的密钥可能来自本地存储或服务端预下发不是纯前端写死的排查的时候建议逐步调试先把可能参与拼接的所有字段都列出来逐个比对确认差异后缩小范围。还可以在抓包里找一个请求手算一遍签名把每一步结果都打印出来和自己的脚本输出对比再一个字段一个字段地排查。6.2 收到的签名是动态的每次都不一样怎么办如果每次请求的签名都不一样有可能是签名里包含了时间戳或随机 nonce。这种情况不意味着签名不可生成而是你需要把“动态因子”也一起复制出来。真实请求里往往可以看到类似timestamp1699999999nonceabc123这样的字段那签名里大概率就把这两个值也拼进去了。你在复刻的时候只要把当前时间戳和随机字符串生成出来塞进原算法签名同样能算对服务端是认这个参数的。如果服务端同时校验时间戳窗口例如 signature 里带了时间戳且服务端只接受前后 5 分钟内的请求那你还得控制脚本生成的时间同步问题必要时用系统时间自动注入避免长时间挂机导致签名过期。6.3 小程序用了云端函数前端完全没有签名逻辑怎么办这种情况说明签名环节已经不在前端的攻击面内你的精力应该重新分配换方向转向云函数本身如果云函数的接口鉴权弱可能会被直接调用尝试篡改基础逻辑看有没有别的接口不校验签名比如某些管理后台接口寻找历史版本包有些小程序的历史版本里签名逻辑还在前端虽然线上已经迁移但历史包可能仍然有效提示如果服务端做了完善的服务端签名、时间戳、nonce、频控四件套那我建议测试人员不要死磕签名绕过把注意力放到别的攻击面比如越权、逻辑漏洞、第三方 SDK 漏洞上。安全的本质是找最薄弱的一环而不是挑战最强的防线。7. 写在最后签名的价值边界与测试思路的转移我做安全测试这些年越来越觉得签名校验属于“必要但不充分”的一环。它能挡住低水平的批量攻击却挡不住真正有耐心、有技术能力的对手。关键问题不在于“有没有签名”而在于“签名的根信任落在了哪里”。对于开发团队我的建议是签名逻辑一定要尽量落在服务端或云函数绝不把核心密钥写在前端即便要落在前端也必须搭配时间戳 nonce 服务端二次校验对“扩展字段不参与签名”这种情况要建立审查机制漏一个字段就多一个风险敞口对于安全测试人员我的建议是不要总想着把签名绕过去而是把签名当成一个入口顺着它往下推——算出来了就测业务逻辑算不出来就找别的薄弱点。技术突破的乐趣并不只在“绕过”本身而在于你真正理解了整个系统是怎么设计的然后才能找到最优雅的突破方式。最后再分享个小技巧复刻签名脚本的时候建议把时间戳和 nonce 都做成可配置参数或者直接从抓包工具里复制。这样在调试签名的时候可以反复用同一组数据比对不用每次重新算一遍省掉很多无效劳动。踩过几次“参数不一致”的坑之后你自然会明白这步有多值钱。

相关新闻

海明码从原理到纠错实践:分组覆盖与校正子定位单比特翻转

海明码从原理到纠错实践:分组覆盖与校正子定位单比特翻转

1. 为什么需要海明码:数据传输中的错误问题 1.1 一个简单的例子:隔壁老王发来的数字 先别急着背公式,我们从最朴素的场景说起。假设你正在用微信给朋友发送一串数字“985 211 996 007”,结果对方收到的是“985 211 986 007”。中…

2026/10/13 11:58:46 阅读更多 →
DAO设计模式实战拆解:数据访问层解耦、事务与持久化框架选型

DAO设计模式实战拆解:数据访问层解耦、事务与持久化框架选型

写代码多年,我见过太多团队在数据访问这层栽跟头。早期做个CRUD,大家都喜欢直接在业务方法里写SQL,一开始确实方便,但等项目跑到两三年,各种业务逻辑和数据访问代码纠缠在一起,改一个字段要动七八处&#x…

2026/10/13 11:58:46 阅读更多 →
车载DVD读碟故障诊断与激光头更换指南

车载DVD读碟故障诊断与激光头更换指南

1. 故障现象与初步诊断上周遇到一台老款丰田凯美瑞的车载DVD播放器出现读碟困难的问题。具体表现为:放入光盘后,机芯反复尝试读取但无法识别碟片,最终显示"NO DISC"错误。这种故障在老车机中相当常见,尤其是使用5年以上…

2026/10/13 11:58:46 阅读更多 →

最新新闻

打破园区数据孤岛:统一数字平台与智慧园区落地实践

打破园区数据孤岛:统一数字平台与智慧园区落地实践

简介:面向园区管理者、物业运营方与智慧城市方案设计人员,这份《华为智慧园区通用场景解决方案》以“平台生态”为主线,针对管理对象多、运营成本高、服务体验差、应用重复建设等痛点,给出分层分场景的数字化转型路径,…

2026/10/13 12:41:45 阅读更多 →
Elasticsearch集群搭建实战:节点角色、核心配置与健康检查

Elasticsearch集群搭建实战:节点角色、核心配置与健康检查

1. 先搞明白:这个集群到底要解决什么问题我最初接触ELK的时候,对Elasticsearch集群的理解特别肤浅:以为把ES多装几台,启动起来就算集群了。结果在测试环境里把三台机器都配成了默认角色,数据全挤在一台机器的磁盘上&am…

2026/10/13 12:41:45 阅读更多 →
Java Word占位符替换:Apache POI与docx4j选型及跨run处理避坑指南

Java Word占位符替换:Apache POI与docx4j选型及跨run处理避坑指南

1. 为什么“替换Word占位符”这件事,远比想象中麻烦做过Java后端的人,几乎都绕不开一个需求:生成一份格式规范的Word文档。合同、通知书、报表、简历、项目验收单,业务方给过来的永远是一个排版精美的.docx模板,里面写…

2026/10/13 12:41:45 阅读更多 →
SAP-PP替代物料完全手册:策略、配置与MRP消耗顺序避坑指南

SAP-PP替代物料完全手册:策略、配置与MRP消耗顺序避坑指南

简介:这份手册面向SAP-PP模块顾问、生产计划员及物料管理相关人员,系统讲解替代物料与后继物料的配置逻辑与实操方法,帮助解决生产过程中物料短缺、切换与按比例消耗等常见难题。资源包内含1个docx文档,大小约871KB,以…

2026/10/13 12:41:45 阅读更多 →
测试工程师面试题:底层能力分析与高效准备指南

测试工程师面试题:底层能力分析与高效准备指南

如果把测试工程师的面试题当成一份题库来背,基本就输了一半。这几年我接触过不少准备面试的朋友,也看过很多测试工程师/测试开发岗位的面试题合集,大家普遍的做法是刷题、背答案、记八股,结果面试官换一种问法,立刻就原…

2026/10/13 12:41:45 阅读更多 →
鲲鹏云ARM架构Hadoop集群部署实验全流程拆解

鲲鹏云ARM架构Hadoop集群部署实验全流程拆解

简介:《鲲鹏云大数据实验docx》是一份面向云计算与大数据初学者的实验报告,完整记录了在华为云鲲鹏环境下从零搭建Hadoop集群的全过程。报告从购买弹性云服务器与对象存储服务开始,介绍如何获取AK/SK认证密钥,并逐步完成OpenJDK安…

2026/10/13 12:40:44 阅读更多 →

日新闻

物联网系统集成避坑指南:从选型到验收的关键要点

物联网系统集成避坑指南:从选型到验收的关键要点

做了这么多年物联网系统集成的项目,我最大的感受是,大部分坑不是出在技术前沿,而是出在一些看起来很平常的地方。设备选了便宜的、协议没问清楚、现场没摸透、验收标准写得含糊——这些细枝末节,最后都会变成延期、超支和扯皮的大…

2026/10/13 0:00:57 阅读更多 →
ESP32舵机控制全攻略:从PWM原理到高级玩法与常见故障排查

ESP32舵机控制全攻略:从PWM原理到高级玩法与常见故障排查

如果你玩ESP32有一阵子了,很难不碰到舵机控制这道坎。做智能小车要转方向,做机械臂要抬关节,做云台要偏航俯仰,背后都是同一个东西在干活:舵机。而我见过太多人卡在“代码能编译、舵机就是不听话”这个阶段&#xff0c…

2026/10/13 0:00:57 阅读更多 →
CAN总线原理与STM32实战:抗干扰、仲裁、错误处理全解析

CAN总线原理与STM32实战:抗干扰、仲裁、错误处理全解析

1. 为什么CAN总线至今仍是工业现场的“硬通货”?——从汽车底盘到智能温室的真实需求你有没有拆过一辆老款燃油车的中控台?或者调试过某高校实验室里那台跑着Modbus协议却频频丢帧的PLC控制柜?又或者,在某个农业物联网项目里&…

2026/10/13 0:00:57 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/13 2:54:56 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/12 0:16:38 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/13 1:58:08 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/12 21:05:09 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/12 14:48:49 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/12 18:11:17 阅读更多 →